sawera/sawera-boards
Sawera Boards - a team task board for the Dodil Boards Challenge. Boards, lists, cards, roles, activity log, MCP actions and AI extras. FastAPI + DataK3 + appid + Models + MCP.
A remix template · 0 remixes · Remix sawera/sawera-boards
app/boards/domain/model.py
"""The board's language and rules. No database, no HTTP: plain Python you can test in a second.
Every rule lives here or in the use cases, so the web page and Claude/ChatGPT (MCP) can never
disagree: a viewer can't edit through either door, and only the owner manages members.
Design choices worth knowing:
- Lists are real rows, not fixed names: a board starts with To do / Doing / Done, and the team
can add, rename and remove lists. A list is "done-like" when its name reads as finished
(done / complete / completed / shipped); cards there are never overdue and never block.
- Cards carry a fractional `position` inside their list, so drag-and-drop ordering is one
UPDATE, never a renumber of the whole list.
- `blocked_by` holds card ids on the same board. Cycles are refused in the domain, so
"what is blocking this?" always terminates.
"""
from dataclasses import dataclass, field
from datetime import date
from enum import Enum
from typing import Optional
DEFAULT_LISTS = ("To do", "Doing", "Done")
DONE_WORDS = {"done", "complete", "completed", "shipped", "finished", "closed"}
MAX_LABELS = 6
MAX_LABEL_LEN = 24
class Role(str, Enum):
OWNER = "owner"
MEMBER = "member"
VIEWER = "viewer"
class Forbidden(Exception):
"""The person may not do this. The doors turn it into 403 (web) or a tool error (MCP)."""
class Invalid(Exception):
"""The request breaks a rule of the board itself. 422 / tool error."""
@dataclass(frozen=True)
class Person:
"""Who is acting: taken from the gateway's sign-in, never from the request body."""
email: str
def __post_init__(self):
object.__setattr__(self, "email", self.email.strip().lower())
def clean_labels(labels) -> tuple:
"""Labels are short, lowercase, unique; order is the order first given."""
out = []
for raw in labels or ():
lab = " ".join(str(raw).strip().lower().split())
if lab and lab not in out:
out.append(lab[:MAX_LABEL_LEN])
if len(out) > MAX_LABELS:
raise Invalid(f"a card can have at most {MAX_LABELS} labels")
return tuple(out)
@dataclass
class BoardList:
id: str
board_id: str
name: str
position: float = 0.0
@property
def is_done(self) -> bool:
return self.name.strip().lower() in DONE_WORDS
@dataclass
class Card:
id: str
board_id: str
list_id: str
title: str
description: str = ""
assignee: Optional[str] = None
due_date: Optional[date] = None
labels: tuple = ()
position: float = 0.0
blocked_by: tuple = () # card ids on the same board
def is_overdue(self, today: date, in_done_list: bool) -> bool:
return bool(self.due_date) and self.due_date < today and not in_done_list
@dataclass
class Comment:
"""A note a person leaves on a card. Discussion lives on the card, so the
conversation travels with the work (and is readable through both doors)."""
id: str
card_id: str
author: str
body: str
at: Optional[str] = None # ISO timestamp, set by the store
@dataclass
class Board:
id: str
title: str
owner_email: str
members: dict = field(default_factory=dict) # email -> Role
lists: list = field(default_factory=list) # [BoardList], ordered by position
# --- people -------------------------------------------------------------
def role_of(self, person: Person) -> Optional[Role]:
if person.email == self.owner_email:
return Role.OWNER
return self.members.get(person.email)
def require_view(self, person: Person) -> None:
if self.role_of(person) is None:
raise Forbidden("you are not on this board")
def require_edit(self, person: Person) -> None:
role = self.role_of(person)
if role is None:
raise Forbidden("you are not on this board")
if role is Role.VIEWER:
raise Forbidden("viewers can't change the board")
def require_owner(self, person: Person) -> None:
if self.role_of(person) is not Role.OWNER:
raise Forbidden("only the owner manages members")
def check_deletable(self, by: Person) -> None:
"""Deleting a board is the owner's call alone: it takes every list,
card, comment and the whole activity log with it."""
if self.role_of(by) is not Role.OWNER:
raise Forbidden("only the owner can delete the board")
def add_member(self, by: Person, email: str, role: Role) -> None:
self.require_owner(by)
if role is Role.OWNER:
raise Invalid("a board has one owner")
email = email.strip().lower()
if email == self.owner_email:
raise Invalid("the owner is already on the board")
if not email or "@" not in email:
raise Invalid("an email address is needed")
self.members[email] = role
def remove_member(self, by: Person, email: str) -> None:
self.require_owner(by)
email = email.strip().lower()
if email == self.owner_email:
raise Invalid("the owner can't be removed")
if email not in self.members:
raise Invalid("that person is not a member of this board")
del self.members[email]
# --- lists ---------------------------------------------------------------
def list_by_id(self, list_id: str) -> BoardList:
for lst in self.lists:
if lst.id == list_id:
return lst
raise Invalid("no such list on this board")
def list_by_name(self, name: str) -> Optional[BoardList]:
for lst in self.lists:
if lst.name.strip().lower() == (name or "").strip().lower():
return lst
return None
def add_list(self, by: Person, lst: BoardList) -> BoardList:
self.require_edit(by)
lst.name = lst.name.strip()
if not lst.name:
raise Invalid("a list needs a name")
if self.list_by_name(lst.name):
raise Invalid(f"there is already a list called {lst.name!r}")
self.lists.append(lst)
return lst
def rename_list(self, by: Person, list_id: str, name: str) -> BoardList:
self.require_edit(by)
lst = self.list_by_id(list_id)
name = name.strip()
if not name:
raise Invalid("a list needs a name")
clash = self.list_by_name(name)
if clash and clash.id != list_id:
raise Invalid(f"there is already a list called {name!r}")
lst.name = name
return lst
def remove_list(self, by: Person, list_id: str, cards_in_it: int) -> None:
self.require_edit(by)
self.list_by_id(list_id)
if len(self.lists) <= 1:
raise Invalid("a board keeps at least one list")
if cards_in_it:
raise Invalid("move or delete the cards in this list first")
self.lists = [l for l in self.lists if l.id != list_id]
# --- cards ---------------------------------------------------------------
def check_new_card(self, by: Person, card: Card) -> None:
self.require_edit(by)
self.list_by_id(card.list_id)
if not card.title.strip():
raise Invalid("a card needs a title")
def check_block_links(self, card: Card, proposed_ids, all_cards: dict) -> None:
"""Refuses self-links, cross-board links and cycles — checked against the
PROPOSED links, before anything is stored. all_cards: {id: Card} for the
whole board, so the cycle walk can follow links beyond the direct blockers."""
for bid in proposed_ids:
if bid == card.id:
raise Invalid("a card can't block itself")
b = all_cards.get(bid)
if b is None or b.board_id != self.id:
raise Invalid("a card can only be blocked by cards on the same board")
seen, stack = set(), [all_cards[b] for b in proposed_ids]
while stack:
cur = stack.pop()
if cur.id == card.id:
raise Invalid("that link would make a circle of blocked cards")
if cur.id in seen:
continue
seen.add(cur.id)
stack.extend(all_cards[nxt] for nxt in cur.blocked_by if nxt in all_cards)