north-scape/relayboard
Relayboard: accessible team boards with audited actions, dependency maps, and shared web/MCP use cases.
A remix template · 0 remixes · Remix north-scape/relayboard
app/boards/doors/web.py
"""Authenticated JSON routes + a zero-build, accessible web client."""
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel, ConfigDict, Field, StrictInt
from boards.application.use_cases import NotFound
from boards.doors.identity import person_from
from boards.domain.model import Conflict, Forbidden, Invalid
STATIC=Path(__file__).parent/'static'
class Input(BaseModel):
model_config=ConfigDict(extra='forbid')
class Title(Input): title: str
class CardIn(Input):
title: str
list_name: str='To do'
description: str=''
assignee: str | None=None
due_date: str | None=None
labels: list[str]=Field(default_factory=list)
class Patch(Input):
title: str | None=None
description: str | None=None
assignee: str | None=None
due_date: str | None=None
labels: list[str] | None=None
checklist: list[dict] | None=None
expected_revision: StrictInt
class Move(Input):
list_name: str
expected_revision: StrictInt
class Member(Input):
email: str
role: str='member'
class RemoveMember(Input):
email: str
class Blockers(Input):
blocked_by: list[str]
expected_revision: StrictInt
def build_router(boards):
r=APIRouter()
def me(request):
p=person_from(request.headers)
if p is None: raise HTTPException(401,'sign in first: /.dodil/auth/login')
return p
def run(fn,*args,**kwargs):
try: return fn(*args,**kwargs)
except NotFound as e: raise HTTPException(404,str(e)) from None
except Forbidden as e: raise HTTPException(403,str(e)) from None
except Invalid as e: raise HTTPException(422,str(e)) from None
except Conflict as e: raise HTTPException(409,str(e)) from None
@r.get('/api/me')
def identity(request: Request): return {'email':me(request).email}
@r.get('/api/boards')
def my_boards(request: Request): return run(boards.my_boards,me(request))
@r.get('/api/resolve/boards')
def resolve_board(reference: str,request: Request): return run(boards.resolve_board,me(request),reference)
@r.get('/api/resolve/cards')
def resolve_card(reference: str,request: Request,board_id: str | None=None):
return run(boards.resolve_card,me(request),reference,board_id)
@r.post('/api/boards',status_code=201)
def create_board(body: Title,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.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')
def remove_member(board_id: str,body: RemoveMember,request: Request): return run(boards.remove_member,me(request),board_id,body.email)
@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.model_dump())
@r.patch('/api/cards/{card_id}')
def update_card(card_id: str,body: Patch,request: Request):
changes=body.model_dump(exclude_unset=True,exclude={'expected_revision'})
return run(boards.update_card,me(request),card_id,changes,body.expected_revision)
@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,body.expected_revision)
@r.get('/api/boards/{board_id}/activity')
def activity(board_id: str,request: Request): return run(boards.activity,me(request),board_id)
@r.get('/api/my-cards')
def mine(request: Request,days: int=7): return run(boards.my_cards,me(request),days)
@r.get('/api/overdue')
def overdue(request: Request): return run(boards.overdue,me(request))
@r.post('/api/cards/{card_id}/checklist')
def checklist(card_id: str,request: Request): return run(boards.suggest_checklist,me(request),card_id)
@r.post('/api/cards/{card_id}/blockers')
def set_blockers(card_id: str,body: Blockers,request: Request): return run(boards.set_blockers,me(request),card_id,body.blocked_by,body.expected_revision)
@r.get('/api/cards/{card_id}/blockers')
def blockers(card_id: str,request: Request): return run(boards.blockers,me(request),card_id)
@r.post('/api/boards/{board_id}/summary')
def summary(board_id: str,request: Request): return run(boards.board_summary,me(request),board_id)
@r.post('/api/cards/{card_id}/similar')
def similar(card_id: str,request: Request): return run(boards.similar_cards,me(request),card_id)
@r.get('/')
def page(request: Request):
if person_from(request.headers) is None: return RedirectResponse('/.dodil/auth/login?return_to=/',303)
return FileResponse(STATIC/'index.html')
@r.get('/static/{filename}')
def asset(filename: str,request: Request):
me(request)
if filename not in ('app.js','style.css'): raise HTTPException(404)
return FileResponse(STATIC/filename)
return r