diff --git a/alembic/versions/0020_library_audit_run.py b/alembic/versions/0020_library_audit_run.py new file mode 100644 index 0000000..07a8b87 --- /dev/null +++ b/alembic/versions/0020_library_audit_run.py @@ -0,0 +1,65 @@ +"""fc-cleanup: library_audit_run table for async transparency/single_color audits + +Revision ID: 0020 +Revises: 0019 +Create Date: 2026-05-26 + +The table backs the async audit lifecycle: rule + params snapshot, status +state machine ('running' → 'ready' → 'applied'/'cancelled'/'error'), and +the matched_ids JSONB array that the apply step deletes. Capped at 50k IDs +per row by the scan task (oversize = rule too aggressive, operator narrows +before re-running). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0020" +down_revision: Union[str, None] = "0019" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "library_audit_run", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("rule", sa.String(32), nullable=False), + sa.Column("params", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column( + "status", sa.String(16), + nullable=False, server_default="running", + ), + sa.Column( + "started_at", sa.DateTime(timezone=True), + nullable=False, server_default=sa.func.now(), + ), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "scanned_count", sa.Integer(), + nullable=False, server_default="0", + ), + sa.Column( + "matched_count", sa.Integer(), + nullable=False, server_default="0", + ), + sa.Column( + "matched_ids", postgresql.JSONB(astext_type=sa.Text()), + nullable=False, server_default=sa.text("'[]'::jsonb"), + ), + sa.Column("error", sa.Text(), nullable=True), + ) + op.create_index( + "ix_library_audit_run_rule", "library_audit_run", ["rule"], + ) + op.create_index( + "ix_library_audit_run_status", "library_audit_run", ["status"], + ) + + +def downgrade() -> None: + op.drop_index("ix_library_audit_run_status", table_name="library_audit_run") + op.drop_index("ix_library_audit_run_rule", table_name="library_audit_run") + op.drop_table("library_audit_run") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0cefb2d..00a7c80 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,6 +11,7 @@ from .image_record import ImageRecord from .import_batch import ImportBatch from .import_settings import ImportSettings from .import_task import ImportTask +from .library_audit_run import LibraryAuditRun from .migration_run import MigrationRun from .ml_settings import MLSettings from .post import Post @@ -43,6 +44,7 @@ __all__ = [ "ImportBatch", "ImportTask", "ImportSettings", + "LibraryAuditRun", "MLSettings", "MigrationRun", "TagAlias", diff --git a/backend/app/models/library_audit_run.py b/backend/app/models/library_audit_run.py new file mode 100644 index 0000000..ca5aa82 --- /dev/null +++ b/backend/app/models/library_audit_run.py @@ -0,0 +1,37 @@ +"""LibraryAuditRun — async transparency / single_color audit lifecycle. + +State machine: running → ready → applied / cancelled / error. +matched_ids JSONB is appended-to by scan_library_for_rule; apply_audit_run +reads it and routes through cleanup_service.delete_images. +""" + +from datetime import datetime +from typing import Any + +from sqlalchemy import DateTime, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class LibraryAuditRun(Base): + __tablename__ = "library_audit_run" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + rule: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + status: Mapped[str] = mapped_column( + String(16), nullable=False, default="running", index=True, + ) + # running | ready | applied | cancelled | error + started_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), + ) + finished_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, + ) + scanned_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + matched_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + matched_ids: Mapped[list[int]] = mapped_column(JSONB, nullable=False, default=list) + error: Mapped[str | None] = mapped_column(Text, nullable=True)