theaio/theaio-boards
Laneway, team task boards you can run from Claude or ChatGPT. FastAPI + DataK3 + appid + Models + MCP.
A remix template · 0 remixes · Remix theaio/theaio-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, timedelta
from enum import Enum
from typing import Iterable, Optional
DEFAULT_LISTS = ("To do", "Doing", "Done")
MAX_TITLE, MAX_TEXT = 200, 5000
MAX_LABELS, MAX_LABEL = 8, 24
EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
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(frozen=True)
class Change:
"""One field of a card going from old to new: what the history records."""
field: str
old: str
new: str
@dataclass(frozen=True)
class HistoryEntry:
id: str
board_id: str
card_id: str
actor: str
at: datetime
field: str
old: str
new: str
def _text(value) -> str:
"""How a value reads in the history: dates as ISO, labels comma-joined, nothing as ''."""
if value is None:
return ""
if isinstance(value, date):
return value.isoformat()
if isinstance(value, (list, tuple)):
return ", ".join(value)
return str(value)
def clean_title(title: str, what: str = "card") -> str:
title = " ".join((title or "").split())
if not title:
raise Invalid(f"a {what} needs a title")
if len(title) > MAX_TITLE:
raise Invalid(f"keep the {what} title under {MAX_TITLE} characters")
return title
def clean_labels(labels: Iterable[str]) -> list:
out, seen = [], set()
for raw in labels or []:
label = " ".join(str(raw).split())
if not label or label.lower() in seen:
continue
if len(label) > MAX_LABEL:
raise Invalid(f"label {label!r} is too long (max {MAX_LABEL} characters)")
seen.add(label.lower())
out.append(label)
if len(out) > MAX_LABELS:
raise Invalid(f"a card takes at most {MAX_LABELS} labels")
return out
def clean_email(email: str) -> str:
email = (email or "").strip().lower()
if not EMAIL.match(email):
raise Invalid(f"{email!r} is not an email address")
return email
def parse_due(text, today: date) -> Optional[date]:
"""A due date as people say it: 2026-11-30, today, tomorrow, +3 / in 3 days; '' clears it."""
if text is None or isinstance(text, date):
return text
t = str(text).strip().lower()
if t in ("", "none", "clear", "no date"):
return None
if t == "today":
return today
if t == "tomorrow":
return today + timedelta(days=1)
m = re.fullmatch(r"(?:\+|in )?(\d{1,3})(?: ?days?)?", t)
if m and (t.startswith("+") or t.startswith("in ")):
return today + timedelta(days=int(m.group(1)))
try:
return date.fromisoformat(t)
except ValueError:
raise Invalid(f"can't read the date {text!r}: use YYYY-MM-DD, today, tomorrow or +N days")
def reorder(items: list, item, index: Optional[int]) -> list:
"""Put item at index (end when None) and renumber every pos from 0. Returns the new order."""
rest = [i for i in items if i is not item]
at = len(rest) if index is None else max(0, min(int(index), len(rest)))
rest.insert(at, item)
for pos, i in enumerate(rest):
i.pos = pos
return rest
@dataclass
class BoardList:
id: str
board_id: str
name: str
pos: int = 0
@dataclass
class Card:
id: str # board_id + ':' + number: the number is claimed by the primary key
board_id: str
number: int
list_id: str
title: str
description: str = ""
assignee: Optional[str] = None
due_date: Optional[date] = None
labels: list = field(default_factory=list)
pos: int = 0
created_by: str = ""
EDITABLE = ("title", "description", "assignee", "due_date", "labels")
def update(self, **values) -> list:
"""Set fields and say what changed. Unchanged fields record nothing."""
changes = []
for name, value in values.items():
if name not in self.EDITABLE:
raise Invalid(f"a card has no field {name!r}")
if name == "title":
value = clean_title(value)
elif name == "description":
value = (value or "").strip()
if len(value) > MAX_TEXT:
raise Invalid(f"keep the description under {MAX_TEXT} characters")
elif name == "labels":
value = clean_labels(value)
elif name == "assignee":
value = value or None
old = getattr(self, name)
if old != value:
setattr(self, name, value)
changes.append(Change(name, _text(old), _text(value)))
return changes
def is_overdue(self, today: date, done_list_id: str) -> bool:
return bool(self.due_date and self.due_date < today and self.list_id != done_list_id)
def blocking_path(links: Iterable[tuple], card_id: str, blocker_id: str) -> Optional[list]:
"""links are (card, blocked_by) pairs. Adding card <- blocker closes a cycle when card already
(directly or through others) blocks blocker. Returns that chain, or None when it is safe."""
if card_id == blocker_id:
return [card_id]
blocked_by = {}
for c, b in links:
blocked_by.setdefault(c, []).append(b)
stack, seen = [(blocker_id, [blocker_id])], set()
while stack:
node, path = stack.pop()
if node == card_id:
return path
if node in seen:
continue
seen.add(node)
stack.extend((n, path + [n]) for n in blocked_by.get(node, []))
return None
@dataclass
class Board:
id: str
title: str
owner_email: str
members: dict = field(default_factory=dict) # email -> Role (the owner is not in here)
lists: list = field(default_factory=list) # BoardList, any order; .ordered() sorts them
@classmethod
def new(cls, board_id: str, title: str, owner: Person, list_ids: Iterable[str]) -> "Board":
board = cls(id=board_id, title=clean_title(title, "board"), owner_email=owner.email)
board.lists = [BoardList(i, board_id, name, pos)
for pos, (i, name) in enumerate(zip(list_ids, DEFAULT_LISTS))]
return board
# --- 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 look but can't change the board; ask the owner")
def require_owner(self, person: Person) -> None:
self.require_view(person)
if self.role_of(person) is not Role.OWNER:
raise Forbidden("only the board's owner manages members")
# --- people ----------------------------------------------------------------------------
def people(self) -> list:
return [(self.owner_email, Role.OWNER)] + sorted(self.members.items())
def invite(self, by: Person, email: str, role: Role) -> str:
self.require_owner(by)
email = clean_email(email)
if role is Role.OWNER:
raise Invalid("a board has one owner; invite as 'member' or 'viewer'")
if email == self.owner_email or email in self.members:
raise Invalid(f"{email} is already on this board; use set_member_role to change it")
self.members[email] = role
return email
def set_role(self, by: Person, email: str, role: Role) -> str:
self.require_owner(by)
email = (email or "").strip().lower()
if email == self.owner_email:
raise Invalid("the owner's role can't change")
if email not in self.members:
raise Invalid(f"{email} is not on this board")
if role is Role.OWNER:
raise Invalid("a board has one owner; choose 'member' or 'viewer'")
self.members[email] = role
return email
def remove(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 be removed from their board")
if email not in self.members:
raise Invalid(f"{email} is not on this board")
del self.members[email]
return email
def require_assignable(self, email: str) -> None:
role = self.role_of(Person(email))
if role is None:
raise Invalid(f"{email} is not on this board; the owner can invite them first")
if role is Role.VIEWER:
raise Invalid(f"{email} is a viewer; only owners and members can be assigned")
# --- lists -----------------------------------------------------------------------------
def ordered(self) -> list:
return sorted(self.lists, key=lambda l: (l.pos, l.name))
@property
def done_list(self) -> BoardList:
"""The last list counts as done: its cards are never overdue nor blocking."""
return self.ordered()[-1]
def find_list(self, ref: str) -> BoardList:
key = (ref or "").strip().lower()
for lst in self.lists:
if lst.id == ref or lst.name.lower() == key:
return lst
names = ", ".join(l.name for l in self.ordered())
raise Invalid(f"no list {ref!r} on {self.title}; lists are {names}")
def _unique_name(self, name: str, keep: Optional[BoardList] = None) -> str:
name = clean_title(name, "list")
if any(l is not keep and l.name.lower() == name.lower() for l in self.lists):
raise Invalid(f"{self.title} already has a list called {name!r}")
return name
def add_list(self, by: Person, list_id: str, name: str) -> BoardList:
"""A new list goes just before the last one, so the done list stays last."""
self.require_edit(by)
lst = BoardList(list_id, self.id, self._unique_name(name), len(self.lists))
self.lists = reorder(self.ordered(), lst, max(len(self.lists) - 1, 0))
return lst
def rename_list(self, by: Person, lst: BoardList, name: str) -> str:
self.require_edit(by)
old, lst.name = lst.name, self._unique_name(name, keep=lst)
return old
def move_list(self, by: Person, lst: BoardList, index: int) -> None:
self.require_edit(by)
self.lists = reorder(self.ordered(), lst, index)
# --- cards -----------------------------------------------------------------------------
def edit_card(self, by: Person, card: Card, **values) -> list:
self.require_edit(by)
if values.get("assignee"):
values["assignee"] = values["assignee"].strip().lower()
self.require_assignable(values["assignee"])
return card.update(**values)
def move_card(self, by: Person, card: Card, to: BoardList, siblings: list,
index: Optional[int]) -> list:
"""Move card into list `to` at index among `siblings` (the cards already in it, in order).
Returns what changed for the history; every sibling's pos is renumbered."""
self.require_edit(by)
before_list = next((l.name for l in self.lists if l.id == card.list_id), "")
same_list = card.list_id == to.id
before = next((i for i, c in enumerate(siblings) if c is card), None)
card.list_id = to.id
reorder(siblings, card, index)
if not same_list:
return [Change("list", before_list, to.name)]
if before is not None and before != card.pos:
return [Change("position", str(before + 1), str(card.pos + 1))]
return []