Tagging & viewing roadmap: tag query surface + hygiene projections + Explore view (Clusters A/B/C) #128
@@ -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(
|
||||
|
||||
@@ -79,6 +79,23 @@ class MergeResult:
|
||||
source_deleted: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MergePreview:
|
||||
"""Non-mutating projection of what merge(source→target) would do (#8).
|
||||
Shares the apply's predicates (rule 93) so the preview can't drift."""
|
||||
source_id: int
|
||||
source_name: str
|
||||
target_id: int
|
||||
target_name: str
|
||||
compatible: bool # same kind + fandom — would apply succeed?
|
||||
images_moving: int # source links that move (image lacks target)
|
||||
images_already_on_target: int # source links dropped (image has target)
|
||||
source_total: int # images carrying source
|
||||
series_pages: int # series pages repointed
|
||||
will_alias: bool # source name kept as a protective alias
|
||||
sample_thumbnails: list[str] # a few of the moving images
|
||||
|
||||
|
||||
class TagService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
@@ -376,6 +393,96 @@ class TagService:
|
||||
await self.session.flush()
|
||||
return tag
|
||||
|
||||
async def merge_preview(
|
||||
self, source_id: int, target_id: int
|
||||
) -> MergePreview:
|
||||
"""Read-only dry-run of merge(source→target): the same counts the apply
|
||||
would produce, plus a thumbnail sample of the images that move. Raises
|
||||
TagValidationError only for missing/self-merge (so the UI can render a
|
||||
kind/fandom-mismatch warning rather than a hard error)."""
|
||||
if source_id == target_id:
|
||||
raise TagValidationError("Cannot merge a tag into itself")
|
||||
source = await self.session.get(Tag, source_id)
|
||||
target = await self.session.get(Tag, target_id)
|
||||
if source is None or target is None:
|
||||
raise TagValidationError("Tag not found")
|
||||
|
||||
compatible = source.kind == target.kind and (
|
||||
(source.fandom_id or 0) == (target.fandom_id or 0)
|
||||
)
|
||||
|
||||
# Mirrors _repoint_image_tags: links whose image already has target are
|
||||
# dropped (already_on_target); the rest move.
|
||||
target_images = select(image_tag.c.image_record_id).where(
|
||||
image_tag.c.tag_id == target_id
|
||||
)
|
||||
moving = await self.session.scalar(
|
||||
select(func.count())
|
||||
.select_from(image_tag)
|
||||
.where(
|
||||
image_tag.c.tag_id == source_id,
|
||||
image_tag.c.image_record_id.notin_(target_images),
|
||||
)
|
||||
)
|
||||
already = await self.session.scalar(
|
||||
select(func.count())
|
||||
.select_from(image_tag)
|
||||
.where(
|
||||
image_tag.c.tag_id == source_id,
|
||||
image_tag.c.image_record_id.in_(target_images),
|
||||
)
|
||||
)
|
||||
|
||||
from ..models.series_page import SeriesPage
|
||||
|
||||
series_pages = await self.session.scalar(
|
||||
select(func.count())
|
||||
.select_from(SeriesPage)
|
||||
.where(SeriesPage.series_tag_id == source_id)
|
||||
)
|
||||
|
||||
will_alias = await self._keep_as_alias(source_id)
|
||||
sample = await self._merge_sample_thumbnails(source_id, target_images)
|
||||
|
||||
return MergePreview(
|
||||
source_id=source_id,
|
||||
source_name=source.name,
|
||||
target_id=target_id,
|
||||
target_name=target.name,
|
||||
compatible=compatible,
|
||||
images_moving=moving or 0,
|
||||
images_already_on_target=already or 0,
|
||||
source_total=(moving or 0) + (already or 0),
|
||||
series_pages=series_pages or 0,
|
||||
will_alias=will_alias,
|
||||
sample_thumbnails=sample,
|
||||
)
|
||||
|
||||
async def _merge_sample_thumbnails(
|
||||
self, source_id: int, target_images
|
||||
) -> list[str]:
|
||||
"""Up to 6 thumbnails of the images that WOULD move (carry source, not
|
||||
target) — a sanity check that the merge targets the right content."""
|
||||
from ..models import ImageRecord
|
||||
from .gallery_service import thumbnail_url
|
||||
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(
|
||||
ImageRecord.thumbnail_path,
|
||||
ImageRecord.sha256,
|
||||
ImageRecord.mime,
|
||||
)
|
||||
.join(image_tag, image_tag.c.image_record_id == ImageRecord.id)
|
||||
.where(
|
||||
image_tag.c.tag_id == source_id,
|
||||
image_tag.c.image_record_id.notin_(target_images),
|
||||
)
|
||||
.limit(6)
|
||||
)
|
||||
).all()
|
||||
return [thumbnail_url(tp, sha, mime) for tp, sha, mime in rows]
|
||||
|
||||
async def merge(self, source_id: int, target_id: int) -> MergeResult:
|
||||
"""Transactionally repoint every FK from source→target, optionally
|
||||
keep source's name as a tagger alias, delete source. Atomic: any
|
||||
|
||||
@@ -270,6 +270,41 @@ async def test_tag_merge_succeeds_for_same_kind(client, db):
|
||||
assert body["result"]["target_id"] == dest_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_merge_dry_run_previews_without_mutating(client, db):
|
||||
from backend.app.models import ImageRecord
|
||||
from backend.app.models.tag import image_tag
|
||||
|
||||
src = Tag(name="dry-src", kind=TagKind.general)
|
||||
dest = Tag(name="dry-dest", kind=TagKind.general)
|
||||
db.add_all([src, dest])
|
||||
await db.flush()
|
||||
img = ImageRecord(
|
||||
path="/images/dry.jpg", sha256="dry" + "0" * 61, size_bytes=1,
|
||||
mime="image/jpeg", width=1, height=1,
|
||||
origin="imported_filesystem", integrity_status="unknown",
|
||||
)
|
||||
db.add(img)
|
||||
await db.flush()
|
||||
await db.execute(image_tag.insert().values(
|
||||
image_record_id=img.id, tag_id=src.id, source="manual",
|
||||
))
|
||||
src_id, dest_id = src.id, dest.id
|
||||
await db.commit()
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/admin/tags/{dest_id}/merge",
|
||||
json={"source_id": src_id, "dry_run": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
p = (await resp.get_json())["preview"]
|
||||
assert p["compatible"] is True
|
||||
assert p["images_moving"] == 1
|
||||
assert p["source_total"] == 1
|
||||
# No mutation: source still exists.
|
||||
assert await db.get(Tag, src_id) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_merge_rejects_self_merge(client, db):
|
||||
t = Tag(name="x", kind=TagKind.general)
|
||||
|
||||
@@ -56,6 +56,48 @@ async def test_merge_conflict_is_a_tag_validation_error(db):
|
||||
assert issubclass(TagMergeConflict, TagValidationError)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_preview_matches_apply(db):
|
||||
# #8 rule-93 parity: the preview's moving-count is exactly what the apply
|
||||
# moves, and already-on-target links are the ones dropped.
|
||||
svc = TagService(db)
|
||||
target = await svc.find_or_create("MTarget", TagKind.general)
|
||||
source = await svc.find_or_create("MSource", TagKind.general)
|
||||
a, b, c = await _img(db), await _img(db), await _img(db)
|
||||
for img in (a, b, c):
|
||||
await svc.add_to_image(img, source.id, source="manual")
|
||||
await svc.add_to_image(a, target.id, source="manual") # a already on target
|
||||
|
||||
preview = await svc.merge_preview(source.id, target.id)
|
||||
assert preview.compatible is True
|
||||
assert preview.images_moving == 2 # b, c
|
||||
assert preview.images_already_on_target == 1 # a
|
||||
assert preview.source_total == 3
|
||||
assert preview.will_alias is False # purely manual
|
||||
|
||||
result = await svc.merge(source.id, target.id)
|
||||
assert result.merged_count == preview.images_moving # parity
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_preview_flags_incompatible_without_raising(db):
|
||||
svc = TagService(db)
|
||||
target = await svc.find_or_create("CharT", TagKind.character)
|
||||
source = await svc.find_or_create("GenS", TagKind.general)
|
||||
preview = await svc.merge_preview(source.id, target.id)
|
||||
assert preview.compatible is False # kind mismatch surfaced, not raised
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_preview_self_and_missing_raise(db):
|
||||
svc = TagService(db)
|
||||
t = await svc.find_or_create("Solo", TagKind.general)
|
||||
with pytest.raises(TagValidationError):
|
||||
await svc.merge_preview(t.id, t.id)
|
||||
with pytest.raises(TagValidationError):
|
||||
await svc.merge_preview(t.id, 999999)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_will_alias_true_when_machine_sourced(db):
|
||||
svc = TagService(db)
|
||||
|
||||
Reference in New Issue
Block a user