"""ORM rows owned by the platform_tips domain. A single table for now — :class:`PlatformSecurityTip` — backing the header awareness ribbon. Bilingual content + per-row enable + display order; mutations are global_admin-gated and audited at the service layer. """ from __future__ import annotations import uuid from datetime import datetime from sqlalchemy import ( Boolean, DateTime, ForeignKey, Integer, String, ) from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.sql import func from app.core.database import Base from app.core.sql_types import GUID as Uuid class PlatformSecurityTip(Base): """One rotating security / IT awareness tip shown in the app header. Display flow: * Frontend ``SecurityTipsBanner`` calls ``GET /api/v1/security-tips`` on mount. * Backend returns rows with ``enabled=True`` ordered by ``display_order`` ASC, ``created_at`` ASC as a stable tiebreak. * Banner cycles through them with a fade transition; picks the ``text_zh`` or ``text_en`` field by the active i18n language. Admin flow lives behind ``/api/v1/admin/security-tips/*`` — full CRUD + reorder, gated by ``can_manage_assets`` is wrong here so we use ``can_manage_platform_tips`` (global_admin only). Each mutation appends to ``audit_logs`` with the actor and the resource id. ``text_zh`` / ``text_en`` are both NOT NULL by design — the banner needs something to render whichever language a user has selected, and a half-translated tip is worse than no tip. Admins must fill both at create time. ``text_ja`` is optional (migration 598): the platform speaks three languages, and before that column existed a Japanese reader could not be served even in principle. The renderer falls back ja → en → zh. """ __tablename__ = "platform_security_tips" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, default=uuid.uuid4, ) text_zh: Mapped[str] = mapped_column(String(500), nullable=False) text_en: Mapped[str] = mapped_column(String(500), nullable=False) # Added 2026-09-03 (migration 598). Nullable and deliberately NOT # backfilled: NULL is the honest value for "not translated yet", and # the renderer falls back ja → en → zh. Copying the English in would # disguise "untranslated" as "translated" and make the two # indistinguishable forever after. text_ja: Mapped[str | None] = mapped_column(String(500), nullable=True) enabled: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="1", ) # Lower values render first. Admins reorder by drag-and-drop on # the management page, which issues a single bulk reorder POST so # the whole set updates atomically (no half-saved order). display_order: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0", ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) created_by_person_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=True, ) updated_by_person_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=True, )