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