freelancer/flowboard

Boards starter for the Dodil Boards Challenge. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 0 remixes · Remix freelancer/flowboard

app/boards/domain/model.py


"""The board's language and rules. No database or HTTP dependencies."""

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 person may not perform this action."""


class Invalid(Exception):
    """The request violates a board rule."""


@dataclass(frozen=True)
class Person:
    """Identity comes from Dodil authentication, never the request body."""

    email: str


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

    def move_to(self, list_name: str) -> None:
        if list_name not in LISTS:
            raise Invalid(
                f"unknown list {list_name!r}; lists are {', '.join(LISTS)}"
            )
        self.list_name = list_name

    def update_details(
        self,
        *,
        title: Optional[str] = None,
        description: Optional[str] = None,
        assignee: Optional[str] = None,
        due_date: Optional[date] = None,
        labels: Optional[list[str]] = None,
        clear_assignee: bool = False,
        clear_due_date: bool = False,
    ) -> None:
        """Update card details and explicitly support clearing optional fields."""

        if title is not None:
            cleaned_title = title.strip()
            if not cleaned_title:
                raise Invalid("a card needs a title")
            self.title = cleaned_title

        if description is not None:
            self.description = description.strip()

        if clear_assignee:
            self.assignee = None
        elif assignee is not None:
            self.assignee = assignee.strip().lower() or None

        if clear_due_date:
            self.due_date = None
        elif due_date is not None:
            self.due_date = due_date

        if labels is not None:
            self.labels = list(
                dict.fromkeys(
                    label.strip()
                    for label in labels
                    if label.strip()
                )
            )


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

    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 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")
        if role is Role.OWNER:
            raise Invalid("a board has one owner")

        normalized_email = email.strip().lower()
        if not normalized_email:
            raise Invalid("a member needs an email")
        if normalized_email == self.owner_email:
            raise Invalid("the owner already owns this board")

        self.members[normalized_email] = role