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)