Files
thrilltrack-explorer/django/api/v1/endpoints/photos.py
pacnpal d6ff4cc3a3 Add email templates for user notifications and account management
- Created a base email template (base.html) for consistent styling across all emails.
- Added moderation approval email template (moderation_approved.html) to notify users of approved submissions.
- Added moderation rejection email template (moderation_rejected.html) to inform users of required changes for their submissions.
- Created password reset email template (password_reset.html) for users requesting to reset their passwords.
- Developed a welcome email template (welcome.html) to greet new users and provide account details and tips for using ThrillWiki.
2025-11-08 15:34:04 -05:00

601 lines
18 KiB
Python

"""
Photo management API endpoints.
Provides endpoints for photo upload, management, moderation, and entity attachment.
"""
import logging
from typing import List, Optional
from uuid import UUID
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db.models import Q, Count, Sum
from django.http import HttpRequest
from ninja import Router, File, Form
from ninja.files import UploadedFile
from ninja.pagination import paginate
from api.v1.schemas import (
PhotoOut,
PhotoListOut,
PhotoUpdate,
PhotoUploadResponse,
PhotoModerateRequest,
PhotoReorderRequest,
PhotoAttachRequest,
PhotoStatsOut,
MessageSchema,
ErrorSchema,
)
from apps.media.models import Photo
from apps.media.services import PhotoService, CloudFlareError
from apps.media.validators import validate_image
from apps.users.permissions import jwt_auth, require_moderator, require_admin
from apps.entities.models import Park, Ride, Company, RideModel
logger = logging.getLogger(__name__)
router = Router(tags=["Photos"])
photo_service = PhotoService()
# ============================================================================
# Helper Functions
# ============================================================================
def serialize_photo(photo: Photo) -> dict:
"""
Serialize a Photo instance to dict for API response.
Args:
photo: Photo instance
Returns:
Dict with photo data
"""
# Get entity info if attached
entity_type = None
entity_id = None
entity_name = None
if photo.content_type and photo.object_id:
entity = photo.content_object
entity_type = photo.content_type.model
entity_id = str(photo.object_id)
entity_name = getattr(entity, 'name', str(entity)) if entity else None
# Generate variant URLs
cloudflare_service = photo_service.cloudflare
thumbnail_url = cloudflare_service.get_image_url(photo.cloudflare_image_id, 'thumbnail')
banner_url = cloudflare_service.get_image_url(photo.cloudflare_image_id, 'banner')
return {
'id': photo.id,
'cloudflare_image_id': photo.cloudflare_image_id,
'cloudflare_url': photo.cloudflare_url,
'title': photo.title,
'description': photo.description,
'credit': photo.credit,
'photo_type': photo.photo_type,
'is_visible': photo.is_visible,
'uploaded_by_id': photo.uploaded_by_id,
'uploaded_by_email': photo.uploaded_by.email if photo.uploaded_by else None,
'moderation_status': photo.moderation_status,
'moderated_by_id': photo.moderated_by_id,
'moderated_by_email': photo.moderated_by.email if photo.moderated_by else None,
'moderated_at': photo.moderated_at,
'moderation_notes': photo.moderation_notes,
'entity_type': entity_type,
'entity_id': entity_id,
'entity_name': entity_name,
'width': photo.width,
'height': photo.height,
'file_size': photo.file_size,
'mime_type': photo.mime_type,
'display_order': photo.display_order,
'thumbnail_url': thumbnail_url,
'banner_url': banner_url,
'created': photo.created_at,
'modified': photo.modified_at,
}
def get_entity_by_type(entity_type: str, entity_id: UUID):
"""
Get entity instance by type and ID.
Args:
entity_type: Entity type (park, ride, company, ridemodel)
entity_id: Entity UUID
Returns:
Entity instance
Raises:
ValueError: If entity type is invalid or not found
"""
entity_map = {
'park': Park,
'ride': Ride,
'company': Company,
'ridemodel': RideModel,
}
model = entity_map.get(entity_type.lower())
if not model:
raise ValueError(f"Invalid entity type: {entity_type}")
try:
return model.objects.get(id=entity_id)
except model.DoesNotExist:
raise ValueError(f"{entity_type} with ID {entity_id} not found")
# ============================================================================
# Public Endpoints
# ============================================================================
@router.get("/photos", response=List[PhotoOut], auth=None)
@paginate
def list_photos(
request: HttpRequest,
status: Optional[str] = None,
photo_type: Optional[str] = None,
entity_type: Optional[str] = None,
entity_id: Optional[UUID] = None,
):
"""
List approved photos (public endpoint).
Query Parameters:
- status: Filter by moderation status (defaults to 'approved')
- photo_type: Filter by photo type
- entity_type: Filter by entity type
- entity_id: Filter by entity ID
"""
queryset = Photo.objects.select_related(
'uploaded_by', 'moderated_by', 'content_type'
)
# Default to approved photos for public
if status:
queryset = queryset.filter(moderation_status=status)
else:
queryset = queryset.approved()
if photo_type:
queryset = queryset.filter(photo_type=photo_type)
if entity_type and entity_id:
try:
entity = get_entity_by_type(entity_type, entity_id)
content_type = ContentType.objects.get_for_model(entity)
queryset = queryset.filter(
content_type=content_type,
object_id=entity_id
)
except ValueError as e:
return []
queryset = queryset.filter(is_visible=True).order_by('display_order', '-created_at')
return queryset
@router.get("/photos/{photo_id}", response=PhotoOut, auth=None)
def get_photo(request: HttpRequest, photo_id: UUID):
"""
Get photo details by ID (public endpoint).
Only returns approved photos for non-authenticated users.
"""
try:
photo = Photo.objects.select_related(
'uploaded_by', 'moderated_by', 'content_type'
).get(id=photo_id)
# Only show approved photos to public
if not request.auth and photo.moderation_status != 'approved':
return 404, {"detail": "Photo not found"}
return serialize_photo(photo)
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
@router.get("/{entity_type}/{entity_id}/photos", response=List[PhotoOut], auth=None)
def get_entity_photos(
request: HttpRequest,
entity_type: str,
entity_id: UUID,
photo_type: Optional[str] = None,
):
"""
Get photos for a specific entity (public endpoint).
Path Parameters:
- entity_type: Entity type (park, ride, company, ridemodel)
- entity_id: Entity UUID
Query Parameters:
- photo_type: Filter by photo type
"""
try:
entity = get_entity_by_type(entity_type, entity_id)
photos = photo_service.get_entity_photos(
entity,
photo_type=photo_type,
approved_only=not request.auth
)
return [serialize_photo(photo) for photo in photos]
except ValueError as e:
return 404, {"detail": str(e)}
# ============================================================================
# Authenticated Endpoints
# ============================================================================
@router.post("/photos/upload", response=PhotoUploadResponse, auth=jwt_auth)
def upload_photo(
request: HttpRequest,
file: UploadedFile = File(...),
title: Optional[str] = Form(None),
description: Optional[str] = Form(None),
credit: Optional[str] = Form(None),
photo_type: str = Form('gallery'),
entity_type: Optional[str] = Form(None),
entity_id: Optional[str] = Form(None),
):
"""
Upload a new photo.
Requires authentication. Photo enters moderation queue.
Form Data:
- file: Image file (required)
- title: Photo title
- description: Photo description
- credit: Photo credit/attribution
- photo_type: Type of photo (main, gallery, banner, logo, thumbnail, other)
- entity_type: Entity type to attach to (optional)
- entity_id: Entity ID to attach to (optional)
"""
user = request.auth
try:
# Validate image
validate_image(file, photo_type)
# Get entity if provided
entity = None
if entity_type and entity_id:
try:
entity = get_entity_by_type(entity_type, UUID(entity_id))
except (ValueError, TypeError) as e:
return 400, {"detail": f"Invalid entity: {str(e)}"}
# Create photo
photo = photo_service.create_photo(
file=file,
user=user,
entity=entity,
photo_type=photo_type,
title=title or file.name,
description=description or '',
credit=credit or '',
is_visible=True,
)
return {
'success': True,
'message': 'Photo uploaded successfully and pending moderation',
'photo': serialize_photo(photo),
}
except DjangoValidationError as e:
return 400, {"detail": str(e)}
except CloudFlareError as e:
logger.error(f"CloudFlare upload failed: {str(e)}")
return 500, {"detail": "Failed to upload image"}
except Exception as e:
logger.error(f"Photo upload failed: {str(e)}")
return 500, {"detail": "An error occurred during upload"}
@router.patch("/photos/{photo_id}", response=PhotoOut, auth=jwt_auth)
def update_photo(
request: HttpRequest,
photo_id: UUID,
payload: PhotoUpdate,
):
"""
Update photo metadata.
Users can only update their own photos.
Moderators can update any photo.
"""
user = request.auth
try:
photo = Photo.objects.get(id=photo_id)
# Check permissions
if photo.uploaded_by_id != user.id and not user.is_moderator:
return 403, {"detail": "Permission denied"}
# Update fields
update_fields = []
if payload.title is not None:
photo.title = payload.title
update_fields.append('title')
if payload.description is not None:
photo.description = payload.description
update_fields.append('description')
if payload.credit is not None:
photo.credit = payload.credit
update_fields.append('credit')
if payload.photo_type is not None:
photo.photo_type = payload.photo_type
update_fields.append('photo_type')
if payload.is_visible is not None:
photo.is_visible = payload.is_visible
update_fields.append('is_visible')
if payload.display_order is not None:
photo.display_order = payload.display_order
update_fields.append('display_order')
if update_fields:
photo.save(update_fields=update_fields)
logger.info(f"Photo {photo_id} updated by user {user.id}")
return serialize_photo(photo)
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
@router.delete("/photos/{photo_id}", response=MessageSchema, auth=jwt_auth)
def delete_photo(request: HttpRequest, photo_id: UUID):
"""
Delete own photo.
Users can only delete their own photos.
Photos are soft-deleted and removed from CloudFlare.
"""
user = request.auth
try:
photo = Photo.objects.get(id=photo_id)
# Check permissions
if photo.uploaded_by_id != user.id and not user.is_moderator:
return 403, {"detail": "Permission denied"}
photo_service.delete_photo(photo)
return {
'success': True,
'message': 'Photo deleted successfully',
}
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
@router.post("/{entity_type}/{entity_id}/photos", response=MessageSchema, auth=jwt_auth)
def attach_photo_to_entity(
request: HttpRequest,
entity_type: str,
entity_id: UUID,
payload: PhotoAttachRequest,
):
"""
Attach an existing photo to an entity.
Requires authentication.
"""
user = request.auth
try:
# Get entity
entity = get_entity_by_type(entity_type, entity_id)
# Get photo
photo = Photo.objects.get(id=payload.photo_id)
# Check permissions (can only attach own photos unless moderator)
if photo.uploaded_by_id != user.id and not user.is_moderator:
return 403, {"detail": "Permission denied"}
# Attach photo
photo_service.attach_to_entity(photo, entity)
# Update photo type if provided
if payload.photo_type:
photo.photo_type = payload.photo_type
photo.save(update_fields=['photo_type'])
return {
'success': True,
'message': f'Photo attached to {entity_type} successfully',
}
except ValueError as e:
return 400, {"detail": str(e)}
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
# ============================================================================
# Moderator Endpoints
# ============================================================================
@router.get("/photos/pending", response=List[PhotoOut], auth=require_moderator)
@paginate
def list_pending_photos(request: HttpRequest):
"""
List photos pending moderation (moderators only).
"""
queryset = Photo.objects.select_related(
'uploaded_by', 'moderated_by', 'content_type'
).pending().order_by('-created_at')
return queryset
@router.post("/photos/{photo_id}/approve", response=PhotoOut, auth=require_moderator)
def approve_photo(request: HttpRequest, photo_id: UUID):
"""
Approve a photo (moderators only).
"""
user = request.auth
try:
photo = Photo.objects.get(id=photo_id)
photo = photo_service.moderate_photo(
photo=photo,
status='approved',
moderator=user,
)
return serialize_photo(photo)
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
@router.post("/photos/{photo_id}/reject", response=PhotoOut, auth=require_moderator)
def reject_photo(
request: HttpRequest,
photo_id: UUID,
payload: PhotoModerateRequest,
):
"""
Reject a photo (moderators only).
"""
user = request.auth
try:
photo = Photo.objects.get(id=photo_id)
photo = photo_service.moderate_photo(
photo=photo,
status='rejected',
moderator=user,
notes=payload.notes or '',
)
return serialize_photo(photo)
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
@router.post("/photos/{photo_id}/flag", response=PhotoOut, auth=require_moderator)
def flag_photo(
request: HttpRequest,
photo_id: UUID,
payload: PhotoModerateRequest,
):
"""
Flag a photo for review (moderators only).
"""
user = request.auth
try:
photo = Photo.objects.get(id=photo_id)
photo = photo_service.moderate_photo(
photo=photo,
status='flagged',
moderator=user,
notes=payload.notes or '',
)
return serialize_photo(photo)
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
@router.get("/photos/stats", response=PhotoStatsOut, auth=require_moderator)
def get_photo_stats(request: HttpRequest):
"""
Get photo statistics (moderators only).
"""
stats = Photo.objects.aggregate(
total=Count('id'),
pending=Count('id', filter=Q(moderation_status='pending')),
approved=Count('id', filter=Q(moderation_status='approved')),
rejected=Count('id', filter=Q(moderation_status='rejected')),
flagged=Count('id', filter=Q(moderation_status='flagged')),
total_size=Sum('file_size'),
)
return {
'total_photos': stats['total'] or 0,
'pending_photos': stats['pending'] or 0,
'approved_photos': stats['approved'] or 0,
'rejected_photos': stats['rejected'] or 0,
'flagged_photos': stats['flagged'] or 0,
'total_size_mb': round((stats['total_size'] or 0) / (1024 * 1024), 2),
}
# ============================================================================
# Admin Endpoints
# ============================================================================
@router.delete("/photos/{photo_id}/admin", response=MessageSchema, auth=require_admin)
def admin_delete_photo(request: HttpRequest, photo_id: UUID):
"""
Force delete any photo (admins only).
Permanently removes photo from database and CloudFlare.
"""
try:
photo = Photo.objects.get(id=photo_id)
photo_service.delete_photo(photo, delete_from_cloudflare=True)
logger.info(f"Photo {photo_id} force deleted by admin {request.auth.id}")
return {
'success': True,
'message': 'Photo permanently deleted',
}
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
@router.post(
"/{entity_type}/{entity_id}/photos/reorder",
response=MessageSchema,
auth=require_admin
)
def reorder_entity_photos(
request: HttpRequest,
entity_type: str,
entity_id: UUID,
payload: PhotoReorderRequest,
):
"""
Reorder photos for an entity (admins only).
"""
try:
entity = get_entity_by_type(entity_type, entity_id)
photo_service.reorder_photos(
entity=entity,
photo_ids=payload.photo_ids,
photo_type=payload.photo_type,
)
return {
'success': True,
'message': 'Photos reordered successfully',
}
except ValueError as e:
return 400, {"detail": str(e)}