""" Ride photo API views for ThrillWiki API v1. This module contains ride photo ViewSet following the parks pattern for domain consistency. Enhanced from centralized media API to provide domain-specific ride photo management. """ from .serializers import ( RidePhotoOutputSerializer, RidePhotoCreateInputSerializer, RidePhotoUpdateInputSerializer, RidePhotoListOutputSerializer, RidePhotoApprovalInputSerializer, RidePhotoStatsOutputSerializer, ) from typing import TYPE_CHECKING if TYPE_CHECKING: pass import logging from django.core.exceptions import PermissionDenied from drf_spectacular.utils import extend_schema_view, extend_schema from drf_spectacular.types import OpenApiTypes from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from apps.rides.models import RidePhoto, Ride from apps.rides.services.media_service import RideMediaService from django.contrib.auth import get_user_model UserModel = get_user_model() logger = logging.getLogger(__name__) @extend_schema_view( list=extend_schema( summary="List ride photos", description="Retrieve a paginated list of ride photos with filtering capabilities.", responses={200: RidePhotoListOutputSerializer(many=True)}, tags=["Ride Media"], ), create=extend_schema( summary="Upload ride photo", description="Upload a new photo for a ride. Requires authentication.", request=RidePhotoCreateInputSerializer, responses={ 201: RidePhotoOutputSerializer, 400: OpenApiTypes.OBJECT, 401: OpenApiTypes.OBJECT, }, tags=["Ride Media"], ), retrieve=extend_schema( summary="Get ride photo details", description="Retrieve detailed information about a specific ride photo.", responses={ 200: RidePhotoOutputSerializer, 404: OpenApiTypes.OBJECT, }, tags=["Ride Media"], ), update=extend_schema( summary="Update ride photo", description="Update ride photo information. Requires authentication and ownership or admin privileges.", request=RidePhotoUpdateInputSerializer, responses={ 200: RidePhotoOutputSerializer, 400: OpenApiTypes.OBJECT, 401: OpenApiTypes.OBJECT, 403: OpenApiTypes.OBJECT, 404: OpenApiTypes.OBJECT, }, tags=["Ride Media"], ), partial_update=extend_schema( summary="Partially update ride photo", description="Partially update ride photo information. Requires authentication and ownership or admin privileges.", request=RidePhotoUpdateInputSerializer, responses={ 200: RidePhotoOutputSerializer, 400: OpenApiTypes.OBJECT, 401: OpenApiTypes.OBJECT, 403: OpenApiTypes.OBJECT, 404: OpenApiTypes.OBJECT, }, tags=["Ride Media"], ), destroy=extend_schema( summary="Delete ride photo", description="Delete a ride photo. Requires authentication and ownership or admin privileges.", responses={ 204: None, 401: OpenApiTypes.OBJECT, 403: OpenApiTypes.OBJECT, 404: OpenApiTypes.OBJECT, }, tags=["Ride Media"], ), ) class RidePhotoViewSet(ModelViewSet): """ Enhanced ViewSet for managing ride photos with full feature parity. Provides CRUD operations for ride photos with proper permission checking. Uses RideMediaService for business logic operations. Includes advanced features like bulk approval and statistics. """ permission_classes = [IsAuthenticated] lookup_field = "id" def get_queryset(self): # type: ignore[override] """Get photos for the current ride with optimized queries.""" queryset = RidePhoto.objects.select_related( "ride", "ride__park", "ride__park__operator", "uploaded_by" ) # If ride_pk is provided in URL kwargs, filter by ride ride_pk = self.kwargs.get("ride_pk") if ride_pk: queryset = queryset.filter(ride_id=ride_pk) return queryset.order_by("-created_at") def get_serializer_class(self): # type: ignore[override] """Return appropriate serializer based on action.""" if self.action == "list": return RidePhotoListOutputSerializer elif self.action == "create": return RidePhotoCreateInputSerializer elif self.action in ["update", "partial_update"]: return RidePhotoUpdateInputSerializer else: return RidePhotoOutputSerializer def perform_create(self, serializer): """Create a new ride photo using RideMediaService.""" ride_id = self.kwargs.get("ride_pk") if not ride_id: raise ValidationError("Ride ID is required") try: ride = Ride.objects.get(pk=ride_id) except Ride.DoesNotExist: raise ValidationError("Ride not found") try: # Use the service to create the photo with proper business logic photo = RideMediaService.upload_photo( ride=ride, image_file=serializer.validated_data["image"], user=self.request.user, # type: ignore caption=serializer.validated_data.get("caption", ""), alt_text=serializer.validated_data.get("alt_text", ""), photo_type=serializer.validated_data.get("photo_type", "exterior"), is_primary=serializer.validated_data.get("is_primary", False), auto_approve=False, # Default to requiring approval ) # Set the instance for the serializer response serializer.instance = photo except Exception as e: logger.error(f"Error creating ride photo: {e}") raise ValidationError(f"Failed to create photo: {str(e)}") def perform_update(self, serializer): """Update ride photo with permission checking.""" instance = self.get_object() # Check permissions - allow owner or staff if not ( self.request.user == instance.uploaded_by or getattr(self.request.user, "is_staff", False) ): raise PermissionDenied("You can only edit your own photos or be an admin.") # Handle primary photo logic using service if serializer.validated_data.get("is_primary", False): try: RideMediaService.set_primary_photo(ride=instance.ride, photo=instance) # Remove is_primary from validated_data since service handles it if "is_primary" in serializer.validated_data: del serializer.validated_data["is_primary"] except Exception as e: logger.error(f"Error setting primary photo: {e}") raise ValidationError(f"Failed to set primary photo: {str(e)}") def perform_destroy(self, instance): """Delete ride photo with permission checking.""" # Check permissions - allow owner or staff if not ( self.request.user == instance.uploaded_by or getattr(self.request.user, "is_staff", False) ): raise PermissionDenied( "You can only delete your own photos or be an admin." ) try: # Delete from Cloudflare first if image exists if instance.image: try: from django_cloudflareimages_toolkit.services import CloudflareImagesService service = CloudflareImagesService() service.delete_image(instance.image) logger.info( f"Successfully deleted ride photo from Cloudflare: {instance.image.cloudflare_id}") except Exception as e: logger.error( f"Failed to delete ride photo from Cloudflare: {str(e)}") # Continue with database deletion even if Cloudflare deletion fails RideMediaService.delete_photo( instance, deleted_by=self.request.user # type: ignore ) except Exception as e: logger.error(f"Error deleting ride photo: {e}") raise ValidationError(f"Failed to delete photo: {str(e)}") @extend_schema( summary="Set photo as primary", description="Set this photo as the primary photo for the ride", responses={ 200: OpenApiTypes.OBJECT, 400: OpenApiTypes.OBJECT, 403: OpenApiTypes.OBJECT, 404: OpenApiTypes.OBJECT, }, tags=["Ride Media"], ) @action(detail=True, methods=["post"]) def set_primary(self, request, **kwargs): """Set this photo as the primary photo for the ride.""" photo = self.get_object() # Check permissions - allow owner or staff if not ( request.user == photo.uploaded_by or getattr(request.user, "is_staff", False) ): raise PermissionDenied( "You can only modify your own photos or be an admin." ) try: success = RideMediaService.set_primary_photo(ride=photo.ride, photo=photo) if success: # Refresh the photo instance photo.refresh_from_db() serializer = self.get_serializer(photo) return Response( { "message": "Photo set as primary successfully", "photo": serializer.data, }, status=status.HTTP_200_OK, ) else: return Response( {"error": "Failed to set primary photo"}, status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: logger.error(f"Error setting primary photo: {e}") return Response( {"error": f"Failed to set primary photo: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, ) @extend_schema( summary="Bulk approve/reject photos", description="Bulk approve or reject multiple ride photos (admin only)", request=RidePhotoApprovalInputSerializer, responses={ 200: OpenApiTypes.OBJECT, 400: OpenApiTypes.OBJECT, 403: OpenApiTypes.OBJECT, }, tags=["Ride Media"], ) @action(detail=False, methods=["post"], permission_classes=[IsAuthenticated]) def bulk_approve(self, request, **kwargs): """Bulk approve or reject multiple photos (admin only).""" if not getattr(request.user, "is_staff", False): raise PermissionDenied("Only administrators can approve photos.") serializer = RidePhotoApprovalInputSerializer(data=request.data) serializer.is_valid(raise_exception=True) validated_data = getattr(serializer, "validated_data", {}) photo_ids = validated_data.get("photo_ids") approve = validated_data.get("approve") ride_id = self.kwargs.get("ride_pk") if photo_ids is None or approve is None: return Response( {"error": "Missing required fields: photo_ids and/or approve."}, status=status.HTTP_400_BAD_REQUEST, ) try: # Filter photos to only those belonging to this ride (if ride_pk provided) photos_queryset = RidePhoto.objects.filter(id__in=photo_ids) if ride_id: photos_queryset = photos_queryset.filter(ride_id=ride_id) updated_count = photos_queryset.update(is_approved=approve) return Response( { "message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos", "updated_count": updated_count, }, status=status.HTTP_200_OK, ) except Exception as e: logger.error(f"Error in bulk photo approval: {e}") return Response( {"error": f"Failed to update photos: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, ) @extend_schema( summary="Get ride photo statistics", description="Get photo statistics for the ride", responses={ 200: RidePhotoStatsOutputSerializer, 404: OpenApiTypes.OBJECT, 500: OpenApiTypes.OBJECT, }, tags=["Ride Media"], ) @action(detail=False, methods=["get"]) def stats(self, request, **kwargs): """Get photo statistics for the ride.""" ride_pk = self.kwargs.get("ride_pk") ride = None if ride_pk: try: ride = Ride.objects.get(pk=ride_pk) except Ride.DoesNotExist: return Response( {"error": "Ride not found."}, status=status.HTTP_404_NOT_FOUND, ) try: if ride is not None: stats = RideMediaService.get_photo_stats(ride) else: # Global stats across all rides stats = { "total_photos": RidePhoto.objects.count(), "approved_photos": RidePhoto.objects.filter( is_approved=True ).count(), "pending_photos": RidePhoto.objects.filter( is_approved=False ).count(), "has_primary": False, # Not applicable for global stats "recent_uploads": RidePhoto.objects.order_by("-created_at")[ :5 ].count(), "by_type": {}, } serializer = RidePhotoStatsOutputSerializer(stats) return Response(serializer.data, status=status.HTTP_200_OK) except Exception as e: logger.error(f"Error getting ride photo stats: {e}") return Response( {"error": f"Failed to get photo statistics: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) # Legacy compatibility action using the legacy set_primary logic @extend_schema( summary="Set photo as primary (legacy)", description="Legacy set primary action for backwards compatibility", responses={ 200: OpenApiTypes.OBJECT, 400: OpenApiTypes.OBJECT, 403: OpenApiTypes.OBJECT, }, tags=["Ride Media"], ) @action(detail=True, methods=["post"]) def set_primary_legacy(self, request, id=None): """Legacy set primary action for backwards compatibility.""" photo = self.get_object() if not ( request.user == photo.uploaded_by or request.user.has_perm("rides.change_ridephoto") ): return Response( {"error": "You do not have permission to edit photos for this ride."}, status=status.HTTP_403_FORBIDDEN, ) try: success = RideMediaService.set_primary_photo(ride=photo.ride, photo=photo) if success: return Response({"message": "Photo set as primary successfully."}) else: return Response( {"error": "Failed to set primary photo"}, status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True) return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) @extend_schema( summary="Save Cloudflare image as ride photo", description="Save a Cloudflare image as a ride photo after direct upload to Cloudflare", request=OpenApiTypes.OBJECT, responses={ 201: RidePhotoOutputSerializer, 400: OpenApiTypes.OBJECT, 401: OpenApiTypes.OBJECT, 404: OpenApiTypes.OBJECT, }, tags=["Ride Media"], ) @action(detail=False, methods=["post"]) def save_image(self, request, **kwargs): """Save a Cloudflare image as a ride photo after direct upload to Cloudflare.""" ride_pk = self.kwargs.get("ride_pk") if not ride_pk: return Response( {"error": "Ride ID is required"}, status=status.HTTP_400_BAD_REQUEST, ) try: ride = Ride.objects.get(pk=ride_pk) except Ride.DoesNotExist: return Response( {"error": "Ride not found"}, status=status.HTTP_404_NOT_FOUND, ) cloudflare_image_id = request.data.get("cloudflare_image_id") if not cloudflare_image_id: return Response( {"error": "cloudflare_image_id is required"}, status=status.HTTP_400_BAD_REQUEST, ) try: # Import CloudflareImage model and service from django_cloudflareimages_toolkit.models import CloudflareImage from django_cloudflareimages_toolkit.services import CloudflareImagesService from django.utils import timezone # Always fetch the latest image data from Cloudflare API try: # Get image details from Cloudflare API service = CloudflareImagesService() image_data = service.get_image(cloudflare_image_id) if not image_data: return Response( {"error": "Image not found in Cloudflare"}, status=status.HTTP_400_BAD_REQUEST, ) # Try to find existing CloudflareImage record by cloudflare_id cloudflare_image = None try: cloudflare_image = CloudflareImage.objects.get( cloudflare_id=cloudflare_image_id) # Update existing record with latest data from Cloudflare cloudflare_image.status = 'uploaded' cloudflare_image.uploaded_at = timezone.now() cloudflare_image.metadata = image_data.get('meta', {}) # Extract variants from nested result structure cloudflare_image.variants = image_data.get( 'result', {}).get('variants', []) cloudflare_image.cloudflare_metadata = image_data cloudflare_image.width = image_data.get('width') cloudflare_image.height = image_data.get('height') cloudflare_image.format = image_data.get('format', '') cloudflare_image.save() except CloudflareImage.DoesNotExist: # Create new CloudflareImage record from API response cloudflare_image = CloudflareImage.objects.create( cloudflare_id=cloudflare_image_id, user=request.user, status='uploaded', upload_url='', # Not needed for uploaded images expires_at=timezone.now() + timezone.timedelta(days=365), # Set far future expiry uploaded_at=timezone.now(), metadata=image_data.get('meta', {}), # Extract variants from nested result structure variants=image_data.get('result', {}).get('variants', []), cloudflare_metadata=image_data, width=image_data.get('width'), height=image_data.get('height'), format=image_data.get('format', ''), ) except Exception as api_error: logger.error( f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True) return Response( {"error": f"Failed to fetch image from Cloudflare: {str(api_error)}"}, status=status.HTTP_400_BAD_REQUEST, ) # Create the ride photo with the CloudflareImage reference photo = RidePhoto.objects.create( ride=ride, image=cloudflare_image, uploaded_by=request.user, caption=request.data.get("caption", ""), alt_text=request.data.get("alt_text", ""), photo_type=request.data.get("photo_type", "exterior"), is_primary=request.data.get("is_primary", False), is_approved=False, # Default to requiring approval ) # Handle primary photo logic if requested if request.data.get("is_primary", False): try: RideMediaService.set_primary_photo(ride=ride, photo=photo) except Exception as e: logger.error(f"Error setting primary photo: {e}") # Don't fail the entire operation, just log the error serializer = RidePhotoOutputSerializer(photo, context={"request": request}) return Response(serializer.data, status=status.HTTP_201_CREATED) except Exception as e: logger.error(f"Error saving ride photo: {e}") return Response( {"error": f"Failed to save photo: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, )