stubborn-otter/otter-boards

Otter Boards, a team task board you can run from the web or from Claude and ChatGPT. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 1 remixes · Remix stubborn-otter/otter-boards

app/boards/doors/web.py

"""The web door: JSON routes plus the page. Every route calls a use case and nothing else."""
from pathlib import Path
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from pydantic import BaseModel

from boards.application.ports import AssistantUnavailable
from boards.application.use_cases import Boards, NotFound
from boards.doors.identity import person_from
from boards.domain.model import Forbidden, Invalid, Person

STATIC = Path(__file__).parent / "static"
ASSETS = {"app.js": "text/javascript; charset=utf-8", "app.css": "text/css; charset=utf-8"}
PAGE_HEADERS = {"Content-Security-Policy": "default-src 'self'; img-src 'self' data:; "
                                           "frame-ancestors 'none'; base-uri 'none'",
                "X-Content-Type-Options": "nosniff", "Referrer-Policy": "same-origin"}


def same_site_only(request: Request) -> None:
    """The gateway's sign-in travels with the browser, so another site must not be able to make a
    signed-in person's browser change a board. Non-browser callers don't send this header."""
    if request.method not in ("GET", "HEAD") and request.headers.get("sec-fetch-site") not in (
            None, "same-origin", "none"):
        raise HTTPException(403, "cross-site request refused")


class TitleIn(BaseModel):
    title: str


class CardIn(BaseModel):
    title: str
    list_name: Optional[str] = None
    description: str = ""
    assignee: Optional[str] = None
    due_date: Optional[str] = None
    labels: Optional[list[str]] = None


class CardEdit(BaseModel):
    """Only the fields sent are changed. null clears the assignee or the due date."""
    title: Optional[str] = None
    description: Optional[str] = None
    assignee: Optional[str] = None
    due_date: Optional[str] = None
    labels: Optional[list[str]] = None


class Move(BaseModel):
    list_name: str
    before: Optional[str] = None


class Assign(BaseModel):
    assignee: Optional[str] = None


class Member(BaseModel):
    email: str
    role: str = "member"


class ListIn(BaseModel):
    name: str
    is_done: bool = False


class ListEdit(BaseModel):
    name: Optional[str] = None
    is_done: Optional[bool] = None


class ListMove(BaseModel):
    before: Optional[str] = None


class Blocker(BaseModel):
    blocked_by: str


class Items(BaseModel):
    items: list[dict]


class Tick(BaseModel):
    item: str
    done: bool = True


