"""Pydantic schemas for the policy_notices domain — request bodies + response shapes for the admin CRUD surface, the public banner read, and the employee-facing 制度中心 library read.""" from __future__ import annotations import uuid from datetime import datetime from pydantic import BaseModel, ConfigDict, Field, field_validator # A single policy carries at most this many keyword tags; each tag is # capped so the JSON column stays bounded. Enforced on create/update. MAX_KEYWORDS = 30 MAX_KEYWORD_LEN = 50 def _validate_url(v: str) -> str: """Trim + require an http(s) URL. The banner / library card render the title as a link, so a non-navigable value would be a dead click.""" cleaned = (v or "").strip() if not cleaned: raise ValueError("feishu_url cannot be empty") if not (cleaned.startswith("http://") or cleaned.startswith("https://")): raise ValueError("feishu_url must start with http:// or https://") return cleaned def _normalize_keywords(v: list[str] | None) -> list[str]: """Trim, drop blanks, dedupe (case-insensitive, first-wins), and enforce the per-tag / count caps. ``None`` → ``[]``.""" if not v: return [] out: list[str] = [] seen: set[str] = set() for raw in v: tag = (raw or "").strip() if not tag: continue if len(tag) > MAX_KEYWORD_LEN: raise ValueError( f"keyword too long (max {MAX_KEYWORD_LEN} chars): {tag!r}" ) key = tag.lower() if key in seen: continue seen.add(key) out.append(tag) if len(out) > MAX_KEYWORDS: raise ValueError(f"too many keywords (max {MAX_KEYWORDS})") return out class PolicyNoticeBanner(BaseModel): """Shape returned to ANY signed-in user from ``GET /policy-notices`` (the header ribbon). Pinned subset only; minimal fields.""" id: uuid.UUID title_zh: str title_en: str feishu_url: str model_config = ConfigDict(from_attributes=True) class PolicyNoticeLibrary(PolicyNoticeBanner): """Shape returned from ``GET /policy-notices/library`` — the employee 制度中心 card. Adds the summary + keyword tags. Published rows only; enable flags / audit metadata stay admin-only.""" summary_zh: str | None = None summary_en: str | None = None keywords: list[str] = Field(default_factory=list) @field_validator("keywords", mode="before") @classmethod def _coerce_keywords(cls, v: list[str] | None) -> list[str]: # NULL column (legacy rows) → []. return v or [] class PolicyNoticeAdmin(PolicyNoticeLibrary): """Admin-side shape from ``GET /admin/policy-notices`` — adds the two visibility flags, ordering, audit metadata.""" is_published: bool pin_to_banner: bool display_order: int created_at: datetime updated_at: datetime created_by_person_id: uuid.UUID | None = None updated_by_person_id: uuid.UUID | None = None class PolicyNoticeCreate(BaseModel): """Body for ``POST /admin/policy-notices``. Titles + URL required.""" title_zh: str = Field(..., min_length=1, max_length=200) title_en: str = Field(..., min_length=1, max_length=200) summary_zh: str | None = Field(default=None, max_length=500) summary_en: str | None = Field(default=None, max_length=500) keywords: list[str] = Field(default_factory=list) feishu_url: str = Field(..., min_length=1, max_length=1000) is_published: bool = True pin_to_banner: bool = False @field_validator("feishu_url") @classmethod def _check_url(cls, v: str) -> str: return _validate_url(v) @field_validator("keywords") @classmethod def _check_keywords(cls, v: list[str]) -> list[str]: return _normalize_keywords(v) class PolicyNoticeUpdate(BaseModel): """Body for ``PUT /admin/policy-notices/{id}``. All fields optional — a partial update only mutates the keys present.""" title_zh: str | None = Field(default=None, min_length=1, max_length=200) title_en: str | None = Field(default=None, min_length=1, max_length=200) summary_zh: str | None = Field(default=None, max_length=500) summary_en: str | None = Field(default=None, max_length=500) keywords: list[str] | None = None feishu_url: str | None = Field(default=None, min_length=1, max_length=1000) is_published: bool | None = None pin_to_banner: bool | None = None @field_validator("feishu_url") @classmethod def _check_url(cls, v: str | None) -> str | None: return _validate_url(v) if v is not None else None @field_validator("keywords") @classmethod def _check_keywords(cls, v: list[str] | None) -> list[str] | None: return _normalize_keywords(v) if v is not None else None class PolicyNoticeReorder(BaseModel): """Body for ``POST /admin/policy-notices/reorder``. The ``ordered_ids`` list dictates the new ``display_order`` (0..n-1). Any ids in the DB not present in the list are pushed to the end of the order in their existing relative sequence (graceful handling of races where an admin deleted/added a notice in another tab).""" ordered_ids: list[uuid.UUID] = Field(..., min_length=1)