"""Pydantic schemas for the security_awareness domain. The interesting one is :class:`LocalizedContent`. Everything a material says lives inside a JSON column, and a JSON column is exactly where field validation goes to die — so the whole contract is enforced here, on the way in, and the service never writes a dict it did not get back from ``MaterialWrite.normalized_localized()``. """ from __future__ import annotations import uuid from datetime import datetime from typing import Any from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.domains.security_awareness.catalog import ( BASELINE_LANG, CLIENT_REPORTABLE_SURFACES, IMPRESSION_SURFACES, KIND_ARTICLE, KIND_COMIC, KIND_LINK, MATERIAL_CATEGORIES, MATERIAL_KINDS, MATERIAL_LANGS, MAX_BODY_LEN, MAX_IMAGES_PER_LANG, MAX_LINK_LEN, MAX_SUMMARY_LEN, MAX_TAG_LEN, MAX_TAGS, MAX_TITLE_LEN, ) # Object keys are produced by ``storage_router._build_relative_key`` and # capped there; mirror the cap so an oversize key is rejected at the edge # instead of truncating in the column. MAX_OBJECT_KEY_LEN = 512 def _clean(v: str | None) -> str | None: """Trim; empty-after-trim becomes ``None`` so " " never masquerades as content and light up a language block that has nothing in it.""" if v is None: return None cleaned = v.strip() return cleaned or None def _validate_https_url(v: str) -> str: cleaned = (v or "").strip() if not cleaned: raise ValueError("link_url cannot be empty") if not (cleaned.startswith("http://") or cleaned.startswith("https://")): raise ValueError("link_url must start with http:// or https://") if len(cleaned) > MAX_LINK_LEN: raise ValueError(f"link_url too long (max {MAX_LINK_LEN})") return cleaned class LocalizedContent(BaseModel): """One language's copy of a material. Every field except ``title`` is optional; which of them must be populated depends on the material's ``kind``, and that cross-field rule lives on :class:`MaterialWrite` (the kind is not visible from inside a single language block). """ title: str = Field(..., min_length=1, max_length=MAX_TITLE_LEN) summary: str | None = Field(default=None, max_length=MAX_SUMMARY_LEN) # Markdown. Rendered by the frontend's existing markdown renderer, # which sanitises — this is admin-authored content, but "admin # authored" is not the same as "safe to inject as HTML". body: str | None = Field(default=None, max_length=MAX_BODY_LEN) # S3 object keys, in reading order. NOT urls — see the model docstring. image_keys: list[str] = Field(default_factory=list) link_url: str | None = Field(default=None, max_length=MAX_LINK_LEN) cover_key: str | None = Field(default=None, max_length=MAX_OBJECT_KEY_LEN) @field_validator("title") @classmethod def _check_title(cls, v: str) -> str: cleaned = v.strip() if not cleaned: raise ValueError("title cannot be empty after trimming") return cleaned @field_validator("summary", "body") @classmethod def _trim_optional(cls, v: str | None) -> str | None: return _clean(v) @field_validator("link_url") @classmethod def _check_link(cls, v: str | None) -> str | None: # Trim first, then validate the trimmed value — passing the raw # ``v`` while testing the cleaned one is how " https://x " ends # up stored with its whitespace. cleaned = _clean(v) return _validate_https_url(cleaned) if cleaned else None @field_validator("image_keys") @classmethod def _check_images(cls, v: list[str]) -> list[str]: out: list[str] = [] for raw in v or []: key = (raw or "").strip() if not key: continue if len(key) > MAX_OBJECT_KEY_LEN: raise ValueError( f"object key too long (max {MAX_OBJECT_KEY_LEN})" ) out.append(key) if len(out) > MAX_IMAGES_PER_LANG: raise ValueError( f"too many images for one language (max {MAX_IMAGES_PER_LANG})" ) return out @field_validator("cover_key") @classmethod def _check_cover(cls, v: str | None) -> str | None: return _clean(v) def _normalize_tags(v: list[str] | None) -> list[str]: """Trim, drop blanks, dedupe case-insensitively (first spelling wins), enforce caps. ``None`` → ``[]``. Same contract as ``policy_notices._normalize_keywords`` — deliberately, so the two tag surfaces behave identically for an admin who uses both.""" 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_TAG_LEN: raise ValueError(f"tag too long (max {MAX_TAG_LEN} chars): {tag!r}") key = tag.lower() if key in seen: continue seen.add(key) out.append(tag) if len(out) > MAX_TAGS: raise ValueError(f"too many tags (max {MAX_TAGS})") return out class MaterialWrite(BaseModel): """Fields shared by create and (full) update. ``localized`` is a map keyed by language code. Only the three languages the platform speaks are accepted; anything else is a typo that would silently become unreachable content. """ kind: str = Field(default=KIND_ARTICLE) category: str = Field(default="other") tags: list[str] = Field(default_factory=list) localized: dict[str, LocalizedContent] estimated_minutes: int | None = Field(default=None, ge=1, le=600) @field_validator("kind") @classmethod def _check_kind(cls, v: str) -> str: if v not in MATERIAL_KINDS: raise ValueError(f"unknown kind {v!r}") return v @field_validator("category") @classmethod def _check_category(cls, v: str) -> str: if v not in MATERIAL_CATEGORIES: raise ValueError(f"unknown category {v!r}") return v @field_validator("tags") @classmethod def _check_tags(cls, v: list[str]) -> list[str]: return _normalize_tags(v) @field_validator("localized") @classmethod def _check_langs( cls, v: dict[str, LocalizedContent], ) -> dict[str, LocalizedContent]: if not v: raise ValueError("at least one language block is required") unknown = sorted(set(v) - set(MATERIAL_LANGS)) if unknown: raise ValueError(f"unsupported language(s): {', '.join(unknown)}") if BASELINE_LANG not in v: # The fallback chain terminates at zh. A material without it # renders as an empty card for anyone whose chain reaches the # end — which is every reader, for at least one language. raise ValueError( f"the {BASELINE_LANG!r} block is required " "(it is the end of the language fallback chain)" ) return v @model_validator(mode="after") def _check_kind_content(self) -> MaterialWrite: """Each language block must actually carry what its kind promises. This is the check that keeps a "comic" with no images out of the library — it renders as a title and nothing else, and nobody notices until an employee opens it. """ for lang, block in self.localized.items(): if self.kind == KIND_COMIC and not block.image_keys: raise ValueError( f"kind=comic requires at least one image in '{lang}'" ) if self.kind == KIND_ARTICLE and not block.body: raise ValueError(f"kind=article requires a body in '{lang}'") if self.kind == KIND_LINK and not block.link_url: raise ValueError(f"kind=link requires a link_url in '{lang}'") return self def normalized_localized(self) -> dict[str, dict[str, Any]]: """The exact dict the service is allowed to persist. Keys the block does not use for this kind are dropped rather than stored as NULL, so switching a material from ``article`` to ``link`` cannot leave an orphan body behind that a future reader renders by accident. """ out: dict[str, dict[str, Any]] = {} for lang, block in self.localized.items(): payload: dict[str, Any] = {"title": block.title} if block.summary: payload["summary"] = block.summary if self.kind == KIND_ARTICLE and block.body: payload["body"] = block.body if self.kind == KIND_COMIC and block.image_keys: payload["image_keys"] = block.image_keys if self.kind == KIND_LINK and block.link_url: payload["link_url"] = block.link_url if block.cover_key: payload["cover_key"] = block.cover_key out[lang] = payload return out def object_keys(self) -> list[str]: """Every S3 key referenced by this payload, across languages. The service validates each one against the upload ACL before persisting: an object key arriving in a request body is attacker-controlled, and a material is a *presigning* surface. """ keys: list[str] = [] for block in self.localized.values(): keys.extend(block.image_keys) if block.cover_key: keys.append(block.cover_key) return keys class MaterialCreate(MaterialWrite): """Body for ``POST /admin/security-awareness/materials``. A material is created as a draft unless the admin explicitly asks to publish — the editor has a preview, and "saved" should not mean "the whole company can see it" by default. """ publish: bool = False is_pinned: bool = False class MaterialUpdate(BaseModel): """Body for ``PUT /admin/security-awareness/materials/{id}``. Partial: only keys present are touched. ``localized`` is replaced wholesale when present (a per-language merge would make "delete the Japanese translation" impossible to express). """ kind: str | None = None category: str | None = None tags: list[str] | None = None localized: dict[str, LocalizedContent] | None = None estimated_minutes: int | None = Field(default=None, ge=1, le=600) is_pinned: bool | None = None @field_validator("kind") @classmethod def _check_kind(cls, v: str | None) -> str | None: if v is not None and v not in MATERIAL_KINDS: raise ValueError(f"unknown kind {v!r}") return v @field_validator("category") @classmethod def _check_category(cls, v: str | None) -> str | None: if v is not None and v not in MATERIAL_CATEGORIES: raise ValueError(f"unknown category {v!r}") return v @field_validator("tags") @classmethod def _check_tags(cls, v: list[str] | None) -> list[str] | None: return _normalize_tags(v) if v is not None else None @field_validator("localized") @classmethod def _check_langs( cls, v: dict[str, LocalizedContent] | None, ) -> dict[str, LocalizedContent] | None: if v is None: return None return MaterialWrite._check_langs(v) class MaterialStatusUpdate(BaseModel): """Body for the lifecycle endpoint. Separate from the content update so "publish" is one deliberate click, not a checkbox that rides along with an unrelated typo fix.""" status: str @field_validator("status") @classmethod def _check_status(cls, v: str) -> str: from app.domains.security_awareness.catalog import MATERIAL_STATUSES if v not in MATERIAL_STATUSES: raise ValueError(f"unknown status {v!r}") return v class MaterialReorder(BaseModel): """Body for ``POST /admin/security-awareness/materials/reorder``. ``ordered_ids`` dictates ``display_order`` 0..n-1. Ids in the table but absent from the list keep their relative sequence at the end — the same graceful-race handling ``policy_notices`` uses for an admin who deleted a row in another tab. """ ordered_ids: list[uuid.UUID] = Field(..., min_length=1) class MaterialTranslateRequest(BaseModel): """Body for ``POST /admin/security-awareness/materials/{id}/translate``. ``targets`` defaults to "whatever this material is missing", which is what the console's one-click affordance wants. ``overwrite`` is the deliberate re-run: it replaces an existing block, so it is off by default — a re-translate that silently discarded a human's edit would be the worst possible default on a page whose whole job is content. """ targets: list[str] | None = None overwrite: bool = False #: ``False`` runs the model and returns the blocks WITHOUT storing #: them. That is the material editor's path: it fills the form, the #: author reviews on screen, and saving stays their action — a #: translate button that wrote behind their back would also leave a #: translation behind when they cancelled the drawer. persist: bool = True @field_validator("targets") @classmethod def _check_targets(cls, v: list[str] | None) -> list[str] | None: if v is None: return None unknown = sorted(set(v) - set(MATERIAL_LANGS)) if unknown: raise ValueError(f"unsupported language(s): {', '.join(unknown)}") if BASELINE_LANG in v: raise ValueError( f"{BASELINE_LANG!r} is the source language and is never " "machine-translated" ) # Dedupe, preserving order. return list(dict.fromkeys(v)) class TranslateMissingRequest(BaseModel): """Body for ``POST /admin/security-awareness/materials/translate-missing``. The bulk "补齐" button. ``limit`` is capped rather than unbounded: each material costs up to four model calls at a few seconds each, and nginx cuts a request at 300s. The service commits per material so a cut connection does not discard what it already paid for, but the honest cap is still "a run that finishes inside the gateway window" — the response reports ``outstanding`` and the button says to click again. """ limit: int = Field(default=10, ge=1, le=10) include_drafts: bool = False class TranslationReviewed(BaseModel): """Body for ``POST /admin/.../materials/{id}/translation-reviewed``. "I read the machine translation and it is fine." The badge is also cleared automatically by an edit; this is the path for the common case where the review produced no edit at all. """ lang: str @field_validator("lang") @classmethod def _check_lang(cls, v: str) -> str: if v not in MATERIAL_LANGS: raise ValueError(f"unsupported language {v!r}") return v class ImpressionCreate(BaseModel): """Body for ``POST /security-awareness/impressions``. The client says *what it showed*; the server decides whether that is a new exposure (dedupe window) and stamps who/when/which version. The accepted set is ``CLIENT_REPORTABLE_SURFACES`` — the ambient bubble alone — not every surface the ledger can hold. The browser is only trusted for the exposure the server has no other way to see; ``detail`` is written by the material read route (where a human actually opened the page) and campaign reach is the outbox's job. Trusting a client's word for ``detail`` would let anybody mark fifteen materials read without opening one, and "read" is the stronger claim the participation report and the badges are built on. """ material_id: uuid.UUID surface: str @field_validator("surface") @classmethod def _check_surface(cls, v: str) -> str: if v not in CLIENT_REPORTABLE_SURFACES: # Named separately from "unknown": a client sending a real # but server-owned surface is a different mistake than a # typo, and the message should say which. if v in IMPRESSION_SURFACES: raise ValueError( f"surface {v!r} is recorded by the server, not reported " "by the client" ) raise ValueError(f"unknown surface {v!r}") return v # ── Read shapes ───────────────────────────────────────────────────── class MaterialCard(BaseModel): """The employee library card — resolved into ONE language already. The frontend never sees the raw ``localized`` map: language resolution is a backend decision (``app.core.locale``) and doing it twice is how the platform ended up with six drifting ``preferred_language.startswith`` tests. What the reader is shown is also what the impression ledger records, which only works if one side decides. """ id: uuid.UUID kind: str category: str tags: list[str] = Field(default_factory=list) title: str summary: str | None = None cover_url: str | None = None estimated_minutes: int | None = None is_pinned: bool version: int published_at: datetime | None = None # Which language block actually rendered, and whether that was a # fallback. The card shows a quiet "only available in 中文" hint when # it was — better than silently serving content the reader may not # be able to read. lang: str is_fallback_lang: bool # Whether the block being rendered was written by a model and not # yet reviewed by a human (``SecawMaterial.mt_langs``). A DIFFERENT # statement from ``is_fallback_lang``: that one says "this is not # your language", this one says "this IS your language, a machine # wrote it". Conflating them would have the English card telling an # English reader it was Chinese. machine_translated: bool = False machine_translated_from: str | None = None # This reader's own history with the material. viewed_at: datetime | None = None model_config = ConfigDict(from_attributes=True) class MaterialDetail(MaterialCard): """The employee detail view. Adds the actual content, with every S3 key already exchanged for a short-lived presigned URL.""" body: str | None = None image_urls: list[str] = Field(default_factory=list) link_url: str | None = None # Languages this material exists in, so the detail page can offer a # switch when the reader wants the original. available_langs: list[str] = Field(default_factory=list) # Of those, the ones a model wrote — so the switcher can mark them # rather than presenting machine and human copy as equivalent. machine_translated_langs: list[str] = Field(default_factory=list) class MaterialAdminRow(BaseModel): """Admin console row — raw localized payload, lifecycle, reach.""" id: uuid.UUID kind: str category: str tags: list[str] = Field(default_factory=list) localized: dict[str, Any] # Language blocks written by a model and not yet reviewed. mt_langs: list[str] = Field(default_factory=list) status: str is_pinned: bool display_order: int estimated_minutes: int | None = None version: int published_at: datetime | None = None archived_at: datetime | None = None created_at: datetime updated_at: datetime created_by_person_id: uuid.UUID | None = None updated_by_person_id: uuid.UUID | None = None created_by_label: str | None = None updated_by_label: str | None = None # Reach so far, from the impression ledger. Distinct people, not # rows: "shown 900 times to 12 people" and "shown to 300 people" are # different facts and the admin console must not conflate them. reached_people: int = 0 impressions: int = 0 model_config = ConfigDict(from_attributes=True) class BubbleSettingsUpdate(BaseModel): """Body for ``PUT /admin/security-awareness/bubble-settings``. Partial: only keys present are written. The bounds are enforced here AND coerced again in ``ambient.load_settings`` — this endpoint is not the only way a value can land in ``platform_settings`` (the generic settings page can write the same key), and the reader runs on every authenticated page, so it must survive a value it did not validate. """ enabled: bool | None = None #: 0 means "show nothing new" — a legitimate way to pause the surface #: without disabling it, which keeps whatever is already in today's #: rotation on screen instead of blanking it mid-day. daily_new_materials: int | None = Field(default=None, ge=0, le=10) max_repeats_per_material: int | None = Field(default=None, ge=1, le=20) class QuizSubmission(BaseModel): """Body for ``POST /security-awareness/materials/{id}/quiz``. ``answers`` is ``{question_id: "A" | ["A", "C"]}``. Both spellings are accepted and normalised identically (sorted, upper-cased key concatenation) — the frontend sends a list for a multi-select and a string for a single, and making the API pick one would push a conversion into every client. """ answers: dict[str, str | list[str]] = Field(default_factory=dict) class QuizQuestionSet(BaseModel): """Body for ``PUT /admin/security-awareness/materials/{id}/quiz``. Wholesale replacement, in order. Empty list removes the quiz, which turns the material's confirm control back into the plain acknowledgement button. """ question_ids: list[uuid.UUID] = Field(default_factory=list)