ghpsolutions/quadro

Quadro: team boards with roles, history and an MCP endpoint for Claude and ChatGPT

A remix template · 0 remixes · Remix ghpsolutions/quadro

app/boards/doors/web.py

"""The web door: a JSON API under /api and the board page. Same use cases as the MCP door."""
from __future__ import annotations

from pathlib import Path
from typing import Optional
from urllib.parse import quote

from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles

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

STATIC = Path(__file__).with_name("static")
STATUS = {NotFound: 404, Forbidden: 403, Invalid: 400, Conflict: 409}


def install_errors(app) -> None:
    for kind, code in STATUS.items():
        def handler(_request, exc, code=code):
            return JSONResponse({"detail": str(exc)}, status_code=code)
        app.add_exception_handler(kind, handler)


def static_files() -> StaticFiles:
    return StaticFiles(directory=STATIC)


def build_router(boards: Boards) -> APIRouter:
    router = APIRouter()

    def who(request: Request) -> Person:
        person = person_from(request.headers)
        if person is None:
            raise HTTPException(401, "sign in first")
        if request.method not in ("GET", "HEAD") and \
                "application/json" not in request.headers.get("content-type", ""):
            # A cross-site form cannot send JSON without a preflight, so this blocks CSRF.
            raise HTTPException(415, "send JSON")
        return person

    @router.get("/", include_in_schema=False)
    def page(request: Request):
        if person_from(request.headers) is None:
            return RedirectResponse("/.dodil/auth/login?return_to=" + quote("/"))
        return FileResponse(STATIC / "index.html", headers={"cache-control": "no-cache"})

    # people and boards
    @router.get("/api/me")
    def me(p: Person = Depends(who)):
        return {"email": p.email}

    @router.get("/api/me/plate")
    def plate(days: int = 7, p: Person = Depends(who)):
        return boards.my_plate(p, days)

    @router.get("/api/overdue")
    def overdue(board: Optional[str] = None, p: Person = Depends(who)):
        return boards.overdue(p, board)

    @router.get("/api/search")
    def search(q: str, board: Optional[str] = None, p: Person = Depends(who)):
        return boards.search(p, q, board)

    @router.get("/api/boards")
    def my_boards(p: Person = Depends(who)):
        return boards.my_boards(p)

    @router.post("/api/boards")
    def create_board(title: str = Body(..., embed=True), p: Person = Depends(who)):
        return boards.create_board(p, title)

    @router.get("/api/boards/{board_id}")
    def show_board(board_id: str, p: Person = Depends(who)):
        return boards.show_board(p, board_id)

    @router.get("/api/boards/{board_id}/activity")
    def activity(board_id: str, limit: int = 60, p: Person = Depends(who)):
        return boards.board_activity(p, board_id, limit)

    @router.get("/api/boards/{board_id}/summary")
    def summary(board_id: str, p: Person = Depends(who)):
        return boards.board_summary(p, board_id)

    @router.post("/api/boards/{board_id}/members")
    def invite(board_id: str, email: str = Body(...), role: str = Body("member"),
               p: Person = Depends(who)):
        return boards.invite_member(p, board_id, email, role)

    @router.delete("/api/boards/{board_id}/members/{email}")
    def remove(board_id: str, email: str, p: Person = Depends(who)):
        return boards.remove_member(p, board_id, email)

    # lists
    @router.post("/api/boards/{board_id}/lists")
    def add_list(board_id: str, name: str = Body(..., embed=True), p: Person = Depends(who)):
        return boards.add_list(p, board_id, name)

    @router.patch("/api/boards/{board_id}/lists/{list_id}")
    def update_list(board_id: str, list_id: str, body: dict = Body(...), p: Person = Depends(who)):
        return boards.update_list(p, board_id, list_id, body.get("name"), body.get("done"),
                                  body.get("index"))

    @router.delete("/api/boards/{board_id}/lists/{list_id}")
    def delete_list(board_id: str, list_id: str, p: Person = Depends(who)):
        return boards.delete_list(p, board_id, list_id)

    # cards
    @router.post("/api/boards/{board_id}/cards")
    def add_card(board_id: str, body: dict = Body(...), p: Person = Depends(who)):
        return boards.add_card(p, board_id, body.get("title", ""), body.get("list"),
                               body.get("description", ""), body.get("assignee"),
                               body.get("due_date"), body.get("labels") or ())

    @router.get("/api/cards/{card_id}")
    def card(card_id: str, p: Person = Depends(who)):
        return boards.card_detail(p, card_id)

    @router.patch("/api/cards/{card_id}")
    def update_card(card_id: str, body: dict = Body(...), p: Person = Depends(who)):
        fields = {k: v for k, v in body.items() if k != "version"}
        return boards.update_card(p, card_id, body.get("version"), **fields)

    @router.post("/api/cards/{card_id}/move")
    def move_card(card_id: str, body: dict = Body(...), p: Person = Depends(who)):
        return boards.move_card(p, card_id, body.get("list", ""), body.get("index"),
                                body.get("version"))

    @router.delete("/api/cards/{card_id}")
    def delete_card(card_id: str, p: Person = Depends(who)):
        return boards.delete_card(p, card_id)

    @router.post("/api/cards/{card_id}/comments")
    def comment(card_id: str, body: str = Body(..., embed=True), p: Person = Depends(who)):
        return boards.comment(p, card_id, body)

    @router.post("/api/cards/{card_id}/blockers")
    def block(card_id: str, blocker: str = Body(..., embed=True), p: Person = Depends(who)):
        return boards.block(p, card_id, blocker)

    @router.delete("/api/cards/{card_id}/blockers/{blocker}")
    def unblock(card_id: str, blocker: str, p: Person = Depends(who)):
        return boards.unblock(p, card_id, blocker)

    @router.get("/api/cards/{card_id}/blocking")
    def blocking(card_id: str, p: Person = Depends(who)):
        return boards.what_blocks(p, card_id)

    @router.get("/api/cards/{card_id}/similar")
    def similar(card_id: str, p: Person = Depends(who)):
        return boards.similar_cards(p, card_id)

    @router.post("/api/cards/{card_id}/checklist/suggest")
    def suggest(card_id: str, p: Person = Depends(who)):
        return boards.suggest_checklist(p, card_id)

    @router.post("/api/cards/{card_id}/checklist")
    def apply(card_id: str, steps: list = Body(..., embed=True), p: Person = Depends(who)):
        return boards.apply_checklist(p, card_id, steps)

    return router