mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 02:11:08 -05:00
485 lines
18 KiB
Python
485 lines
18 KiB
Python
"""
|
|
Media API views for ThrillWiki API v1.
|
|
|
|
This module provides API endpoints for media management including
|
|
photo uploads, captions, and media operations.
|
|
Consolidated from apps.media.views with proper domain service integration.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any, Dict, Union
|
|
from django.db.models import Q, QuerySet
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.core.exceptions import PermissionDenied
|
|
from django.http import Http404
|
|
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
|
from drf_spectacular.types import OpenApiTypes
|
|
from rest_framework import status
|
|
from rest_framework.decorators import action
|
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
|
from rest_framework.request import Request
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
from rest_framework.viewsets import ModelViewSet
|
|
from rest_framework.parsers import MultiPartParser, FormParser
|
|
|
|
# Import domain-specific models and services instead of generic Photo model
|
|
from apps.parks.models import ParkPhoto, Park
|
|
from apps.rides.models import RidePhoto, Ride
|
|
from apps.parks.services import ParkMediaService
|
|
from apps.rides.services import RideMediaService
|
|
from .serializers import (
|
|
PhotoUploadInputSerializer,
|
|
PhotoUploadOutputSerializer,
|
|
PhotoDetailOutputSerializer,
|
|
PhotoUpdateInputSerializer,
|
|
PhotoListOutputSerializer,
|
|
MediaStatsOutputSerializer,
|
|
BulkPhotoActionInputSerializer,
|
|
BulkPhotoActionOutputSerializer,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@extend_schema_view(
|
|
post=extend_schema(
|
|
summary="Upload photo",
|
|
description="Upload a photo and associate it with a content object (park, ride, etc.)",
|
|
request=PhotoUploadInputSerializer,
|
|
responses={
|
|
201: PhotoUploadOutputSerializer,
|
|
400: OpenApiTypes.OBJECT,
|
|
403: OpenApiTypes.OBJECT,
|
|
},
|
|
tags=["Media"],
|
|
),
|
|
)
|
|
class PhotoUploadAPIView(APIView):
|
|
"""API endpoint for photo uploads."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [MultiPartParser, FormParser]
|
|
|
|
def post(self, request: Request) -> Response:
|
|
"""Upload a photo and associate it with a content object."""
|
|
try:
|
|
serializer = PhotoUploadInputSerializer(data=request.data)
|
|
if not serializer.is_valid():
|
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
validated_data = serializer.validated_data
|
|
|
|
# Get content object
|
|
try:
|
|
content_type = ContentType.objects.get(
|
|
app_label=validated_data["app_label"], model=validated_data["model"]
|
|
)
|
|
content_object = content_type.get_object_for_this_type(
|
|
pk=validated_data["object_id"]
|
|
)
|
|
except ContentType.DoesNotExist:
|
|
return Response(
|
|
{
|
|
"error": f"Invalid content type: {validated_data['app_label']}.{validated_data['model']}"
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
except content_type.model_class().DoesNotExist:
|
|
return Response(
|
|
{"error": "Content object not found"},
|
|
status=status.HTTP_404_NOT_FOUND,
|
|
)
|
|
|
|
# Determine which domain service to use based on content object
|
|
if hasattr(content_object, '_meta') and content_object._meta.app_label == 'parks':
|
|
# Check permissions for park photos
|
|
if not request.user.has_perm("parks.add_parkphoto"):
|
|
return Response(
|
|
{"error": "You do not have permission to upload park photos"},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
# Create park photo using park media service
|
|
photo = ParkMediaService.upload_photo(
|
|
park=content_object,
|
|
image_file=validated_data["photo"],
|
|
user=request.user,
|
|
caption=validated_data.get("caption", ""),
|
|
alt_text=validated_data.get("alt_text", ""),
|
|
is_primary=validated_data.get("is_primary", False),
|
|
)
|
|
elif hasattr(content_object, '_meta') and content_object._meta.app_label == 'rides':
|
|
# Check permissions for ride photos
|
|
if not request.user.has_perm("rides.add_ridephoto"):
|
|
return Response(
|
|
{"error": "You do not have permission to upload ride photos"},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
# Create ride photo using ride media service
|
|
photo = RideMediaService.upload_photo(
|
|
ride=content_object,
|
|
image_file=validated_data["photo"],
|
|
user=request.user,
|
|
caption=validated_data.get("caption", ""),
|
|
alt_text=validated_data.get("alt_text", ""),
|
|
is_primary=validated_data.get("is_primary", False),
|
|
photo_type=validated_data.get("photo_type", "general"),
|
|
)
|
|
else:
|
|
return Response(
|
|
{"error": f"Unsupported content type for media upload: {content_object._meta.label}"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
response_serializer = PhotoUploadOutputSerializer(
|
|
{
|
|
"id": photo.id,
|
|
"url": photo.image.url,
|
|
"caption": photo.caption,
|
|
"alt_text": photo.alt_text,
|
|
"is_primary": photo.is_primary,
|
|
"message": "Photo uploaded successfully",
|
|
}
|
|
)
|
|
|
|
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in photo upload: {str(e)}", exc_info=True)
|
|
return Response(
|
|
{"error": f"An error occurred while uploading the photo: {str(e)}"},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
|
|
@extend_schema_view(
|
|
list=extend_schema(
|
|
summary="List photos",
|
|
description="Retrieve a list of photos with optional filtering",
|
|
parameters=[
|
|
OpenApiParameter(
|
|
name="content_type",
|
|
type=OpenApiTypes.STR,
|
|
location=OpenApiParameter.QUERY,
|
|
description="Filter by content type (e.g., 'parks.park', 'rides.ride')",
|
|
),
|
|
OpenApiParameter(
|
|
name="object_id",
|
|
type=OpenApiTypes.INT,
|
|
location=OpenApiParameter.QUERY,
|
|
description="Filter by object ID",
|
|
),
|
|
OpenApiParameter(
|
|
name="is_primary",
|
|
type=OpenApiTypes.BOOL,
|
|
location=OpenApiParameter.QUERY,
|
|
description="Filter by primary photos only",
|
|
),
|
|
],
|
|
responses={200: PhotoListOutputSerializer(many=True)},
|
|
tags=["Media"],
|
|
),
|
|
retrieve=extend_schema(
|
|
summary="Get photo details",
|
|
description="Retrieve detailed information about a specific photo",
|
|
responses={
|
|
200: PhotoDetailOutputSerializer,
|
|
404: OpenApiTypes.OBJECT,
|
|
},
|
|
tags=["Media"],
|
|
),
|
|
update=extend_schema(
|
|
summary="Update photo",
|
|
description="Update photo information (caption, alt text, etc.)",
|
|
request=PhotoUpdateInputSerializer,
|
|
responses={
|
|
200: PhotoDetailOutputSerializer,
|
|
400: OpenApiTypes.OBJECT,
|
|
403: OpenApiTypes.OBJECT,
|
|
404: OpenApiTypes.OBJECT,
|
|
},
|
|
tags=["Media"],
|
|
),
|
|
destroy=extend_schema(
|
|
summary="Delete photo",
|
|
description="Delete a photo (only by owner or admin)",
|
|
responses={
|
|
204: None,
|
|
403: OpenApiTypes.OBJECT,
|
|
404: OpenApiTypes.OBJECT,
|
|
},
|
|
tags=["Media"],
|
|
),
|
|
set_primary=extend_schema(
|
|
summary="Set photo as primary",
|
|
description="Set this photo as the primary photo for its content object",
|
|
responses={
|
|
200: OpenApiTypes.OBJECT,
|
|
403: OpenApiTypes.OBJECT,
|
|
404: OpenApiTypes.OBJECT,
|
|
},
|
|
tags=["Media"],
|
|
),
|
|
)
|
|
class PhotoViewSet(ModelViewSet):
|
|
"""ViewSet for managing photos across domains."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
lookup_field = "id"
|
|
|
|
def get_queryset(self) -> QuerySet:
|
|
"""Get queryset combining photos from all domains."""
|
|
# Combine park and ride photos
|
|
park_photos = ParkPhoto.objects.select_related('uploaded_by', 'park')
|
|
ride_photos = RidePhoto.objects.select_related('uploaded_by', 'ride')
|
|
|
|
# Apply filters
|
|
content_type = self.request.query_params.get('content_type')
|
|
object_id = self.request.query_params.get('object_id')
|
|
is_primary = self.request.query_params.get('is_primary')
|
|
|
|
if content_type == 'parks.park':
|
|
queryset = park_photos
|
|
if object_id:
|
|
queryset = queryset.filter(park_id=object_id)
|
|
elif content_type == 'rides.ride':
|
|
queryset = ride_photos
|
|
if object_id:
|
|
queryset = queryset.filter(ride_id=object_id)
|
|
else:
|
|
# Return combined queryset (this is complex due to different models)
|
|
# For now, return park photos as default - in production might need Union
|
|
queryset = park_photos
|
|
|
|
if is_primary is not None:
|
|
is_primary_bool = is_primary.lower() in ('true', '1', 'yes')
|
|
queryset = queryset.filter(is_primary=is_primary_bool)
|
|
|
|
return queryset.order_by('-uploaded_at')
|
|
|
|
def get_serializer_class(self):
|
|
"""Return appropriate serializer based on action."""
|
|
if self.action == "list":
|
|
return PhotoListOutputSerializer
|
|
elif self.action in ["update", "partial_update"]:
|
|
return PhotoUpdateInputSerializer
|
|
return PhotoDetailOutputSerializer
|
|
|
|
def get_object(self):
|
|
"""Get photo object from either domain."""
|
|
photo_id = self.kwargs.get('id')
|
|
|
|
# Try to find in park photos first
|
|
try:
|
|
return ParkPhoto.objects.select_related('uploaded_by', 'park').get(id=photo_id)
|
|
except ParkPhoto.DoesNotExist:
|
|
pass
|
|
|
|
# Try ride photos
|
|
try:
|
|
return RidePhoto.objects.select_related('uploaded_by', 'ride').get(id=photo_id)
|
|
except RidePhoto.DoesNotExist:
|
|
pass
|
|
|
|
raise Http404("Photo not found")
|
|
|
|
def update(self, request: Request, *args, **kwargs) -> Response:
|
|
"""Update photo details."""
|
|
photo = self.get_object()
|
|
|
|
# Check permissions
|
|
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
|
raise PermissionDenied("You can only edit your own photos")
|
|
|
|
serializer = self.get_serializer(data=request.data, partial=True)
|
|
if not serializer.is_valid():
|
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Update fields
|
|
for field, value in serializer.validated_data.items():
|
|
setattr(photo, field, value)
|
|
|
|
photo.save()
|
|
|
|
# Return updated photo details
|
|
response_serializer = PhotoDetailOutputSerializer(photo)
|
|
return Response(response_serializer.data)
|
|
|
|
def destroy(self, request: Request, *args, **kwargs) -> Response:
|
|
"""Delete a photo."""
|
|
photo = self.get_object()
|
|
|
|
# Check permissions
|
|
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
|
raise PermissionDenied("You can only delete your own photos")
|
|
|
|
photo.delete()
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
|
|
@action(detail=True, methods=['post'])
|
|
def set_primary(self, request: Request, id=None) -> Response:
|
|
"""Set this photo as primary for its content object."""
|
|
photo = self.get_object()
|
|
|
|
# Check permissions
|
|
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
|
raise PermissionDenied("You can only modify your own photos")
|
|
|
|
# Use appropriate service based on photo type
|
|
if isinstance(photo, ParkPhoto):
|
|
ParkMediaService.set_primary_photo(photo.park, photo)
|
|
elif isinstance(photo, RidePhoto):
|
|
RideMediaService.set_primary_photo(photo.ride, photo)
|
|
|
|
return Response({
|
|
"message": "Photo set as primary successfully",
|
|
"photo_id": photo.id,
|
|
"is_primary": True
|
|
})
|
|
|
|
|
|
@extend_schema_view(
|
|
get=extend_schema(
|
|
summary="Get media statistics",
|
|
description="Retrieve statistics about photos and media usage",
|
|
responses={200: MediaStatsOutputSerializer},
|
|
tags=["Media"],
|
|
),
|
|
)
|
|
class MediaStatsAPIView(APIView):
|
|
"""API endpoint for media statistics."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request: Request) -> Response:
|
|
"""Get media statistics."""
|
|
from django.db.models import Count
|
|
from datetime import datetime, timedelta
|
|
|
|
# Count photos by type
|
|
park_photo_count = ParkPhoto.objects.count()
|
|
ride_photo_count = RidePhoto.objects.count()
|
|
total_photos = park_photo_count + ride_photo_count
|
|
|
|
# Recent uploads (last 30 days)
|
|
thirty_days_ago = datetime.now() - timedelta(days=30)
|
|
recent_park_uploads = ParkPhoto.objects.filter(
|
|
uploaded_at__gte=thirty_days_ago).count()
|
|
recent_ride_uploads = RidePhoto.objects.filter(
|
|
uploaded_at__gte=thirty_days_ago).count()
|
|
recent_uploads = recent_park_uploads + recent_ride_uploads
|
|
|
|
# Top uploaders
|
|
from django.db.models import Q
|
|
from django.contrib.auth import get_user_model
|
|
User = get_user_model()
|
|
|
|
# This is a simplified version - in production might need more complex aggregation
|
|
top_uploaders = []
|
|
|
|
stats = MediaStatsOutputSerializer({
|
|
"total_photos": total_photos,
|
|
"photos_by_content_type": {
|
|
"parks": park_photo_count,
|
|
"rides": ride_photo_count,
|
|
},
|
|
"recent_uploads": recent_uploads,
|
|
"top_uploaders": top_uploaders,
|
|
"storage_usage": {
|
|
"total_size": 0, # Would need to calculate from file sizes
|
|
"average_size": 0,
|
|
}
|
|
})
|
|
|
|
return Response(stats.data)
|
|
|
|
|
|
@extend_schema_view(
|
|
post=extend_schema(
|
|
summary="Bulk photo actions",
|
|
description="Perform bulk actions on multiple photos (delete, approve, etc.)",
|
|
request=BulkPhotoActionInputSerializer,
|
|
responses={
|
|
200: BulkPhotoActionOutputSerializer,
|
|
400: OpenApiTypes.OBJECT,
|
|
403: OpenApiTypes.OBJECT,
|
|
},
|
|
tags=["Media"],
|
|
),
|
|
)
|
|
class BulkPhotoActionAPIView(APIView):
|
|
"""API endpoint for bulk photo operations."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def post(self, request: Request) -> Response:
|
|
"""Perform bulk action on photos."""
|
|
serializer = BulkPhotoActionInputSerializer(data=request.data)
|
|
if not serializer.is_valid():
|
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
photo_ids = serializer.validated_data['photo_ids']
|
|
action = serializer.validated_data['action']
|
|
|
|
success_count = 0
|
|
failed_count = 0
|
|
errors = []
|
|
|
|
for photo_id in photo_ids:
|
|
try:
|
|
# Find photo in either domain
|
|
photo = None
|
|
try:
|
|
photo = ParkPhoto.objects.get(id=photo_id)
|
|
except ParkPhoto.DoesNotExist:
|
|
try:
|
|
photo = RidePhoto.objects.get(id=photo_id)
|
|
except RidePhoto.DoesNotExist:
|
|
errors.append(f"Photo {photo_id} not found")
|
|
failed_count += 1
|
|
continue
|
|
|
|
# Check permissions
|
|
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
|
errors.append(f"No permission for photo {photo_id}")
|
|
failed_count += 1
|
|
continue
|
|
|
|
# Perform action
|
|
if action == 'delete':
|
|
photo.delete()
|
|
success_count += 1
|
|
elif action == 'approve':
|
|
if hasattr(photo, 'is_approved'):
|
|
photo.is_approved = True
|
|
photo.save()
|
|
success_count += 1
|
|
else:
|
|
errors.append(f"Photo {photo_id} does not support approval")
|
|
failed_count += 1
|
|
elif action == 'reject':
|
|
if hasattr(photo, 'is_approved'):
|
|
photo.is_approved = False
|
|
photo.save()
|
|
success_count += 1
|
|
else:
|
|
errors.append(f"Photo {photo_id} does not support approval")
|
|
failed_count += 1
|
|
|
|
except Exception as e:
|
|
errors.append(f"Error processing photo {photo_id}: {str(e)}")
|
|
failed_count += 1
|
|
|
|
response_data = BulkPhotoActionOutputSerializer({
|
|
"success_count": success_count,
|
|
"failed_count": failed_count,
|
|
"errors": errors,
|
|
"message": f"Bulk {action} completed: {success_count} successful, {failed_count} failed"
|
|
})
|
|
|
|
return Response(response_data.data)
|