feat(fc-cleanup): audit lifecycle service functions (start/apply/cancel) + tests — Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

This commit is contained in:
2026-05-26 01:20:41 -04:00
parent 8a5b337a53
commit 4f2ceaaf31
2 changed files with 195 additions and 2 deletions
+99 -2
View File
@@ -12,12 +12,14 @@ re-exports from this module and then delete the wrapper.
"""
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from sqlalchemy import func, select
from sqlalchemy import func, select, update
from sqlalchemy.orm import Session
from ..models import Artist, ImageRecord, Tag
from ..models import Artist, ImageRecord, LibraryAuditRun, Tag
from ..models.series_page import SeriesPage
from ..models.tag import image_tag
@@ -409,3 +411,98 @@ def delete_min_dimension_violations(
session, image_ids=list(ids), images_root=images_root,
)
return result["images_deleted"]
# ---------------------------------------------------------------------------
# Audit lifecycle (transparency + single_color async scans).
# ---------------------------------------------------------------------------
class AuditAlreadyRunning(Exception):
"""Another audit_run is currently in status='running' — wait or
cancel it before starting a new one. Surfaces as HTTP 409 in the
/api/cleanup/audit POST endpoint."""
class AuditNotReady(Exception):
"""apply_audit_run called on an audit whose status is not 'ready'."""
class ConfirmTokenMismatch(Exception):
"""Operator-supplied confirm token did not match server-recomputed token."""
_VALID_RULES = ("transparency", "single_color")
def start_audit_run(
session: Session, *, rule: str, params: dict[str, Any],
) -> int:
"""Create a LibraryAuditRun row in status='running' and dispatch the
scan_library_for_rule Celery task. Returns the new audit_id.
Concurrent-runs guard: raises AuditAlreadyRunning if any audit_run
has status='running'. Operator must cancel or wait."""
if rule not in _VALID_RULES:
raise ValueError(f"unknown rule {rule!r}; expected one of {_VALID_RULES}")
existing = session.execute(
select(LibraryAuditRun.id).where(LibraryAuditRun.status == "running")
).scalar_one_or_none()
if existing is not None:
raise AuditAlreadyRunning(existing)
audit = LibraryAuditRun(
rule=rule,
params=params,
status="running",
scanned_count=0,
matched_count=0,
matched_ids=[],
)
session.add(audit)
session.flush()
audit_id = audit.id
# Dispatch after flush so audit_id is populated; commit happens in
# the API handler so the audit row + dispatch are visible together.
from ..tasks.library_audit import scan_library_for_rule
scan_library_for_rule.delay(audit_id)
return audit_id
def apply_audit_run(
session: Session, *, audit_id: int, confirm_token: str, images_root: Path,
) -> int:
"""Delete all images in audit_run.matched_ids after confirming token.
Marks audit status='applied'. Routes through delete_images so files
+ cascading FK rows are handled uniformly."""
audit = session.execute(
select(LibraryAuditRun).where(LibraryAuditRun.id == audit_id)
).scalar_one_or_none()
if audit is None:
raise ValueError(f"audit_run {audit_id} not found")
if audit.status != "ready":
raise AuditNotReady(audit.status)
expected = f"apply-audit-{audit_id}"
if confirm_token != expected:
raise ConfirmTokenMismatch(expected)
ids = list(audit.matched_ids or [])
deleted = 0
if ids:
result = delete_images(session, image_ids=ids, images_root=images_root)
deleted = result["images_deleted"]
session.execute(
update(LibraryAuditRun)
.where(LibraryAuditRun.id == audit_id)
.values(status="applied", finished_at=datetime.now(UTC))
)
return deleted
def cancel_audit_run(session: Session, *, audit_id: int) -> None:
"""Flip a running audit_run to 'cancelled'. The scan task checks
for status=='cancelled' between batches and exits cleanly."""
session.execute(
update(LibraryAuditRun)
.where(LibraryAuditRun.id == audit_id)
.where(LibraryAuditRun.status == "running")
.values(status="cancelled", finished_at=datetime.now(UTC))
)
+96
View File
@@ -74,3 +74,99 @@ def test_delete_min_dimension_violations_unlinks_and_cascades(db_sync, tmp_path)
).scalars().all()
assert remaining_ids == [rec_big.id]
assert not small_path.exists() # file unlinked
# --- Audit lifecycle tests (Task 5) ---
import backend.app.tasks.library_audit # noqa: F401, E402 — celery registration
def test_start_audit_run_creates_row_and_dispatches(db_sync, monkeypatch):
dispatched = []
from backend.app.tasks import library_audit as la_mod
monkeypatch.setattr(
la_mod.scan_library_for_rule, "delay",
lambda audit_id: dispatched.append(audit_id),
)
audit_id = cleanup_service.start_audit_run(
db_sync, rule="transparency", params={"threshold": 0.9},
)
db_sync.commit()
row_status = db_sync.execute(
select(LibraryAuditRun.status).where(LibraryAuditRun.id == audit_id)
).scalar_one()
assert row_status == "running"
assert dispatched == [audit_id]
def test_start_audit_run_rejects_when_another_is_running(db_sync, monkeypatch):
from backend.app.tasks import library_audit as la_mod
monkeypatch.setattr(
la_mod.scan_library_for_rule, "delay", lambda audit_id: None,
)
cleanup_service.start_audit_run(
db_sync, rule="transparency", params={"threshold": 0.9},
)
db_sync.commit()
with pytest.raises(cleanup_service.AuditAlreadyRunning):
cleanup_service.start_audit_run(
db_sync, rule="single_color",
params={"threshold": 0.95, "tolerance": 30},
)
def test_apply_audit_run_with_correct_token_deletes_matched(db_sync, tmp_path):
rec = _make_image_record(
db_sync, tmp_path, width=100, height=100, color=(60, 0, 0),
)
db_sync.flush()
audit = LibraryAuditRun(
rule="transparency", params={"threshold": 0.9},
status="ready", scanned_count=1, matched_count=1,
matched_ids=[rec.id],
)
db_sync.add(audit)
db_sync.commit()
deleted = cleanup_service.apply_audit_run(
db_sync, audit_id=audit.id,
confirm_token=f"apply-audit-{audit.id}",
images_root=tmp_path,
)
assert deleted == 1
remaining_count = db_sync.execute(
select(func.count()).select_from(ImageRecord)
).scalar_one()
assert remaining_count == 0
new_status = db_sync.execute(
select(LibraryAuditRun.status).where(LibraryAuditRun.id == audit.id)
).scalar_one()
assert new_status == "applied"
def test_apply_audit_run_with_wrong_token_raises(db_sync, tmp_path):
audit = LibraryAuditRun(
rule="transparency", params={"threshold": 0.9},
status="ready", matched_ids=[],
)
db_sync.add(audit)
db_sync.commit()
with pytest.raises(cleanup_service.ConfirmTokenMismatch):
cleanup_service.apply_audit_run(
db_sync, audit_id=audit.id,
confirm_token="wrong-token", images_root=tmp_path,
)
def test_cancel_audit_run_flips_status(db_sync):
audit = LibraryAuditRun(
rule="transparency", params={"threshold": 0.9},
status="running", matched_ids=[],
)
db_sync.add(audit)
db_sync.commit()
cleanup_service.cancel_audit_run(db_sync, audit_id=audit.id)
new_status = db_sync.execute(
select(LibraryAuditRun.status).where(LibraryAuditRun.id == audit.id)
).scalar_one()
assert new_status == "cancelled"