"""API schemas (Pydantic) for LeaveWish. Schemas validate request bodies and shape responses. Separate from domain entities so the wire format can evolve independently. Business-rule validation (e.g. the parent_mode / parent_personal_data_uuid pairing) lives in the service, not here — these schemas only enforce shape and range. """ from datetime import datetime from uuid import UUID from pydantic import BaseModel, Field from app.entities.leave_wish import LeaveWish class LeaveWishCreate(BaseModel): workspace_id: UUID child_id: UUID | None = None name: str = Field(min_length=1, max_length=255) # i18n message key when `name` came from a preset (e.g. # "wishEditor.presetSommar") — see app/entities/leave_wish.py. Stored # alongside the translated `name` so the frontend can re-translate it; # not validated against a key catalogue here (that's a frontend concern). name_message_key: str | None = Field(default=None, max_length=255) window_start_month: int = Field(ge=1, le=12) window_start_day: int = Field(ge=1, le=31) window_end_month: int = Field(ge=1, le=12) window_end_day: int = Field(ge=1, le=31) target_weeks: int = Field(ge=1, le=52) # A calendar-drawn period is not a whole number of weeks. None (default) # falls back to `target_weeks * 7` — see `LeaveWish.target_span_days()`. target_days: int | None = Field(default=None, ge=1) # An INSTRUCTION, not a stored field (see app/services/leave_wish_service.py): # 'yearly' materialises one row per year, all sharing a `series_id`, from # `first_year` through `last_year` (or, when that is None, the child's 4th # birthday year). 'once' creates a single standalone occurrence. What comes # back is always one occurrence — the `first_year` one. recurrence: str = "once" # 'yearly' | 'once' first_year: int = Field(ge=2000, le=2200) # Series-level end-year intent; None = "until the child turns 4". Ignored # (normalised to None) when `recurrence == 'once'`. last_year: int | None = Field(default=None, ge=2000, le=2200) priority: int = Field(default=1, ge=1) parent_mode: str = "either" # 'either' | 'both' | 'specific' parent_personal_data_uuid: UUID | None = None # PLANNING_ENGINE_SPEC.md §10.1 — 'fixed': window_start_*/window_end_* are the # exact placement (default). 'flexible': the same fields describe the # bounding range the allocator searches within for the best-costing window. window_mode: str = "fixed" # 'fixed' | 'flexible' # Re-attach this one occurrence to an EXISTING series (the series must # already have at least one row in the same workspace). Only valid with # `recurrence == 'once'` — a 'yearly' create mints its own series id. This # exists so the calendar's undo can put back a single year it removed as # the same year of the same repeating wish, rather than as a detached # one-off that a later "every year" edit would miss. series_id: UUID | None = None # §Decision A (co-leave) — opt-in dubbeldagar; see app/entities/leave_wish.py. # Only meaningful when parent_mode == 'both'. wants_dubbeldag: bool = False class LeaveWishUpdate(BaseModel): """Partial update — all fields optional; only provided fields are changed.""" child_id: UUID | None = None name: str | None = Field(None, min_length=1, max_length=255) # Present + null in the payload means "clear it"; absent means "leave # unchanged" (see `model_fields_set` handling in the router). The service # also clears this automatically whenever `name` actually changes, so a # caller can never forget to and leave a stale key pointing at old text. name_message_key: str | None = Field(default=None, max_length=255) window_start_month: int | None = Field(None, ge=1, le=12) window_start_day: int | None = Field(None, ge=1, le=31) window_end_month: int | None = Field(None, ge=1, le=12) window_end_day: int | None = Field(None, ge=1, le=31) target_weeks: int | None = Field(None, ge=1, le=52) # Present + null means "clear it" (fall back to target_weeks); absent # means "leave unchanged" — same convention as the other nullable fields # on this model (see `_clear_*` handling in `LeaveWishService.update`). target_days: int | None = Field(default=None, ge=1) # Same instruction as on `LeaveWishCreate`, applied to an existing row: # 'yearly' on a standalone occurrence materialises a series around it; # 'once' on a series occurrence keeps this row and deletes its siblings. # Absent means "leave the row's repeat-ness alone". recurrence: str | None = None first_year: int | None = Field(None, ge=2000, le=2200) # Series-level; changing it on a row that is already part of a series # requires `?scope=series` (the service rejects it otherwise), since it # extends or shortens every year at once. last_year: int | None = Field(None, ge=2000, le=2200) priority: int | None = Field(None, ge=1) parent_mode: str | None = None parent_personal_data_uuid: UUID | None = None window_mode: str | None = None # 'fixed' | 'flexible' # `None` means "leave unchanged" here too, same as every other field on # this partial-update model -- there is no "clear it back to False" need # (False is already the shape-level default), so no `_clear_*` flag. wants_dubbeldag: bool | None = None class LeaveWishResponse(BaseModel): id: UUID workspace_id: UUID child_id: UUID | None name: str name_message_key: str | None window_start_month: int window_start_day: int window_end_month: int window_end_day: int target_weeks: int target_days: int | None first_year: int # Series-level intent, mirrored on every row of a series; None on a # standalone occurrence (and on a series that runs "until the child # turns 4"). See app/entities/leave_wish.py. last_year: int | None priority: int parent_mode: str parent_personal_data_uuid: UUID | None window_mode: str # Non-null when this occurrence is one year of a repeating wish; every # year of that wish shares the value. This replaces the old `recurrence` # field: "does this repeat?" is now "does it belong to a series?". series_id: UUID | None wants_dubbeldag: bool created_at: datetime | None updated_at: datetime | None @classmethod def from_entity(cls, entity: LeaveWish) -> "LeaveWishResponse": return cls( id=entity.id, workspace_id=entity.workspace_id, child_id=entity.child_id, name=entity.name, name_message_key=entity.name_message_key, window_start_month=entity.window_start_month, window_start_day=entity.window_start_day, window_end_month=entity.window_end_month, window_end_day=entity.window_end_day, target_weeks=entity.target_weeks, target_days=entity.target_days, first_year=entity.first_year, last_year=entity.last_year, priority=entity.priority, parent_mode=entity.parent_mode, parent_personal_data_uuid=entity.parent_personal_data_uuid, window_mode=entity.window_mode, series_id=entity.series_id, wants_dubbeldag=entity.wants_dubbeldag, created_at=entity.created_at, updated_at=entity.updated_at, )