sirenamc/stackline

Stackline, a team task board you run from the web or from Claude/ChatGPT. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 0 remixes · Remix sirenamc/stackline

app/boards/domain/model.py

"""The board's language and rules. No database, no HTTP: plain Python you can test in a second.

Extend HERE first: a rule written in the domain holds for the web page and for Claude alike.
"""
from dataclasses import dataclass, field, replace
from datetime import date
from enum import Enum
from typing import Optional

DEFAULT_LISTS = ("To do", "Doing", "Done")
MAX_LABELS = 8


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


@dataclass
class BoardList:
    id: str
    name: str
    position: int
    is_done: bool = False  # cards here count as finished: never overdue, never on your plate


@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

    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 is_on_board(self, email: str) -> bool:
        return email == self.owner_email or email in self.members

    def set_member(self, by: Person, email: str, role: Role) -> None:
        self.require_owner(by)
        email = normalize_email(email)
        if role is Role.OWNER or email == self.owner_email:
            raise Invalid("a board has one owner")
        self.members[email] = role

    def remove_member(self, by: Person, email: str) -> None:
        self.require_owner(by)
        email = normalize_email(email)
        if email == self.owner_email:
            raise Invalid("the owner can't be removed")
        if email not in self.members:
            raise Invalid(f"{email} is not on this board")
        del self.members[email]

    def find_list(self, name_or_id: str) -> BoardList:
        """Lists are found by id or by name, case-insensitively, so Claude can say 'doing'."""
        key = (name_or_id or "").strip().lower()
        for lst in self.lists:
            if lst.id == name_or_id or lst.name.lower() == key:
                return lst
        names = ", ".join(lst.name for lst in self.lists)
        raise Invalid(f"no list {name_or_id!r} on this board; lists are {names}")

    def first_list(self) -> BoardList:
        return self.lists[0]

    def add_list(self, list_id: str, name: str) -> BoardList:
        name = clean_text(name, "a list needs a name", 60)
        if any(lst.name.lower() == name.lower() for lst in self.lists):
            raise Invalid(f"there is already a list called {name!r}")
        lst = BoardList(id=list_id, name=name, position=len(self.lists))
        self.lists.append(lst)
        return lst

    def rename_list(self, name_or_id: str, new_name: str) -> BoardList:
        lst = self.find_list(name_or_id)
        new_name = clean_text(new_name, "a list needs a name", 60)
        if any(o.name.lower() == new_name.lower() and o.id != lst.id for o in self.lists):
            raise Invalid(f"there is already a list called {new_name!r}")
        lst.name = new_name
        return lst


@dataclass
class Card:
    """A card has a short number (#12) people and Claude use to talk about it."""
    number: int
    board_id: str
    list_id: str
    title: str
    description: str = ""
    assignee: Optional[str] = None
    due_date: Optional[date] = None
    labels: tuple = ()
    blocked_by: tuple = ()     # numbers of cards that must finish first
    checklist: tuple = ()      # ((text, done), ...)
    position: float = 0.0

    @property
    def id(self) -> str:
        return str(self.number)

    def is_overdue(self, today: date, done: bool) -> bool:
        return bool(self.due_date and self.due_date < today and not done)


# Card fields a person may change, and how each is checked. Moving and assigning have their own
# use cases because they carry extra rules (the list must exist, the assignee must be on the board).
EDITABLE = ("title", "description", "due_date", "labels")


def edit_card(card: Card, changes: dict) -> tuple:
    """Apply changes to a card. Returns (new card, [(field, before, after), ...]) with only the
    fields that really changed, which is exactly what the activity log records."""
    clean = {}
    for name, value in changes.items():
        if name not in EDITABLE:
            raise Invalid(f"{name!r} can't be edited here")
        if name == "title":
            value = clean_text(value, "a card needs a title", 200)
        elif name == "description":
            value = (value or "").strip()[:4000]
        elif name == "labels":
            value = clean_labels(value)
        clean[name] = value
    diff = [(k, getattr(card, k), v) for k, v in clean.items() if getattr(card, k) != v]
    return replace(card, **clean), diff


def clean_text(value: str, error: str, limit: int) -> str:
    value = (value or "").strip()
    if not value:
        raise Invalid(error)
    if len(value) > limit:
        raise Invalid(f"keep it under {limit} characters")
    return value


def clean_labels(labels) -> tuple:
    if isinstance(labels, str):
        labels = labels.split(",")
    out = []
    for label in labels or ():
        label = str(label).strip().lower()[:24]
        if label and label not in out:
            out.append(label)
    if len(out) > MAX_LABELS:
        raise Invalid(f"at most {MAX_LABELS} labels on a card")
    return tuple(out)


def normalize_email(email: str) -> str:
    email = (email or "").strip().lower()
    if "@" not in email or " " in email:
        raise Invalid(f"{email!r} doesn't look like an email address")
    return email


def check_blocker(card: Card, blocker: Card, blockers_of) -> None:
    """A card can't wait on itself, on a card from another board, or on a chain that leads back
    to it. blockers_of(number) returns the numbers a card is blocked by."""
    if blocker.number == card.number:
        raise Invalid("a card can't block itself")
    if blocker.board_id != card.board_id:
        raise Invalid("blocking links stay inside one board")
    seen, todo = set(), [blocker.number]
    while todo:
        n = todo.pop()
        if n == card.number:
            raise Invalid(f"#{blocker.number} already waits on #{card.number}; that would be a loop")
        if n not in seen:
            seen.add(n)
            todo.extend(blockers_of(n))