feat(tags): TagService.merge guards + MergeResult + repoint stubs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 13:35:26 -04:00
parent 96e6003b66
commit e0a455466d
2 changed files with 144 additions and 0 deletions
+91
View File
@@ -46,6 +46,16 @@ class TagAutocompleteHit:
image_count: int
@dataclass(frozen=True)
class MergeResult:
target_id: int
target_name: str
target_kind: str
merged_count: int
alias_created: bool
source_deleted: bool
class TagService:
def __init__(self, session: AsyncSession):
self.session = session
@@ -259,3 +269,84 @@ class TagService:
tag.name = new_name
await self.session.flush()
return tag
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
exception rolls back via the caller's session."""
if source_id == target_id:
raise TagValidationError("Cannot merge a tag into itself")
source = await self.session.get(Tag, source_id)
if source is None:
raise TagValidationError(f"Tag {source_id} not found")
target = await self.session.get(Tag, target_id)
if target is None:
raise TagValidationError(f"Tag {target_id} not found")
if source.kind != target.kind or (source.fandom_id or 0) != (
target.fandom_id or 0
):
raise TagValidationError(
"Tags must be the same kind and fandom to merge"
)
keep_as_alias = await self._keep_as_alias(source_id)
source_name = source.name
source_kind = source.kind
target_name = target.name
target_kind = (
target.kind.value
if hasattr(target.kind, "value")
else str(target.kind)
)
merged_count = await self._repoint_image_tags(source_id, target_id)
await self._repoint_rejections(source_id, target_id)
await self._repoint_allowlist(source_id, target_id)
await self._repoint_embedding(source_id)
await self._repoint_aliases(source_id, target_id)
await self._repoint_fandom_children(
source_id, target_id, source_kind
)
alias_created = False
if keep_as_alias:
alias_created = await self._create_protective_aliases(
source_name, source_kind, target_id
)
await self.session.delete(source)
await self.session.flush()
return MergeResult(
target_id=target_id,
target_name=target_name,
target_kind=target_kind,
merged_count=merged_count,
alias_created=alias_created,
source_deleted=True,
)
async def _repoint_image_tags(self, src: int, tgt: int) -> int:
return 0
async def _repoint_rejections(self, src: int, tgt: int) -> None:
pass
async def _repoint_allowlist(self, src: int, tgt: int) -> None:
pass
async def _repoint_embedding(self, src: int) -> None:
pass
async def _repoint_aliases(self, src: int, tgt: int) -> None:
pass
async def _repoint_fandom_children(
self, src: int, tgt: int, src_kind: TagKind
) -> None:
pass
async def _create_protective_aliases(
self, src_name: str, src_kind: TagKind, tgt: int
) -> bool:
return False