contest/orbit-boards

Orbit Boards, a private, remixable team task board built with FastAPI, DataK3, AppID, Models and MCP.

A remix template · 0 remixes · Remix contest/orbit-boards

app/boards/domain/model.py

"""Pure board entities and rules, independent of HTTP, MCP and persistence."""
from dataclasses import dataclass, field
from datetime import date
from enum import Enum
from typing import Optional

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


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


class Forbidden(Exception):
    """The caller does not have permission for this operation."""


class Invalid(Exception):
    """A requested value violates a board rule."""


@dataclass(frozen=True)
class Person:
    """Identity supplied by the Dodil gateway, never by a mutation payload."""
    email: str

    def __post_init__(self):
        object.__setattr__(self, "email", self.email.strip().lower())


@dataclass
class Card:
    id: str
    board_id: str
    title: str
    list_name: str = LISTS[0]
    assignee: Optional[str] = None
    due_date: Optional[date] = None
    description: str = ""
    labels: list[str] = field(default_factory=list)
    position: int = 0

    def validate(self) -> None:
        self.title = self.title.strip()
        if not self.title:
            raise Invalid("a card needs a title")
        if len(self.title) > 240:
            raise Invalid("card titles must be 240 characters or fewer")
        if len(self.description) > 10000:
            raise Invalid("descriptions must be 10,000 characters or fewer")
        if self.list_name not in LISTS:
            raise Invalid(f"unknown list {self.list_name!r}; lists are {', '.join(LISTS)}")
        if self.position < 0:
            raise Invalid("card position must be zero or greater")
        clean = []
        for label in self.labels:
            value = str(label).strip()
            if value and value not in clean:
                clean.append(value[:40])
        if len(clean) > 12:
            raise Invalid("a card can have at most 12 labels")
        self.labels = clean

    def move_to(self, list_name: str, position: Optional[int] = None) -> None:
        if list_name not in LISTS:
            raise Invalid(f"unknown list {list_name!r}; lists are {', '.join(LISTS)}")
        if position is not None and position < 0:
            raise Invalid("card position must be zero or greater")
        self.list_name = list_name
        if position is not None:
            self.position = position


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

    def __post_init__(self):
        self.owner_email = self.owner_email.strip().lower()
        self.members = {email.strip().lower(): Role(role) for email, role in self.members.items()}

    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 can_assign(self, email: str) -> bool:
        normalized = email.strip().lower()
        return normalized == self.owner_email or normalized in self.members

    def add_member(self, by: Person, email: str, role: Role) -> None:
        if self.role_of(by) is not Role.OWNER:
            raise Forbidden("only the owner manages members")
        normalized = email.strip().lower()
        if "@" not in normalized or any(ch.isspace() for ch in normalized):
            raise Invalid("enter a valid member email address")
        if role is Role.OWNER:
            raise Invalid("a board has one owner")
        if normalized == self.owner_email:
            raise Invalid("the owner already has access")
        self.members[normalized] = role