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:
+83
-1
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user