flc/crewboard

Crewboard - team task board (boards, lists, cards, roles, history, drag and drop, MCP, AI extras). FastAPI + DataK3 + appid + Models.

A remix template · 1 remixes · Remix flc/crewboard

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, so it holds for the web page and for Claude alike.
"""
import re
from dataclasses import dataclass, field
from datetime import date
from enum import Enum
from typing import Optional

DEFAULT_LISTS = ("To do", "Doing", "Done")
MAX_LISTS = 12
MAX_LABELS = 8
CARD_FIELDS = ("title", "description", "assignee", "due_date", "labels")


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."""


class Conflict(Exception):
    """Someone changed the card since you opened it. 409: reload, nothing was written."""


@dataclass(frozen=True)
class Person:
    """Who is acting: taken from the gateway's sign-in, never from the request body."""
    email: str


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


def board_key(title: str) -> str:
    """A short, readable prefix for card numbers: 'Ops launch' -> 'OL', 'Marketing' -> 'MAR'."""
    words = re.findall(r"[A-Za-z0-9]+", title)
    if not words:
        return "B"
    if len(words) == 1:
        return words[0][:3].upper()
    return "".join(w[0] for w in words[:4]).upper()


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


@dataclass
class Card:
    id: str                      # "<board key>-<number>", e.g. "OPS-12"
    board_id: str
    number: int
    title: str
    list_name: str = DEFAULT_LISTS[0]
    position: float = 0.0
    description: str = ""
    assignee: Optional[str] = None
    due_date: Optional[date] = None
    labels: list = field(default_factory=list)
    blocked_by: list = field(default_factory=list)   # card ids on the same board
    archived: bool = False
    version: int = 1

    def is_overdue(self, today: date, done_list: str) -> bool:
        return (self.due_date is not None and self.due_date < today
                and self.list_name != done_list and not self.archived)

    def edit(self, **changes) -> dict:
        """Apply field changes; return {field: [before, after]} for the ones that really changed."""
        diff = {}
        for name, value in changes.items():
            if name not in CARD_FIELDS:
                raise Invalid(f"unknown card field {name!r}")
            if name == "title":
                value = (value or "").strip()
                if not value:
                    raise Invalid("a card needs a title")
                value = value[:200]
            elif name == "description":
                value = (value or "").strip()[:5000]
            elif name == "labels":
                value = clean_labels(value)
            before = getattr(self, name)
            if before != value:
                diff[name] = [_plain(before), _plain(value)]
                setattr(self, name, value)
        return diff


def _plain(v):
    return v.isoformat() if isinstance(v, date) else v


@dataclass
class Board:
    id: str
    title: str
    owner_email: str
    members: dict = field(default_factory=dict)  # email -> Role (owner is not stored here)
    lists: list = field(default_factory=lambda: list(DEFAULT_LISTS))

    # --- who may do what -------------------------------------------------------------------
    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 the board's people and lists")

    def can_work(self, email: str) -> bool:
        """Owner and members can be given cards; viewers only watch."""
        return email == self.owner_email or self.members.get(email) is Role.MEMBER

    # --- people ---------------------------------------------------------------------------
    def add_member(self, by: Person, email: str, role: Role) -> None:
        self.require_owner(by)
        email = clean_email(email)
        if role is Role.OWNER:
            raise Invalid("a board has one owner")
        if email == self.owner_email:
            raise Invalid("that person owns this board")
        self.members[email] = role

    def remove_member(self, by: Person, email: str) -> None:
        self.require_owner(by)
        email = clean_email(email)
        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]

    def people(self) -> list:
        return ([{"email": self.owner_email, "role": Role.OWNER.value}] +
                [{"email": e, "role": r.value} for e, r in sorted(self.members.items())])

    # --- lists ----------------------------------------------------------------------------
    @property
    def done_list(self) -> str:
        """The last list counts as finished: its cards are never overdue or blocking."""
        return self.lists[-1]

    def list_named(self, name: str) -> str:
        """Find a list by name, forgiving case and spaces ('doing' finds 'Doing')."""
        want = (name or "").strip().lower()
        for existing in self.lists:
            if existing.lower() == want:
                return existing
        raise Invalid(f"no list {name!r}; lists are {', '.join(self.lists)}")

    def add_list(self, by: Person, name: str, before: Optional[str] = None) -> None:
        self.require_owner(by)
        name = (name or "").strip()[:40]
        if not name:
            raise Invalid("a list needs a name")
        if any(name.lower() == x.lower() for x in self.lists):
            raise Invalid(f"there is already a list {name!r}")
        if len(self.lists) >= MAX_LISTS:
            raise Invalid(f"a board has at most {MAX_LISTS} lists")
        at = self.lists.index(self.list_named(before)) if before else len(self.lists) - 1
        self.lists.insert(at, name)  # by default just before the done list

    def rename_list(self, by: Person, old: str, new: str) -> tuple:
        self.require_owner(by)
        old = self.list_named(old)
        new = (new or "").strip()[:40]
        if not new:
            raise Invalid("a list needs a name")
        if new.lower() != old.lower() and any(new.lower() == x.lower() for x in self.lists):
            raise Invalid(f"there is already a list {new!r}")
        self.lists[self.lists.index(old)] = new
        return old, new

    def remove_list(self, by: Person, name: str, cards_in_it: int) -> str:
        self.require_owner(by)
        name = self.list_named(name)
        if cards_in_it:
            raise Invalid(f"move the {cards_in_it} card(s) out of {name!r} first")
        if len(self.lists) <= 2:
            raise Invalid("a board keeps at least two lists")
        self.lists.remove(name)
        return name


def check_dependency(card: Card, blocker: Card, cards_by_id: dict) -> None:
    """'card is blocked by blocker' must stay on one board and never form a loop."""
    if card.id == blocker.id:
        raise Invalid("a card can't block itself")
    if card.board_id != blocker.board_id:
        raise Invalid("both cards must be on the same board")
    seen, todo = set(), [blocker.id]
    while todo:  # walk what the blocker waits for; reaching the card means a loop
        cid = todo.pop()
        if cid == card.id:
            raise Invalid(f"{blocker.id} already waits for {card.id}: that would be a loop")
        if cid in seen:
            continue
        seen.add(cid)
        todo.extend(getattr(cards_by_id.get(cid), "blocked_by", []))


def open_blockers(card: Card, cards_by_id: dict, done_list: str) -> list:
    """Everything still unfinished that this card waits for, directly or through other cards."""
    out, seen, todo = [], set(), list(card.blocked_by)
    while todo:
        cid = todo.pop(0)
        if cid in seen:
            continue
        seen.add(cid)
        c = cards_by_id.get(cid)
        if c is None or c.archived or c.list_name == done_list:
            continue
        out.append(c)
        todo.extend(c.blocked_by)
    return out