damien-kaiser/taskloft
Taskloft — team task boards (owner / member / viewer), card history, my plate, overdue, blockers, AI checklists and summaries, usable from Claude and ChatGPT over MCP.
A remix template · 0 remixes · Remix damien-kaiser/taskloft
app/boards/domain/model.py
"""Taskloft's language and rules. No database, no HTTP: plain Python you can test in a second.
Every rule lives here, so it holds the same way for the web page and for Claude or ChatGPT:
- the owner manages people; a member edits; a viewer only reads;
- a board you are not on does not exist for you;
- a card lives in one list of its own board; a card can't block itself or form a loop;
- every change to a card is described field by field (who, when, what changed).
"""
from dataclasses import dataclass, field
from datetime import date
from enum import Enum
from typing import Optional
DEFAULT_LISTS = ("To do", "Doing", "Done")
MAX_LABELS, MAX_LABEL_LEN, MAX_TITLE = 10, 24, 200
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_email(email: str) -> str:
e = (email or "").strip().lower()
if "@" not in e or " " in e or len(e) > 254:
raise Invalid(f"{email!r} is not an email address")
return e
def clean_title(title: str, what: str = "a card") -> str:
t = " ".join((title or "").split())
if not t:
raise Invalid(f"{what} needs a title")
if len(t) > MAX_TITLE:
raise Invalid(f"keep the title under {MAX_TITLE} characters")
return t
def clean_labels(labels) -> list:
if labels is None:
return []
if isinstance(labels, str):
labels = labels.split(",")
out = []
for raw in labels:
lab = " ".join(str(raw).split()).lower()
if not lab:
continue
if len(lab) > MAX_LABEL_LEN:
raise Invalid(f"label {lab!r} is longer than {MAX_LABEL_LEN} characters")
if lab not in out:
out.append(lab)
if len(out) > MAX_LABELS:
raise Invalid(f"a card takes at most {MAX_LABELS} labels")
return out
def parse_due(value) -> Optional[date]:
if value in (None, ""):
return None
if isinstance(value, date):
return value
try:
return date.fromisoformat(str(value).strip()[:10])
except ValueError:
raise Invalid(f"due date {value!r} must be YYYY-MM-DD")
@dataclass
class BoardList:
id: str
board_id: str
name: str
position: int
is_done: bool = False # cards here count as finished: never "on your plate", never overdue
@dataclass
class Card:
id: str
board_id: str
number: int
title: str
list_id: str
list_name: str
description: str = ""
assignee: Optional[str] = None
due_date: Optional[date] = None
labels: list = field(default_factory=list)
position: float = 0.0
blocked_by: list = field(default_factory=list) # card ids that must finish first
def is_overdue(self, today: date, done_lists: set) -> bool:
return bool(self.due_date and self.due_date < today and self.list_id not in done_lists)
def edit(self, **changes) -> dict:
"""Apply the changes that really change something; return {field: [before, after]}."""
diff = {}
for name, new in changes.items():
old = getattr(self, name)
if new != old:
setattr(self, name, new)
diff[name] = [_show(old), _show(new)]
return diff
def _show(v):
if isinstance(v, date):
return v.isoformat()
if isinstance(v, list):
return list(v)
return v
@dataclass
class Board:
id: str
title: str
owner_email: str
members: dict = field(default_factory=dict) # email -> Role (never the owner)
lists: list = field(default_factory=list) # BoardList, by position
# --- 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 members")
# --- people ----------------------------------------------------------------------------
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")
if email == self.owner_email:
raise Invalid("that's the owner")
self.members[email] = role
return email
def remove(self, by: Person, email: str) -> str:
self.require_owner(by)
email = clean_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]
return email
def can_be_assigned(self, email: str) -> bool:
return email == self.owner_email or self.members.get(email) in (Role.MEMBER, Role.OWNER)
# --- lists -----------------------------------------------------------------------------
def find_list(self, ref: str) -> BoardList:
"""A list by id, or by name (case-insensitive) — so 'move it to doing' works."""
key = " ".join(str(ref or "").split()).lower()
for lst in self.lists:
if lst.id == ref or lst.name.lower() == key:
return lst
names = ", ".join(lst.name for lst in self.lists)
raise Invalid(f"no list {ref!r} on {self.title!r}; lists are {names}")
def check_new_list_name(self, name: str) -> str:
n = clean_title(name, "a list")
if any(lst.name.lower() == n.lower() for lst in self.lists):
raise Invalid(f"there is already a list called {n!r}")
return n
def done_list_ids(self) -> set:
return {lst.id for lst in self.lists if lst.is_done}
def check_blocker(card: Card, blocker: Card, blocked_by_of) -> None:
"""`blocked_by_of(card_id)` returns the ids blocking that card. Refuse self, other boards, loops."""
if blocker.id == card.id:
raise Invalid("a card can't block itself")
if blocker.board_id != card.board_id:
raise Invalid("a card can only be blocked by a card on the same board")
seen, todo = set(), [blocker.id]
while todo:
cur = todo.pop()
if cur == card.id:
raise Invalid(f"#{blocker.number} already waits on #{card.number}: that would be a loop")
if cur in seen:
continue
seen.add(cur)
todo.extend(blocked_by_of(cur))