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)