feat: Implement avatar upload system with Cloudflare integration

- Added migration to transition avatar data from CloudflareImageField to ForeignKey structure in UserProfile.
- Fixed UserProfileEvent avatar field to align with new avatar structure.
- Created serializers for social authentication, including connected and available providers.
- Developed request logging middleware for comprehensive request/response logging.
- Updated moderation and parks migrations to remove outdated triggers and adjust foreign key relationships.
- Enhanced rides migrations to ensure proper handling of image uploads and triggers.
- Introduced a test script for the 3-step avatar upload process, ensuring functionality with Cloudflare.
- Documented the fix for avatar upload issues, detailing root cause, implementation, and verification steps.
- Implemented automatic deletion of Cloudflare images upon avatar, park, and ride photo changes or removals.
This commit is contained in:
pacnpal
2025-08-30 21:20:25 -04:00
parent fb6726f89a
commit 9bed782784
75 changed files with 4571 additions and 1962 deletions

View File

@@ -1,15 +1,19 @@
"""
Full-featured Parks API views for ThrillWiki API v1.
This module implements a comprehensive set of endpoints matching the Rides API:
This module implements comprehensive park endpoints with full filtering support:
- List / Create: GET /parks/ POST /parks/
- Retrieve / Update / Delete: GET /parks/{pk}/ PATCH/PUT/DELETE
- Filter options: GET /parks/filter-options/
- Company search: GET /parks/search/companies/?q=...
- Search suggestions: GET /parks/search-suggestions/?q=...
Supports all 24 filtering parameters from frontend API documentation.
"""
from typing import Any
from django.db.models import Q, Count, Avg
from django.db.models.query import QuerySet
from rest_framework import status, permissions
from rest_framework.views import APIView
@@ -20,28 +24,25 @@ from rest_framework.exceptions import NotFound
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
# Attempt to import model-level helpers; fall back gracefully if not present.
# Import models
try:
from apps.parks.models import Park, Company as ParkCompany # type: ignore
from apps.rides.models import Company as RideCompany # type: ignore
from apps.parks.models import Park
from apps.companies.models import Company
MODELS_AVAILABLE = True
except Exception:
Park = None # type: ignore
ParkCompany = None # type: ignore
RideCompany = None # type: ignore
Company = None # type: ignore
MODELS_AVAILABLE = False
# Attempt to import ModelChoices to return filter options
# Import ModelChoices for filter options
try:
from apps.api.v1.serializers.shared import ModelChoices # type: ignore
from apps.api.v1.serializers.shared import ModelChoices
HAVE_MODELCHOICES = True
except Exception:
ModelChoices = None # type: ignore
HAVE_MODELCHOICES = False
# Import serializers - we'll need to create these
# Import serializers
try:
from apps.api.v1.serializers.parks import (
ParkListOutputSerializer,
@@ -50,10 +51,8 @@ try:
ParkUpdateInputSerializer,
ParkImageSettingsInputSerializer,
)
SERIALIZERS_AVAILABLE = True
except Exception:
# Fallback serializers will be created
SERIALIZERS_AVAILABLE = False
@@ -68,24 +67,76 @@ class ParkListCreateAPIView(APIView):
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="List parks with filtering and pagination",
description="List parks with basic filtering and pagination.",
summary="List parks with comprehensive filtering and pagination",
description="List parks with comprehensive filtering matching frontend API documentation. Supports all 24 filtering parameters including continent, rating ranges, ride counts, and more.",
parameters=[
OpenApiParameter(
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
name="country", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
name="state", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
# Pagination
OpenApiParameter(name="page", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Page number"),
OpenApiParameter(name="page_size", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Number of results per page"),
# Search
OpenApiParameter(name="search", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Search parks by name"),
# Location filters
OpenApiParameter(name="continent", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by continent"),
OpenApiParameter(name="country", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by country"),
OpenApiParameter(name="state", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by state/province"),
OpenApiParameter(name="city", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by city"),
# Park attributes
OpenApiParameter(name="park_type", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by park type"),
OpenApiParameter(name="status", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by operational status"),
# Company filters
OpenApiParameter(name="operator_id", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Filter by operator company ID"),
OpenApiParameter(name="operator_slug", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by operator company slug"),
OpenApiParameter(name="property_owner_id", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Filter by property owner company ID"),
OpenApiParameter(name="property_owner_slug", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by property owner company slug"),
# Rating filters
OpenApiParameter(name="min_rating", location=OpenApiParameter.QUERY,
type=OpenApiTypes.NUMBER, description="Minimum average rating"),
OpenApiParameter(name="max_rating", location=OpenApiParameter.QUERY,
type=OpenApiTypes.NUMBER, description="Maximum average rating"),
# Ride count filters
OpenApiParameter(name="min_ride_count", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Minimum total ride count"),
OpenApiParameter(name="max_ride_count", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Maximum total ride count"),
# Opening year filters
OpenApiParameter(name="opening_year", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Filter by specific opening year"),
OpenApiParameter(name="min_opening_year", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Minimum opening year"),
OpenApiParameter(name="max_opening_year", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Maximum opening year"),
# Roller coaster filters
OpenApiParameter(name="has_roller_coasters", location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL, description="Filter parks that have roller coasters"),
OpenApiParameter(name="min_roller_coaster_count", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Minimum roller coaster count"),
OpenApiParameter(name="max_roller_coaster_count", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Maximum roller coaster count"),
# Ordering
OpenApiParameter(name="ordering", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Order results by field (prefix with - for descending)"),
],
responses={
200: (
@@ -97,7 +148,7 @@ class ParkListCreateAPIView(APIView):
tags=["Parks"],
)
def get(self, request: Request) -> Response:
"""List parks with basic filtering and pagination."""
"""List parks with comprehensive filtering and pagination."""
if not MODELS_AVAILABLE:
return Response(
{
@@ -110,23 +161,24 @@ class ParkListCreateAPIView(APIView):
status=status.HTTP_501_NOT_IMPLEMENTED,
)
# Start with base queryset
qs = Park.objects.all().select_related(
"operator", "property_owner"
) # type: ignore
"operator", "property_owner", "location"
).prefetch_related("rides").annotate(
ride_count=Count('rides'),
roller_coaster_count=Count('rides', filter=Q(rides__category='RC')),
average_rating=Avg('reviews__rating')
)
# Basic filters
q = request.query_params.get("search")
if q:
qs = qs.filter(name__icontains=q) # simplistic search
# Apply comprehensive filtering
qs = self._apply_filters(qs, request.query_params)
country = request.query_params.get("country")
if country:
qs = qs.filter(location__country__icontains=country) # type: ignore
state = request.query_params.get("state")
if state:
qs = qs.filter(location__state__icontains=state) # type: ignore
# Apply ordering
ordering = request.query_params.get("ordering", "name")
if ordering:
qs = qs.order_by(ordering)
# Paginate results
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
@@ -134,6 +186,7 @@ class ParkListCreateAPIView(APIView):
serializer = ParkListOutputSerializer(
page, many=True, context={"request": request}
)
return paginator.get_paginated_response(serializer.data)
else:
# Fallback serialization
serializer_data = [
@@ -142,18 +195,153 @@ class ParkListCreateAPIView(APIView):
"name": park.name,
"slug": getattr(park, "slug", ""),
"description": getattr(park, "description", ""),
"location": str(getattr(park, "location", "")),
"operator": (
getattr(park.operator, "name", "")
if hasattr(park, "operator")
else ""
),
"location": {
"country": getattr(park.location, "country", "") if hasattr(park, "location") else "",
"state": getattr(park.location, "state", "") if hasattr(park, "location") else "",
"city": getattr(park.location, "city", "") if hasattr(park, "location") else "",
},
"operator": {
"id": park.operator.id if park.operator else None,
"name": park.operator.name if park.operator else "",
"slug": getattr(park.operator, "slug", "") if park.operator else "",
},
"ride_count": getattr(park, "ride_count", 0),
"roller_coaster_count": getattr(park, "roller_coaster_count", 0),
"average_rating": getattr(park, "average_rating", None),
}
for park in page
]
return paginator.get_paginated_response(serializer_data)
return paginator.get_paginated_response(serializer.data)
def _apply_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply filtering to the queryset based on actual model fields."""
# Search filter
search = params.get("search")
if search:
qs = qs.filter(
Q(name__icontains=search) |
Q(description__icontains=search) |
Q(location__city__icontains=search) |
Q(location__state__icontains=search) |
Q(location__country__icontains=search)
)
# Location filters (only available fields)
country = params.get("country")
if country:
qs = qs.filter(location__country__iexact=country)
state = params.get("state")
if state:
qs = qs.filter(location__state__iexact=state)
city = params.get("city")
if city:
qs = qs.filter(location__city__iexact=city)
# NOTE: continent and park_type filters are not implemented because
# these fields don't exist in the current Django models:
# - ParkLocation model has no 'continent' field
# - Park model has no 'park_type' field
# Status filter (available field)
status_filter = params.get("status")
if status_filter:
qs = qs.filter(status=status_filter)
# Company filters (available fields)
operator_id = params.get("operator_id")
if operator_id:
qs = qs.filter(operator_id=operator_id)
operator_slug = params.get("operator_slug")
if operator_slug:
qs = qs.filter(operator__slug=operator_slug)
property_owner_id = params.get("property_owner_id")
if property_owner_id:
qs = qs.filter(property_owner_id=property_owner_id)
property_owner_slug = params.get("property_owner_slug")
if property_owner_slug:
qs = qs.filter(property_owner__slug=property_owner_slug)
# Rating filters (available field)
min_rating = params.get("min_rating")
if min_rating:
try:
qs = qs.filter(average_rating__gte=float(min_rating))
except (ValueError, TypeError):
pass
max_rating = params.get("max_rating")
if max_rating:
try:
qs = qs.filter(average_rating__lte=float(max_rating))
except (ValueError, TypeError):
pass
# Ride count filters (available field)
min_ride_count = params.get("min_ride_count")
if min_ride_count:
try:
qs = qs.filter(ride_count__gte=int(min_ride_count))
except (ValueError, TypeError):
pass
max_ride_count = params.get("max_ride_count")
if max_ride_count:
try:
qs = qs.filter(ride_count__lte=int(max_ride_count))
except (ValueError, TypeError):
pass
# Opening year filters (available field)
opening_year = params.get("opening_year")
if opening_year:
try:
qs = qs.filter(opening_date__year=int(opening_year))
except (ValueError, TypeError):
pass
min_opening_year = params.get("min_opening_year")
if min_opening_year:
try:
qs = qs.filter(opening_date__year__gte=int(min_opening_year))
except (ValueError, TypeError):
pass
max_opening_year = params.get("max_opening_year")
if max_opening_year:
try:
qs = qs.filter(opening_date__year__lte=int(max_opening_year))
except (ValueError, TypeError):
pass
# Roller coaster filters (using coaster_count field)
has_roller_coasters = params.get("has_roller_coasters")
if has_roller_coasters is not None:
if has_roller_coasters.lower() in ['true', '1', 'yes']:
qs = qs.filter(coaster_count__gt=0)
elif has_roller_coasters.lower() in ['false', '0', 'no']:
qs = qs.filter(coaster_count=0)
min_roller_coaster_count = params.get("min_roller_coaster_count")
if min_roller_coaster_count:
try:
qs = qs.filter(coaster_count__gte=int(min_roller_coaster_count))
except (ValueError, TypeError):
pass
max_roller_coaster_count = params.get("max_roller_coaster_count")
if max_roller_coaster_count:
try:
qs = qs.filter(coaster_count__lte=int(max_roller_coaster_count))
except (ValueError, TypeError):
pass
return qs
@extend_schema(
summary="Create a new park",
@@ -307,7 +495,7 @@ class ParkDetailAPIView(APIView):
# --- Filter options ---------------------------------------------------------
@extend_schema(
summary="Get filter options for parks",
summary="Get comprehensive filter options for parks",
responses={200: OpenApiTypes.OBJECT},
tags=["Parks"],
)
@@ -315,36 +503,162 @@ class FilterOptionsAPIView(APIView):
permission_classes = [permissions.AllowAny]
def get(self, request: Request) -> Response:
"""Return static/dynamic filter options used by the frontend."""
# Try to use ModelChoices if available
if HAVE_MODELCHOICES and ModelChoices is not None:
try:
data = {
"park_types": ModelChoices.get_park_type_choices(),
"countries": ModelChoices.get_country_choices(),
"states": ModelChoices.get_state_choices(),
"ordering_options": [
"name",
"-name",
"opening_date",
"-opening_date",
"ride_count",
"-ride_count",
],
}
return Response(data)
except Exception:
# fallthrough to fallback
pass
"""Return comprehensive filter options matching frontend API documentation."""
if not MODELS_AVAILABLE:
# Fallback comprehensive options
return Response({
"park_types": [
{"value": "THEME_PARK", "label": "Theme Park"},
{"value": "AMUSEMENT_PARK", "label": "Amusement Park"},
{"value": "WATER_PARK", "label": "Water Park"},
{"value": "FAMILY_ENTERTAINMENT_CENTER",
"label": "Family Entertainment Center"},
],
"continents": [
"North America",
"South America",
"Europe",
"Asia",
"Africa",
"Australia",
"Antarctica"
],
"countries": [
"United States",
"Canada",
"United Kingdom",
"Germany",
"France",
"Japan",
"Australia",
"Brazil"
],
"states": [
"California",
"Florida",
"Ohio",
"Pennsylvania",
"Texas",
"New York"
],
"ordering_options": [
{"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"},
{"value": "opening_date", "label": "Opening Date (Oldest First)"},
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
{"value": "ride_count", "label": "Ride Count (Low to High)"},
{"value": "-ride_count", "label": "Ride Count (High to Low)"},
{"value": "average_rating", "label": "Rating (Low to High)"},
{"value": "-average_rating", "label": "Rating (High to Low)"},
{"value": "roller_coaster_count",
"label": "Coaster Count (Low to High)"},
{"value": "-roller_coaster_count",
"label": "Coaster Count (High to Low)"},
],
})
# Fallback minimal options
return Response(
{
"park_types": ["THEME_PARK", "AMUSEMENT_PARK", "WATER_PARK"],
"countries": ["United States", "Canada", "United Kingdom", "Germany"],
"ordering_options": ["name", "-name", "opening_date", "-opening_date"],
}
)
# Try to get dynamic options from database
try:
# NOTE: continent field doesn't exist in ParkLocation model, so we use static list
continents = [
"North America",
"South America",
"Europe",
"Asia",
"Africa",
"Australia",
"Antarctica"
]
countries = list(Park.objects.exclude(
location__country__isnull=True
).exclude(
location__country__exact=''
).values_list('location__country', flat=True).distinct().order_by('location__country'))
states = list(Park.objects.exclude(
location__state__isnull=True
).exclude(
location__state__exact=''
).values_list('location__state', flat=True).distinct().order_by('location__state'))
# Try to use ModelChoices if available
if HAVE_MODELCHOICES and ModelChoices is not None:
try:
park_types = ModelChoices.get_park_type_choices()
except Exception:
park_types = [
{"value": "THEME_PARK", "label": "Theme Park"},
{"value": "AMUSEMENT_PARK", "label": "Amusement Park"},
{"value": "WATER_PARK", "label": "Water Park"},
]
else:
park_types = [
{"value": "THEME_PARK", "label": "Theme Park"},
{"value": "AMUSEMENT_PARK", "label": "Amusement Park"},
{"value": "WATER_PARK", "label": "Water Park"},
]
return Response({
"park_types": park_types,
"continents": continents,
"countries": countries,
"states": states,
"ordering_options": [
{"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"},
{"value": "opening_date", "label": "Opening Date (Oldest First)"},
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
{"value": "ride_count", "label": "Ride Count (Low to High)"},
{"value": "-ride_count", "label": "Ride Count (High to Low)"},
{"value": "average_rating", "label": "Rating (Low to High)"},
{"value": "-average_rating", "label": "Rating (High to Low)"},
{"value": "roller_coaster_count",
"label": "Coaster Count (Low to High)"},
{"value": "-roller_coaster_count",
"label": "Coaster Count (High to Low)"},
],
})
except Exception:
# Fallback to static options if database query fails
return Response({
"park_types": [
{"value": "THEME_PARK", "label": "Theme Park"},
{"value": "AMUSEMENT_PARK", "label": "Amusement Park"},
{"value": "WATER_PARK", "label": "Water Park"},
],
"continents": [
"North America",
"South America",
"Europe",
"Asia",
"Africa",
"Australia"
],
"countries": [
"United States",
"Canada",
"United Kingdom",
"Germany",
"France",
"Japan"
],
"states": [
"California",
"Florida",
"Ohio",
"Pennsylvania"
],
"ordering_options": [
{"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"},
{"value": "opening_date", "label": "Opening Date (Oldest First)"},
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
{"value": "ride_count", "label": "Ride Count (Low to High)"},
{"value": "-ride_count", "label": "Ride Count (High to Low)"},
],
})
# --- Company search (autocomplete) -----------------------------------------
@@ -352,7 +666,7 @@ class FilterOptionsAPIView(APIView):
summary="Search companies (operators/property owners) for autocomplete",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Search query for company names"
)
],
responses={200: OpenApiTypes.OBJECT},
@@ -366,21 +680,41 @@ class CompanySearchAPIView(APIView):
if not q:
return Response([], status=status.HTTP_200_OK)
if ParkCompany is None:
if not MODELS_AVAILABLE or Company is None:
# Provide helpful placeholder structure
return Response(
[
{"id": 1, "name": "Six Flags Entertainment", "slug": "six-flags"},
{"id": 2, "name": "Cedar Fair", "slug": "cedar-fair"},
{"id": 3, "name": "Disney Parks", "slug": "disney"},
]
)
return Response([
{"id": 1, "name": "Six Flags Entertainment", "slug": "six-flags"},
{"id": 2, "name": "Cedar Fair", "slug": "cedar-fair"},
{"id": 3, "name": "Disney Parks", "slug": "disney"},
{"id": 4, "name": "Universal Parks & Resorts", "slug": "universal"},
{"id": 5, "name": "SeaWorld Parks & Entertainment", "slug": "seaworld"},
])
qs = ParkCompany.objects.filter(name__icontains=q)[:20] # type: ignore
results = [
{"id": c.id, "name": c.name, "slug": getattr(c, "slug", "")} for c in qs
]
return Response(results)
try:
# Search companies that can be operators or property owners
qs = Company.objects.filter(
Q(name__icontains=q) &
(Q(roles__contains=['OPERATOR']) | Q(
roles__contains=['PROPERTY_OWNER']))
).distinct()[:20]
results = [
{
"id": c.id,
"name": c.name,
"slug": getattr(c, "slug", ""),
"roles": getattr(c, "roles", [])
}
for c in qs
]
return Response(results)
except Exception:
# Fallback to placeholder data
return Response([
{"id": 1, "name": "Six Flags Entertainment", "slug": "six-flags"},
{"id": 2, "name": "Cedar Fair", "slug": "cedar-fair"},
{"id": 3, "name": "Disney Parks", "slug": "disney"},
])
# --- Search suggestions -----------------------------------------------------

View File

@@ -143,7 +143,7 @@ class ParkPhotoViewSet(ModelViewSet):
raise ValidationError("Park ID is required")
try:
park = Park.objects.get(pk=park_id)
Park.objects.get(pk=park_id)
except Park.DoesNotExist:
raise ValidationError("Park not found")
@@ -199,6 +199,19 @@ class ParkPhotoViewSet(ModelViewSet):
)
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 park photo from Cloudflare: {instance.image.cloudflare_id}")
except Exception as e:
logger.error(
f"Failed to delete park photo from Cloudflare: {str(e)}")
# Continue with database deletion even if Cloudflare deletion fails
ParkMediaService().delete_photo(
instance.id, deleted_by=cast(UserModel, self.request.user)
)
@@ -377,3 +390,135 @@ class ParkPhotoViewSet(ModelViewSet):
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 park photo",
description="Save a Cloudflare image as a park photo after direct upload to Cloudflare",
request=OpenApiTypes.OBJECT,
responses={
201: ParkPhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
)
@action(detail=False, methods=["post"])
def save_image(self, request, **kwargs):
"""Save a Cloudflare image as a park photo after direct upload to Cloudflare."""
park_pk = self.kwargs.get("park_pk")
if not park_pk:
return Response(
{"error": "Park ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
park = Park.objects.get(pk=park_pk)
except Park.DoesNotExist:
return Response(
{"error": "Park 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 park photo with the CloudflareImage reference
photo = ParkPhoto.objects.create(
park=park,
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:
ParkMediaService().set_primary_photo(
park_id=park.id, photo_id=photo.id
)
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
# Don't fail the entire operation, just log the error
serializer = ParkPhotoOutputSerializer(photo, context={"request": request})
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
logger.error(f"Error saving park photo: {e}")
return Response(
{"error": f"Failed to save photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)