"""Post — provenance anchor for one creator post (may contain many images). `source_id` is nullable since alembic 0030 — filesystem-imported posts with no live subscription have NULL source_id. `artist_id` is the denormalized always-present link to the creator (added in 0030 so artist-filter queries don't depend on the Source detour). """ from datetime import datetime from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column from .base import Base class Post(Base): __tablename__ = "post" __table_args__ = ( # Source-bound dedup. Postgres treats NULL != NULL so rows # with source_id IS NULL aren't deduped by this constraint; # the partial unique index `uq_post_artist_external_id_null_source` # (created in alembic 0030) covers that case via # (artist_id, external_post_id). UniqueConstraint("source_id", "external_post_id", name="uq_post_source_external_id"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) source_id: Mapped[int | None] = mapped_column( ForeignKey("source.id", ondelete="SET NULL"), nullable=True, index=True ) # Denormalized; always equals source.artist_id when source_id is set # (the importer is responsible for keeping them consistent on insert). # Filter queries (artist detail, artist-scoped posts feed) use this # directly instead of joining through Source. artist_id: Mapped[int] = mapped_column( ForeignKey("artist.id", ondelete="CASCADE"), nullable=False, index=True, ) external_post_id: Mapped[str] = mapped_column(String(128), nullable=False) post_url: Mapped[str | None] = mapped_column(Text, nullable=True) post_title: Mapped[str | None] = mapped_column(Text, nullable=True) post_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) raw_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) attachment_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # -- Post-text translation (milestone 143). Filled by the translate_posts # sweep via the Interpreter LAN service so viewing is instant. # translated_source_lang is the DETECTED original language; "en" (or a # passthrough) means nothing to translate and the *_translated columns stay # NULL. engine_version keys the Interpreter cache — re-runs are ~1ms and a # model upgrade re-translates instead of serving stale. post_title_translated: Mapped[str | None] = mapped_column(Text, nullable=True) description_translated: Mapped[str | None] = mapped_column(Text, nullable=True) translated_source_lang: Mapped[str | None] = mapped_column( String(8), nullable=True ) translation_engine_version: Mapped[str | None] = mapped_column( String(128), nullable=True ) translated_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) downloaded_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() )