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