feat(bulk): BulkTagService (common_tags/bulk_add/bulk_remove) + 3 image bulk-tag routes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 18:02:01 -04:00
parent 93b6fc21c8
commit e1c8400c87
4 changed files with 389 additions and 1 deletions
+83 -1
View File
@@ -2,10 +2,12 @@
from quart import Blueprint, jsonify, request
from sqlalchemy import exists, select
from sqlalchemy.exc import IntegrityError
from ..extensions import get_session
from ..models import TagKind
from ..models import Tag, TagKind
from ..models.tag_allowlist import TagAllowlist
from ..services.bulk_tag_service import BulkTagService
from ..services.tag_directory_service import TagDirectoryService
from ..services.tag_service import (
TagMergeConflict,
@@ -25,6 +27,31 @@ def _coerce_kind(raw: str | None) -> TagKind | None:
return None
def _parse_bulk_ids(body) -> tuple[list[int] | None, tuple | None]:
"""Returns (image_ids, error). error is (json, status) or None."""
if not body or "image_ids" not in body:
return None, (jsonify({"error": "image_ids required"}), 400)
raw = body["image_ids"]
if not isinstance(raw, list) or not raw:
return None, (
jsonify({"error": "image_ids must be a non-empty list"}),
400,
)
try:
ids = [int(x) for x in raw]
except (TypeError, ValueError):
return None, (
jsonify({"error": "image_ids must be integers"}),
400,
)
if len(ids) > 200:
return None, (
jsonify({"error": "selection too large (max 200)"}),
400,
)
return ids, None
@tags_bp.route("/tags/autocomplete", methods=["GET"])
async def autocomplete():
q = request.args.get("q", "")
@@ -200,3 +227,58 @@ async def merge_tag(source_id: int):
"source_deleted": result.source_deleted,
}
)
@tags_bp.route("/images/common-tags", methods=["POST"])
async def common_tags():
body = await request.get_json()
ids, err = _parse_bulk_ids(body)
if err:
return err
async with get_session() as session:
tags = await BulkTagService(session).common_tags(ids)
return jsonify({"tags": tags})
@tags_bp.route("/images/bulk/tags", methods=["POST"])
async def bulk_add_tag():
body = await request.get_json()
ids, err = _parse_bulk_ids(body)
if err:
return err
if not body or "tag_id" not in body:
return jsonify({"error": "tag_id required"}), 400
source = body.get("source", "manual")
if source not in ("manual", "ml_accepted"):
return jsonify({"error": f"invalid source {source!r}"}), 400
async with get_session() as session:
if await session.get(Tag, body["tag_id"]) is None:
return jsonify({"error": "tag not found"}), 404
try:
added = await BulkTagService(session).bulk_add(
ids, body["tag_id"], source=source
)
except IntegrityError:
return jsonify(
{"error": "one or more image_ids do not exist"}
), 400
await session.commit()
return jsonify({"added_count": added, "total": len(ids)})
@tags_bp.route("/images/bulk/tags/remove", methods=["POST"])
async def bulk_remove_tag():
body = await request.get_json()
ids, err = _parse_bulk_ids(body)
if err:
return err
if not body or "tag_id" not in body:
return jsonify({"error": "tag_id required"}), 400
async with get_session() as session:
if await session.get(Tag, body["tag_id"]) is None:
return jsonify({"error": "tag not found"}), 404
removed = await BulkTagService(session).bulk_remove(
ids, body["tag_id"]
)
await session.commit()
return jsonify({"removed_count": removed})
+93
View File
@@ -0,0 +1,93 @@
"""Bulk tag operations over a set of images (Core, set-based, atomic)."""
from sqlalchemy import and_, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Tag
from ..models.tag import image_tag
from ..models.tag_suggestion_rejection import TagSuggestionRejection
class BulkTagService:
def __init__(self, session: AsyncSession):
self.session = session
async def common_tags(self, image_ids: list[int]) -> list[dict]:
"""Tags present on EVERY image in image_ids."""
if not image_ids:
return []
n = len(set(image_ids))
stmt = (
select(Tag.id, Tag.name, Tag.kind)
.join(image_tag, image_tag.c.tag_id == Tag.id)
.where(image_tag.c.image_record_id.in_(image_ids))
.group_by(Tag.id, Tag.name, Tag.kind)
.having(
func.count(func.distinct(image_tag.c.image_record_id)) == n
)
.order_by(Tag.name.asc())
)
rows = (await self.session.execute(stmt)).all()
return [
{
"id": r.id,
"name": r.name,
"kind": r.kind.value if hasattr(r.kind, "value") else r.kind,
}
for r in rows
]
async def bulk_add(
self, image_ids: list[int], tag_id: int, source: str = "manual"
) -> int:
"""Apply tag_id to every image. Idempotent per (image, tag).
Returns the number of links actually created."""
if not image_ids:
return 0
stmt = (
pg_insert(image_tag)
.values(
[
{
"image_record_id": iid,
"tag_id": tag_id,
"source": source,
}
for iid in set(image_ids)
]
)
.on_conflict_do_nothing(
index_elements=["image_record_id", "tag_id"]
)
)
res = await self.session.execute(stmt)
return res.rowcount or 0
async def bulk_remove(self, image_ids: list[int], tag_id: int) -> int:
"""Remove tag_id from every image, and record a per-image rejection
so the proactive allowlist sweep won't immediately re-add it
(mirrors single-image removal semantics). Returns links removed."""
if not image_ids:
return 0
res = await self.session.execute(
image_tag.delete().where(
and_(
image_tag.c.tag_id == tag_id,
image_tag.c.image_record_id.in_(image_ids),
)
)
)
await self.session.execute(
pg_insert(TagSuggestionRejection)
.values(
[
{"image_record_id": iid, "tag_id": tag_id}
for iid in set(image_ids)
]
)
.on_conflict_do_nothing(
index_elements=["image_record_id", "tag_id"]
)
)
return res.rowcount or 0