feat(attachments): PostAttachment model + 0009 (table + batch counter)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||||
@@ -11,6 +11,7 @@ from .import_settings import ImportSettings
|
|||||||
from .import_task import ImportTask
|
from .import_task import ImportTask
|
||||||
from .ml_settings import MLSettings
|
from .ml_settings import MLSettings
|
||||||
from .post import Post
|
from .post import Post
|
||||||
|
from .post_attachment import PostAttachment
|
||||||
from .series_page import SeriesPage
|
from .series_page import SeriesPage
|
||||||
from .source import Source
|
from .source import Source
|
||||||
from .tag import Tag, TagKind, image_tag
|
from .tag import Tag, TagKind, image_tag
|
||||||
@@ -25,6 +26,7 @@ __all__ = [
|
|||||||
"Source",
|
"Source",
|
||||||
"Credential",
|
"Credential",
|
||||||
"Post",
|
"Post",
|
||||||
|
"PostAttachment",
|
||||||
"SeriesPage",
|
"SeriesPage",
|
||||||
"ImageRecord",
|
"ImageRecord",
|
||||||
"ImageProvenance",
|
"ImageProvenance",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class ImportBatch(Base):
|
|||||||
imported: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
imported: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
skipped: 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)
|
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)
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="running", index=True)
|
||||||
# running | complete | cancelled
|
# running | complete | cancelled
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user