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))