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