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)