feat: Add pending linkages management API and user interface for tag approvals

This commit introduces a new API for managing pending tag linkages, allowing admins to review and approve or deny user-suggested tags. The frontend has been updated with a new User Tagged Photos page for displaying pending linkages, including options for filtering and submitting decisions. Additionally, the Layout component has been modified to include navigation to the new page. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09
2025-11-27 14:40:43 -05:00
parent 999e79f859
commit d5d6dc82b1
10 changed files with 1627 additions and 3 deletions
+448
View File
@@ -0,0 +1,448 @@
"""Pending linkage review endpoints - admin only."""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Optional, Union
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from src.web.api.users import require_feature_permission
from src.web.db.models import Photo, PhotoTagLinkage, Tag
from src.web.db.session import get_auth_db, get_db
router = APIRouter(prefix="/pending-linkages", tags=["pending-linkages"])
def _get_or_create_tag_by_name(db: Session, tag_name: str) -> tuple[Tag, bool]:
"""Return a tag for the provided name, creating it if necessary."""
normalized = (tag_name or "").strip()
if not normalized:
raise ValueError("Tag name cannot be empty")
existing = (
db.query(Tag)
.filter(Tag.tag_name.ilike(normalized))
.first()
)
if existing:
return existing, False
tag = Tag(tag_name=normalized)
db.add(tag)
db.flush()
return tag, True
def _format_datetime(value: Union[str, datetime, None]) -> Optional[str]:
"""Safely serialize datetime values returned from different drivers."""
if value is None:
return None
if isinstance(value, str):
return value
if isinstance(value, datetime):
return value.isoformat()
return str(value)
class PendingLinkageResponse(BaseModel):
"""Pending linkage DTO returned from API."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
photo_id: int
tag_id: Optional[int] = None
proposed_tag_name: Optional[str] = None
resolved_tag_name: Optional[str] = None
user_id: int
user_name: Optional[str] = None
user_email: Optional[str] = None
status: str
notes: Optional[str] = None
created_at: str
updated_at: Optional[str] = None
photo_filename: Optional[str] = None
photo_path: Optional[str] = None
photo_tags: list[str] = Field(default_factory=list)
class PendingLinkagesListResponse(BaseModel):
"""List of pending linkage rows."""
model_config = ConfigDict(protected_namespaces=())
items: list[PendingLinkageResponse]
total: int
class ReviewDecision(BaseModel):
"""Decision payload for a pending linkage row."""
model_config = ConfigDict(protected_namespaces=())
id: int
decision: str # 'approve' or 'deny'
class ReviewRequest(BaseModel):
"""Request payload for reviewing pending linkages."""
model_config = ConfigDict(protected_namespaces=())
decisions: list[ReviewDecision]
class ReviewResponse(BaseModel):
"""Review summary returned after processing decisions."""
model_config = ConfigDict(protected_namespaces=())
approved: int
denied: int
tags_created: int
linkages_created: int
errors: list[str]
@router.get("", response_model=PendingLinkagesListResponse)
def list_pending_linkages(
current_user: Annotated[
dict, Depends(require_feature_permission("user_tagged"))
],
status_filter: Annotated[
Optional[str],
Query(
description="Optional status filter: pending, approved, or denied."
),
] = None,
auth_db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
) -> PendingLinkagesListResponse:
"""List all pending linkages stored in the auth database."""
valid_statuses = {"pending", "approved", "denied"}
if status_filter and status_filter not in valid_statuses:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid status_filter. Use pending, approved, or denied.",
)
try:
params = {}
status_clause = ""
if status_filter:
status_clause = "WHERE pl.status = :status_filter"
params["status_filter"] = status_filter
result = auth_db.execute(
text(
f"""
SELECT
pl.id,
pl.photo_id,
pl.tag_id,
pl.tag_name,
pl.user_id,
pl.status,
pl.notes,
pl.created_at,
pl.updated_at,
u.name AS user_name,
u.email AS user_email
FROM pending_linkages pl
LEFT JOIN users u ON pl.user_id = u.id
{status_clause}
ORDER BY pl.created_at DESC
"""
),
params,
)
rows = result.fetchall()
photo_ids = {row.photo_id for row in rows if row.photo_id}
tag_ids = {row.tag_id for row in rows if row.tag_id}
photo_map: dict[int, Photo] = {}
if photo_ids:
photos = (
main_db.query(Photo)
.filter(Photo.id.in_(photo_ids))
.all()
)
photo_map = {photo.id: photo for photo in photos}
tag_map: dict[int, str] = {}
if tag_ids:
tags = (
main_db.query(Tag)
.filter(Tag.id.in_(tag_ids))
.all()
)
tag_map = {tag.id: tag.tag_name for tag in tags}
photo_tags_map: dict[int, list[str]] = {
photo_id: [] for photo_id in photo_ids
}
if photo_ids:
tag_rows = (
main_db.query(PhotoTagLinkage.photo_id, Tag.tag_name)
.join(Tag, Tag.id == PhotoTagLinkage.tag_id)
.filter(PhotoTagLinkage.photo_id.in_(photo_ids))
.all()
)
for photo_id, tag_name in tag_rows:
photo_tags_map.setdefault(photo_id, []).append(tag_name)
items: list[PendingLinkageResponse] = []
for row in rows:
created_at = _format_datetime(getattr(row, "created_at", None)) or ""
updated_at = _format_datetime(getattr(row, "updated_at", None))
photo = photo_map.get(row.photo_id)
resolved_tag_name = None
if row.tag_id:
resolved_tag_name = tag_map.get(row.tag_id)
proposal_name = row.tag_name
items.append(
PendingLinkageResponse(
id=row.id,
photo_id=row.photo_id,
tag_id=row.tag_id,
proposed_tag_name=proposal_name,
resolved_tag_name=resolved_tag_name or proposal_name,
user_id=row.user_id,
user_name=row.user_name,
user_email=row.user_email,
status=row.status,
notes=row.notes,
created_at=created_at,
updated_at=updated_at,
photo_filename=photo.filename if photo else None,
photo_path=photo.path if photo else None,
photo_tags=photo_tags_map.get(row.photo_id, []),
)
)
return PendingLinkagesListResponse(items=items, total=len(items))
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error reading pending linkages: {exc}",
)
@router.post("/review", response_model=ReviewResponse)
def review_pending_linkages(
current_user: Annotated[
dict, Depends(require_feature_permission("user_tagged"))
],
request: ReviewRequest,
auth_db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
) -> ReviewResponse:
"""Approve or deny pending user-proposed tag linkages."""
approved = 0
denied = 0
tags_created = 0
linkages_created = 0
errors: list[str] = []
now = datetime.utcnow()
for decision in request.decisions:
try:
row = auth_db.execute(
text(
"""
SELECT id, photo_id, tag_id, tag_name, status
FROM pending_linkages
WHERE id = :id
"""
),
{"id": decision.id},
).fetchone()
if not row:
errors.append(
f"Pending linkage {decision.id} not found or already deleted"
)
continue
if row.status != "pending":
errors.append(
f"Pending linkage {decision.id} cannot be reviewed (status={row.status})"
)
continue
if decision.decision == "deny":
auth_db.execute(
text(
"""
UPDATE pending_linkages
SET status = 'denied',
updated_at = :updated_at
WHERE id = :id
"""
),
{"id": decision.id, "updated_at": now},
)
auth_db.commit()
denied += 1
continue
if decision.decision != "approve":
errors.append(
f"Invalid decision '{decision.decision}' for linkage {decision.id}"
)
continue
photo = (
main_db.query(Photo)
.filter(Photo.id == row.photo_id)
.first()
)
if not photo:
errors.append(
f"Photo {row.photo_id} not found for linkage {decision.id}"
)
continue
tag_obj: Optional[Tag] = None
created_tag = False
if row.tag_id:
tag_obj = (
main_db.query(Tag)
.filter(Tag.id == row.tag_id)
.first()
)
if not tag_obj and row.tag_name:
tag_obj, created_tag = _get_or_create_tag_by_name(
main_db, row.tag_name
)
elif not tag_obj:
errors.append(
f"Tag {row.tag_id} missing for linkage {decision.id}"
)
continue
else:
if not row.tag_name:
errors.append(
f"No tag information provided for linkage {decision.id}"
)
continue
tag_obj, created_tag = _get_or_create_tag_by_name(
main_db, row.tag_name
)
if created_tag:
tags_created += 1
resolved_tag_id = tag_obj.id # type: ignore[union-attr]
existing_linkage = (
main_db.query(PhotoTagLinkage)
.filter(
PhotoTagLinkage.photo_id == row.photo_id,
PhotoTagLinkage.tag_id == resolved_tag_id,
)
.first()
)
if not existing_linkage:
linkage = PhotoTagLinkage(
photo_id=row.photo_id,
tag_id=resolved_tag_id,
)
main_db.add(linkage)
linkages_created += 1
main_db.commit()
auth_db.execute(
text(
"""
UPDATE pending_linkages
SET status = 'approved',
tag_id = :tag_id,
updated_at = :updated_at
WHERE id = :id
"""
),
{
"id": decision.id,
"tag_id": resolved_tag_id,
"updated_at": now,
},
)
auth_db.commit()
approved += 1
except ValueError as exc:
main_db.rollback()
auth_db.rollback()
errors.append(f"Validation error for linkage {decision.id}: {exc}")
except Exception as exc:
main_db.rollback()
auth_db.rollback()
errors.append(f"Error processing linkage {decision.id}: {exc}")
return ReviewResponse(
approved=approved,
denied=denied,
tags_created=tags_created,
linkages_created=linkages_created,
errors=errors,
)
class CleanupResponse(BaseModel):
"""Response payload for cleanup operations."""
model_config = ConfigDict(protected_namespaces=())
deleted_records: int
errors: list[str]
warnings: list[str] = []
@router.post("/cleanup", response_model=CleanupResponse)
def cleanup_pending_linkages(
current_user: Annotated[
dict, Depends(require_feature_permission("user_tagged"))
],
auth_db: Session = Depends(get_auth_db),
) -> CleanupResponse:
"""Delete all approved or denied records from pending_linkages table."""
warnings: list[str] = []
try:
result = auth_db.execute(
text(
"""
DELETE FROM pending_linkages
WHERE status IN ('approved', 'denied')
"""
)
)
deleted_records = result.rowcount if hasattr(result, "rowcount") else 0
auth_db.commit()
if deleted_records == 0:
warnings.append("No approved or denied pending linkages to delete.")
return CleanupResponse(
deleted_records=deleted_records,
errors=[],
warnings=warnings,
)
except Exception as exc:
auth_db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to cleanup pending linkages: {exc}",
)
+2
View File
@@ -17,6 +17,7 @@ from src.web.api.jobs import router as jobs_router
from src.web.api.metrics import router as metrics_router
from src.web.api.people import router as people_router
from src.web.api.pending_identifications import router as pending_identifications_router
from src.web.api.pending_linkages import router as pending_linkages_router
from src.web.api.photos import router as photos_router
from src.web.api.reported_photos import router as reported_photos_router
from src.web.api.pending_photos import router as pending_photos_router
@@ -408,6 +409,7 @@ def create_app() -> FastAPI:
app.include_router(faces_router, prefix="/api/v1")
app.include_router(people_router, prefix="/api/v1")
app.include_router(pending_identifications_router, prefix="/api/v1")
app.include_router(pending_linkages_router, prefix="/api/v1")
app.include_router(reported_photos_router, prefix="/api/v1")
app.include_router(pending_photos_router, prefix="/api/v1")
app.include_router(tags_router, prefix="/api/v1")
+4 -3
View File
@@ -17,6 +17,7 @@ ROLE_FEATURES: Final[List[dict[str, str]]] = [
{"key": "faces_maintenance", "label": "Faces Maintenance"},
{"key": "user_identified", "label": "User Identified"},
{"key": "user_reported", "label": "User Reported"},
{"key": "user_tagged", "label": "User Tagged Photos"},
{"key": "user_uploaded", "label": "User Uploaded"},
{"key": "manage_users", "label": "Manage Users"},
{"key": "manage_roles", "label": "Manage Roles"},
@@ -28,10 +29,10 @@ DEFAULT_ROLE_FEATURE_MATRIX: Final[Dict[str, Set[str]]] = {
UserRole.ADMIN.value: set(ROLE_FEATURE_KEYS),
UserRole.MANAGER.value: set(ROLE_FEATURE_KEYS),
UserRole.MODERATOR.value: {"scan", "process", "manage_users"},
UserRole.REVIEWER.value: {"user_identified", "user_reported", "user_uploaded"},
UserRole.EDITOR.value: {"user_identified", "user_uploaded", "manage_users"},
UserRole.REVIEWER.value: {"user_identified", "user_reported", "user_uploaded", "user_tagged"},
UserRole.EDITOR.value: {"user_identified", "user_uploaded", "manage_users", "user_tagged"},
UserRole.IMPORTER.value: {"user_uploaded"},
UserRole.VIEWER.value: {"user_identified", "user_reported"},
UserRole.VIEWER.value: {"user_identified", "user_reported", "user_tagged"},
}