""" 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)}