From 4f2ceaaf312ece6b30b9cf449efa5751cddaff98 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 01:20:41 -0400 Subject: [PATCH] =?UTF-8?q?feat(fc-cleanup):=20audit=20lifecycle=20service?= =?UTF-8?q?=20functions=20(start/apply/cancel)=20+=20tests=20=E2=80=94=20C?= =?UTF-8?q?o-Authored-By:=20Claude=20Opus=204.7=20(1M=20context)=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/cleanup_service.py | 101 +++++++++++++++++++++++- tests/test_cleanup_service_audit.py | 96 ++++++++++++++++++++++ 2 files changed, 195 insertions(+), 2 deletions(-) diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index 99d2a6f..b8325d5 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -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)) + ) diff --git a/tests/test_cleanup_service_audit.py b/tests/test_cleanup_service_audit.py index 69514c5..b215dbf 100644 --- a/tests/test_cleanup_service_audit.py +++ b/tests/test_cleanup_service_audit.py @@ -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"