""" Centralized map API views. Migrated from apps.core.views.map_views """ import logging from django.http import HttpRequest from django.db.models import Q from django.core.cache import cache # from django.contrib.gis.geos import Polygon # Disabled temporarily for setup 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 from apps.rides.models import Ride from ..serializers.maps import ( MapLocationsResponseSerializer, 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 _parse_request_parameters(self, request: HttpRequest) -> dict: """Parse and validate request parameters.""" return { "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(), } def _build_cache_key(self, params: dict) -> str: """Build cache key from parameters.""" return ( f"map_locations_{params['north']}_{params['south']}_" f"{params['east']}_{params['west']}_{params['zoom']}_" f"{','.join(params['types'])}_{params['cluster']}_{params['query']}" ) def _create_bounds_polygon(self, north: str, south: str, east: str, west: str): # -> Polygon | None # Disabled for setup: """Create bounds polygon from coordinate strings.""" if not all([north, south, east, west]): return None try: # return Polygon.from_bbox( # Disabled for setup # (float(west), float(south), float(east), float(north)) # ) return None # Temporarily disabled for setup except (ValueError, TypeError): return None def _serialize_park_location(self, park) -> dict: """Serialize park location data.""" location = park.location if hasattr( park, "location") and park.location else None return { "city": location.city if location else "", "state": location.state if location else "", "country": location.country if location else "", "formatted_address": location.formatted_address if location else "", } def _serialize_park_data(self, park) -> dict: """Serialize park data for map response.""" location = park.location if hasattr( park, "location") and park.location else None return { "id": park.id, "type": "park", "name": park.name, "slug": park.slug, "latitude": location.latitude if location else None, "longitude": location.longitude if location else None, "status": park.status, "location": self._serialize_park_location(park), "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 ), }, } def _get_parks_data(self, params: dict) -> list: """Get and serialize parks data.""" if "park" not in params["types"]: return [] parks_query = Park.objects.select_related( "location", "operator" ).filter(location__point__isnull=False) # Apply bounds filtering bounds_polygon = self._create_bounds_polygon( params["north"], params["south"], params["east"], params["west"] ) if bounds_polygon: parks_query = parks_query.filter(location__point__within=bounds_polygon) # Apply text search if params["query"]: parks_query = parks_query.filter( Q(name__icontains=params["query"]) | Q(location__city__icontains=params["query"]) | Q(location__state__icontains=params["query"]) ) return [self._serialize_park_data(park) for park in parks_query[:100]] def _serialize_ride_location(self, ride) -> dict: """Serialize ride location data.""" location = ( ride.park.location if hasattr(ride.park, "location") and ride.park.location else None ) return { "city": location.city if location else "", "state": location.state if location else "", "country": location.country if location else "", "formatted_address": location.formatted_address if location else "", } def _serialize_ride_data(self, ride) -> dict: """Serialize ride data for map response.""" location = ( ride.park.location if hasattr(ride.park, "location") and ride.park.location else None ) return { "id": ride.id, "type": "ride", "name": ride.name, "slug": ride.slug, "latitude": location.latitude if location else None, "longitude": location.longitude if location else None, "status": ride.status, "location": self._serialize_ride_location(ride), "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, }, } def _get_rides_data(self, params: dict) -> list: """Get and serialize rides data.""" if "ride" not in params["types"]: return [] rides_query = Ride.objects.select_related( "park__location", "manufacturer" ).filter(park__location__point__isnull=False) # Apply bounds filtering bounds_polygon = self._create_bounds_polygon( params["north"], params["south"], params["east"], params["west"] ) if bounds_polygon: rides_query = rides_query.filter( park__location__point__within=bounds_polygon) # Apply text search if params["query"]: rides_query = rides_query.filter( Q(name__icontains=params["query"]) | Q(park__name__icontains=params["query"]) | Q(park__location__city__icontains=params["query"]) ) return [self._serialize_ride_data(ride) for ride in rides_query[:100]] def _calculate_bounds(self, locations: list) -> dict: """Calculate bounds from location results.""" if not locations: return {} lats = [loc["latitude"] for loc in locations if loc["latitude"]] lngs = [loc["longitude"] for loc in locations if loc["longitude"]] if not lats or not lngs: return {} return { "north": max(lats), "south": min(lats), "east": max(lngs), "west": min(lngs), } def _build_response(self, locations: list, params: dict) -> dict: """Build the final response data.""" return { "status": "success", "locations": locations, "clusters": [], # TODO: Implement clustering "bounds": self._calculate_bounds(locations), "total_count": len(locations), "clustered": params["cluster"], } def get(self, request: HttpRequest) -> Response: """Get map locations with optional clustering and filtering.""" try: params = self._parse_request_parameters(request) cache_key = self._build_cache_key(params) # Check cache first cached_result = cache.get(cache_key) if cached_result: return Response(cached_result) # Get location data parks_data = self._get_parks_data(params) rides_data = self._get_rides_data(params) locations = parks_data + rides_data # Build response result = self._build_response(locations, params) # 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 north_str = request.GET.get("north") south_str = request.GET.get("south") east_str = request.GET.get("east") west_str = request.GET.get("west") if not all([north_str, south_str, east_str, west_str]): return Response( {"status": "error", "message": "All bounds parameters (north, south, east, west) are required"}, status=status.HTTP_400_BAD_REQUEST, ) try: north = float(north_str) if north_str else 0.0 south = float(south_str) if south_str else 0.0 east = float(east_str) if east_str else 0.0 west = float(west_str) if west_str else 0.0 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)) # Disabled for setup bounds_polygon = None # Temporarily disabled # 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 # Note: cache.keys() may not be available in all cache backends try: cache_keys = cache.keys("map_*") if cache_keys: cache.delete_many(cache_keys) cleared_count = len(cache_keys) else: cleared_count = 0 except AttributeError: # Fallback: clear cache without pattern matching cache.clear() cleared_count = 1 # Indicate cache was cleared 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 request_data = getattr(request, 'data', {}) cache_keys = request_data.get("cache_keys", []) if request_data else [] 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