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:
@@ -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))
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user