mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 02:51:12 -05:00
- 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.
601 lines
18 KiB
Python
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)}
|