feat: extend Tag model for unified ML tagging
Unify object detections, scene labels, and face clusters with user tags via new columns on the existing Tag model: - kind (user|object|scene|face_cluster), source, representative_photo_id - photo_tags gains confidence, bbox (JSONB), source per-association - Uniqueness moves from (name) to (name, kind) so ML labels coexist with user tags without collision Add Alembic migration 0002 with defensive IF NOT EXISTS guards. Update tags router: kind filter on GET, merge endpoint for combining auto-detected clusters/objects, include kind/source in list response. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
"""
|
||||
Tags API router
|
||||
Tags API router.
|
||||
|
||||
Unified across user tags, ML-detected objects, and face clusters via
|
||||
the `kind` query parameter. Default behaviour (no kind filter) returns
|
||||
all tags — the frontend's "Hide auto-generated tags" toggle filters
|
||||
client-side or passes `kind=user`.
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func, insert, delete
|
||||
from sqlalchemy import select, func, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
@@ -19,6 +24,7 @@ router = APIRouter()
|
||||
class TagCreate(BaseModel):
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
kind: str = "user"
|
||||
|
||||
|
||||
class TagUpdate(BaseModel):
|
||||
@@ -26,11 +32,18 @@ class TagUpdate(BaseModel):
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class TagMerge(BaseModel):
|
||||
target_id: str # tag to merge INTO
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_tags(db: AsyncSession = Depends(get_db)):
|
||||
"""List all tags with their photo counts."""
|
||||
async def list_tags(
|
||||
kind: Optional[str] = Query(None, description="Filter by kind: user, object, scene, face_cluster"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all tags with their photo counts, optionally filtered by kind."""
|
||||
count_subq = (
|
||||
select(
|
||||
photo_tags.c.tag_id,
|
||||
@@ -42,8 +55,11 @@ async def list_tags(db: AsyncSession = Depends(get_db)):
|
||||
stmt = (
|
||||
select(Tag, count_subq.c.photo_count)
|
||||
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
|
||||
.order_by(Tag.name.asc())
|
||||
)
|
||||
if kind:
|
||||
stmt = stmt.where(Tag.kind == kind)
|
||||
stmt = stmt.order_by(Tag.name.asc())
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
@@ -52,6 +68,9 @@ async def list_tags(db: AsyncSession = Depends(get_db)):
|
||||
"id": tag.id,
|
||||
"name": tag.name,
|
||||
"color": tag.color,
|
||||
"kind": tag.kind,
|
||||
"source": tag.source,
|
||||
"representative_photo_id": tag.representative_photo_id,
|
||||
"photo_count": int(count or 0),
|
||||
}
|
||||
for tag, count in rows
|
||||
@@ -60,30 +79,37 @@ async def list_tags(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a new tag. Names are unique — re-creating an existing name
|
||||
returns the existing row instead of erroring (idempotent for the
|
||||
autocomplete UI flow)."""
|
||||
"""Create a new tag. The (name, kind) pair is unique — re-creating an
|
||||
existing pair returns the existing row (idempotent for autocomplete)."""
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Tag name is required")
|
||||
|
||||
existing = await db.execute(select(Tag).where(Tag.name == name))
|
||||
existing = await db.execute(
|
||||
select(Tag).where(Tag.name == name, Tag.kind == body.kind)
|
||||
)
|
||||
found = existing.scalar_one_or_none()
|
||||
if found:
|
||||
return {"id": found.id, "name": found.name, "color": found.color, "photo_count": 0}
|
||||
return {
|
||||
"id": found.id, "name": found.name, "color": found.color,
|
||||
"kind": found.kind, "photo_count": 0,
|
||||
}
|
||||
|
||||
tag = Tag(name=name, color=body.color)
|
||||
tag = Tag(name=name, color=body.color, kind=body.kind)
|
||||
db.add(tag)
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
return {"id": tag.id, "name": tag.name, "color": tag.color, "photo_count": 0}
|
||||
return {
|
||||
"id": tag.id, "name": tag.name, "color": tag.color,
|
||||
"kind": tag.kind, "photo_count": 0,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{tag_id}")
|
||||
async def update_tag(
|
||||
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Rename or recolor a tag."""
|
||||
"""Rename or recolor a tag (works for any kind — user, object, face_cluster)."""
|
||||
result = await db.execute(select(Tag).where(Tag.id == tag_id))
|
||||
tag = result.scalar_one_or_none()
|
||||
if not tag:
|
||||
@@ -99,7 +125,52 @@ async def update_tag(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
return {"id": tag.id, "name": tag.name, "color": tag.color}
|
||||
return {"id": tag.id, "name": tag.name, "color": tag.color, "kind": tag.kind}
|
||||
|
||||
|
||||
@router.post("/{tag_id}/merge")
|
||||
async def merge_tag(
|
||||
tag_id: str, body: TagMerge, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Merge tag_id INTO target_id. All photo associations from the source
|
||||
tag are moved to the target, then the source tag is deleted.
|
||||
|
||||
Useful for merging auto-detected face clusters (e.g. "Person 3" → "Alice")
|
||||
or merging duplicate object labels."""
|
||||
if tag_id == body.target_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot merge a tag into itself")
|
||||
|
||||
source = (await db.execute(select(Tag).where(Tag.id == tag_id))).scalar_one_or_none()
|
||||
target = (await db.execute(select(Tag).where(Tag.id == body.target_id))).scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source tag not found")
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target tag not found")
|
||||
|
||||
# Move photo associations: update tag_id from source → target.
|
||||
# Skip any that would violate the PK (photo already tagged with target).
|
||||
existing_target_photos = select(photo_tags.c.photo_id).where(
|
||||
photo_tags.c.tag_id == body.target_id
|
||||
)
|
||||
await db.execute(
|
||||
update(photo_tags)
|
||||
.where(
|
||||
photo_tags.c.tag_id == tag_id,
|
||||
photo_tags.c.photo_id.notin_(existing_target_photos),
|
||||
)
|
||||
.values(tag_id=body.target_id)
|
||||
)
|
||||
# Delete remaining source associations (duplicates that couldn't move)
|
||||
from sqlalchemy import delete as sa_delete
|
||||
await db.execute(
|
||||
sa_delete(photo_tags).where(photo_tags.c.tag_id == tag_id)
|
||||
)
|
||||
|
||||
# Delete source tag
|
||||
await db.delete(source)
|
||||
await db.commit()
|
||||
|
||||
return {"merged_into": target.id, "target_name": target.name}
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", status_code=204)
|
||||
|
||||
Reference in New Issue
Block a user