stubborn-otter/otter-boards

Otter Boards, a team task board you can run from the web or from Claude and ChatGPT. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 1 remixes · Remix stubborn-otter/otter-boards

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.
"""
import re
from dataclasses import dataclass, field
from datetime import date, datetime
from enum import Enum
from typing import Callable, Iterable, Optional, TypeVar

# (name, cards here count as finished)
DEFAULT_LISTS = (("To do", False), ("Doing", False), ("Done", True))

MAX_TITLE, MAX_NAME, MAX_DESCRIPTION = 200, 60, 5000
MAX_LABELS, MAX_LABEL, MAX_CHECKLIST, MAX_ITEM = 10, 30, 30, 200

T = TypeVar("T")


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 clean_text(value: Optional[str], what: str, limit: int, required: bool = True) -> str:
    text = (value or "").strip()
    if required and not text:
        raise Invalid(f"a {what} can't be empty")
    if len(text) > limit:
        raise Invalid(f"a {what} can be at most {limit} characters")
    return text


def clean_email(value: Optional[str]) -> str:
    email = (value or "").strip().lower()
    if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email):
        raise Invalid(f"{value!r} is not an email address")
    return email


def clean_due_date(value) -> Optional[date]:
    """A date, an ISO 'YYYY-MM-DD' string, or nothing (which clears it)."""
    if value in (None, ""):
        return None
    if isinstance(value, date):
        return value
    try:
        return date.fromisoformat(str(value).strip())
    except ValueError:
        raise Invalid(f"{value!r} is not a date; use YYYY-MM-DD")


def clean_labels(value: Optional[Iterable[str]]) -> list:
    if isinstance(value, str):
        value = value.split(",")
    labels: list = []
    for raw in value or []:
        label = clean_text(raw, "label", MAX_LABEL, required=False)
        if label and label.casefold() not in (x.casefold() for x in labels):
            labels.append(label)
    if len(labels) > MAX_LABELS:
        raise Invalid(f"a card can have at most {MAX_LABELS} labels")
    return labels


def key(text: str) -> str:
    """How people match names: 'to-do', 'To do' and 'TODO' are the same list."""
    return "".join(ch for ch in text.casefold() if ch.isalnum())


def match_one(items: Iterable[T], wanted: str, name_of: Callable[[T], str],
              what: str) -> Optional[T]:
    """Find what a person meant by a name: an exact match, else one partial match.
    Two partial matches is a question for the person, never a guess."""
    wanted_key = key(wanted)
    if not wanted_key:
        return None
    items = list(items)
    exact = [i for i in items if key(name_of(i)) == wanted_key]
    found = exact or [i for i in items if wanted_key in key(name_of(i))]
    if len(found) > 1:
        names = ", ".join(sorted(repr(name_of(i)) for i in found))
        raise Invalid(f"{wanted!r} could be more than one {what}: {names}. Which one?")
    return found[0] if found else None


_SENTENCES = {
    "archived": "archived the card",
    "list": "moved from {old} to {new}",
    "order": "reordered within {new}",
    "description": "changed the description",
    "checklist": "changed the checklist: {new}",
}


@dataclass(frozen=True)
class Change:
    """One thing that changed on a card, in the terms history shows."""
    field: str
    old: str
    new: str

    def sentence(self) -> str:
        fixed = _SENTENCES.get(self.field)
        if fixed:
            return fixed.format(old=self.old, new=self.new)
        name = self.field.replace("_", " ")
        if not self.old:
            return f"set {name} to {self.new}"
        if not self.new:
            return f"cleared {name} (was {self.old})"
        return f"changed {name} from {self.old} to {self.new}"


@dataclass
class Event:
    """One line of history: who did what, and when. Never edited, never removed."""
    id: str
    board_id: str
    card_id: Optional[str]
    actor: str
    action: str
    at: datetime
    field: Optional[str] = None
    old: Optional[str] = None
    new: Optional[str] = None


@dataclass
class ChecklistItem:
    text: str
    done: bool = False


@dataclass
class BoardList:
    id: str
    board_id: str
    name: str
    position: float
    is_done: bool = False     # cards here are finished: not overdue, not blocking, off your plate
    archived: bool = False


def _shown(value) -> str:
    if isinstance(value, (list, tuple)):
        return ", ".join(str(v) for v in value)
    return "" if value is None else str(value)


@dataclass
class Card:
    id: str
    board_id: str
    number: int               # the short number people say: "card 12". Unique per board.
    title: str
    list_id: str
    position: float = 0.0
    description: str = ""
    assignee: Optional[str] = None
    due_date: Optional[date] = None
    labels: list = field(default_factory=list)
    checklist: list = field(default_factory=list)      # ChecklistItem
    blocked_by: list = field(default_factory=list)     # numbers of cards on the same board
    archived: bool = False

    def edit(self, fields: dict) -> list:
        """Apply edits, returning only what really changed, so history stays honest."""
        cleaners = {
            "title": lambda v: clean_text(v, "card title", MAX_TITLE),
            "description": lambda v: clean_text(v, "description", MAX_DESCRIPTION, required=False),
            "due_date": clean_due_date,
            "labels": clean_labels,
            "assignee": lambda v: (v or "").strip().lower() or None,
        }
        changes = []
        for name, value in fields.items():
            if name not in cleaners:
                raise Invalid(f"a card has no field {name!r}")
            new, old = cleaners[name](value), getattr(self, name)
            if new != old:
                setattr(self, name, new)
                changes.append(Change(name, _shown(old), _shown(new)))
        return changes

    def move_to(self, target: BoardList, position: float, from_name: str) -> list:
        if target.board_id != self.board_id:
            raise Invalid("a card can only move to a list on its own board")
        moved = target.id != self.list_id
        self.list_id, self.position = target.id, position
        return [Change("list", from_name, target.name) if moved
                else Change("order", target.name, target.name)]

    def set_checklist(self, items: Iterable) -> list:
        fresh = []
        for item in items:
            text, done = (item.text, item.done) if isinstance(item, ChecklistItem) else (
                (item.get("text"), bool(item.get("done"))) if isinstance(item, dict)
                else (item, False))
            fresh.append(ChecklistItem(clean_text(text, "checklist item", MAX_ITEM), done))
        if len(fresh) > MAX_CHECKLIST:
            raise Invalid(f"a checklist can have at most {MAX_CHECKLIST} items")
        if fresh == self.checklist:
            return []
        old, self.checklist = self.checklist, fresh
        return [Change("checklist", _checklist_counts(old), _checklist_counts(fresh))]

    def tick(self, item: str, done: bool) -> list:
        """Tick an item by its place on the list (1 is the first) or by its words."""
        wanted = str(item).strip()
        if wanted.isdigit() and 1 <= int(wanted) <= len(self.checklist):
            found = self.checklist[int(wanted) - 1]
        else:
            found = match_one(self.checklist, wanted, lambda i: i.text, "checklist item")
        if found is None:
            raise Invalid(f"no checklist item {item!r} on this card")
        if found.done == done:
            return []
        found.done = done
        return [Change("checklist", "", f"{'ticked' if done else 'unticked'} {found.text!r}")]

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

    def archive(self) -> list:
        self.archived = True
        return [Change("archived", "", "archived")]


def _checklist_counts(items: list) -> str:
    return f"{sum(i.done for i in items)} of {len(items)} done"


def would_cycle(card: Card, blocker: Card, by_number: dict) -> bool:
    """Would 'card is blocked by blocker' mean a card ends up waiting on itself?"""
    seen, stack = set(), [blocker.number]
    while stack:
        number = stack.pop()
        if number == card.number:
            return True
        if number not in seen:
            seen.add(number)
            stack.extend(getattr(by_number.get(number), "blocked_by", []))
    return False


def blocker_chain(card: Card, by_number: dict) -> list:
    """Everything this card waits on, nearest first, as (card, steps away)."""
    chain, seen, level, depth = [], {card.number}, list(card.blocked_by), 1
    while level:
        following = []
        for number in level:
            found = by_number.get(number)
            if found is not None and number not in seen:
                seen.add(number)
                chain.append((found, depth))
                following.extend(found.blocked_by)
        level, depth = following, depth + 1
    return chain


def position_between(previous: Optional[float], following: Optional[float]) -> float:
    """A sort position between two neighbours, so a move writes one row and renumbers nothing."""
    if previous is None and following is None:
        return 1.0
    if previous is None:
        return following - 1.0
    if following is None:
        return previous + 1.0
    return (previous + following) / 2


@dataclass
class Board:
    id: str
    title: str
    owner_email: str
    members: dict = field(default_factory=dict)  # email -> Role, not counting the owner

    def role_of(self, person: Person) -> Optional[Role]:
        return self.role_of_email(person.email)

    def role_of_email(self, email: str) -> Optional[Role]:
        if email == self.owner_email:
            return Role.OWNER
        return self.members.get(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 and the board itself")

    def add_member(self, by: Person, email: str, role: Role) -> str:
        """Invite someone, or change the role of someone already here."""
        self.require_owner(by)
        if role is Role.OWNER:
            raise Invalid("a board has one owner")
        email = clean_email(email)
        if email == self.owner_email:
            raise Invalid("the owner can't also be a member")
        self.members[email] = role
        return email

    def remove_member(self, by: Person, email: str) -> str:
        self.require_owner(by)
        email = (email or "").strip().lower()
        if email == self.owner_email:
            raise Invalid("the owner can't leave their own board")
        if email not in self.members:
            raise Invalid(f"{email} is not on this board")
        del self.members[email]
        return email

    def people(self) -> list:
        return [(self.owner_email, Role.OWNER)] + sorted(self.members.items())

    def find_assignee(self, wanted: Optional[str], me: Person) -> Optional[str]:
        """Who a card can go to. 'me', an email, or the start of one ('ana'). Viewers can't
        act on a card, so a card can't be theirs."""
        text = (wanted or "").strip()
        if not text:
            return None
        if text.casefold() == "me":
            text = me.email
        doers = [e for e, role in self.people() if role is not Role.VIEWER]
        found = match_one(doers, text, lambda e: e, "person")
        if found is None:
            raise Invalid(f"{wanted!r} can't take cards on this board: only the owner and "
                          "members can")
        return found