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:
tanyar09
2025-11-21 11:00:38 -05:00
parent 2c3b2d7a08
commit e6c66e564e
6 changed files with 1046 additions and 0 deletions
+396
View File
@@ -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
)
+2
View File
@@ -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")