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