rgboards/rg-boards
Lanes, team task boards you run from the web or from Claude/ChatGPT (MCP). FastAPI + DataK3 + appid + Dodil Models.
A remix template · 0 remixes · Remix rgboards/rg-boards
app/boards/domain/model.py
"""The board's language and rules. No database, no HTTP: plain Python you can test in a second.
Every rule lives here, so it holds for the web page and for Claude alike:
- a viewer can see a board but never change it
- only the owner manages members
- nobody sees a board they are not on
- a card can only be assigned to someone on its board (not a viewer)
- a card can't block itself, and blocking links can't form a loop
"""
import re
from dataclasses import dataclass, field
from datetime import date, timedelta
from enum import Enum
from typing import Optional
DEFAULT_LISTS = ("To do", "Doing", "Done")
MAX_TITLE = 200
MAX_DESCRIPTION = 5000
MAX_LABELS = 10
EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
WEEKDAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
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."""
class Conflict(Exception):
"""Someone changed the card since you opened it. 409; nothing was written."""
@dataclass(frozen=True)
class Person:
"""Who is acting: taken from the gateway's sign-in, never from the request body."""
email: str
def clean_title(title: str, what: str = "card") -> str:
t = (title or "").strip()
if not t:
raise Invalid(f"a {what} needs a title")
if len(t) > MAX_TITLE:
raise Invalid(f"a {what} title is at most {MAX_TITLE} characters")
return t
def clean_description(text) -> str:
t = (text or "").strip()
if len(t) > MAX_DESCRIPTION:
raise Invalid(f"a description is at most {MAX_DESCRIPTION} characters")
return t
def clean_email(email) -> str:
e = (email or "").strip().lower()
if not EMAIL.match(e):
raise Invalid(f"{email!r} is not an email address")
return e
def clean_labels(labels) -> list:
"""Labels are stored comma separated, so a comma always splits ("a,b" is two labels)."""
if labels is None:
return []
if isinstance(labels, str):
labels = [labels]
out = []
for raw in labels:
for part in str(raw).split(","):
lab = part.strip().lower()[:30]
if lab and lab not in out:
out.append(lab)
if len(out) > MAX_LABELS:
raise Invalid(f"a card has at most {MAX_LABELS} labels")
return out
def parse_due(value, today: Optional[date] = None) -> Optional[date]:
"""An ISO date (2026-11-07, a timestamp is cut to its date), or the words people and
assistants actually use: today, tomorrow, friday, next friday, next week, in 3 days."""
if value in (None, ""):
return None
if isinstance(value, date):
return value
text = str(value).strip().lower()
try:
return date.fromisoformat(text[:10])
except ValueError:
pass
today = today or date.today()
words = text.removeprefix("on ").removeprefix("by ").strip()
if words in ("today", "tonight", "eod"):
return today
if words == "tomorrow":
return today + timedelta(days=1)
if words in ("next week", "in a week"):
return today + timedelta(days=7)
m = re.fullmatch(r"(?:in )?\+?(\d{1,3}) ?(d|day|days|w|week|weeks)", words)
if m:
n = int(m.group(1)) * (7 if m.group(2).startswith("w") else 1)
return today + timedelta(days=n)
day = words.removeprefix("next ").removeprefix("this ").strip()
if day in WEEKDAYS or day[:3] in [d[:3] for d in WEEKDAYS] and len(day) == 3:
idx = [d[:3] for d in WEEKDAYS].index(day[:3])
ahead = (idx - today.weekday()) % 7 or 7 # the coming one: "friday" on a Friday = +7
return today + timedelta(days=ahead)
raise Invalid(f"due date {value!r} is not a date: use 2026-11-07, today, tomorrow, "
f"friday or 'in 3 days'")
@dataclass
class Card:
id: str
board_id: str
number: int
title: 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
version: int = 1
archived: bool = False
created_by: str = ""
def is_overdue(self, today: date, done_list: str) -> bool:
return bool(self.due_date and self.due_date < today and self.list_name != done_list
and not self.archived)
@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=lambda: list(DEFAULT_LISTS))
# --- 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, what: str = "manages members") -> None:
role = self.role_of(person)
if role is None:
raise Forbidden("you are not on this board")
if role is not Role.OWNER:
raise Forbidden(f"only the owner {what}")
# --- 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 person already owns the board")
self.members[email] = role
return email
def remove_member(self, by: Person, email: str) -> str:
self.require_owner(by)
email = (email or "").strip().lower()
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:
role = self.role_of(Person(email))
return role in (Role.OWNER, Role.MEMBER)
# --- lists -----------------------------------------------------------------------------
@property
def done_list(self) -> str:
"""The list that means 'finished': one called Done, else the last list."""
for name in self.lists:
if name.lower() == "done":
return name
return self.lists[-1]
def list_named(self, name: str) -> str:
"""Lists are matched case-insensitively, so 'doing' finds 'Doing'."""
wanted = (name or "").strip().lower()
for existing in self.lists:
if existing.lower() == wanted:
return existing
raise Invalid(f"unknown list {name!r}; lists are {', '.join(self.lists)}")
def add_list(self, by: Person, name: str) -> str:
self.require_edit(by)
name = clean_title(name, "list")
if any(n.lower() == name.lower() for n in self.lists):
raise Invalid(f"there is already a list called {name!r}")
if len(self.lists) >= 12:
raise Invalid("a board has at most 12 lists")
done = next((i for i, n in enumerate(self.lists) if n.lower() == "done"), None)
if done is None:
self.lists.append(name)
else: # new lists go before Done, so "finished" stays last
self.lists.insert(done, name)
return name
def remove_list(self, by: Person, name: str, cards_in_it: int) -> str:
self.require_edit(by)
name = self.list_named(name)
if cards_in_it:
raise Invalid(f"move the {cards_in_it} card(s) out of {name!r} first")
if len(self.lists) == 1:
raise Invalid("a board keeps at least one list")
self.lists.remove(name)
return name
def check_no_cycle(card_id: str, blocker_id: str, blockers_of) -> None:
"""Refuse a 'blocked by' link that would make a loop. blockers_of(id) -> ids that block id."""
if card_id == blocker_id:
raise Invalid("a card can't block itself")
seen, stack = set(), [blocker_id]
while stack:
cur = stack.pop()
if cur == card_id:
raise Invalid("that link would make a loop: the card already blocks it")
if cur in seen:
continue
seen.add(cur)
stack.extend(blockers_of(cur))