diff --git a/alembic/versions/0009_fc2d_iii_post_attachment.py b/alembic/versions/0009_fc2d_iii_post_attachment.py new file mode 100644 index 0000000..4820fa4 --- /dev/null +++ b/alembic/versions/0009_fc2d_iii_post_attachment.py @@ -0,0 +1,68 @@ +"""fc2d-iii: post_attachment + import_batch.attachments + +Revision ID: 0009 +Revises: 0008 +Create Date: 2026-05-19 + +Internal forward-correctness migration (big legacy-import migration +stays deferred). No backfill — no attachments exist yet. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0009" +down_revision: Union[str, None] = "0008" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "post_attachment", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "post_id", sa.Integer(), + sa.ForeignKey("post.id", ondelete="SET NULL"), nullable=True, + ), + sa.Column( + "artist_id", sa.Integer(), + sa.ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, + ), + sa.Column("sha256", sa.String(64), nullable=False), + sa.Column("path", sa.Text(), nullable=False), + sa.Column("original_filename", sa.Text(), nullable=False), + sa.Column("ext", sa.String(32), nullable=False), + sa.Column("mime", sa.String(128), nullable=True), + sa.Column("size_bytes", sa.BigInteger(), nullable=False), + sa.Column( + "captured_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False, + ), + ) + op.create_index( + "ix_post_attachment_sha256", "post_attachment", ["sha256"], + unique=True, + ) + op.create_index( + "ix_post_attachment_post_id", "post_attachment", ["post_id"], + ) + op.create_index( + "ix_post_attachment_artist_id", "post_attachment", ["artist_id"], + ) + op.add_column( + "import_batch", + sa.Column( + "attachments", sa.Integer(), nullable=False, + server_default="0", + ), + ) + + +def downgrade() -> None: + op.drop_column("import_batch", "attachments") + op.drop_index("ix_post_attachment_artist_id", table_name="post_attachment") + op.drop_index("ix_post_attachment_post_id", table_name="post_attachment") + op.drop_index("ix_post_attachment_sha256", table_name="post_attachment") + op.drop_table("post_attachment") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index db65914..25c2d12 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,6 +11,7 @@ from .import_settings import ImportSettings from .import_task import ImportTask from .ml_settings import MLSettings from .post import Post +from .post_attachment import PostAttachment from .series_page import SeriesPage from .source import Source from .tag import Tag, TagKind, image_tag @@ -25,6 +26,7 @@ __all__ = [ "Source", "Credential", "Post", + "PostAttachment", "SeriesPage", "ImageRecord", "ImageProvenance", diff --git a/backend/app/models/import_batch.py b/backend/app/models/import_batch.py index 26003e3..d26db4c 100644 --- a/backend/app/models/import_batch.py +++ b/backend/app/models/import_batch.py @@ -25,6 +25,7 @@ class ImportBatch(Base): imported: Mapped[int] = mapped_column(Integer, nullable=False, default=0) skipped: Mapped[int] = mapped_column(Integer, nullable=False, default=0) failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + attachments: Mapped[int] = mapped_column(Integer, nullable=False, default=0) status: Mapped[str] = mapped_column(String(16), nullable=False, default="running", index=True) # running | complete | cancelled diff --git a/backend/app/models/post_attachment.py b/backend/app/models/post_attachment.py new file mode 100644 index 0000000..4fea125 --- /dev/null +++ b/backend/app/models/post_attachment.py @@ -0,0 +1,47 @@ +"""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, + Integer, + String, + Text, + func, +) +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class PostAttachment(Base): + __tablename__ = "post_attachment" + + 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, unique=True, 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() + ) diff --git a/tests/test_migration_0009.py b/tests/test_migration_0009.py new file mode 100644 index 0000000..f2a0d1f --- /dev/null +++ b/tests/test_migration_0009.py @@ -0,0 +1,37 @@ +"""FC-2d-iii: post_attachment table + import_batch.attachments column.""" + +import pytest + +from backend.app.models import ImportBatch, PostAttachment + +pytestmark = pytest.mark.integration + + +def test_post_attachment_columns(): + cols = {c.name for c in PostAttachment.__table__.columns} + assert { + "id", "post_id", "artist_id", "sha256", "path", + "original_filename", "ext", "mime", "size_bytes", "captured_at", + } <= cols + + +def test_import_batch_has_attachments_counter(): + assert "attachments" in {c.name for c in ImportBatch.__table__.columns} + + +@pytest.mark.asyncio +async def test_post_attachment_roundtrip(db): + from backend.app.models import Artist + + a = Artist(name="Zed", slug="zed") + db.add(a) + await db.flush() + att = PostAttachment( + post_id=None, artist_id=a.id, sha256="z" + "0" * 63, + path="/images/attachments/z00/z.zip", original_filename="pack.zip", + ext=".zip", mime="application/zip", size_bytes=123, + ) + db.add(att) + await db.flush() + got = await db.get(PostAttachment, att.id) + assert got.original_filename == "pack.zip" and got.post_id is None