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:
@@ -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
|
||||
Reference in New Issue
Block a user