ghpsolutions/quadro

Quadro: team boards with roles, history and an MCP endpoint for Claude and ChatGPT

A remix template · 0 remixes · Remix ghpsolutions/quadro

app/boards/domain/model.py

"""The board domain: people, roles, boards, lists, cards and the rules between them.

Nothing here knows about HTTP, MCP or storage. The rules live on the objects so that the web
page and the MCP tools cannot disagree about who may do what.
"""
from __future__ import annotations

import re
from dataclasses import dataclass, field, replace
from datetime import date, datetime
from enum import Enum
from typing import Iterable, Mapping, Optional

DEFAULT_LISTS = (("To do", False), ("Doing", False), ("Done", True))
LABEL_COLORS = ("slate", "red", "amber", "green", "teal", "blue", "violet", "pink")
MAX_TITLE, MAX_TEXT, MAX_LABELS, MAX_LABEL = 200, 8000, 8, 24


class DomainError(Exception):
    """Base for errors a caller can act on."""


class Forbidden(DomainError):
    pass


class Invalid(DomainError):
    pass


class NotFound(DomainError):
    pass


class Conflict(DomainError):
    """Someone else changed the record first (stale version)."""


class Role(str, Enum):
    OWNER = "owner"
    MEMBER = "member"
    VIEWER = "viewer"

    @classmethod
    def parse(cls, value: str) -> "Role":
        try:
            return cls((value or "").strip().lower())
        except ValueError:
            raise Invalid(f"unknown role '{value}' (use member or viewer)")


EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")


def clean_email(value: str) -> str:
    email = (value or "").strip().lower()
    if not EMAIL.match(email):
        raise Invalid(f"'{value}' is not an email address")
    return email


@dataclass(frozen=True)
class Person:
    email: str


def board_key(title: str) -> str:
    """A short handle used in card references, e.g. 'Ops board' -> OPS, 'Product launch' -> PL."""
    words = re.findall(r"[A-Za-z0-9]+", title)
    words = [w for w in words if w.lower() not in {"board", "the", "a", "of", "and"}] or words
    if not words:
        return "B"
    if len(words) == 1:
        return words[0][:4].upper()
    return "".join(w[0] for w in words[:4]).upper()


@dataclass
class Board:
    id: str
    key: str
    title: str
    owner: str
    created_at: str
    members: dict = field(default_factory=dict)  # email -> Role

    def role_of(self, person: Person) -> Optional[Role]:
        if person.email == self.owner:
            return Role.OWNER
        return self.members.get(person.email)

    def can_view(self, person: Person) -> bool:
        return self.role_of(person) is not None

    def require_edit(self, person: Person) -> Role:
        role = self.role_of(person)
        if role in (Role.OWNER, Role.MEMBER):
            return role
        raise Forbidden("viewers can read this board but not change it")

    def require_owner(self, person: Person) -> None:
        if self.role_of(person) is not Role.OWNER:
            raise Forbidden("only the board owner can manage members")

    def check_new_role(self, by: Person, email: str, role: Role) -> None:
        self.require_owner(by)
        if role is Role.OWNER:
            raise Invalid("a board has one owner; invite people as member or viewer")
        if email == self.owner:
            raise Invalid("the owner's role cannot be changed")

    def check_removal(self, by: Person, email: str) -> None:
        self.require_owner(by)
        if email == self.owner:
            raise Invalid("the owner cannot be removed")
        if email not in self.members:
            raise NotFound(f"{email} is not on this board")

    def people(self) -> list:
        out = [{"email": self.owner, "role": Role.OWNER.value}]
        out += [{"email": e, "role": r.value} for e, r in sorted(self.members.items())]
        return out


@dataclass
class BoardList:
    id: str
    board_id: str
    name: str
    position: float
    done: bool = False


@dataclass
class Card:
    id: str
    board_id: str
    number: int
    list_id: str
    title: str
    position: float
    created_by: str
    created_at: str
    updated_at: str
    description: str = ""
    assignee: Optional[str] = None
    due_date: Optional[str] = None  # ISO date
    labels: tuple = ()
    blocked_by: tuple = ()  # card ids
    version: int = 1

    def due(self) -> Optional[date]:
        return date.fromisoformat(self.due_date) if self.due_date else None

    def is_overdue(self, today: date, done_lists: Iterable[str]) -> bool:
        d = self.due()
        return d is not None and d < today and self.list_id not in set(done_lists)

    def checklist(self) -> tuple:
        """(done, total) for '- [ ]' / '- [x]' lines in the description."""
        boxes = re.findall(r"^\s*[-*] \[( |x|X)\]", self.description or "", re.M)
        return sum(1 for b in boxes if b.lower() == "x"), len(boxes)


TRACKED = ("title", "description", "assignee", "due_date", "labels", "list_id", "blocked_by")


def changes_between(before: Card, after: Card) -> list:
    """Field-level differences, in the shape the activity log stores."""
    out = []
    for name in TRACKED:
        a, b = getattr(before, name), getattr(after, name)
        if a != b:
            out.append({"field": name, "from": list(a) if isinstance(a, tuple) else a,
                        "to": list(b) if isinstance(b, tuple) else b})
    return out


def clean_title(value: str) -> str:
    title = " ".join((value or "").split())
    if not title:
        raise Invalid("a title is required")
    if len(title) > MAX_TITLE:
        raise Invalid(f"titles are at most {MAX_TITLE} characters")
    return title


def clean_text(value: Optional[str]) -> str:
    text = (value or "").strip()
    if len(text) > MAX_TEXT:
        raise Invalid(f"text is at most {MAX_TEXT} characters")
    return text


def clean_labels(values: Iterable[str]) -> tuple:
    seen = []
    for v in values or ():
        label = " ".join(str(v).split())[:MAX_LABEL]
        if label and label.lower() not in [s.lower() for s in seen]:
            seen.append(label)
    if len(seen) > MAX_LABELS:
        raise Invalid(f"a card takes at most {MAX_LABELS} labels")
    return tuple(seen)


def clean_due(value: Optional[str]) -> Optional[str]:
    if value in (None, ""):
        return None
    if isinstance(value, (date, datetime)):
        return (value.date() if isinstance(value, datetime) else value).isoformat()
    try:
        return date.fromisoformat(str(value).strip()[:10]).isoformat()
    except ValueError:
        raise Invalid(f"'{value}' is not a date (use YYYY-MM-DD)")


def check_blocker(card: Card, blocker: Card, blocked_by: Mapping[str, Iterable[str]]) -> None:
    """A card cannot wait on itself, on another board, or on something that waits on it."""
    if blocker.id == card.id:
        raise Invalid("a card cannot block itself")
    if blocker.board_id != card.board_id:
        raise Invalid("blockers must be on the same board")
    stack, seen = [blocker.id], set()
    while stack:
        current = stack.pop()
        if current == card.id:
            raise Invalid("that would create a loop of blockers")
        if current in seen:
            continue
        seen.add(current)
        stack.extend(blocked_by.get(current, ()))


def with_changes(card: Card, **fields) -> Card:
    return replace(card, **fields)