feat: Implement initial schema and add various API, service, and management command enhancements across the application.

This commit is contained in:
pacnpal
2026-01-01 15:13:01 -05:00
parent c95f99ca10
commit b243b17af7
413 changed files with 11164 additions and 17433 deletions

View File

@@ -134,9 +134,7 @@ class ParkPhotoViewSet(ModelViewSet):
def get_queryset(self): # type: ignore[override]
"""Get photos for the current park with optimized queries."""
queryset = ParkPhoto.objects.select_related(
"park", "park__operator", "uploaded_by"
)
queryset = ParkPhoto.objects.select_related("park", "park__operator", "uploaded_by")
# If park_pk is provided in URL kwargs, filter by park
# If park_pk is provided in URL kwargs, filter by park
@@ -172,7 +170,7 @@ class ParkPhotoViewSet(ModelViewSet):
# Use real park ID
park_id = park.id
except Park.DoesNotExist:
raise ValidationError("Park not found")
raise ValidationError("Park not found") from None
try:
# Use the service to create the photo with proper business logic
@@ -188,48 +186,38 @@ class ParkPhotoViewSet(ModelViewSet):
except (ValidationException, ValidationError) as e:
logger.warning(f"Validation error creating park photo: {e}")
raise ValidationError(str(e))
raise ValidationError(str(e)) from None
except ServiceError as e:
logger.error(f"Service error creating park photo: {e}")
raise ValidationError(f"Failed to create photo: {str(e)}")
raise ValidationError(f"Failed to create photo: {str(e)}") from None
def perform_update(self, serializer):
"""Update park photo with permission checking."""
instance = self.get_object()
# Check permissions - allow owner or staff
if not (
self.request.user == instance.uploaded_by
or cast(Any, self.request.user).is_staff
):
if not (self.request.user == instance.uploaded_by or cast(Any, self.request.user).is_staff):
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:
ParkMediaService().set_primary_photo(
park_id=instance.park_id, photo_id=instance.id
)
ParkMediaService().set_primary_photo(park_id=instance.park_id, photo_id=instance.id)
# Remove is_primary from validated_data since service handles it
if "is_primary" in serializer.validated_data:
del serializer.validated_data["is_primary"]
except (ValidationException, ValidationError) as e:
logger.warning(f"Validation error setting primary photo: {e}")
raise ValidationError(str(e))
raise ValidationError(str(e)) from None
except ServiceError as e:
logger.error(f"Service error setting primary photo: {e}")
raise ValidationError(f"Failed to set primary photo: {str(e)}")
raise ValidationError(f"Failed to set primary photo: {str(e)}") from None
def perform_destroy(self, instance):
"""Delete park photo with permission checking."""
# Check permissions - allow owner or staff
if not (
self.request.user == instance.uploaded_by
or cast(Any, self.request.user).is_staff
):
raise PermissionDenied(
"You can only delete your own photos or be an admin."
)
if not (self.request.user == instance.uploaded_by or cast(Any, self.request.user).is_staff):
raise PermissionDenied("You can only delete your own photos or be an admin.")
# Delete from Cloudflare first if image exists
if instance.image:
@@ -240,9 +228,7 @@ class ParkPhotoViewSet(ModelViewSet):
service = CloudflareImagesService()
service.delete_image(instance.image)
logger.info(
f"Successfully deleted park photo from Cloudflare: {instance.image.cloudflare_id}"
)
logger.info(f"Successfully deleted park photo from Cloudflare: {instance.image.cloudflare_id}")
except ImportError:
logger.warning("CloudflareImagesService not available")
except ServiceError as e:
@@ -250,12 +236,10 @@ class ParkPhotoViewSet(ModelViewSet):
# Continue with database deletion even if Cloudflare deletion fails
try:
ParkMediaService().delete_photo(
instance.id, deleted_by=cast(UserModel, self.request.user)
)
ParkMediaService().delete_photo(instance.id, deleted_by=cast(UserModel, self.request.user))
except ServiceError as e:
logger.error(f"Service error deleting park photo: {e}")
raise ValidationError(f"Failed to delete photo: {str(e)}")
raise ValidationError(f"Failed to delete photo: {str(e)}") from None
@extend_schema(
summary="Set photo as primary",
@@ -275,14 +259,10 @@ class ParkPhotoViewSet(ModelViewSet):
# Check permissions - allow owner or staff
if not (request.user == photo.uploaded_by or cast(Any, request.user).is_staff):
raise PermissionDenied(
"You can only modify your own photos or be an admin."
)
raise PermissionDenied("You can only modify your own photos or be an admin.")
try:
ParkMediaService().set_primary_photo(
park_id=photo.park_id, photo_id=photo.id
)
ParkMediaService().set_primary_photo(park_id=photo.park_id, photo_id=photo.id)
# Refresh the photo instance
photo.refresh_from_db()
@@ -290,7 +270,7 @@ class ParkPhotoViewSet(ModelViewSet):
return Response(
{
"message": "Photo set as primary successfully",
"detail": "Photo set as primary successfully",
"photo": serializer.data,
},
status=status.HTTP_200_OK,
@@ -337,7 +317,7 @@ class ParkPhotoViewSet(ModelViewSet):
if photo_ids is None or approve is None:
return Response(
{"error": "Missing required fields: photo_ids and/or approve."},
{"detail": "Missing required fields: photo_ids and/or approve."},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -354,7 +334,7 @@ class ParkPhotoViewSet(ModelViewSet):
return Response(
{
"message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
"detail": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
"updated_count": updated_count,
},
status=status.HTTP_200_OK,
@@ -430,19 +410,14 @@ class ParkPhotoViewSet(ModelViewSet):
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("parks.change_parkphoto")
):
if not (request.user == photo.uploaded_by or request.user.has_perm("parks.change_parkphoto")):
return Response(
{"error": "You do not have permission to edit photos for this park."},
{"detail": "You do not have permission to edit photos for this park."},
status=status.HTTP_403_FORBIDDEN,
)
try:
ParkMediaService().set_primary_photo(
park_id=photo.park_id, photo_id=photo.id
)
return Response({"message": "Photo set as primary successfully."})
ParkMediaService().set_primary_photo(park_id=photo.park_id, photo_id=photo.id)
return Response({"detail": "Photo set as primary successfully."})
except (ValidationException, ValidationError) as e:
logger.warning(f"Validation error in set_primary_photo: {str(e)}")
return ErrorHandler.handle_api_error(
@@ -475,7 +450,7 @@ class ParkPhotoViewSet(ModelViewSet):
park_pk = self.kwargs.get("park_pk")
if not park_pk:
return Response(
{"error": "Park ID is required"},
{"detail": "Park ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -483,14 +458,14 @@ class ParkPhotoViewSet(ModelViewSet):
park = Park.objects.get(pk=park_pk) if str(park_pk).isdigit() else Park.objects.get(slug=park_pk)
except Park.DoesNotExist:
return Response(
{"error": "Park not found"},
{"detail": "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"},
{"detail": "cloudflare_image_id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -515,18 +490,14 @@ class ParkPhotoViewSet(ModelViewSet):
# Try to find existing CloudflareImage record by cloudflare_id
cloudflare_image = None
try:
cloudflare_image = CloudflareImage.objects.get(
cloudflare_id=cloudflare_image_id
)
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.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")
@@ -540,8 +511,7 @@ class ParkPhotoViewSet(ModelViewSet):
user=request.user,
status="uploaded",
upload_url="", # Not needed for uploaded images
expires_at=timezone.now()
+ timezone.timedelta(days=365), # Set far future expiry
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
@@ -567,9 +537,7 @@ class ParkPhotoViewSet(ModelViewSet):
# 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
)
ParkMediaService().set_primary_photo(park_id=park.id, photo_id=photo.id)
except ServiceError as e:
logger.error(f"Error setting primary photo: {e}")
# Don't fail the entire operation, just log the error
@@ -624,12 +592,8 @@ class ParkPhotoViewSet(ModelViewSet):
OpenApiTypes.STR,
description="Filter by state (comma-separated for multiple)",
),
OpenApiParameter(
"opening_year_min", OpenApiTypes.INT, description="Minimum opening year"
),
OpenApiParameter(
"opening_year_max", OpenApiTypes.INT, description="Maximum opening year"
),
OpenApiParameter("opening_year_min", OpenApiTypes.INT, description="Minimum opening year"),
OpenApiParameter("opening_year_max", OpenApiTypes.INT, description="Maximum opening year"),
OpenApiParameter(
"size_min",
OpenApiTypes.NUMBER,
@@ -640,18 +604,10 @@ class ParkPhotoViewSet(ModelViewSet):
OpenApiTypes.NUMBER,
description="Maximum park size in acres",
),
OpenApiParameter(
"rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"
),
OpenApiParameter(
"rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"
),
OpenApiParameter(
"ride_count_min", OpenApiTypes.INT, description="Minimum ride count"
),
OpenApiParameter(
"ride_count_max", OpenApiTypes.INT, description="Maximum ride count"
),
OpenApiParameter("rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"),
OpenApiParameter("rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"),
OpenApiParameter("ride_count_min", OpenApiTypes.INT, description="Minimum ride count"),
OpenApiParameter("ride_count_max", OpenApiTypes.INT, description="Maximum ride count"),
OpenApiParameter(
"coaster_count_min",
OpenApiTypes.INT,
@@ -688,9 +644,7 @@ class ParkPhotoViewSet(ModelViewSet):
"properties": {
"parks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HybridParkSerializer"
},
"items": {"$ref": "#/components/schemas/HybridParkSerializer"},
},
"total_count": {"type": "integer"},
"strategy": {
@@ -808,7 +762,7 @@ class HybridParkAPIView(APIView):
for param in int_params:
value = query_params.get(param)
if value:
try:
try: # noqa: SIM105
filters[param] = int(value)
except ValueError:
pass # Skip invalid integer values
@@ -818,7 +772,7 @@ class HybridParkAPIView(APIView):
for param in float_params:
value = query_params.get(param)
if value:
try:
try: # noqa: SIM105
filters[param] = float(value)
except ValueError:
pass # Skip invalid float values