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