feat(tags): merge repoints image_tag and tag_suggestion_rejection with dedup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 13:36:13 -04:00
parent e0a455466d
commit 003181ed41
2 changed files with 114 additions and 2 deletions
+37 -2
View File
@@ -327,10 +327,45 @@ class TagService:
)
async def _repoint_image_tags(self, src: int, tgt: int) -> int:
return 0
"""Drop source links whose image already links target, then move
the rest. Returns the number actually moved."""
await self.session.execute(
image_tag.delete().where(
and_(
image_tag.c.tag_id == src,
image_tag.c.image_record_id.in_(
select(image_tag.c.image_record_id).where(
image_tag.c.tag_id == tgt
)
),
)
)
)
res = await self.session.execute(
image_tag.update()
.where(image_tag.c.tag_id == src)
.values(tag_id=tgt)
)
return res.rowcount or 0
async def _repoint_rejections(self, src: int, tgt: int) -> None:
pass
from ..models.tag_suggestion_rejection import TagSuggestionRejection
await self.session.execute(
text(
"DELETE FROM tag_suggestion_rejection r "
"WHERE r.tag_id = :src AND EXISTS ("
" SELECT 1 FROM tag_suggestion_rejection r2 "
" WHERE r2.tag_id = :tgt "
" AND r2.image_record_id = r.image_record_id)"
),
{"src": src, "tgt": tgt},
)
await self.session.execute(
update(TagSuggestionRejection)
.where(TagSuggestionRejection.tag_id == src)
.values(tag_id=tgt)
)
async def _repoint_allowlist(self, src: int, tgt: int) -> None:
pass
+77
View File
@@ -1,4 +1,5 @@
import pytest
from sqlalchemy import select
from backend.app.models import Tag, TagKind, image_tag
from backend.app.models.tag_allowlist import TagAllowlist
@@ -129,3 +130,79 @@ async def test_merge_empty_tags_returns_result(db):
assert result.alias_created is False
assert result.source_deleted is True
assert await db.get(Tag, a.id) is None
@pytest.mark.asyncio
async def test_merge_moves_image_tags_and_counts(db):
svc = TagService(db)
a = await svc.find_or_create("SrcIT", TagKind.general)
b = await svc.find_or_create("TgtIT", TagKind.general)
i1, i2 = await _img(db), await _img(db)
await svc.add_to_image(i1, a.id, source="manual")
await svc.add_to_image(i2, a.id, source="manual")
result = await svc.merge(a.id, b.id)
assert result.merged_count == 2
rows = (
await db.execute(
image_tag.select().where(image_tag.c.tag_id == b.id)
)
).all()
assert {r.image_record_id for r in rows} == {i1, i2}
assert (
await db.execute(
image_tag.select().where(image_tag.c.tag_id == a.id)
)
).first() is None
@pytest.mark.asyncio
async def test_merge_dedups_image_tag_when_image_has_both(db):
svc = TagService(db)
a = await svc.find_or_create("SrcDup", TagKind.general)
b = await svc.find_or_create("TgtDup", TagKind.general)
img = await _img(db)
await svc.add_to_image(img, a.id, source="manual")
await svc.add_to_image(img, b.id, source="manual")
result = await svc.merge(a.id, b.id)
# The image already had the target → that source row is dropped, not moved.
assert result.merged_count == 0
rows = (
await db.execute(
image_tag.select().where(
image_tag.c.image_record_id == img
)
)
).all()
assert [r.tag_id for r in rows] == [b.id]
@pytest.mark.asyncio
async def test_merge_dedups_suggestion_rejections(db):
from backend.app.models.tag_suggestion_rejection import (
TagSuggestionRejection,
)
svc = TagService(db)
a = await svc.find_or_create("SrcRej", TagKind.general)
b = await svc.find_or_create("TgtRej", TagKind.general)
i1, i2 = await _img(db), await _img(db)
db.add(TagSuggestionRejection(image_record_id=i1, tag_id=a.id))
db.add(TagSuggestionRejection(image_record_id=i2, tag_id=a.id))
db.add(TagSuggestionRejection(image_record_id=i1, tag_id=b.id))
await db.flush()
await svc.merge(a.id, b.id)
rej = (
await db.execute(
select(TagSuggestionRejection).where(
TagSuggestionRejection.tag_id == b.id
)
)
).scalars().all()
assert {r.image_record_id for r in rej} == {i1, i2}
assert (
await db.execute(
select(TagSuggestionRejection).where(
TagSuggestionRejection.tag_id == a.id
)
)
).first() is None