feat(fc-cleanup): min-dimension service functions + tests — Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -365,3 +365,47 @@ def prune_unused_tags(session: Session, *, dry_run: bool = False) -> dict:
|
|||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"deleted": len(ids), "sample_names": sample}
|
return {"deleted": len(ids), "sample_names": sample}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MIN_DIM_SAMPLE_CAP = 50
|
||||||
|
|
||||||
|
|
||||||
|
def project_min_dimension_violations(
|
||||||
|
session: Session, *, min_width: int, min_height: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Return {count, sample_ids} for image_record rows with width or
|
||||||
|
height below the thresholds. Synchronous SQL — no PIL inspection
|
||||||
|
needed since width/height are stored columns."""
|
||||||
|
base = select(ImageRecord.id).where(
|
||||||
|
(ImageRecord.width < min_width) | (ImageRecord.height < min_height)
|
||||||
|
)
|
||||||
|
count = session.execute(
|
||||||
|
select(func.count()).select_from(base.subquery())
|
||||||
|
).scalar_one()
|
||||||
|
sample_ids = session.execute(
|
||||||
|
base.order_by(ImageRecord.id).limit(_MIN_DIM_SAMPLE_CAP)
|
||||||
|
).scalars().all()
|
||||||
|
return {"count": count, "sample_ids": list(sample_ids)}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_min_dimension_violations(
|
||||||
|
session: Session, *, min_width: int, min_height: int, images_root: Path,
|
||||||
|
) -> int:
|
||||||
|
"""Delete every image_record where width<min_w OR height<min_h.
|
||||||
|
Routes through delete_images so file-unlink + cascading FKs
|
||||||
|
(image_tag / image_provenance / etc.) are handled uniformly."""
|
||||||
|
ids = session.execute(
|
||||||
|
select(ImageRecord.id).where(
|
||||||
|
(ImageRecord.width < min_width) | (ImageRecord.height < min_height)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
if not ids:
|
||||||
|
return 0
|
||||||
|
result = delete_images(
|
||||||
|
session, image_ids=list(ids), images_root=images_root,
|
||||||
|
)
|
||||||
|
return result["images_deleted"]
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Tests for cleanup_service's library-audit additions.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- project_min_dimension_violations: SQL-only query against width/height
|
||||||
|
- delete_min_dimension_violations: routes through existing delete_images
|
||||||
|
- audit lifecycle (start_audit_run / apply_audit_run / cancel_audit_run)
|
||||||
|
|
||||||
|
Tests assert via column selects per reference-async-coredml-test-assertions
|
||||||
|
(post-DML ORM entity access via session.get() raises MissingGreenlet on
|
||||||
|
async sessions; we use db_sync here but the convention is preserved for
|
||||||
|
consistency).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from backend.app.models import ImageRecord, LibraryAuditRun
|
||||||
|
from backend.app.services import cleanup_service
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
def _make_image_record(db_sync, tmp_path, *, width, height, color):
|
||||||
|
"""Helper: write a real PIL file to a UNIQUE path (reference-image-record-path-unique)
|
||||||
|
and insert an ImageRecord row with all required NOT-NULL columns set."""
|
||||||
|
path = tmp_path / f"img_{width}x{height}_{color[0]}.png"
|
||||||
|
Image.new("RGB", (width, height), color).save(path)
|
||||||
|
rec = ImageRecord(
|
||||||
|
path=str(path),
|
||||||
|
sha256=f"fake-{width}-{height}-{color[0]:03d}" + "0" * 50,
|
||||||
|
size_bytes=path.stat().st_size,
|
||||||
|
mime="image/png", # reference-image-record-required-columns
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
origin="imported_filesystem", # feedback-check-existing-enums
|
||||||
|
integrity_status="ok",
|
||||||
|
)
|
||||||
|
db_sync.add(rec)
|
||||||
|
db_sync.flush()
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_min_dimension_violations_returns_count_and_samples(db_sync, tmp_path):
|
||||||
|
_make_image_record(db_sync, tmp_path, width=100, height=100, color=(10, 0, 0))
|
||||||
|
_make_image_record(db_sync, tmp_path, width=50, height=50, color=(20, 0, 0))
|
||||||
|
_make_image_record(db_sync, tmp_path, width=400, height=400, color=(30, 0, 0))
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
result = cleanup_service.project_min_dimension_violations(
|
||||||
|
db_sync, min_width=200, min_height=200,
|
||||||
|
)
|
||||||
|
assert result["count"] == 2 # 100x100 and 50x50 violate
|
||||||
|
assert len(result["sample_ids"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_min_dimension_violations_unlinks_and_cascades(db_sync, tmp_path):
|
||||||
|
rec_small = _make_image_record(db_sync, tmp_path, width=50, height=50, color=(40, 0, 0))
|
||||||
|
rec_big = _make_image_record(db_sync, tmp_path, width=500, height=500, color=(50, 0, 0))
|
||||||
|
small_path = Path(rec_small.path)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
# images_root is tmp_path here because the test fixtures stored files there;
|
||||||
|
# delete_images() uses it to unlink originals + thumbs from the right tree.
|
||||||
|
deleted = cleanup_service.delete_min_dimension_violations(
|
||||||
|
db_sync, min_width=200, min_height=200, images_root=tmp_path,
|
||||||
|
)
|
||||||
|
assert deleted == 1
|
||||||
|
# Verify via column selects per banked rule.
|
||||||
|
remaining_ids = db_sync.execute(
|
||||||
|
select(ImageRecord.id).order_by(ImageRecord.id)
|
||||||
|
).scalars().all()
|
||||||
|
assert remaining_ids == [rec_big.id]
|
||||||
|
assert not small_path.exists() # file unlinked
|
||||||
Reference in New Issue
Block a user