""" Centralized map API views. Migrated from apps.core.views.map_views """ import logging from typing import Dict, List, Any, Optional from django.http import HttpRequest from django.db.models import Q from django.core.cache import cache from django.contrib.gis.geos import Polygon from django.contrib.gis.db.models.functions import Distance from django.contrib.gis.geos import Point from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import AllowAny from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample from drf_spectacular.types import OpenApiTypes from apps.parks.models import Park, ParkLocation from apps.rides.models import Ride from ..serializers.maps import ( MapLocationSerializer, MapLocationsResponseSerializer, MapSearchResultSerializer, MapSearchResponseSerializer, MapLocationDetailSerializer, ) logger = logging.getLogger(__name__) @extend_schema_view( get=extend_schema( summary="Get map locations", description="Get map locations with optional clustering and filtering.", parameters=[ OpenApiParameter( "north", type=OpenApiTypes.NUMBER, location=OpenApiParameter.QUERY, required=False, description="Northern latitude bound (-90 to 90). Used with south, east, west to define geographic bounds.", examples=[OpenApiExample("Example", value=41.5)], ), OpenApiParameter( "south", type=OpenApiTypes.NUMBER, location=OpenApiParameter.QUERY, required=False, description="Southern latitude bound (-90 to 90). Must be less than north bound.", examples=[OpenApiExample("Example", value=41.4)], ), OpenApiParameter( "east", type=OpenApiTypes.NUMBER, location=OpenApiParameter.QUERY, required=False, description="Eastern longitude bound (-180 to 180). Must be greater than west bound.", examples=[OpenApiExample("Example", value=-82.6)], ), OpenApiParameter( "west", type=OpenApiTypes.NUMBER, location=OpenApiParameter.QUERY, required=False, description="Western longitude bound (-180 to 180). Used with other bounds for geographic filtering.", examples=[OpenApiExample("Example", value=-82.8)], ), OpenApiParameter( "zoom", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="Map zoom level (1-20). Higher values show more detail. Used for clustering decisions.", examples=[OpenApiExample("Example", value=10)], ), OpenApiParameter( "types", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Comma-separated location types to include. Valid values: 'park', 'ride'. Default: 'park,ride'", examples=[ OpenApiExample("All types", value="park,ride"), OpenApiExample("Parks only", value="park"), OpenApiExample("Rides only", value="ride") ], ), OpenApiParameter( "cluster", type=OpenApiTypes.BOOL, location=OpenApiParameter.QUERY, required=False, description="Enable location clustering for high-density areas. Default: false", examples=[ OpenApiExample("Enable clustering", value=True), OpenApiExample("Disable clustering", value=False) ], ), OpenApiParameter( "q", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Text search query. Searches park/ride names, cities, and states.", examples=[ OpenApiExample("Park name", value="Cedar Point"), OpenApiExample("Ride type", value="roller coaster"), OpenApiExample("Location", value="Ohio") ], ), ], responses={ 200: MapLocationsResponseSerializer, 400: OpenApiTypes.OBJECT, 500: OpenApiTypes.OBJECT, }, tags=["Maps"], ), ) class MapLocationsAPIView(APIView): """API endpoint for getting map locations with optional clustering.""" permission_classes = [AllowAny] def get(self, request: HttpRequest) -> Response: """Get map locations with optional clustering and filtering.""" try: # Parse query parameters north = request.GET.get("north") south = request.GET.get("south") east = request.GET.get("east") west = request.GET.get("west") zoom = request.GET.get("zoom", 10) types = request.GET.get("types", "park,ride").split(",") cluster = request.GET.get("cluster", "false").lower() == "true" query = request.GET.get("q", "").strip() # Build cache key cache_key = f"map_locations_{north}_{south}_{east}_{west}_{zoom}_{','.join(types)}_{cluster}_{query}" cached_result = cache.get(cache_key) if cached_result: return Response(cached_result) locations = [] total_count = 0 # Get parks if requested if "park" in types: parks_query = Park.objects.select_related("location", "operator").filter( location__point__isnull=False ) # Apply bounds filtering if all([north, south, east, west]): try: bounds_polygon = Polygon.from_bbox(( float(west), float(south), float(east), float(north) )) parks_query = parks_query.filter( location__point__within=bounds_polygon) except (ValueError, TypeError): pass # Apply text search if query: parks_query = parks_query.filter( Q(name__icontains=query) | Q(location__city__icontains=query) | Q(location__state__icontains=query) ) # Serialize parks for park in parks_query[:100]: # Limit results park_data = { "id": park.id, "type": "park", "name": park.name, "slug": park.slug, "latitude": park.location.latitude if hasattr(park, 'location') and park.location else None, "longitude": park.location.longitude if hasattr(park, 'location') and park.location else None, "status": park.status, "location": { "city": park.location.city if hasattr(park, 'location') and park.location else "", "state": park.location.state if hasattr(park, 'location') and park.location else "", "country": park.location.country if hasattr(park, 'location') and park.location else "", "formatted_address": park.location.formatted_address if hasattr(park, 'location') and park.location else "", }, "stats": { "coaster_count": park.coaster_count or 0, "ride_count": park.ride_count or 0, "average_rating": float(park.average_rating) if park.average_rating else None, }, } locations.append(park_data) # Get rides if requested if "ride" in types: rides_query = Ride.objects.select_related("park__location", "manufacturer").filter( park__location__point__isnull=False ) # Apply bounds filtering if all([north, south, east, west]): try: bounds_polygon = Polygon.from_bbox(( float(west), float(south), float(east), float(north) )) rides_query = rides_query.filter( park__location__point__within=bounds_polygon) except (ValueError, TypeError): pass # Apply text search if query: rides_query = rides_query.filter( Q(name__icontains=query) | Q(park__name__icontains=query) | Q(park__location__city__icontains=query) ) # Serialize rides for ride in rides_query[:100]: # Limit results ride_data = { "id": ride.id, "type": "ride", "name": ride.name, "slug": ride.slug, "latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None, "longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None, "status": ride.status, "location": { "city": ride.park.location.city if hasattr(ride.park, 'location') and ride.park.location else "", "state": ride.park.location.state if hasattr(ride.park, 'location') and ride.park.location else "", "country": ride.park.location.country if hasattr(ride.park, 'location') and ride.park.location else "", "formatted_address": ride.park.location.formatted_address if hasattr(ride.park, 'location') and ride.park.location else "", }, "stats": { "category": ride.get_category_display() if ride.category else None, "average_rating": float(ride.average_rating) if ride.average_rating else None, "park_name": ride.park.name, }, } locations.append(ride_data) total_count = len(locations) # Calculate bounds from results bounds = {} if locations: lats = [loc["latitude"] for loc in locations if loc["latitude"]] lngs = [loc["longitude"] for loc in locations if loc["longitude"]] if lats and lngs: bounds = { "north": max(lats), "south": min(lats), "east": max(lngs), "west": min(lngs), } result = { "status": "success", "locations": locations, "clusters": [], # TODO: Implement clustering "bounds": bounds, "total_count": total_count, "clustered": cluster, } # Cache result for 5 minutes cache.set(cache_key, result, 300) return Response(result) except Exception as e: logger.error(f"Error in MapLocationsAPIView: {str(e)}", exc_info=True) return Response( {"status": "error", "message": "Failed to retrieve map locations"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @extend_schema_view( get=extend_schema( summary="Get location details", description="Get detailed information about a specific location.", parameters=[ OpenApiParameter( "location_type", type=OpenApiTypes.STR, location=OpenApiParameter.PATH, required=True, description="Type of location", ), OpenApiParameter( "location_id", type=OpenApiTypes.INT, location=OpenApiParameter.PATH, required=True, description="ID of the location", ), ], responses={ 200: MapLocationDetailSerializer, 400: OpenApiTypes.OBJECT, 404: OpenApiTypes.OBJECT, 500: OpenApiTypes.OBJECT, }, tags=["Maps"], ), ) class MapLocationDetailAPIView(APIView): """API endpoint for getting detailed information about a specific location.""" permission_classes = [AllowAny] def get( self, request: HttpRequest, location_type: str, location_id: int ) -> Response: """Get detailed information for a specific location.""" try: if location_type == "park": try: obj = Park.objects.select_related( "location", "operator").get(id=location_id) except Park.DoesNotExist: return Response( {"status": "error", "message": "Park not found"}, status=status.HTTP_404_NOT_FOUND, ) elif location_type == "ride": try: obj = Ride.objects.select_related( "park__location", "manufacturer").get(id=location_id) except Ride.DoesNotExist: return Response( {"status": "error", "message": "Ride not found"}, status=status.HTTP_404_NOT_FOUND, ) else: return Response( {"status": "error", "message": "Invalid location type"}, status=status.HTTP_400_BAD_REQUEST, ) # Serialize the object if location_type == "park": data = { "id": obj.id, "type": "park", "name": obj.name, "slug": obj.slug, "description": obj.description, "latitude": obj.location.latitude if hasattr(obj, 'location') and obj.location else None, "longitude": obj.location.longitude if hasattr(obj, 'location') and obj.location else None, "status": obj.status, "location": { "street_address": obj.location.street_address if hasattr(obj, 'location') and obj.location else "", "city": obj.location.city if hasattr(obj, 'location') and obj.location else "", "state": obj.location.state if hasattr(obj, 'location') and obj.location else "", "country": obj.location.country if hasattr(obj, 'location') and obj.location else "", "postal_code": obj.location.postal_code if hasattr(obj, 'location') and obj.location else "", "formatted_address": obj.location.formatted_address if hasattr(obj, 'location') and obj.location else "", }, "stats": { "coaster_count": obj.coaster_count or 0, "ride_count": obj.ride_count or 0, "average_rating": float(obj.average_rating) if obj.average_rating else None, "size_acres": float(obj.size_acres) if obj.size_acres else None, "opening_date": obj.opening_date.isoformat() if obj.opening_date else None, }, "nearby_locations": [], # TODO: Implement nearby locations } else: # ride data = { "id": obj.id, "type": "ride", "name": obj.name, "slug": obj.slug, "description": obj.description, "latitude": obj.park.location.latitude if hasattr(obj.park, 'location') and obj.park.location else None, "longitude": obj.park.location.longitude if hasattr(obj.park, 'location') and obj.park.location else None, "status": obj.status, "location": { "street_address": obj.park.location.street_address if hasattr(obj.park, 'location') and obj.park.location else "", "city": obj.park.location.city if hasattr(obj.park, 'location') and obj.park.location else "", "state": obj.park.location.state if hasattr(obj.park, 'location') and obj.park.location else "", "country": obj.park.location.country if hasattr(obj.park, 'location') and obj.park.location else "", "postal_code": obj.park.location.postal_code if hasattr(obj.park, 'location') and obj.park.location else "", "formatted_address": obj.park.location.formatted_address if hasattr(obj.park, 'location') and obj.park.location else "", }, "stats": { "category": obj.get_category_display() if obj.category else None, "average_rating": float(obj.average_rating) if obj.average_rating else None, "park_name": obj.park.name, "opening_date": obj.opening_date.isoformat() if obj.opening_date else None, "manufacturer": obj.manufacturer.name if obj.manufacturer else None, }, "nearby_locations": [], # TODO: Implement nearby locations } return Response({ "status": "success", "data": data, }) except Exception as e: logger.error(f"Error in MapLocationDetailAPIView: {str(e)}", exc_info=True) return Response( {"status": "error", "message": "Failed to retrieve location details"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @extend_schema_view( get=extend_schema( summary="Search map locations", description="Search locations by text query with optional bounds filtering.", parameters=[ OpenApiParameter( "q", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True, description="Search query", ), OpenApiParameter( "types", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Comma-separated location types (park,ride)", ), OpenApiParameter( "page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="Page number", ), OpenApiParameter( "page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="Results per page", ), ], responses={ 200: MapSearchResponseSerializer, 400: OpenApiTypes.OBJECT, 500: OpenApiTypes.OBJECT, }, tags=["Maps"], ), ) class MapSearchAPIView(APIView): """API endpoint for searching locations by text query.""" permission_classes = [AllowAny] def get(self, request: HttpRequest) -> Response: """Search locations by text query with pagination.""" try: query = request.GET.get("q", "").strip() if not query: return Response( { "status": "error", "message": "Search query 'q' parameter is required", }, status=status.HTTP_400_BAD_REQUEST, ) types = request.GET.get("types", "park,ride").split(",") page = int(request.GET.get("page", 1)) page_size = min(int(request.GET.get("page_size", 20)), 100) results = [] total_count = 0 # Search parks if "park" in types: parks_query = Park.objects.select_related("location").filter( Q(name__icontains=query) | Q(location__city__icontains=query) | Q(location__state__icontains=query) ).filter(location__point__isnull=False) for park in parks_query[:50]: # Limit results results.append({ "id": park.id, "type": "park", "name": park.name, "slug": park.slug, "latitude": park.location.latitude if hasattr(park, 'location') and park.location else None, "longitude": park.location.longitude if hasattr(park, 'location') and park.location else None, "location": { "city": park.location.city if hasattr(park, 'location') and park.location else "", "state": park.location.state if hasattr(park, 'location') and park.location else "", "country": park.location.country if hasattr(park, 'location') and park.location else "", }, "relevance_score": 1.0, # TODO: Implement relevance scoring }) # Search rides if "ride" in types: rides_query = Ride.objects.select_related("park__location").filter( Q(name__icontains=query) | Q(park__name__icontains=query) | Q(park__location__city__icontains=query) ).filter(park__location__point__isnull=False) for ride in rides_query[:50]: # Limit results results.append({ "id": ride.id, "type": "ride", "name": ride.name, "slug": ride.slug, "latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None, "longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None, "location": { "city": ride.park.location.city if hasattr(ride.park, 'location') and ride.park.location else "", "state": ride.park.location.state if hasattr(ride.park, 'location') and ride.park.location else "", "country": ride.park.location.country if hasattr(ride.park, 'location') and ride.park.location else "", }, "relevance_score": 1.0, # TODO: Implement relevance scoring }) total_count = len(results) # Apply pagination start_idx = (page - 1) * page_size end_idx = start_idx + page_size paginated_results = results[start_idx:end_idx] return Response({ "status": "success", "results": paginated_results, "query": query, "total_count": total_count, "page": page, "page_size": page_size, }) except Exception as e: logger.error(f"Error in MapSearchAPIView: {str(e)}", exc_info=True) return Response( {"status": "error", "message": "Search failed due to internal error"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @extend_schema_view( get=extend_schema( summary="Get locations within bounds", description="Get locations within specific geographic bounds.", parameters=[ OpenApiParameter( "north", type=OpenApiTypes.NUMBER, location=OpenApiParameter.QUERY, required=True, description="Northern latitude bound", ), OpenApiParameter( "south", type=OpenApiTypes.NUMBER, location=OpenApiParameter.QUERY, required=True, description="Southern latitude bound", ), OpenApiParameter( "east", type=OpenApiTypes.NUMBER, location=OpenApiParameter.QUERY, required=True, description="Eastern longitude bound", ), OpenApiParameter( "west", type=OpenApiTypes.NUMBER, location=OpenApiParameter.QUERY, required=True, description="Western longitude bound", ), OpenApiParameter( "types", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Comma-separated location types (park,ride)", ), ], responses={200: OpenApiTypes.OBJECT, 400: OpenApiTypes.OBJECT}, tags=["Maps"], ), ) class MapBoundsAPIView(APIView): """API endpoint for getting locations within specific bounds.""" permission_classes = [AllowAny] def get(self, request: HttpRequest) -> Response: """Get locations within specific geographic bounds.""" try: # Parse required bounds parameters try: north = float(request.GET.get("north")) south = float(request.GET.get("south")) east = float(request.GET.get("east")) west = float(request.GET.get("west")) except (TypeError, ValueError): return Response( {"status": "error", "message": "Invalid bounds parameters"}, status=status.HTTP_400_BAD_REQUEST, ) # Validate bounds if north <= south: return Response( {"status": "error", "message": "North bound must be greater than south bound"}, status=status.HTTP_400_BAD_REQUEST, ) if west >= east: return Response( {"status": "error", "message": "West bound must be less than east bound"}, status=status.HTTP_400_BAD_REQUEST, ) types = request.GET.get("types", "park,ride").split(",") locations = [] # Create bounds polygon bounds_polygon = Polygon.from_bbox((west, south, east, north)) # Get parks within bounds if "park" in types: parks_query = Park.objects.select_related("location").filter( location__point__within=bounds_polygon ) for park in parks_query[:100]: # Limit results locations.append({ "id": park.id, "type": "park", "name": park.name, "slug": park.slug, "latitude": park.location.latitude if hasattr(park, 'location') and park.location else None, "longitude": park.location.longitude if hasattr(park, 'location') and park.location else None, "status": park.status, }) # Get rides within bounds if "ride" in types: rides_query = Ride.objects.select_related("park__location").filter( park__location__point__within=bounds_polygon ) for ride in rides_query[:100]: # Limit results locations.append({ "id": ride.id, "type": "ride", "name": ride.name, "slug": ride.slug, "latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None, "longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None, "status": ride.status, }) return Response({ "status": "success", "locations": locations, "bounds": { "north": north, "south": south, "east": east, "west": west, }, "total_count": len(locations), }) except Exception as e: logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True) return Response( {"status": "error", "message": "Failed to retrieve locations within bounds"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @extend_schema_view( get=extend_schema( summary="Get map service statistics", description="Get map service statistics and performance metrics.", responses={200: OpenApiTypes.OBJECT}, tags=["Maps"], ), ) class MapStatsAPIView(APIView): """API endpoint for getting map service statistics and health information.""" permission_classes = [AllowAny] def get(self, request: HttpRequest) -> Response: """Get map service statistics and performance metrics.""" try: # Count locations with coordinates parks_with_location = Park.objects.filter( location__point__isnull=False).count() rides_with_location = Ride.objects.filter( park__location__point__isnull=False).count() total_locations = parks_with_location + rides_with_location return Response({ "status": "success", "data": { "total_locations": total_locations, "parks_with_location": parks_with_location, "rides_with_location": rides_with_location, "cache_hits": 0, # TODO: Implement cache statistics "cache_misses": 0, # TODO: Implement cache statistics }, }) except Exception as e: logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True) return Response( {"error": f"Internal server error: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @extend_schema_view( delete=extend_schema( summary="Clear map cache", description="Clear all map cache (admin only).", responses={200: OpenApiTypes.OBJECT}, tags=["Maps"], ), post=extend_schema( summary="Invalidate specific cache entries", description="Invalidate specific cache entries.", responses={200: OpenApiTypes.OBJECT}, tags=["Maps"], ), ) class MapCacheAPIView(APIView): """API endpoint for cache management (admin only).""" permission_classes = [AllowAny] # TODO: Add admin permission check def delete(self, request: HttpRequest) -> Response: """Clear all map cache (admin only).""" try: # Clear all map-related cache keys cache_keys = cache.keys("map_*") if cache_keys: cache.delete_many(cache_keys) cleared_count = len(cache_keys) else: cleared_count = 0 return Response({ "status": "success", "message": f"Map cache cleared successfully. Cleared {cleared_count} entries.", }) except Exception as e: logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True) return Response( {"error": f"Internal server error: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) def post(self, request: HttpRequest) -> Response: """Invalidate specific cache entries.""" try: # Get cache keys to invalidate from request data cache_keys = request.data.get("cache_keys", []) if cache_keys: cache.delete_many(cache_keys) invalidated_count = len(cache_keys) else: invalidated_count = 0 return Response({ "status": "success", "message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.", }) except Exception as e: logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True) return Response( {"error": f"Internal server error: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) # Legacy compatibility aliases MapLocationsView = MapLocationsAPIView MapLocationDetailView = MapLocationDetailAPIView MapSearchView = MapSearchAPIView MapBoundsView = MapBoundsAPIView MapStatsView = MapStatsAPIView MapCacheView = MapCacheAPIView