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))