feat: Add cleanup functionality for pending photos and database management

This commit introduces new API endpoints for cleaning up files and records related to pending photos. The frontend has been updated to include buttons for admins to trigger cleanup operations, allowing for the deletion of files from shared space and records from the pending_photos table. Additionally, the README has been updated with instructions for granting DELETE permissions on auth database tables, and a script has been added to automate this process. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09
2025-11-24 13:57:27 -05:00
parent 661e812193
commit 100d39c556
6 changed files with 434 additions and 18 deletions
+231 -1
View File
@@ -7,7 +7,7 @@ from datetime import datetime
from typing import Annotated, Optional
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import text
@@ -439,3 +439,233 @@ def review_pending_photos(
warnings=warnings
)
class CleanupResponse(BaseModel):
"""Response from cleanup operation."""
model_config = ConfigDict(protected_namespaces=())
deleted_files: int
deleted_records: int
errors: list[str]
warnings: list[str] = [] # Informational messages (e.g., files already deleted)
@router.post("/cleanup-files", response_model=CleanupResponse)
def cleanup_shared_files(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
status_filter: Optional[str] = Query(None, description="Filter by status: 'approved', 'rejected', or None for both"),
auth_db: Session = Depends(get_auth_db),
) -> CleanupResponse:
"""Delete photo files from shared space for approved or rejected photos.
Args:
status_filter: Optional filter - 'approved', 'rejected', or None for both
"""
deleted_files = 0
errors = []
warnings = []
upload_base_dir = Path("/mnt/db-server-uploads")
# Build query based on status filter
if status_filter:
query = text("""
SELECT id, file_path, filename, original_filename, status
FROM pending_photos
WHERE status = :status_filter
""")
result = auth_db.execute(query, {"status_filter": status_filter})
else:
query = text("""
SELECT id, file_path, filename, original_filename, status
FROM pending_photos
WHERE status IN ('approved', 'rejected')
""")
result = auth_db.execute(query)
rows = result.fetchall()
for row in rows:
try:
# Find the file - handle both absolute and relative paths
db_file_path = row.file_path
file_path = None
if os.path.isabs(db_file_path):
file_path = Path(db_file_path)
else:
file_path = upload_base_dir / db_file_path
# If file doesn't exist, try with filename
if not file_path.exists():
file_path = upload_base_dir / row.filename
if not file_path.exists() and row.original_filename:
file_path = upload_base_dir / row.original_filename
if file_path.exists():
try:
file_path.unlink()
deleted_files += 1
except PermissionError:
errors.append(f"Permission denied deleting file for pending photo {row.id}: {file_path}")
except Exception as e:
errors.append(f"Failed to delete file for pending photo {row.id}: {str(e)}")
else:
# File not found is expected if already deleted - show as warning, not error
warnings.append(f"File already deleted for pending photo {row.id}")
except Exception as e:
errors.append(f"Error processing pending photo {row.id}: {str(e)}")
return CleanupResponse(
deleted_files=deleted_files,
deleted_records=0, # Files only, not records
errors=errors,
warnings=warnings
)
@router.post("/cleanup-database", response_model=CleanupResponse)
def cleanup_pending_photos_database(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
status_filter: Optional[str] = Query(None, description="Filter by status: 'approved', 'rejected', or None for all"),
auth_db: Session = Depends(get_auth_db),
) -> CleanupResponse:
"""Delete records from pending_photos table.
Args:
status_filter: Optional filter - 'approved', 'rejected', or None for all records
"""
deleted_records = 0
errors = []
warnings = []
try:
# First check if table exists and has records
check_result = auth_db.execute(text("""
SELECT COUNT(*) as count FROM pending_photos
"""))
total_count = check_result.fetchone().count if check_result else 0
if total_count == 0:
# No records to delete - not an error, just return success
return CleanupResponse(
deleted_files=0,
deleted_records=0,
errors=[],
warnings=[]
)
# Perform deletion
if status_filter:
result = auth_db.execute(text("""
DELETE FROM pending_photos
WHERE status = :status_filter
"""), {"status_filter": status_filter})
else:
result = auth_db.execute(text("""
DELETE FROM pending_photos
"""))
deleted_records = result.rowcount if hasattr(result, 'rowcount') else 0
auth_db.commit()
if deleted_records == 0 and total_count > 0:
# No records matched the filter - this shouldn't be an error if status_filter was provided
# But if no filter and total_count > 0, something went wrong
if not status_filter:
errors.append(f"Expected to delete {total_count} record(s) but deleted 0. Check database permissions.")
else:
warnings.append(f"No records found matching status filter: {status_filter}")
except Exception as e:
auth_db.rollback()
import traceback
error_details = traceback.format_exc()
# Check if this is a permission error
error_str = str(e)
if "InsufficientPrivilege" in error_str or "permission denied" in error_str.lower():
# Try to automatically grant the permission using sudo (non-interactive)
import subprocess
import os
from urllib.parse import urlparse
try:
# Get database name from connection
auth_db_url = os.getenv("DATABASE_URL_AUTH", "")
if auth_db_url:
# Parse database URL to get database name
if auth_db_url.startswith("postgresql+psycopg2://"):
auth_db_url = auth_db_url.replace("postgresql+psycopg2://", "postgresql://")
parsed = urlparse(auth_db_url)
db_name = parsed.path.lstrip("/")
# Try to grant permission using sudo -n (non-interactive, requires passwordless sudo)
result = subprocess.run(
[
"sudo", "-n", "-u", "postgres", "psql", "-d", db_name,
"-c", "GRANT DELETE ON TABLE pending_photos TO punimtag;"
],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
# Permission granted, try deletion again
try:
if status_filter:
result = auth_db.execute(text("""
DELETE FROM pending_photos
WHERE status = :status_filter
"""), {"status_filter": status_filter})
else:
result = auth_db.execute(text("""
DELETE FROM pending_photos
"""))
deleted_records = result.rowcount if hasattr(result, 'rowcount') else 0
auth_db.commit()
# Success - return early
return CleanupResponse(
deleted_files=0,
deleted_records=deleted_records,
errors=[],
warnings=[]
)
except Exception as retry_e:
errors.append(f"Permission granted but deletion still failed: {str(retry_e)}")
else:
# Sudo failed (needs password) - provide instructions
errors.append(
"Database permission error. Please run this command manually:\n"
f"sudo -u postgres psql -d {db_name} -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
)
else:
errors.append(
"Database permission error. Please run this command manually:\n"
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
)
except subprocess.TimeoutExpired:
errors.append(
"Database permission error. Please run this command manually:\n"
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
)
except Exception as grant_e:
errors.append(
"Database permission error. Please run this command manually:\n"
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
)
else:
errors.append(f"Failed to delete records from database: {str(e)}")
# Log full traceback for debugging
import logging
logger = logging.getLogger(__name__)
logger.error(f"Cleanup database error: {error_details}")
return CleanupResponse(
deleted_files=0, # Database only, not files
deleted_records=deleted_records,
errors=errors,
warnings=warnings
)