feat(tags): non-mutating merge preview + admin dry_run (#8a)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Failing after 3m17s

Cluster B, milestone #99. TagService.merge_preview(source, target) computes the
same counts the apply produces (rule 93 parity) without mutating: images_moving
(source links the apply UPDATEs), images_already_on_target (links it drops),
source_total, series_pages, will_alias (_keep_as_alias), a kind/fandom
compatible flag (surfaced, not raised, so the UI can warn), and up to 6
thumbnails of the moving images. The admin /tags/<dest>/merge route gains a
dry_run flag returning the preview JSON.

Tests: preview moving-count == apply merged_count (parity), incompatible flagged
without raising, self/missing raise, admin dry_run returns preview + no mutation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
This commit is contained in:
2026-06-23 01:37:11 -04:00
parent e206778a5c
commit 7127714316
4 changed files with 208 additions and 0 deletions
+24
View File
@@ -171,6 +171,30 @@ async def tag_merge(dest_id: int):
if not isinstance(source_id, int) or source_id == dest_id:
return _bad("invalid_source_id", detail="source_id must be int and differ from dest")
# dry_run: non-mutating preview (counts + sample) so the operator can
# confirm the target before the irreversible merge (#8, rule 93 parity).
if body.get("dry_run"):
async with get_session() as session:
try:
p = await TagService(session).merge_preview(
source_id=source_id, target_id=dest_id,
)
except TagValidationError as exc:
return _bad("tag_not_found", status=404, detail=str(exc))
return jsonify({
"preview": {
"source_id": p.source_id, "source_name": p.source_name,
"target_id": p.target_id, "target_name": p.target_name,
"compatible": p.compatible,
"images_moving": p.images_moving,
"images_already_on_target": p.images_already_on_target,
"source_total": p.source_total,
"series_pages": p.series_pages,
"will_alias": p.will_alias,
"sample_thumbnails": p.sample_thumbnails,
},
})
async with get_session() as session:
try:
result = await TagService(session).merge(