feat(tags): system-tag UI markers + full protection sweep (step 4 of #128)
UI: shield marker + tooltip on TagChip and TagCard; system tags hide rename/merge/delete affordances (chip kebab entirely — set-fandom never applies to their general kind; remove stays, un-tagging is normal use). Aliases stay available: mapping model outputs ONTO a system tag is useful. Directory cards carry is_system. Every destructive path that could take out a system row is now guarded, found by sweeping run 1891s off-by-three failures — each one was a surface that would have eaten the seeded tags: - prune-unused: predicate exempts is_system (they ship with zero applications and matched every unused condition) - reset-content: predicate exempts is_system AND keeps their applications — hygiene flags describe the file, not content tagging - admin tag DELETE: refused with system_tag error - normalize_existing_tags: scan excludes is_system — canonicalization would recase wip -> Wip behind TagService.rename's guard, breaking the name-keyed presentation lookup Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
@@ -156,6 +156,10 @@ async def tag_delete(tag_id: int):
|
||||
)
|
||||
except LookupError:
|
||||
return _bad("not_found", status=404)
|
||||
except ValueError as exc:
|
||||
# System tags (#128) — the training-hygiene machinery keys on
|
||||
# these rows.
|
||||
return _bad("system_tag", detail=str(exc))
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, func, or_, select, update
|
||||
from sqlalchemy import and_, delete, func, or_, select, update
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from ..models import (
|
||||
@@ -203,6 +203,9 @@ def _unused_tag_conditions() -> list:
|
||||
Tag.id.not_in(used_via_series),
|
||||
Tag.id.not_in(used_via_chapter),
|
||||
Tag.id.not_in(used_via_fandom),
|
||||
# System tags (#128) ship with zero applications and must survive a
|
||||
# prune — the training-hygiene machinery keys on the rows.
|
||||
Tag.is_system.is_(False),
|
||||
]
|
||||
|
||||
|
||||
@@ -402,6 +405,8 @@ def delete_tag(session: Session, *, tag_id: int) -> dict:
|
||||
tag = session.get(Tag, tag_id)
|
||||
if tag is None:
|
||||
raise LookupError(f"tag id not found: {tag_id}")
|
||||
if tag.is_system:
|
||||
raise ValueError(f"'{tag.name}' is a system tag and cannot be deleted")
|
||||
associations_count = count_tag_associations(session, tag_id=tag_id)
|
||||
info = {"id": tag.id, "name": tag.name, "kind": tag.kind.value}
|
||||
session.delete(tag)
|
||||
@@ -731,7 +736,10 @@ def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
|
||||
heads retrain from whatever the operator re-tags. (The API route gates the
|
||||
live run behind a preview-derived confirm token for exactly this reason.)
|
||||
|
||||
PRESERVED: fandom + series tags and their series_page ordering. CASCADE on
|
||||
PRESERVED: fandom + series tags and their series_page ordering, AND the
|
||||
system hygiene tags (#128) WITH their applications — the reset re-tags
|
||||
CONTENT concepts, while wip/banner flags describe the file itself and
|
||||
re-flagging hundreds of banners by hand would be pure loss. CASCADE on
|
||||
image_tag / tag_alias / tag_suggestion_rejection clears each deleted tag's
|
||||
applications + metadata. Tag.fandom_id is SET NULL, so deleting character
|
||||
tags never touches the fandom rows. Irreversible except via DB backup
|
||||
@@ -744,7 +752,9 @@ def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
|
||||
"sample_names": [first 50],
|
||||
and on live runs "deleted": total}
|
||||
"""
|
||||
predicate = Tag.kind.in_(RESETTABLE_TAG_KINDS)
|
||||
predicate = and_(
|
||||
Tag.kind.in_(RESETTABLE_TAG_KINDS), Tag.is_system.is_(False)
|
||||
)
|
||||
rows = session.execute(
|
||||
select(Tag.id, Tag.name, Tag.kind).where(predicate)
|
||||
).all()
|
||||
|
||||
@@ -107,6 +107,7 @@ class TagDirectoryService:
|
||||
"kind": tag.kind.value if hasattr(tag.kind, "value") else tag.kind,
|
||||
"fandom_id": tag.fandom_id,
|
||||
"fandom_name": fandom_name,
|
||||
"is_system": tag.is_system,
|
||||
"image_count": int(image_count),
|
||||
"preview_thumbnails": previews.get(tag.id, []),
|
||||
}
|
||||
|
||||
@@ -806,6 +806,10 @@ async def normalize_existing_tags(
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Tag.id, Tag.name, Tag.kind, Tag.fandom_id)
|
||||
# System tags (#128) are exempt: their names are the keys the
|
||||
# hygiene machinery matches on, and canonicalization would recase
|
||||
# 'wip' → 'Wip' (this path bypasses TagService.rename's guard).
|
||||
.where(Tag.is_system.is_(False))
|
||||
)
|
||||
).all()
|
||||
groups = _group_existing_tags(rows)
|
||||
|
||||
Reference in New Issue
Block a user