SubscribeStar .art age fix, modal tag-flow sync, system tags / training hygiene (#128) #190

Merged
bvandeusen merged 9 commits from dev into main 2026-07-03 09:16:16 -04:00
7 changed files with 121 additions and 8 deletions
Showing only changes of commit f77e75147d - Show all commits
+4
View File
@@ -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)
+13 -3
View File
@@ -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, []),
}
+4
View File
@@ -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)
@@ -13,9 +13,15 @@
<div class="fc-tagcard__name">
<template v-if="!editing">
{{ card.name }}
<v-icon
v-if="card.is_system" size="14" icon="mdi-shield-outline"
class="fc-tagcard__system"
title="System tag — tagged items are excluded from training other concepts"
/>
<span v-if="card.kind === 'character' && card.fandom_name"
class="fc-tagcard__fandom"> {{ card.fandom_name }}</span>
<v-icon
v-if="!card.is_system"
class="fc-tagcard__edit" size="14" icon="mdi-pencil"
@click.stop="startEdit"
/>
@@ -58,12 +64,18 @@
prepend-icon="mdi-tag-multiple"
@click="$emit('aliases', card)"
/>
<!-- System tags (#128): merge-away and delete are refused
server-side the hygiene machinery keys on the row so
don't offer them. Aliases stay (mapping model outputs ONTO
a system tag is useful). -->
<v-list-item
v-if="!card.is_system"
title="Merge with…"
prepend-icon="mdi-call-merge"
@click="$emit('merge-with', card)"
/>
<v-list-item
v-if="!card.is_system"
title="Delete tag"
prepend-icon="mdi-delete"
base-color="error"
@@ -133,6 +145,7 @@ function submit() {
.fc-tagcard__body { padding: 8px 12px; }
.fc-tagcard__name { font-weight: 600; }
.fc-tagcard__fandom { font-weight: 400; opacity: 0.7; }
.fc-tagcard__system { opacity: 0.6; margin-left: 2px; }
.fc-tagcard__meta {
display: flex; align-items: center; justify-content: space-between;
margin-top: 4px;
+13 -3
View File
@@ -10,14 +10,23 @@
@click:close="$emit('remove', tag.id)"
>
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<span
{{ tag.name }}<v-icon
v-if="tag.is_system" end size="x-small" class="fc-tag-chip__system"
title="System tag — tagged items are excluded from training other concepts"
>mdi-shield-outline</v-icon><span
v-if="tag.fandom_id" class="fc-tag-chip__fandom"
:title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'"
><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></span>
</v-chip>
<!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the
teleported image modal #711). -->
<KebabMenu class="fc-tag-chip__kebab" :label="`More actions for ${tag.name}`">
teleported image modal #711). System tags hide it entirely: rename
is refused server-side (the hygiene machinery keys on the row) and
set-fandom never applies to their general kind. Remove () stays
un-tagging an image is normal use. -->
<KebabMenu
v-if="!tag.is_system"
class="fc-tag-chip__kebab" :label="`More actions for ${tag.name}`"
>
<v-list-item @click="$emit('rename', tag)">
<v-list-item-title>Rename</v-list-item-title>
</v-list-item>
@@ -64,6 +73,7 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 1px;
}
.fc-tag-chip__system { opacity: 0.6; margin-left: 2px; }
.fc-tag-chip__kebab { opacity: 0.7; }
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
+73 -2
View File
@@ -7,9 +7,14 @@ mechanism keys on these exact rows. Merge-INTO stays allowed: adopting an
operator's old hygiene tag into the system row is the intended move.
"""
import pytest
from sqlalchemy import select
from sqlalchemy import insert, select
from backend.app.models import Tag, TagKind
from backend.app.models import ImageRecord, Tag, TagKind
from backend.app.models.tag import image_tag
from backend.app.services.cleanup_service import (
prune_unused_tags,
reset_content_tagging,
)
pytestmark = pytest.mark.integration
@@ -65,6 +70,72 @@ async def test_merge_away_system_tag_refused(client, db):
assert "system tag" in body["error"]
@pytest.mark.asyncio
async def test_admin_delete_system_tag_refused(client, db):
wip = await _system_tag(db, "wip")
resp = await client.delete(f"/api/admin/tags/{wip.id}")
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "system_tag"
still = (await db.execute(
select(Tag.id).where(Tag.id == wip.id)
)).scalar_one_or_none()
assert still == wip.id
@pytest.mark.asyncio
async def test_prune_unused_skips_system_tags(db):
"""The seeded system tags have zero applications and would match every
'unused' condition — the shared predicate must exempt them (preview AND
live delete, same conditions)."""
preview = await db.run_sync(
lambda s: prune_unused_tags(s, dry_run=True)
)
assert preview["count"] == 0
assert preview["sample_names"] == []
await db.run_sync(lambda s: prune_unused_tags(s, dry_run=False))
remaining = (await db.execute(
select(Tag.name).where(Tag.is_system.is_(True))
)).scalars().all()
assert set(remaining) == SYSTEM_NAMES
@pytest.mark.asyncio
async def test_reset_content_preserves_system_tags_and_applications(db):
"""Reset re-tags CONTENT concepts; hygiene flags describe the file itself
and survive with their applications."""
wip = await _system_tag(db, "wip")
plain = Tag(name="doomed concept", kind=TagKind.general)
db.add(plain)
img = ImageRecord(
path="/images/r.jpg", sha256="f" * 64, size_bytes=1, mime="image/jpeg",
width=1, height=1, origin="imported_filesystem",
integrity_status="unknown",
)
db.add(img)
await db.flush()
for tid in (wip.id, plain.id):
await db.execute(insert(image_tag).values(
image_record_id=img.id, tag_id=tid, source="manual",
))
await db.commit()
result = await db.run_sync(
lambda s: reset_content_tagging(s, dry_run=False)
)
assert result["deleted"] == 1 # only the plain general tag
names_left = (await db.execute(
select(Tag.name).where(Tag.kind == TagKind.general)
)).scalars().all()
assert "doomed concept" not in names_left
assert "wip" in names_left
wip_apps = (await db.execute(
select(image_tag.c.image_record_id)
.where(image_tag.c.tag_id == wip.id)
)).scalars().all()
assert wip_apps == [img.id]
@pytest.mark.asyncio
async def test_merge_into_system_tag_allowed(client, db):
wip = await _system_tag(db, "wip")