rahulpawar/taskdeck
Lanebook, a team task board (boards, lists, cards, roles, history) you run from the web or from Claude/ChatGPT over MCP. FastAPI + DataK3 + appid + Models.
A remix template · 0 remixes · Remix rahulpawar/taskdeck
app/boards/domain/model.py
"""The board's language and rules. No database, no HTTP: plain Python, testable in milliseconds.
Every rule lives here, so it holds the same way for the web page and for Claude / ChatGPT:
- roles: the owner manages people; members edit; viewers only look
- a card belongs to one list of its own board; its assignee must be on the board
- a card's last list is "done": a card there is never overdue and never blocks anything
- every change to a card can be described field by field (for the activity log)
"""
from dataclasses import dataclass, field
from datetime import date
from enum import Enum
from typing import Optional
DEFAULT_LISTS = ("To do", "Doing", "Done")
MAX_TITLE = 200
MAX_LABELS = 10
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"titles are at most {MAX_TITLE} characters")
return t
def clean_labels(labels) -> list:
if labels is None:
return []
if isinstance(labels, str):
labels = labels.split(",")
out = []
for l in labels:
l = " ".join(str(l).split()).lower()[:30]
if l and l not in out:
out.append(l)
if len(out) > MAX_LABELS:
raise Invalid(f"a card has at most {MAX_LABELS} labels")
return out
@dataclass
class BoardList:
id: str
board_id: str
name: str
position: float = 0.0
@dataclass
class Card:
id: str
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)
position: float = 0.0
blocked_by: list = field(default_factory=list) # ids of cards on the same board
def is_done(self, done_list_id: Optional[str]) -> bool:
return done_list_id is not None and self.list_id == done_list_id
def is_overdue(self, today: date, done_list_id: Optional[str]) -> bool:
return self.due_date is not None and self.due_date < today and not self.is_done(done_list_id)
def snapshot(self) -> dict:
return {"title": self.title, "description": self.description, "assignee": self.assignee,
"due_date": self.due_date.isoformat() if self.due_date else None,
"labels": ", ".join(self.labels), "list_id": self.list_id,
"blocked_by": ",".join(self.blocked_by)}
def describe_changes(before: dict, after: dict, list_names: dict, numbers: dict) -> list:
"""Human lines for the activity log: 'due date: none -> 2026-11-04'."""
out = []
for key, label in (("title", "title"), ("description", "description"),
("assignee", "assignee"), ("due_date", "due date"),
("labels", "labels"), ("list_id", "list"), ("blocked_by", "blocked by")):
a, b = before.get(key), after.get(key)
if (a or None) == (b or None):
continue
if key == "list_id":
a, b = list_names.get(a, a), list_names.get(b, b)
if key == "blocked_by":
a = ", ".join(f"#{numbers.get(x, '?')}" for x in (a or "").split(",") if x) or None
b = ", ".join(f"#{numbers.get(x, '?')}" for x in (b or "").split(",") if x) or None
if key == "description":
out.append("changed the description" if b else "cleared the description")
continue
out.append(f"{label}: {a or 'none'} -> {b or 'none'}")
return out
@dataclass
class Board:
id: str
title: str
owner_email: str
members: dict = field(default_factory=dict) # email -> Role (the owner is not in here)
def role_of(self, person: Person) -> Optional[Role]:
if person.email == self.owner_email:
return Role.OWNER
return self.members.get(person.email)
def people(self) -> dict:
return {self.owner_email: Role.OWNER, **self.members}
def is_on_board(self, email: str) -> bool:
return email in self.people()
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 add_member(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 is the owner")
self.members[email] = role
return email
def remove_member(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 check_assignee(self, email: Optional[str]) -> Optional[str]:
if not email:
return None
email = clean_email(email)
if not self.is_on_board(email):
raise Invalid(f"{email} is not on this board; the owner can invite them first")
if self.people()[email] is Role.VIEWER:
raise Invalid(f"{email} is a viewer and can't own cards")
return email