"""Pydantic request/response models for the public-holiday calendar. Country codes are normalised here as well as in the service: the schema layer gives the caller a 422 naming the offending field, while the service guard is what protects the non-HTTP entry points (MCP, the seed loader, tests). """ from __future__ import annotations from datetime import date from typing import Any from pydantic import BaseModel, Field, field_validator, model_validator from app.core.geo import normalize_country_code from app.domains.hr.holiday_models import DAY_TYPES from app.domains.hr.holiday_notice import LANGUAGES from app.domains.hr.holiday_service import MAX_BULK_ROWS, MAX_YEAR, MIN_YEAR def _country(value: str) -> str: code = normalize_country_code(value) if not code: raise ValueError( f"unrecognised country/region {value!r} — use an ISO-3166 alpha-2 or " "alpha-3 code, or an English/Chinese country name" ) return code def _unique_dates(values: list[date]) -> list[date]: return sorted(set(values)) class DayRowBody(BaseModel): """One holiday day, for the single-day and bulk write paths.""" country: str = Field(..., max_length=64) day: date name_en: str = Field(..., min_length=1, max_length=255) name_local: str | None = Field(None, max_length=255) day_type: str = Field("public_holiday") note: str | None = Field(None, max_length=500) group_key: str | None = Field(None, max_length=96) @field_validator("country") @classmethod def _v_country(cls, v: str) -> str: return _country(v) @field_validator("day_type") @classmethod def _v_type(cls, v: str) -> str: value = (v or "").strip() if value not in DAY_TYPES: raise ValueError(f"day_type must be one of {list(DAY_TYPES)}") return value @field_validator("day") @classmethod def _v_day(cls, v: date) -> date: if not MIN_YEAR <= v.year <= MAX_YEAR: raise ValueError(f"day must fall between {MIN_YEAR} and {MAX_YEAR}") return v class DayPatchBody(BaseModel): """Partial edit of one day. Omitted fields are left alone. Nulling a nullable field needs an explicit ``clear_*`` flag, because "field absent" and "field set to null" are different intents and a plain ``None`` cannot tell them apart. """ day: date | None = None name_en: str | None = Field(None, min_length=1, max_length=255) name_local: str | None = Field(None, max_length=255) day_type: str | None = None note: str | None = Field(None, max_length=500) clear_local: bool = False clear_note: bool = False @field_validator("day_type") @classmethod def _v_type(cls, v: str | None) -> str | None: if v is None: return None value = v.strip() if value not in DAY_TYPES: raise ValueError(f"day_type must be one of {list(DAY_TYPES)}") return value @field_validator("day") @classmethod def _v_day(cls, v: date | None) -> date | None: if v is not None and not MIN_YEAR <= v.year <= MAX_YEAR: raise ValueError(f"day must fall between {MIN_YEAR} and {MAX_YEAR}") return v class GroupBody(BaseModel): """A whole holiday: its name, its days off, and its makeup workdays. The primary HR write shape — a holiday is created and edited as a unit rather than day by day, which is how HR thinks about it and what the notice generator and Word export need. """ country: str = Field(..., max_length=64) name_en: str = Field(..., min_length=1, max_length=255) name_local: str | None = Field(None, max_length=255) days: list[date] = Field(default_factory=list, max_length=MAX_BULK_ROWS) makeup_days: list[date] = Field(default_factory=list, max_length=MAX_BULK_ROWS) note: str | None = Field(None, max_length=500) # Only honoured on create, to add days to an existing group on purpose. group_key: str | None = Field(None, max_length=96) @field_validator("country") @classmethod def _v_country(cls, v: str) -> str: return _country(v) @field_validator("days", "makeup_days") @classmethod def _v_days(cls, v: list[date]) -> list[date]: for day in v: if not MIN_YEAR <= day.year <= MAX_YEAR: raise ValueError( f"dates must fall between {MIN_YEAR} and {MAX_YEAR}" ) return _unique_dates(v) @model_validator(mode="after") def _v_not_empty(self) -> GroupBody: if not self.days and not self.makeup_days: raise ValueError("a holiday needs at least one day") clash = set(self.days) & set(self.makeup_days) if clash: raise ValueError( "a date cannot be both a day off and a makeup workday: " + ", ".join(d.isoformat() for d in sorted(clash)) ) return self class CalendarBody(BaseModel): """Per country-year metadata.""" label: str | None = Field(None, max_length=64) stated_days: int | None = Field(None, ge=0, le=366) gcal_url: str | None = Field(None, max_length=512) region_note: str | None = Field(None, max_length=2000) clear_gcal: bool = False clear_note: bool = False clear_stated: bool = False class BulkBody(BaseModel): """Batch write — the commit half of the import flow, and the AI-parse sink.""" rows: list[DayRowBody] = Field(..., min_length=1, max_length=MAX_BULK_ROWS) # False (default) keeps existing rows untouched and reports them as skipped, # so re-importing a spreadsheet can never revert a later HR edit. overwrite: bool = False source: str = Field("import") class NoticeHtmlBody(BaseModel): """The (possibly HR-edited) notice text, plus what the text cannot express. ``base`` is the ``poster`` block from the original render. The text carries the wording; ``base`` carries the calendar day, the country and the footer region, so the rebuilt poster keeps its glyph, skyline and footer while following HR's edits everywhere else. """ text: str = Field(..., min_length=1, max_length=20000) base: dict[str, Any] | None = None class NoticeBody(BaseModel): """Options for the copy-ready holiday announcement.""" country: str = Field(..., max_length=64) group_key: str = Field(..., max_length=96) language: str = Field("zh") include_tips: bool = True include_emoji: bool = True closing_zh: str | None = Field(None, max_length=1000) closing_en: str | None = Field(None, max_length=1000) region_label: str | None = Field(None, max_length=64) @field_validator("country") @classmethod def _v_country(cls, v: str) -> str: return _country(v) @field_validator("language") @classmethod def _v_lang(cls, v: str) -> str: value = (v or "").strip().lower() if value not in LANGUAGES: raise ValueError(f"language must be one of {list(LANGUAGES)}") return value class AiParseBody(BaseModel): """Free text for the AI parser, plus the hints that keep it grounded.""" text: str = Field(..., min_length=1, max_length=20_000) # Used when the pasted text never names a country — which is the common case # for a single-country government notice. country: str | None = Field(None, max_length=64) # Rejects rows outside the year HR is maintaining, so a notice that mentions # last year's dates in passing does not add them. year: int | None = Field(None, ge=MIN_YEAR, le=MAX_YEAR) @field_validator("country") @classmethod def _v_country(cls, v: str | None) -> str | None: return _country(v) if v else None class AiClosingBody(BaseModel): """Ask for an alternative closing sentence for one holiday's notice.""" country: str = Field(..., max_length=64) group_key: str = Field(..., max_length=96) language: str = Field("zh") @field_validator("country") @classmethod def _v_country(cls, v: str) -> str: return _country(v) @field_validator("language") @classmethod def _v_lang(cls, v: str) -> str: value = (v or "zh").strip().lower() if value not in ("zh", "en"): raise ValueError("language must be 'zh' or 'en'") return value class RollForwardBody(BaseModel): """Open a new calendar year from an existing one's country roster.""" from_year: int = Field(..., ge=MIN_YEAR, le=MAX_YEAR) to_year: int = Field(..., ge=MIN_YEAR, le=MAX_YEAR) # Omit for every country maintained in from_year. countries: list[str] | None = Field(None, max_length=200) @field_validator("countries") @classmethod def _v_countries(cls, v: list[str] | None) -> list[str] | None: if not v: return None return [_country(code) for code in v] @model_validator(mode="after") def _v_years(self) -> RollForwardBody: if self.from_year == self.to_year: raise ValueError("from_year and to_year must differ") return self class SuggestBody(BaseModel): """Pull a country-year's statutory holidays as reviewable candidates.""" country: str = Field(..., max_length=64) year: int = Field(..., ge=MIN_YEAR, le=MAX_YEAR) @field_validator("country") @classmethod def _v_country(cls, v: str) -> str: return _country(v)