def build_router(boards: Boards) -> APIRouter:
    r = APIRouter(dependencies=[Depends(same_site_only)])

    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, *args):
        try:
            return fn(*args)
        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))
        except AssistantUnavailable as e:
            raise HTTPException(503, str(e))

    @r.get("/api/me")
    def whoami(request: Request):
        return run(boards.whoami, me(request))

    @r.get("/api/me/cards")
    def my_cards(request: Request, days: int = 7):
        return run(boards.my_cards, me(request), days)

    @r.get("/api/overdue")
    def overdue(request: Request, board: Optional[str] = None):
        return run(boards.overdue_cards, me(request), board)

    @r.get("/api/boards")
    def my_boards(request: Request):
        return run(boards.my_boards, me(request))

    @r.post("/api/boards", status_code=201)
    def create_board(body: TitleIn, request: Request):
        return run(boards.create_board, me(request), body.title)

    @r.get("/api/boards/{board_id}")
    def show_board(board_id: str, request: Request):
        return run(boards.show_board, me(request), board_id)

    @r.patch("/api/boards/{board_id}")
    def rename_board(board_id: str, body: TitleIn, request: Request):
        return run(boards.rename_board, me(request), board_id, body.title)

    @r.get("/api/boards/{board_id}/activity")
    def board_activity(board_id: str, request: Request, limit: int = 50):
        return run(boards.board_activity, me(request), board_id, limit)

    @r.get("/api/boards/{board_id}/summary")
    def board_summary(board_id: str, request: Request):
        return run(boards.board_summary, me(request), board_id)

    @r.get("/api/boards/{board_id}/members")
    def list_members(board_id: str, request: Request):
        return run(boards.list_members, me(request), board_id)

    @r.post("/api/boards/{board_id}/members", status_code=201)
    def add_member(board_id: str, body: Member, request: Request):
        return run(boards.add_member, me(request), board_id, body.email, body.role)

    @r.delete("/api/boards/{board_id}/members/{email}")
    def remove_member(board_id: str, email: str, request: Request):
        return run(boards.remove_member, me(request), board_id, email)

    @r.post("/api/boards/{board_id}/lists", status_code=201)
    def add_list(board_id: str, body: ListIn, request: Request):
        return run(boards.add_list, me(request), board_id, body.name, body.is_done)

    @r.patch("/api/boards/{board_id}/lists/{list_id}")
    def update_list(board_id: str, list_id: str, body: ListEdit, request: Request):
        return run(boards.update_list, me(request), board_id, list_id, body.name, body.is_done)

    @r.post("/api/boards/{board_id}/lists/{list_id}/move")
    def move_list(board_id: str, list_id: str, body: ListMove, request: Request):
        return run(boards.move_list, me(request), board_id, list_id, body.before)

    @r.post("/api/boards/{board_id}/lists/{list_id}/archive")
    def archive_list(board_id: str, list_id: str, request: Request):
        return run(boards.archive_list, me(request), board_id, list_id)

    @r.post("/api/boards/{board_id}/cards", status_code=201)
    def add_card(board_id: str, body: CardIn, request: Request):
        return run(boards.add_card, me(request), board_id, body.title, body.list_name,
                   body.description, body.assignee, body.due_date, body.labels)

    @r.get("/api/cards/{card_id}")
    def show_card(card_id: str, request: Request):
        return run(boards.show_card, me(request), card_id)

    @r.get("/api/cards/{card_id}/history")
    def card_history(card_id: str, request: Request):
        return run(boards.card_history, me(request), card_id)

    @r.get("/api/cards/{card_id}/blockers")
    def what_is_blocking(card_id: str, request: Request):
        return run(boards.what_is_blocking, me(request), card_id)

    @r.get("/api/cards/{card_id}/similar")
    def find_similar(card_id: str, request: Request):
        return run(boards.find_similar, me(request), card_id)

    @r.patch("/api/cards/{card_id}")
    def update_card(card_id: str, body: CardEdit, request: Request):
        return run(boards.update_card, me(request), card_id, body.model_dump(exclude_unset=True))

    @r.post("/api/cards/{card_id}/assign")
    def assign_card(card_id: str, body: Assign, request: Request):
        return run(boards.assign_card, me(request), card_id, body.assignee)

    @r.post("/api/cards/{card_id}/move")
    def move_card(card_id: str, body: Move, request: Request):
        return run(boards.move_card, me(request), card_id, body.list_name, None, body.before)

    @r.post("/api/cards/{card_id}/archive")
    def archive_card(card_id: str, request: Request):
        return run(boards.archive_card, me(request), card_id)

    @r.post("/api/cards/{card_id}/blockers")
    def add_blocker(card_id: str, body: Blocker, request: Request):
        return run(boards.add_blocker, me(request), card_id, body.blocked_by)

    @r.delete("/api/cards/{card_id}/blockers/{blocked_by}")
    def remove_blocker(card_id: str, blocked_by: str, request: Request):
        return run(boards.remove_blocker, me(request), card_id, blocked_by)

    @r.post("/api/cards/{card_id}/checklist/suggest")
    def suggest_checklist(card_id: str, request: Request):
        return run(boards.suggest_checklist, me(request), card_id)

    @r.put("/api/cards/{card_id}/checklist")
    def save_checklist(card_id: str, body: Items, request: Request):
        return run(boards.save_checklist, me(request), card_id, body.items)

    @r.post("/api/cards/{card_id}/checklist/tick")
    def tick_checklist_item(card_id: str, body: Tick, request: Request):
        return run(boards.tick_checklist_item, me(request), card_id, body.item, body.done)

    @r.get("/", response_class=HTMLResponse)
    def page(request: Request):
        if person_from(request.headers) is None:
            return RedirectResponse("/.dodil/auth/login?return_to=/", status_code=303)
        return HTMLResponse((STATIC / "index.html").read_text(), headers=PAGE_HEADERS)

    @r.get("/static/{name}", include_in_schema=False)
    def asset(name: str, request: Request):
        me(request)
        if name not in ASSETS:
            raise HTTPException(404, "not found")
        return Response((STATIC / name).read_bytes(), media_type=ASSETS[name],
                        headers={"Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff"})

    return r