"""Pydantic schemas for the feed domain HTTP surface.""" from __future__ import annotations from datetime import date, datetime from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field # ── Post / comment / like ───────────────────────────────────────── class FeedAttachmentBody(BaseModel): s3_key: str = Field(min_length=1, max_length=512) mime_type: str | None = Field(default=None, max_length=64) size_bytes: int | None = Field(default=None, ge=0) width: int | None = Field(default=None, ge=0) height: int | None = Field(default=None, ge=0) class FeedPostCreate(BaseModel): body: str = Field(min_length=1, max_length=4000) visibility: str = Field(default="public") category: str = Field(default="general") mentioned_person_ids: list[str] = Field(default_factory=list) attachments: list[FeedAttachmentBody] = Field(default_factory=list) author_visibility: Literal["named", "anonymous"] = "named" # Idempotency key, unique per author. Send a value that is STABLE across # retries of one logical publish and different between publishes: the # composer mints a UUID per draft, the year-in-review sends the # deterministic ``wrapped:`` so sharing a recap is once-per-year on # the server rather than once-per-browser. Bounded at the column width so # an oversized value is a 422 and not a truncation that silently collides # with somebody else's key. client_token: str | None = Field(default=None, max_length=128) class FeedCommentCreate(BaseModel): body: str = Field(min_length=1, max_length=2000) mentioned_person_ids: list[str] = Field(default_factory=list) # ``feed_comments.id`` is a 36-char UUID string; bound the field at the # schema edge so an oversized value is a clean 422 rather than a silent # never-matches lookup inside ``add_comment``. reply_to_comment_id: str | None = Field(default=None, max_length=36) author_visibility: Literal["named", "anonymous"] = "named" class FeedReportCreate(BaseModel): reason: Literal["spam", "harassment", "privacy", "discrimination", "other"] detail: str | None = Field(default=None, max_length=1000) class FeedReportResolve(BaseModel): status: Literal["resolved", "dismissed"] hide_content: bool = False note: str | None = Field(default=None, max_length=1000) class FeedAuthorRevealRequest(BaseModel): reason: str = Field(min_length=4, max_length=1000) class FeedPostBodyEdit(BaseModel): """Rewrite a user post's body. ``max_length`` mirrors :class:`FeedPostCreate` (4000) — keeps edit and create symmetrically bounded so an author can't expand a post past its original cap. The body is the only editable field — visibility / category / attachments are not re-negotiable post-creation (breaks the dept- snapshot RBAC invariant + shifts the audit story). """ body: str = Field(min_length=1, max_length=4000) class FeedCommentBodyEdit(BaseModel): """Rewrite a comment's body. ``max_length`` mirrors :class:`FeedCommentCreate` (2000) — the previous shared ``FeedBodyEdit`` allowed 4000 and let a 2000-char comment grow past its original cap (a real but minor security review finding 2026-06-02). """ body: str = Field(min_length=1, max_length=2000) class FeedBlessingCreate(BaseModel): sticker_key: str = Field(min_length=1, max_length=32) message: str = Field(min_length=1, max_length=200) class FeedCelebrationThanks(BaseModel): """Body for ``POST /celebrations/{id}/thanks``. ``body`` is the celebrant's own 回谢 text and is published verbatim as a normal user post, so it reuses :class:`FeedPostCreate`'s 4000-char bound — not the 200-char blessing bound. There is no ``visibility`` / ``category`` knob: a thank-you addressed to everyone who wrote is public /general by definition. """ body: str = Field(min_length=1, max_length=4000) # ── Read-side DTOs (used by router serializers) ─────────────────── class PersonLite(BaseModel): id: str display_name: str | None = None work_email: str | None = None avatar_url: str | None = None department: str | None = None model_config = ConfigDict(from_attributes=True) class FeedAttachmentDTO(BaseModel): s3_key: str mime_type: str | None = None width: int | None = None height: int | None = None download_url: str | None = None class FeedCelebrantLite(BaseModel): person_id: str celebration_id: str display_name: str | None department: str | None avatar_url: str | None years: int # 0 for birthday class FeedPostDTO(BaseModel): id: str post_type: str visibility: str category: str author_visibility: str = "named" body: str is_pinned: bool author: PersonLite | None created_at: datetime # Author-edit metadata (migration 237). last_edited_at: datetime | None = None edit_count: int = 0 like_count: int comment_count: int liked_by_me: bool can_delete: bool can_edit: bool = False attachments: list[FeedAttachmentDTO] = [] mentioned_persons: list[PersonLite] = [] # System-post specific celebration: FeedCelebrantLite | None = None # Aggregate banner specific aggregate_celebrants: list[FeedCelebrantLite] = [] class FeedCommentDTO(BaseModel): id: str body: str author_visibility: str = "named" author: PersonLite created_at: datetime last_edited_at: datetime | None = None edit_count: int = 0 reply_to_comment_id: str | None sticker_key: str | None can_delete: bool can_edit: bool = False class FeedListResponse(BaseModel): items: list[FeedPostDTO] next_cursor: str | None # ── Announcements ──────────────────────────────────────────────── class FeedAnnouncementCreate(BaseModel): title: str = Field(min_length=1, max_length=256) body_markdown: str = Field(min_length=1, max_length=20000) category: str = Field(default="general") color: str = Field(default="default") is_pinned: bool = False publish_at: datetime | None = None expire_at: datetime | None = None # Constrain to the three real states (model: draft/published/archived) — # the sibling Update + batch endpoints already use a Literal. A typo'd # status on create would mass-assign a value no list query matches, # silently hiding the announcement on publish. status: Literal["draft", "published", "archived"] = "published" attachments_json: list[dict[str, Any]] | None = None class FeedAnnouncementUpdate(BaseModel): title: str | None = Field(default=None, max_length=256) body_markdown: str | None = Field(default=None, max_length=20000) category: str | None = None color: str | None = None is_pinned: bool | None = None publish_at: datetime | None = None expire_at: datetime | None = None # Constrain to the three real states (model: draft/published/archived). # Was an unconstrained ``str`` while the batch endpoint already used a # Literal — an admin typo would mass-assign a status no list query # matches, silently hiding the announcement. status: Literal["draft", "published", "archived"] | None = None attachments_json: list[dict[str, Any]] | None = None class FeedAnnouncementBatchStatus(BaseModel): """Body for ``POST /admin/announcements/batch-status``. ``ids`` is capped at 500 per the platform batch-endpoint convention (oversized payloads 422 at the schema layer before any row is touched). ``status`` only allows the two bulk-safe targets — ``draft`` stays a per-row editorial decision. """ ids: list[str] = Field(..., min_length=1, max_length=500) status: Literal["published", "archived"] class FeedAnnouncementDTO(BaseModel): id: str title: str body_markdown: str category: str color: str is_pinned: bool publish_at: datetime | None expire_at: datetime | None status: str attachments_json: list[dict[str, Any]] | None author: PersonLite created_at: datetime updated_at: datetime # ── Preferences / me / birthday ────────────────────────────────── class PreferencesDTO(BaseModel): birthday: date | None = None birthday_visibility: str = "month_day" # month_day | hidden notify_comment_feishu: bool = True notify_like_aggregate: bool = True # opt-out, see DEFAULT_PREFERENCES # Appearance — drives app-wide light/dark theme + ambient festival skin. theme_mode: str = "system" # light | dark | system festive_effects_enabled: bool = True wrapped_push_opt_out: bool = False # opt out of the year-end 年度报告 push class PreferencesUpdate(BaseModel): birthday: date | None = None birthday_visibility: str | None = None notify_comment_feishu: bool | None = None notify_like_aggregate: bool | None = None # Constrained enum — Pydantic rejects anything else with a clean 422 # so a malformed client never bricks the frontend theme resolver. theme_mode: Literal["light", "dark", "system"] | None = None festive_effects_enabled: bool | None = None wrapped_push_opt_out: bool | None = None # ── Today celebrations bundle (top banners) ────────────────────── class TodayCelebrationsBundle(BaseModel): occasion_date: date birthdays: list[FeedCelebrantLite] anniversaries: list[FeedCelebrantLite] __all__ = [ "FeedAnnouncementCreate", "FeedAnnouncementDTO", "FeedAnnouncementUpdate", "FeedAttachmentBody", "FeedAttachmentDTO", "FeedBlessingCreate", "FeedCelebrantLite", "FeedCelebrationThanks", "FeedCommentBodyEdit", "FeedCommentCreate", "FeedCommentDTO", "FeedListResponse", "FeedPostBodyEdit", "FeedPostCreate", "FeedPostDTO", "PersonLite", "PreferencesDTO", "PreferencesUpdate", "TodayCelebrationsBundle", ]