feat: Add Pending Photos management with API integration and UI updates
This commit introduces a new Pending Photos feature, allowing admins to manage user-uploaded photos awaiting review. A dedicated PendingPhotos page has been created in the frontend, which fetches and displays pending photos with options to approve or reject them. The backend has been updated with new API endpoints for listing and reviewing pending photos, ensuring seamless integration with the frontend. The Layout component has been modified to include navigation to the new Pending Photos page, enhancing the overall user experience. Documentation has been updated to reflect these changes.
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
"""Pending photos endpoints - admin only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.db.session import get_auth_db, get_db
|
||||
from src.web.api.users import get_current_admin_user
|
||||
from src.web.api.auth import get_current_user
|
||||
from src.web.services.photo_service import import_photo_from_path
|
||||
from src.web.settings import PHOTO_STORAGE_DIR
|
||||
|
||||
router = APIRouter(prefix="/pending-photos", tags=["pending-photos"])
|
||||
|
||||
|
||||
class PendingPhotoResponse(BaseModel):
|
||||
"""Pending photo DTO returned from API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
user_name: Optional[str] = None
|
||||
user_email: Optional[str] = None
|
||||
filename: str
|
||||
original_filename: str
|
||||
file_path: str
|
||||
file_size: int
|
||||
mime_type: str
|
||||
status: str
|
||||
submitted_at: str
|
||||
reviewed_at: Optional[str] = None
|
||||
reviewed_by: Optional[int] = None
|
||||
rejection_reason: Optional[str] = None
|
||||
|
||||
|
||||
class PendingPhotosListResponse(BaseModel):
|
||||
"""List of pending photos."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[PendingPhotoResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class ReviewDecision(BaseModel):
|
||||
"""Decision for a single pending photo."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
id: int
|
||||
decision: str # 'approve' or 'reject'
|
||||
rejection_reason: Optional[str] = None
|
||||
|
||||
|
||||
class ReviewRequest(BaseModel):
|
||||
"""Request to review multiple pending photos."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
decisions: list[ReviewDecision]
|
||||
|
||||
|
||||
class ReviewResponse(BaseModel):
|
||||
"""Response from review operation."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
approved: int
|
||||
rejected: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
@router.get("", response_model=PendingPhotosListResponse)
|
||||
def list_pending_photos(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
status_filter: Optional[str] = None,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> PendingPhotosListResponse:
|
||||
"""List all pending photos from the auth database.
|
||||
|
||||
This endpoint reads from the separate auth database (DATABASE_URL_AUTH)
|
||||
and returns all pending photos from the pending_photos table.
|
||||
Optionally filter by status: 'pending', 'approved', or 'rejected'.
|
||||
"""
|
||||
try:
|
||||
# Query pending_photos from auth database using raw SQL
|
||||
# Join with users table to get user name/email
|
||||
if status_filter:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
pp.id,
|
||||
pp.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
pp.filename,
|
||||
pp.original_filename,
|
||||
pp.file_path,
|
||||
pp.file_size,
|
||||
pp.mime_type,
|
||||
pp.status,
|
||||
pp.submitted_at,
|
||||
pp.reviewed_at,
|
||||
pp.reviewed_by,
|
||||
pp.rejection_reason
|
||||
FROM pending_photos pp
|
||||
LEFT JOIN users u ON pp.user_id = u.id
|
||||
WHERE pp.status = :status_filter
|
||||
ORDER BY pp.submitted_at DESC
|
||||
"""), {"status_filter": status_filter})
|
||||
else:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
pp.id,
|
||||
pp.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
pp.filename,
|
||||
pp.original_filename,
|
||||
pp.file_path,
|
||||
pp.file_size,
|
||||
pp.mime_type,
|
||||
pp.status,
|
||||
pp.submitted_at,
|
||||
pp.reviewed_at,
|
||||
pp.reviewed_by,
|
||||
pp.rejection_reason
|
||||
FROM pending_photos pp
|
||||
LEFT JOIN users u ON pp.user_id = u.id
|
||||
ORDER BY pp.submitted_at DESC
|
||||
"""))
|
||||
|
||||
rows = result.fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
items.append(PendingPhotoResponse(
|
||||
id=row.id,
|
||||
user_id=row.user_id,
|
||||
user_name=row.user_name,
|
||||
user_email=row.user_email,
|
||||
filename=row.filename,
|
||||
original_filename=row.original_filename,
|
||||
file_path=row.file_path,
|
||||
file_size=row.file_size,
|
||||
mime_type=row.mime_type,
|
||||
status=row.status,
|
||||
submitted_at=str(row.submitted_at) if row.submitted_at else '',
|
||||
reviewed_at=str(row.reviewed_at) if row.reviewed_at else None,
|
||||
reviewed_by=row.reviewed_by,
|
||||
rejection_reason=row.rejection_reason,
|
||||
))
|
||||
|
||||
return PendingPhotosListResponse(items=items, total=len(items))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error reading from auth database: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{photo_id}/image")
|
||||
def get_pending_photo_image(
|
||||
photo_id: int,
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> FileResponse:
|
||||
"""Get the image file for a pending photo.
|
||||
|
||||
Photos are stored in /mnt/db-server-uploads. The file_path in the database
|
||||
may be relative (just filename) or absolute. This function handles both cases.
|
||||
"""
|
||||
import os
|
||||
|
||||
try:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT file_path, mime_type, filename
|
||||
FROM pending_photos
|
||||
WHERE id = :id
|
||||
"""), {"id": photo_id})
|
||||
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Pending photo {photo_id} not found"
|
||||
)
|
||||
|
||||
# Base directory for uploaded photos
|
||||
base_dir = Path("/mnt/db-server-uploads")
|
||||
|
||||
# Handle both absolute and relative paths
|
||||
db_file_path = row.file_path
|
||||
if os.path.isabs(db_file_path):
|
||||
# Absolute path - use as is
|
||||
file_path = Path(db_file_path)
|
||||
else:
|
||||
# Relative path - prepend base directory
|
||||
file_path = base_dir / db_file_path
|
||||
|
||||
# If file doesn't exist at constructed path, try just the filename
|
||||
if not file_path.exists():
|
||||
# Try with just the filename from database
|
||||
file_path = base_dir / row.filename
|
||||
if not file_path.exists():
|
||||
# Try with original_filename if available
|
||||
result2 = auth_db.execute(text("""
|
||||
SELECT original_filename
|
||||
FROM pending_photos
|
||||
WHERE id = :id
|
||||
"""), {"id": photo_id})
|
||||
row2 = result2.fetchone()
|
||||
if row2 and row2.original_filename:
|
||||
file_path = base_dir / row2.original_filename
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Photo file not found at {file_path}"
|
||||
)
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
media_type=row.mime_type or "image/jpeg",
|
||||
filename=file_path.name
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error retrieving photo: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/review", response_model=ReviewResponse)
|
||||
def review_pending_photos(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
request: ReviewRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> ReviewResponse:
|
||||
"""Review pending photos - approve or reject them.
|
||||
|
||||
For 'approve' decision:
|
||||
- Moves photo file from /mnt/db-server-uploads to main photo storage
|
||||
- Imports photo into main database (Scan process)
|
||||
- Updates status in auth database to 'approved'
|
||||
|
||||
For 'reject' decision:
|
||||
- Updates status in auth database to 'rejected'
|
||||
- Photo file remains in place (can be deleted later if needed)
|
||||
"""
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
approved_count = 0
|
||||
rejected_count = 0
|
||||
errors = []
|
||||
admin_user_id = current_admin.get("user_id")
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Base directories
|
||||
upload_base_dir = Path("/mnt/db-server-uploads")
|
||||
main_storage_dir = Path(PHOTO_STORAGE_DIR)
|
||||
main_storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for decision in request.decisions:
|
||||
try:
|
||||
# Get pending photo from auth database with file info
|
||||
# Only allow processing 'pending' status photos
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
pp.id,
|
||||
pp.status,
|
||||
pp.file_path,
|
||||
pp.filename,
|
||||
pp.original_filename
|
||||
FROM pending_photos pp
|
||||
WHERE pp.id = :id AND pp.status = 'pending'
|
||||
"""), {"id": decision.id})
|
||||
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
errors.append(f"Pending photo {decision.id} not found or already reviewed")
|
||||
continue
|
||||
|
||||
if decision.decision == 'approve':
|
||||
# Find the source file
|
||||
db_file_path = row.file_path
|
||||
source_path = None
|
||||
|
||||
# Try to find the file - handle both absolute and relative paths
|
||||
if os.path.isabs(db_file_path):
|
||||
source_path = Path(db_file_path)
|
||||
else:
|
||||
source_path = upload_base_dir / db_file_path
|
||||
|
||||
# If file doesn't exist, try with filename
|
||||
if not source_path.exists():
|
||||
source_path = upload_base_dir / row.filename
|
||||
if not source_path.exists() and row.original_filename:
|
||||
source_path = upload_base_dir / row.original_filename
|
||||
|
||||
if not source_path.exists():
|
||||
errors.append(f"Photo file not found for pending photo {decision.id}: {source_path}")
|
||||
continue
|
||||
|
||||
# Generate unique filename for main storage to avoid conflicts
|
||||
file_ext = source_path.suffix
|
||||
unique_filename = f"{uuid.uuid4()}{file_ext}"
|
||||
dest_path = main_storage_dir / unique_filename
|
||||
|
||||
# Move file to main storage
|
||||
try:
|
||||
shutil.move(str(source_path), str(dest_path))
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to move photo file for {decision.id}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Import photo into main database (Scan process)
|
||||
try:
|
||||
photo, is_new = import_photo_from_path(main_db, str(dest_path))
|
||||
if not is_new:
|
||||
# Photo already exists - delete the moved file
|
||||
if dest_path.exists():
|
||||
dest_path.unlink()
|
||||
errors.append(f"Photo already exists in main database: {photo.path}")
|
||||
continue
|
||||
except Exception as e:
|
||||
# If import fails, try to move file back
|
||||
if dest_path.exists():
|
||||
try:
|
||||
shutil.move(str(dest_path), str(source_path))
|
||||
except:
|
||||
pass
|
||||
errors.append(f"Failed to import photo {decision.id} into main database: {str(e)}")
|
||||
continue
|
||||
|
||||
# Update status to approved in auth database
|
||||
auth_db.execute(text("""
|
||||
UPDATE pending_photos
|
||||
SET status = 'approved',
|
||||
reviewed_at = :reviewed_at,
|
||||
reviewed_by = :reviewed_by
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": decision.id,
|
||||
"reviewed_at": now,
|
||||
"reviewed_by": admin_user_id,
|
||||
})
|
||||
auth_db.commit()
|
||||
|
||||
approved_count += 1
|
||||
|
||||
elif decision.decision == 'reject':
|
||||
# Update status to rejected
|
||||
auth_db.execute(text("""
|
||||
UPDATE pending_photos
|
||||
SET status = 'rejected',
|
||||
reviewed_at = :reviewed_at,
|
||||
reviewed_by = :reviewed_by,
|
||||
rejection_reason = :rejection_reason
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": decision.id,
|
||||
"reviewed_at": now,
|
||||
"reviewed_by": admin_user_id,
|
||||
"rejection_reason": decision.rejection_reason or None,
|
||||
})
|
||||
auth_db.commit()
|
||||
|
||||
rejected_count += 1
|
||||
else:
|
||||
errors.append(f"Invalid decision '{decision.decision}' for pending photo {decision.id}")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Error processing pending photo {decision.id}: {str(e)}")
|
||||
# Rollback any partial changes
|
||||
auth_db.rollback()
|
||||
main_db.rollback()
|
||||
|
||||
return ReviewResponse(
|
||||
approved=approved_count,
|
||||
rejected=rejected_count,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ 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.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
|
||||
from src.web.api.tags import router as tags_router
|
||||
from src.web.api.users import router as users_router
|
||||
from src.web.api.version import router as version_router
|
||||
@@ -240,6 +241,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(people_router, prefix="/api/v1")
|
||||
app.include_router(pending_identifications_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")
|
||||
app.include_router(users_router, prefix="/api/v1")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user