sirenamc/stackline
Stackline, a team task board you run from the web or from Claude/ChatGPT. FastAPI + DataK3 + appid + Models + MCP.
A remix template · 0 remixes · Remix sirenamc/stackline
app/boards/doors/web.py
"""The web door: JSON routes plus one page. Every route calls a use case and nothing else."""
import html
import os
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from boards.application.use_cases import Boards, NotFound
from boards.doors.identity import person_from
from boards.domain.model import Forbidden, Invalid, Person
PAGE = (Path(__file__).parent / "page.html").read_text(encoding="utf-8")
LANDING = (Path(__file__).parent / "landing.html").read_text(encoding="utf-8")
def demo_block(accounts: tuple, password: str) -> str:
"""The "Try it" box of the front page. A copy that runs the public demo lists its three
accounts; any other copy (a fresh remix) points to the example board instead."""
if len(accounts) == 3 and password:
rows = "".join(f"<tr><td>{role}</td><td><code>{html.escape(email)}</code></td></tr>"
for role, email in zip(("Owner", "Member", "Viewer"), accounts))
return (f"<h3>Sign in as anyone on the team</h3><p>Sign in with any of these. The password for all three is "
f"<code>{html.escape(password)}</code>.</p><table class=\"logins\">{rows}</table>"
"<ol class=\"checks\"><li>As the <b>viewer</b>: you see Launch but not Ops, "
"and no card moves.</li><li>As the <b>member</b>: drag a card to Done, then "
"open Activity.</li><li>As the <b>owner</b>: open Members and change a role.</li></ol>"
"<p class=\"muted\">Someone made a mess? \"Reset the demo\" at the top of the "
"board puts it back.</p>")
return ("<h3>Start with the example board</h3><p>Sign in, then press <b>Open the example board</b>: a website launch with "
"nine cards, two of them late, and a chain of blockers.</p><ol class=\"checks\">"
"<li>Invite a second account as a viewer and see that it can't move a card.</li>"
"<li>Draft a board from one sentence with Dodil Models.</li>"
"<li>Connect Claude and ask what is blocking the launch.</li></ol>")
def build_router(boards: Boards) -> APIRouter:
r = APIRouter()
landing = LANDING.replace("<!--DEMO-->",
demo_block(boards.demo, os.environ.get("DEMO_PASSWORD", "")))
def me(request: Request) -> Person:
p = person_from(request.headers)
if p is None:
raise HTTPException(401, "sign in first: /.dodil/auth/login")
return p
def run(fn, request: Request, *args, **kwargs):
person = me(request)
try:
return fn(person, *args, **kwargs)
except NotFound as e:
raise HTTPException(404, str(e))
except Forbidden as e:
raise HTTPException(403, str(e))
except Invalid as e:
raise HTTPException(422, str(e))
class Title(BaseModel):
title: str
class ListIn(BaseModel):
name: str
is_done: bool = False
class CardIn(BaseModel):
title: str
list_name: str = ""
description: str = ""
assignee: Optional[str] = None
due_date: Optional[str] = None
labels: list[str] = []
class CardEdit(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
due_date: Optional[str] = None
labels: Optional[list[str]] = None
class Move(BaseModel):
list_name: str
position: Optional[int] = None
class Assign(BaseModel):
assignee: Optional[str] = None
class Member(BaseModel):
email: str
role: str = "member"
class Blocker(BaseModel):
blocked_by: str
class Items(BaseModel):
items: list[str]
class Check(BaseModel):
done: bool = True
class Plan(BaseModel):
title: str
goal: str = ""
deadline: Optional[str] = None
people: list[str] = []
steps: Optional[list[dict]] = None
@r.get("/api/me")
def whoami(request: Request):
email = me(request).email
return {"email": email, "demo": email in boards.demo}
@r.post("/api/boards/plan", status_code=201)
def set_up_board(body: Plan, request: Request):
return run(boards.set_up_board, request, body.title, body.goal, body.deadline,
body.people, body.steps)
@r.post("/api/example", status_code=201)
def start_example(request: Request):
return run(boards.start_example, request)
@r.post("/api/demo/reset")
def reset_demo(request: Request):
return run(boards.reset_demo, request)
@r.get("/api/boards")
def my_boards(request: Request):
return run(boards.my_boards, request)
@r.post("/api/boards", status_code=201)
def create_board(body: Title, request: Request):
return run(boards.create_board, request, body.title)
@r.get("/api/boards/{board}")
def show_board(board: str, request: Request):
return run(boards.show_board, request, board)
@r.get("/api/boards/{board}/activity")
def activity(board: str, request: Request, limit: int = 50):
return run(boards.board_activity, request, board, limit)
@r.get("/api/boards/{board}/summary")
def summary(board: str, request: Request):
return run(boards.board_summary, request, board)
@r.post("/api/boards/{board}/members", status_code=201)
def invite(board: str, body: Member, request: Request):
return run(boards.invite_member, request, board, body.email, body.role)
@r.delete("/api/boards/{board}/members/{email}")
def remove(board: str, email: str, request: Request):
return run(boards.remove_member, request, board, email)
@r.post("/api/boards/{board}/lists", status_code=201)
def add_list(board: str, body: ListIn, request: Request):
return run(boards.add_list, request, board, body.name, body.is_done)
@r.patch("/api/boards/{board}/lists/{list_id}")
def rename_list(board: str, list_id: str, body: ListIn, request: Request):
return run(boards.rename_list, request, board, list_id, body.name)
@r.post("/api/boards/{board}/cards", status_code=201)
def add_card(board: str, body: CardIn, request: Request):
return run(boards.add_card, request, board, body.title, body.list_name, body.description,
body.assignee, body.due_date, body.labels)
@r.get("/api/cards/{card}")
def card_details(card: str, request: Request):
return run(boards.card_details, request, card)
@r.patch("/api/cards/{card}")
def update_card(card: str, body: CardEdit, request: Request):
return run(boards.update_card, request, card, **body.model_dump(exclude_unset=True))
@r.delete("/api/cards/{card}")
def delete_card(card: str, request: Request):
return run(boards.delete_card, request, card)
@r.post("/api/cards/{card}/move")
def move_card(card: str, body: Move, request: Request):
return run(boards.move_card, request, card, body.list_name, body.position)
@r.post("/api/cards/{card}/assign")
def assign_card(card: str, body: Assign, request: Request):
return run(boards.assign_card, request, card, body.assignee)
@r.post("/api/cards/{card}/blockers")
def block(card: str, body: Blocker, request: Request):
return run(boards.block_card, request, card, body.blocked_by)
@r.delete("/api/cards/{card}/blockers/{blocked_by}")
def unblock(card: str, blocked_by: str, request: Request):
return run(boards.unblock_card, request, card, blocked_by)
@r.get("/api/cards/{card}/blocking")
def blocking(card: str, request: Request):
return run(boards.what_is_blocking, request, card)
@r.post("/api/cards/{card}/checklist/suggest")
def suggest_checklist(card: str, request: Request):
return run(boards.suggest_checklist, request, card)
@r.post("/api/cards/{card}/checklist")
def add_checklist(card: str, body: Items, request: Request):
return run(boards.add_checklist, request, card, body.items)
@r.post("/api/cards/{card}/checklist/{index}")
def check_item(card: str, index: int, body: Check, request: Request):
return run(boards.check_item, request, card, index, body.done)
@r.get("/api/cards/{card}/similar")
def similar(card: str, request: Request):
return run(boards.similar_cards, request, card)
@r.get("/api/me/plate")
def plate(request: Request, days: int = 7):
return run(boards.my_plate, request, days)
@r.get("/api/overdue")
def overdue(request: Request, board: Optional[str] = None):
return run(boards.overdue, request, board)
@r.get("/api/search")
def search(q: str, request: Request, board: Optional[str] = None):
return run(boards.search_cards, request, q, board)
@r.get("/", response_class=HTMLResponse)
def page(request: Request):
"""Signed in: the board. Not signed in: the front page, which says what this is and
links to the pool's sign-in. It shows no data, so it needs no identity."""
return PAGE if person_from(request.headers) else landing
return r