north-scape/relayboard

Relayboard: accessible team boards with audited actions, dependency maps, and shared web/MCP use cases.

A remix template · 0 remixes · Remix north-scape/relayboard

app/boards/domain/model.py

"""Business rules: no HTTP, identity provider, database, or model dependency."""
from dataclasses import dataclass, field
from datetime import date
from enum import Enum
import re

LISTS = ("To do", "Doing", "Done")

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

class Forbidden(Exception): pass
class Invalid(Exception): pass
class Conflict(Exception): pass


def text(value, name, maximum, required=False):
    if not isinstance(value, str):
        raise Invalid(f"{name} must be text")
    value = value.strip()
    if required and not value:
        raise Invalid(f"{name} cannot be empty")
    if len(value) > maximum:
        raise Invalid(f"{name} must be at most {maximum} characters")
    return value


def email_address(value):
    value = text(value, "email", 254, True).lower()
    if not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", value):
        raise Invalid("enter a valid email address")
    return value

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

@dataclass
class Card:
    id: str
    board_id: str
    title: str
    list_name: str = LISTS[0]
    assignee: str | None = None
    due_date: date | None = None
    description: str = ""
    labels: list[str] = field(default_factory=list)
    blocked_by: list[str] = field(default_factory=list)
    revision: int = 1
    checklist: list[dict] = field(default_factory=list)

    @property
    def reference(self):
        """A stable, readable alias; lookup must still reject prefix collisions."""
        return 'RB-' + self.id[:8].upper()

    def move_to(self, list_name):
        if list_name not in LISTS:
            raise Invalid("list must be To do, Doing, or Done")
        self.list_name = list_name

    def overdue(self, today):
        return bool(self.due_date and self.due_date < today and self.list_name != "Done")

@dataclass
class Board:
    id: str
    title: str
    owner_email: str
    members: dict = field(default_factory=dict)

    def role_of(self, person):
        return Role.OWNER if person.email == self.owner_email else self.members.get(person.email)

    def require_view(self, person):
        if self.role_of(person) is None:
            raise Forbidden("you are not on this board")

    def require_edit(self, person):
        self.require_view(person)
        if self.role_of(person) is Role.VIEWER:
            raise Forbidden("viewers can't change the board")

    def add_member(self, by, email, role):
        if self.role_of(by) is not Role.OWNER:
            raise Forbidden("only the owner manages members")
        email = email_address(email)
        try:
            role = Role(role)
        except ValueError:
            raise Invalid("role must be member or viewer") from None
        if role is Role.OWNER or email == self.owner_email:
            raise Invalid("a board has one owner; its owner cannot be reassigned here")
        self.members[email] = role

    def remove_member(self, by, email):
        if self.role_of(by) is not Role.OWNER:
            raise Forbidden("only the owner manages members")
        email = email_address(email)
        if email == self.owner_email:
            raise Invalid("the board owner cannot be removed")
        return self.members.pop(email, None)