"""PostAttachment — a non-art file preserved from a post. Art images become ImageRecords; everything else a post contained (archives, .exe, .pdf, ...) is captured here so nothing is lost. post_id is nullable (set only when an adjacent sidecar yields a Post); artist_id mirrors the canonical attribution model (FC-2d-vii-c). Both FKs are SET NULL so deleting a Post/Artist never deletes the preserved binary row. """ from datetime import datetime from sqlalchemy import ( BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func, text, ) from sqlalchemy.orm import Mapped, mapped_column from .base import Base class PostAttachment(Base): __tablename__ = "post_attachment" # Dedup is PER-POST, not global (2026-06-08): the same non-art file attached # to many posts gets one row per post over a single sha-addressed blob, so no # post is left a bare shell. Partial uniques: (post_id, sha256) for real posts; # (sha256) alone for the NULL-post filesystem case (one row per file there). __table_args__ = ( Index( "uq_post_attachment_post_sha", "post_id", "sha256", unique=True, postgresql_where=text("post_id IS NOT NULL"), ), Index( "uq_post_attachment_null_post_sha", "sha256", unique=True, postgresql_where=text("post_id IS NULL"), ), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) post_id: Mapped[int | None] = mapped_column( ForeignKey("post.id", ondelete="SET NULL"), nullable=True, index=True ) artist_id: Mapped[int | None] = mapped_column( ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True ) sha256: Mapped[str] = mapped_column( String(64), nullable=False, index=True ) path: Mapped[str] = mapped_column(Text, nullable=False) original_filename: Mapped[str] = mapped_column(Text, nullable=False) ext: Mapped[str] = mapped_column(String(32), nullable=False) mime: Mapped[str | None] = mapped_column(String(128), nullable=True) size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) captured_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) def attachment_download_url(attachment_id: int) -> str: """The path that streams this attachment's bytes. Both serializers that expose an attachment to the frontend (`provenance_service`, `post_feed_service`) built this literal themselves, so changing the route in `api/attachments.py` meant two edits and only one would be remembered (#3072). `test_attachment_download_url` pins it against the app's registered rule, so the drift is caught rather than trusted to. """ return f"/api/attachments/{attachment_id}/download"