mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:31:12 -05:00
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.
This commit is contained in:
600
django/api/v1/endpoints/photos.py
Normal file
600
django/api/v1/endpoints/photos.py
Normal file
@@ -0,0 +1,600 @@
|
||||
"""
|
||||
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)}
|
||||
Reference in New Issue
Block a user