mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:51:10 -05:00
feat: complete monorepo structure with frontend and shared resources
- Add complete backend/ directory with full Django application - Add frontend/ directory with Vite + TypeScript setup ready for Next.js - Add comprehensive shared/ directory with: - Complete documentation and memory-bank archives - Media files and avatars (letters, park/ride images) - Deployment scripts and automation tools - Shared types and utilities - Add architecture/ directory with migration guides - Configure pnpm workspace for monorepo development - Update .gitignore to exclude .django_tailwind_cli/ build artifacts - Preserve all historical documentation in shared/docs/memory-bank/ - Set up proper structure for full-stack development with shared resources
This commit is contained in:
0
backend/apps/parks/__init__.py
Normal file
0
backend/apps/parks/__init__.py
Normal file
223
backend/apps/parks/admin.py
Normal file
223
backend/apps/parks/admin.py
Normal file
@@ -0,0 +1,223 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.gis.admin import GISModelAdmin
|
||||
from .models import Park, ParkArea, ParkLocation, Company, CompanyHeadquarters
|
||||
|
||||
|
||||
class ParkLocationInline(admin.StackedInline):
|
||||
"""Inline admin for ParkLocation"""
|
||||
|
||||
model = ParkLocation
|
||||
extra = 0
|
||||
fields = (
|
||||
("city", "state", "country"),
|
||||
"street_address",
|
||||
"postal_code",
|
||||
"point",
|
||||
("highway_exit", "best_arrival_time"),
|
||||
"parking_notes",
|
||||
"seasonal_notes",
|
||||
("osm_id", "osm_type"),
|
||||
)
|
||||
|
||||
|
||||
class ParkLocationAdmin(GISModelAdmin):
|
||||
"""Admin for standalone ParkLocation management"""
|
||||
|
||||
list_display = (
|
||||
"park",
|
||||
"city",
|
||||
"state",
|
||||
"country",
|
||||
"latitude",
|
||||
"longitude",
|
||||
)
|
||||
list_filter = ("country", "state")
|
||||
search_fields = (
|
||||
"park__name",
|
||||
"city",
|
||||
"state",
|
||||
"country",
|
||||
"street_address",
|
||||
)
|
||||
readonly_fields = ("latitude", "longitude", "coordinates")
|
||||
fieldsets = (
|
||||
("Park", {"fields": ("park",)}),
|
||||
(
|
||||
"Address",
|
||||
{
|
||||
"fields": (
|
||||
"street_address",
|
||||
"city",
|
||||
"state",
|
||||
"country",
|
||||
"postal_code",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Geographic Coordinates",
|
||||
{
|
||||
"fields": ("point", "latitude", "longitude", "coordinates"),
|
||||
"description": "Set coordinates by clicking on the map or entering latitude/longitude",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Travel Information",
|
||||
{
|
||||
"fields": (
|
||||
"highway_exit",
|
||||
"best_arrival_time",
|
||||
"parking_notes",
|
||||
"seasonal_notes",
|
||||
),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
(
|
||||
"OpenStreetMap Integration",
|
||||
{"fields": ("osm_id", "osm_type"), "classes": ("collapse",)},
|
||||
),
|
||||
)
|
||||
|
||||
def latitude(self, obj):
|
||||
return obj.latitude
|
||||
|
||||
latitude.short_description = "Latitude"
|
||||
|
||||
def longitude(self, obj):
|
||||
return obj.longitude
|
||||
|
||||
longitude.short_description = "Longitude"
|
||||
|
||||
|
||||
class ParkAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"formatted_location",
|
||||
"status",
|
||||
"operator",
|
||||
"property_owner",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("status", "location__country", "location__state")
|
||||
search_fields = (
|
||||
"name",
|
||||
"description",
|
||||
"location__city",
|
||||
"location__state",
|
||||
"location__country",
|
||||
)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
inlines = [ParkLocationInline]
|
||||
|
||||
def formatted_location(self, obj):
|
||||
"""Display formatted location string"""
|
||||
return obj.formatted_location
|
||||
|
||||
formatted_location.short_description = "Location"
|
||||
|
||||
|
||||
class ParkAreaAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "park", "created_at", "updated_at")
|
||||
list_filter = ("park",)
|
||||
search_fields = ("name", "description", "park__name")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
|
||||
|
||||
class CompanyHeadquartersInline(admin.StackedInline):
|
||||
"""Inline admin for CompanyHeadquarters"""
|
||||
|
||||
model = CompanyHeadquarters
|
||||
extra = 0
|
||||
fields = (
|
||||
("city", "state_province", "country"),
|
||||
"street_address",
|
||||
"postal_code",
|
||||
"mailing_address",
|
||||
)
|
||||
|
||||
|
||||
class CompanyHeadquartersAdmin(admin.ModelAdmin):
|
||||
"""Admin for standalone CompanyHeadquarters management"""
|
||||
|
||||
list_display = (
|
||||
"company",
|
||||
"location_display",
|
||||
"city",
|
||||
"country",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = ("country", "state_province")
|
||||
search_fields = (
|
||||
"company__name",
|
||||
"city",
|
||||
"state_province",
|
||||
"country",
|
||||
"street_address",
|
||||
)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
fieldsets = (
|
||||
("Company", {"fields": ("company",)}),
|
||||
(
|
||||
"Address",
|
||||
{
|
||||
"fields": (
|
||||
"street_address",
|
||||
"city",
|
||||
"state_province",
|
||||
"country",
|
||||
"postal_code",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Additional Information",
|
||||
{"fields": ("mailing_address",), "classes": ("collapse",)},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CompanyAdmin(admin.ModelAdmin):
|
||||
"""Enhanced Company admin with headquarters inline"""
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"roles_display",
|
||||
"headquarters_location",
|
||||
"website",
|
||||
"founded_year",
|
||||
)
|
||||
list_filter = ("roles",)
|
||||
search_fields = ("name", "description")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
inlines = [CompanyHeadquartersInline]
|
||||
|
||||
def roles_display(self, obj):
|
||||
"""Display roles as a formatted string"""
|
||||
return ", ".join(obj.roles) if obj.roles else "No roles"
|
||||
|
||||
roles_display.short_description = "Roles"
|
||||
|
||||
def headquarters_location(self, obj):
|
||||
"""Display headquarters location if available"""
|
||||
if hasattr(obj, "headquarters"):
|
||||
return obj.headquarters.location_display
|
||||
return "No headquarters"
|
||||
|
||||
headquarters_location.short_description = "Headquarters"
|
||||
|
||||
|
||||
# Register the models with their admin classes
|
||||
admin.site.register(Park, ParkAdmin)
|
||||
admin.site.register(ParkArea, ParkAreaAdmin)
|
||||
admin.site.register(ParkLocation, ParkLocationAdmin)
|
||||
admin.site.register(Company, CompanyAdmin)
|
||||
admin.site.register(CompanyHeadquarters, CompanyHeadquartersAdmin)
|
||||
1
backend/apps/parks/api/__init__.py
Normal file
1
backend/apps/parks/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Parks API module
|
||||
304
backend/apps/parks/api/serializers.py
Normal file
304
backend/apps/parks/api/serializers.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Serializers for Parks API following Django styleguide patterns.
|
||||
Separates Input and Output serializers for clear boundaries.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from ..models import Park
|
||||
|
||||
|
||||
class ParkLocationOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park location data."""
|
||||
|
||||
latitude = serializers.SerializerMethodField()
|
||||
longitude = serializers.SerializerMethodField()
|
||||
city = serializers.SerializerMethodField()
|
||||
state = serializers.SerializerMethodField()
|
||||
country = serializers.SerializerMethodField()
|
||||
formatted_address = serializers.SerializerMethodField()
|
||||
|
||||
def get_latitude(self, obj):
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return obj.location.latitude
|
||||
return None
|
||||
|
||||
def get_longitude(self, obj):
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return obj.location.longitude
|
||||
return None
|
||||
|
||||
def get_city(self, obj):
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return obj.location.city
|
||||
return None
|
||||
|
||||
def get_state(self, obj):
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return obj.location.state
|
||||
return None
|
||||
|
||||
def get_country(self, obj):
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return obj.location.country
|
||||
return None
|
||||
|
||||
def get_formatted_address(self, obj):
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return obj.location.formatted_address
|
||||
return ""
|
||||
|
||||
|
||||
class CompanyOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for company data."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
roles = serializers.ListField(child=serializers.CharField())
|
||||
|
||||
|
||||
class ParkAreaOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park area data."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
|
||||
|
||||
class ParkListOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park list view."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
|
||||
# Statistics
|
||||
average_rating = serializers.DecimalField(
|
||||
max_digits=3, decimal_places=2, allow_null=True
|
||||
)
|
||||
coaster_count = serializers.IntegerField(allow_null=True)
|
||||
ride_count = serializers.IntegerField(allow_null=True)
|
||||
|
||||
# Location (simplified for list view)
|
||||
location = ParkLocationOutputSerializer(allow_null=True)
|
||||
|
||||
# Operator info
|
||||
operator = CompanyOutputSerializer()
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
|
||||
class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park detail view."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
|
||||
# Details
|
||||
opening_date = serializers.DateField(allow_null=True)
|
||||
closing_date = serializers.DateField(allow_null=True)
|
||||
operating_season = serializers.CharField()
|
||||
size_acres = serializers.DecimalField(
|
||||
max_digits=10, decimal_places=2, allow_null=True
|
||||
)
|
||||
website = serializers.URLField()
|
||||
|
||||
# Statistics
|
||||
average_rating = serializers.DecimalField(
|
||||
max_digits=3, decimal_places=2, allow_null=True
|
||||
)
|
||||
coaster_count = serializers.IntegerField(allow_null=True)
|
||||
ride_count = serializers.IntegerField(allow_null=True)
|
||||
|
||||
# Location (full details)
|
||||
location = ParkLocationOutputSerializer(allow_null=True)
|
||||
|
||||
# Companies
|
||||
operator = CompanyOutputSerializer()
|
||||
property_owner = CompanyOutputSerializer(allow_null=True)
|
||||
|
||||
# Areas
|
||||
areas = ParkAreaOutputSerializer(many=True)
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
|
||||
class ParkCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating parks."""
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
status = serializers.ChoiceField(choices=Park.STATUS_CHOICES, default="OPERATING")
|
||||
|
||||
# Optional details
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
operating_season = serializers.CharField(
|
||||
max_length=255, required=False, allow_blank=True
|
||||
)
|
||||
size_acres = serializers.DecimalField(
|
||||
max_digits=10, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
website = serializers.URLField(required=False, allow_blank=True)
|
||||
|
||||
# Required operator
|
||||
operator_id = serializers.IntegerField()
|
||||
|
||||
# Optional property owner
|
||||
property_owner_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, data):
|
||||
"""Cross-field validation."""
|
||||
opening_date = data.get("opening_date")
|
||||
closing_date = data.get("closing_date")
|
||||
|
||||
if opening_date and closing_date and closing_date < opening_date:
|
||||
raise serializers.ValidationError(
|
||||
"Closing date cannot be before opening date"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class ParkUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating parks."""
|
||||
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
status = serializers.ChoiceField(choices=Park.STATUS_CHOICES, required=False)
|
||||
|
||||
# Optional details
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
operating_season = serializers.CharField(
|
||||
max_length=255, required=False, allow_blank=True
|
||||
)
|
||||
size_acres = serializers.DecimalField(
|
||||
max_digits=10, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
website = serializers.URLField(required=False, allow_blank=True)
|
||||
|
||||
# Companies
|
||||
operator_id = serializers.IntegerField(required=False)
|
||||
property_owner_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, data):
|
||||
"""Cross-field validation."""
|
||||
opening_date = data.get("opening_date")
|
||||
closing_date = data.get("closing_date")
|
||||
|
||||
if opening_date and closing_date and closing_date < opening_date:
|
||||
raise serializers.ValidationError(
|
||||
"Closing date cannot be before opening date"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class ParkFilterInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for park filtering and search."""
|
||||
|
||||
# Search
|
||||
search = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Status filter
|
||||
status = serializers.MultipleChoiceField(
|
||||
choices=Park.STATUS_CHOICES, required=False
|
||||
)
|
||||
|
||||
# Location filters
|
||||
country = serializers.CharField(required=False, allow_blank=True)
|
||||
state = serializers.CharField(required=False, allow_blank=True)
|
||||
city = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Rating filter
|
||||
min_rating = serializers.DecimalField(
|
||||
max_digits=3,
|
||||
decimal_places=2,
|
||||
required=False,
|
||||
min_value=1,
|
||||
max_value=10,
|
||||
)
|
||||
|
||||
# Size filter
|
||||
min_size_acres = serializers.DecimalField(
|
||||
max_digits=10, decimal_places=2, required=False, min_value=0
|
||||
)
|
||||
max_size_acres = serializers.DecimalField(
|
||||
max_digits=10, decimal_places=2, required=False, min_value=0
|
||||
)
|
||||
|
||||
# Company filters
|
||||
operator_id = serializers.IntegerField(required=False)
|
||||
property_owner_id = serializers.IntegerField(required=False)
|
||||
|
||||
# Ordering
|
||||
ordering = serializers.ChoiceField(
|
||||
choices=[
|
||||
"name",
|
||||
"-name",
|
||||
"opening_date",
|
||||
"-opening_date",
|
||||
"average_rating",
|
||||
"-average_rating",
|
||||
"coaster_count",
|
||||
"-coaster_count",
|
||||
"created_at",
|
||||
"-created_at",
|
||||
],
|
||||
required=False,
|
||||
default="name",
|
||||
)
|
||||
|
||||
|
||||
class ParkReviewOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park reviews."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
rating = serializers.IntegerField()
|
||||
title = serializers.CharField()
|
||||
content = serializers.CharField()
|
||||
visit_date = serializers.DateField()
|
||||
created_at = serializers.DateTimeField()
|
||||
|
||||
# User info (limited for privacy)
|
||||
user = serializers.SerializerMethodField()
|
||||
|
||||
def get_user(self, obj):
|
||||
return {
|
||||
"username": obj.user.username,
|
||||
"display_name": obj.user.get_full_name() or obj.user.username,
|
||||
}
|
||||
|
||||
|
||||
class ParkStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park statistics."""
|
||||
|
||||
total_parks = serializers.IntegerField()
|
||||
operating_parks = serializers.IntegerField()
|
||||
closed_parks = serializers.IntegerField()
|
||||
under_construction = serializers.IntegerField()
|
||||
|
||||
# Averages
|
||||
average_rating = serializers.DecimalField(
|
||||
max_digits=3, decimal_places=2, allow_null=True
|
||||
)
|
||||
average_coaster_count = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, allow_null=True
|
||||
)
|
||||
|
||||
# Top countries
|
||||
top_countries = serializers.ListField(child=serializers.DictField())
|
||||
|
||||
# Recently added
|
||||
recently_added_count = serializers.IntegerField()
|
||||
65
backend/apps/parks/api/urls.py
Normal file
65
backend/apps/parks/api/urls.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
URL configuration for Parks API following Django styleguide patterns.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
ParkListApi,
|
||||
ParkDetailApi,
|
||||
ParkCreateApi,
|
||||
ParkUpdateApi,
|
||||
ParkDeleteApi,
|
||||
ParkApi,
|
||||
)
|
||||
|
||||
app_name = "parks_api"
|
||||
|
||||
# Option 1: Separate ViewSets for each operation (more explicit)
|
||||
router_separate = DefaultRouter()
|
||||
router_separate.register(r"list", ParkListApi, basename="park-list")
|
||||
router_separate.register(r"detail", ParkDetailApi, basename="park-detail")
|
||||
router_separate.register(r"create", ParkCreateApi, basename="park-create")
|
||||
router_separate.register(r"update", ParkUpdateApi, basename="park-update")
|
||||
router_separate.register(r"delete", ParkDeleteApi, basename="park-delete")
|
||||
|
||||
# Option 2: Unified ViewSet (more conventional DRF)
|
||||
router_unified = DefaultRouter()
|
||||
router_unified.register(r"parks", ParkApi, basename="park")
|
||||
|
||||
# Use unified approach for cleaner URLs
|
||||
urlpatterns = [
|
||||
path("v1/", include(router_unified.urls)),
|
||||
]
|
||||
|
||||
# Alternative manual URL patterns for more control
|
||||
urlpatterns_manual = [
|
||||
# List and create
|
||||
path(
|
||||
"v1/parks/",
|
||||
ParkApi.as_view({"get": "list", "post": "create"}),
|
||||
name="park-list",
|
||||
),
|
||||
# Stats endpoint
|
||||
path("v1/parks/stats/", ParkApi.as_view({"get": "stats"}), name="park-stats"),
|
||||
# Detail operations
|
||||
path(
|
||||
"v1/parks/<slug:slug>/",
|
||||
ParkApi.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="park-detail",
|
||||
),
|
||||
# Park reviews
|
||||
path(
|
||||
"v1/parks/<slug:slug>/reviews/",
|
||||
ParkApi.as_view({"get": "reviews"}),
|
||||
name="park-reviews",
|
||||
),
|
||||
]
|
||||
295
backend/apps/parks/api/views.py
Normal file
295
backend/apps/parks/api/views.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
Parks API views following Django styleguide patterns.
|
||||
Uses ClassNameApi naming convention and proper Input/Output serializers.
|
||||
"""
|
||||
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from rest_framework.permissions import (
|
||||
IsAuthenticated,
|
||||
IsAuthenticatedOrReadOnly,
|
||||
)
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.filters import SearchFilter, OrderingFilter
|
||||
|
||||
from apps.core.api.mixins import (
|
||||
CreateApiMixin,
|
||||
UpdateApiMixin,
|
||||
ListApiMixin,
|
||||
RetrieveApiMixin,
|
||||
DestroyApiMixin,
|
||||
)
|
||||
from ..selectors import (
|
||||
park_list_with_stats,
|
||||
park_detail_optimized,
|
||||
park_reviews_for_park,
|
||||
park_statistics,
|
||||
)
|
||||
from ..services import ParkService
|
||||
from .serializers import (
|
||||
ParkListOutputSerializer,
|
||||
ParkDetailOutputSerializer,
|
||||
ParkCreateInputSerializer,
|
||||
ParkUpdateInputSerializer,
|
||||
ParkFilterInputSerializer,
|
||||
ParkReviewOutputSerializer,
|
||||
ParkStatsOutputSerializer,
|
||||
)
|
||||
|
||||
|
||||
class ParkListApi(ListApiMixin, GenericViewSet):
|
||||
"""
|
||||
API endpoint for listing parks with filtering and search.
|
||||
|
||||
GET /api/v1/parks/
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
search_fields = ["name", "description"]
|
||||
ordering_fields = [
|
||||
"name",
|
||||
"opening_date",
|
||||
"average_rating",
|
||||
"coaster_count",
|
||||
"created_at",
|
||||
]
|
||||
ordering = ["name"]
|
||||
|
||||
OutputSerializer = ParkListOutputSerializer
|
||||
FilterSerializer = ParkFilterInputSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Use selector to get optimized queryset."""
|
||||
# Parse filter parameters
|
||||
filter_serializer = self.FilterSerializer(data=self.request.query_params)
|
||||
filter_serializer.is_valid(raise_exception=True)
|
||||
filters = filter_serializer.validated_data
|
||||
|
||||
return park_list_with_stats(filters=filters)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def stats(self, request: Request) -> Response:
|
||||
"""
|
||||
Get park statistics.
|
||||
|
||||
GET /api/v1/parks/stats/
|
||||
"""
|
||||
stats = park_statistics()
|
||||
serializer = ParkStatsOutputSerializer(stats)
|
||||
|
||||
return self.create_response(
|
||||
data=serializer.data,
|
||||
metadata={"cache_duration": 3600}, # 1 hour cache hint
|
||||
)
|
||||
|
||||
|
||||
class ParkDetailApi(RetrieveApiMixin, GenericViewSet):
|
||||
"""
|
||||
API endpoint for retrieving individual park details.
|
||||
|
||||
GET /api/v1/parks/{id}/
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||
lookup_field = "slug"
|
||||
|
||||
OutputSerializer = ParkDetailOutputSerializer
|
||||
|
||||
def get_object(self):
|
||||
"""Use selector for optimized detail query."""
|
||||
slug = self.kwargs.get("slug")
|
||||
return park_detail_optimized(slug=slug)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def reviews(self, request: Request, slug: str = None) -> Response:
|
||||
"""
|
||||
Get reviews for a specific park.
|
||||
|
||||
GET /api/v1/parks/{slug}/reviews/
|
||||
"""
|
||||
park = self.get_object()
|
||||
reviews = park_reviews_for_park(park_id=park.id, limit=50)
|
||||
|
||||
serializer = ParkReviewOutputSerializer(reviews, many=True)
|
||||
|
||||
return self.create_response(
|
||||
data=serializer.data,
|
||||
metadata={"total_reviews": len(reviews), "park_name": park.name},
|
||||
)
|
||||
|
||||
|
||||
class ParkCreateApi(CreateApiMixin, GenericViewSet):
|
||||
"""
|
||||
API endpoint for creating parks.
|
||||
|
||||
POST /api/v1/parks/create/
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
InputSerializer = ParkCreateInputSerializer
|
||||
OutputSerializer = ParkDetailOutputSerializer
|
||||
|
||||
def perform_create(self, **validated_data):
|
||||
"""Create park using service layer."""
|
||||
return ParkService.create_park(**validated_data)
|
||||
|
||||
|
||||
class ParkUpdateApi(UpdateApiMixin, RetrieveApiMixin, GenericViewSet):
|
||||
"""
|
||||
API endpoint for updating parks.
|
||||
|
||||
PUT /api/v1/parks/{slug}/update/
|
||||
PATCH /api/v1/parks/{slug}/update/
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
lookup_field = "slug"
|
||||
|
||||
InputSerializer = ParkUpdateInputSerializer
|
||||
OutputSerializer = ParkDetailOutputSerializer
|
||||
|
||||
def get_object(self):
|
||||
"""Use selector for optimized detail query."""
|
||||
slug = self.kwargs.get("slug")
|
||||
return park_detail_optimized(slug=slug)
|
||||
|
||||
def perform_update(self, instance, **validated_data):
|
||||
"""Update park using service layer."""
|
||||
return ParkService.update_park(park_id=instance.id, **validated_data)
|
||||
|
||||
|
||||
class ParkDeleteApi(DestroyApiMixin, RetrieveApiMixin, GenericViewSet):
|
||||
"""
|
||||
API endpoint for deleting parks.
|
||||
|
||||
DELETE /api/v1/parks/{slug}/delete/
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated] # TODO: Add staff/admin permission
|
||||
lookup_field = "slug"
|
||||
|
||||
def get_object(self):
|
||||
"""Use selector for optimized detail query."""
|
||||
slug = self.kwargs.get("slug")
|
||||
return park_detail_optimized(slug=slug)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete park using service layer."""
|
||||
ParkService.delete_park(park_id=instance.id)
|
||||
|
||||
|
||||
# Unified API ViewSet (alternative approach)
|
||||
class ParkApi(
|
||||
CreateApiMixin,
|
||||
UpdateApiMixin,
|
||||
ListApiMixin,
|
||||
RetrieveApiMixin,
|
||||
DestroyApiMixin,
|
||||
GenericViewSet,
|
||||
):
|
||||
"""
|
||||
Unified API endpoint for parks with all CRUD operations.
|
||||
|
||||
GET /api/v1/parks/ - List parks
|
||||
POST /api/v1/parks/ - Create park
|
||||
GET /api/v1/parks/{slug}/ - Get park detail
|
||||
PUT /api/v1/parks/{slug}/ - Update park
|
||||
PATCH /api/v1/parks/{slug}/ - Partial update park
|
||||
DELETE /api/v1/parks/{slug}/ - Delete park
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||
lookup_field = "slug"
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
search_fields = ["name", "description"]
|
||||
ordering_fields = [
|
||||
"name",
|
||||
"opening_date",
|
||||
"average_rating",
|
||||
"coaster_count",
|
||||
"created_at",
|
||||
]
|
||||
ordering = ["name"]
|
||||
|
||||
# Serializers for different operations
|
||||
InputSerializer = ParkCreateInputSerializer # Used for create
|
||||
UpdateInputSerializer = ParkUpdateInputSerializer # Used for update
|
||||
OutputSerializer = ParkDetailOutputSerializer # Used for retrieve
|
||||
ListOutputSerializer = ParkListOutputSerializer # Used for list
|
||||
FilterSerializer = ParkFilterInputSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Use selector to get optimized queryset."""
|
||||
if self.action == "list":
|
||||
# Parse filter parameters for list view
|
||||
filter_serializer = self.FilterSerializer(data=self.request.query_params)
|
||||
filter_serializer.is_valid(raise_exception=True)
|
||||
filters = filter_serializer.validated_data
|
||||
return park_list_with_stats(**filters)
|
||||
|
||||
# For detail views, this won't be used since we override get_object
|
||||
return []
|
||||
|
||||
def get_object(self):
|
||||
"""Use selector for optimized detail query."""
|
||||
slug = self.kwargs.get("slug")
|
||||
return park_detail_optimized(slug=slug)
|
||||
|
||||
def get_output_serializer(self, *args, **kwargs):
|
||||
"""Return appropriate output serializer based on action."""
|
||||
if self.action == "list":
|
||||
return self.ListOutputSerializer(*args, **kwargs)
|
||||
return self.OutputSerializer(*args, **kwargs)
|
||||
|
||||
def get_input_serializer(self, *args, **kwargs):
|
||||
"""Return appropriate input serializer based on action."""
|
||||
if self.action in ["update", "partial_update"]:
|
||||
return self.UpdateInputSerializer(*args, **kwargs)
|
||||
return self.InputSerializer(*args, **kwargs)
|
||||
|
||||
def perform_create(self, **validated_data):
|
||||
"""Create park using service layer."""
|
||||
return ParkService.create_park(**validated_data)
|
||||
|
||||
def perform_update(self, instance, **validated_data):
|
||||
"""Update park using service layer."""
|
||||
return ParkService.update_park(park_id=instance.id, **validated_data)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete park using service layer."""
|
||||
ParkService.delete_park(park_id=instance.id)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def stats(self, request: Request) -> Response:
|
||||
"""
|
||||
Get park statistics.
|
||||
|
||||
GET /api/v1/parks/stats/
|
||||
"""
|
||||
stats = park_statistics()
|
||||
serializer = ParkStatsOutputSerializer(stats)
|
||||
|
||||
return self.create_response(
|
||||
data=serializer.data, metadata={"cache_duration": 3600}
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def reviews(self, request: Request, slug: str = None) -> Response:
|
||||
"""
|
||||
Get reviews for a specific park.
|
||||
|
||||
GET /api/v1/parks/{slug}/reviews/
|
||||
"""
|
||||
park = self.get_object()
|
||||
reviews = park_reviews_for_park(park_id=park.id, limit=50)
|
||||
|
||||
serializer = ParkReviewOutputSerializer(reviews, many=True)
|
||||
|
||||
return self.create_response(
|
||||
data=serializer.data,
|
||||
metadata={"total_reviews": len(reviews), "park_name": park.name},
|
||||
)
|
||||
9
backend/apps/parks/apps.py
Normal file
9
backend/apps/parks/apps.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ParksConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.parks"
|
||||
|
||||
def ready(self):
|
||||
import apps.parks.signals # noqa: F401 - Register signals
|
||||
390
backend/apps/parks/filters.py
Normal file
390
backend/apps/parks/filters.py
Normal file
@@ -0,0 +1,390 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db import models
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import Distance
|
||||
from django_filters import (
|
||||
NumberFilter,
|
||||
ModelChoiceFilter,
|
||||
DateFromToRangeFilter,
|
||||
ChoiceFilter,
|
||||
FilterSet,
|
||||
CharFilter,
|
||||
BooleanFilter,
|
||||
OrderingFilter,
|
||||
)
|
||||
from .models import Park, Company
|
||||
from .querysets import get_base_park_queryset
|
||||
import requests
|
||||
|
||||
|
||||
def validate_positive_integer(value):
|
||||
"""Validate that a value is a positive integer"""
|
||||
try:
|
||||
value = float(value)
|
||||
if not value.is_integer() or value < 0:
|
||||
raise ValidationError(_("Value must be a positive integer"))
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(_("Invalid number format"))
|
||||
|
||||
|
||||
class ParkFilter(FilterSet):
|
||||
"""Filter set for parks with search and validation capabilities"""
|
||||
|
||||
class Meta:
|
||||
model = Park
|
||||
fields = []
|
||||
|
||||
# Search field with better description
|
||||
search = CharFilter(
|
||||
method="filter_search",
|
||||
label=_("Search Parks"),
|
||||
help_text=_("Search by park name, description, or location"),
|
||||
)
|
||||
|
||||
# Status filter with clearer label
|
||||
status = ChoiceFilter(
|
||||
field_name="status",
|
||||
choices=Park.STATUS_CHOICES,
|
||||
empty_label=_("Any status"),
|
||||
label=_("Operating Status"),
|
||||
help_text=_("Filter parks by their current operating status"),
|
||||
)
|
||||
|
||||
# Operator filters with helpful descriptions
|
||||
operator = ModelChoiceFilter(
|
||||
field_name="operator",
|
||||
queryset=Company.objects.filter(roles__contains=["OPERATOR"]),
|
||||
empty_label=_("Any operator"),
|
||||
label=_("Operating Company"),
|
||||
help_text=_("Filter parks by their operating company"),
|
||||
)
|
||||
has_operator = BooleanFilter(
|
||||
method="filter_has_operator",
|
||||
label=_("Operator Status"),
|
||||
help_text=_("Show parks with or without an operating company"),
|
||||
)
|
||||
|
||||
# Ride and attraction filters
|
||||
min_rides = NumberFilter(
|
||||
field_name="ride_count",
|
||||
lookup_expr="gte",
|
||||
validators=[validate_positive_integer],
|
||||
label=_("Minimum Rides"),
|
||||
help_text=_("Show parks with at least this many rides"),
|
||||
)
|
||||
min_coasters = NumberFilter(
|
||||
field_name="coaster_count",
|
||||
lookup_expr="gte",
|
||||
validators=[validate_positive_integer],
|
||||
label=_("Minimum Roller Coasters"),
|
||||
help_text=_("Show parks with at least this many roller coasters"),
|
||||
)
|
||||
|
||||
# Size filter
|
||||
min_size = NumberFilter(
|
||||
field_name="size_acres",
|
||||
lookup_expr="gte",
|
||||
validators=[validate_positive_integer],
|
||||
label=_("Minimum Size (acres)"),
|
||||
help_text=_("Show parks of at least this size in acres"),
|
||||
)
|
||||
|
||||
# Opening date filter with better label
|
||||
opening_date = DateFromToRangeFilter(
|
||||
field_name="opening_date",
|
||||
label=_("Opening Date Range"),
|
||||
help_text=_("Filter parks by their opening date"),
|
||||
)
|
||||
|
||||
# Location-based filters
|
||||
location_search = CharFilter(
|
||||
method="filter_location_search",
|
||||
label=_("Location Search"),
|
||||
help_text=_("Search by city, state, country, or address"),
|
||||
)
|
||||
|
||||
near_location = CharFilter(
|
||||
method="filter_near_location",
|
||||
label=_("Near Location"),
|
||||
help_text=_("Find parks near a specific location"),
|
||||
)
|
||||
|
||||
radius_km = NumberFilter(
|
||||
method="filter_radius",
|
||||
label=_("Radius (km)"),
|
||||
help_text=_("Search radius in kilometers (use with 'Near Location')"),
|
||||
)
|
||||
|
||||
country_filter = CharFilter(
|
||||
method="filter_country",
|
||||
label=_("Country"),
|
||||
help_text=_("Filter parks by country"),
|
||||
)
|
||||
|
||||
state_filter = CharFilter(
|
||||
method="filter_state",
|
||||
label=_("State/Region"),
|
||||
help_text=_("Filter parks by state or region"),
|
||||
)
|
||||
|
||||
# Practical filter fields that people actually use
|
||||
park_type = ChoiceFilter(
|
||||
method="filter_park_type",
|
||||
label=_("Park Type"),
|
||||
help_text=_("Filter by popular park categories"),
|
||||
choices=[
|
||||
("disney", _("Disney Parks")),
|
||||
("universal", _("Universal Parks")),
|
||||
("six_flags", _("Six Flags")),
|
||||
("cedar_fair", _("Cedar Fair")),
|
||||
("independent", _("Independent Parks")),
|
||||
],
|
||||
empty_label=_("All parks"),
|
||||
)
|
||||
|
||||
has_coasters = BooleanFilter(
|
||||
method="filter_has_coasters",
|
||||
label=_("Has Roller Coasters"),
|
||||
help_text=_("Show only parks with roller coasters"),
|
||||
)
|
||||
|
||||
min_rating = ChoiceFilter(
|
||||
method="filter_min_rating",
|
||||
label=_("Minimum Rating"),
|
||||
help_text=_("Show parks with at least this rating"),
|
||||
choices=[
|
||||
("3", _("3+ stars")),
|
||||
("4", _("4+ stars")),
|
||||
("4.5", _("4.5+ stars")),
|
||||
],
|
||||
empty_label=_("Any rating"),
|
||||
)
|
||||
|
||||
big_parks_only = BooleanFilter(
|
||||
method="filter_big_parks",
|
||||
label=_("Major Parks Only"),
|
||||
help_text=_("Show only large theme parks (10+ rides)"),
|
||||
)
|
||||
|
||||
# Simple, useful ordering
|
||||
ordering = OrderingFilter(
|
||||
fields=(
|
||||
("name", "name"),
|
||||
("average_rating", "rating"),
|
||||
("coaster_count", "coasters"),
|
||||
("ride_count", "rides"),
|
||||
),
|
||||
field_labels={
|
||||
"name": _("Name (A-Z)"),
|
||||
"-name": _("Name (Z-A)"),
|
||||
"-average_rating": _("Highest Rated"),
|
||||
"-coaster_count": _("Most Coasters"),
|
||||
"-ride_count": _("Most Rides"),
|
||||
},
|
||||
label=_("Sort by"),
|
||||
)
|
||||
|
||||
def filter_search(self, queryset, name, value):
|
||||
"""Custom search implementation"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
search_fields = [
|
||||
"name__icontains",
|
||||
"description__icontains",
|
||||
"location__city__icontains",
|
||||
"location__state__icontains",
|
||||
"location__country__icontains",
|
||||
]
|
||||
|
||||
queries = [models.Q(**{field: value}) for field in search_fields]
|
||||
query = queries.pop()
|
||||
for item in queries:
|
||||
query |= item
|
||||
|
||||
return queryset.filter(query).distinct()
|
||||
|
||||
def filter_has_operator(self, queryset, name, value):
|
||||
"""Filter parks based on whether they have an operator"""
|
||||
return queryset.filter(operator__isnull=not value)
|
||||
|
||||
@property
|
||||
def qs(self):
|
||||
"""
|
||||
Override qs property to ensure we always use base queryset with annotations
|
||||
"""
|
||||
if not hasattr(self, "_qs"):
|
||||
# Start with optimized base queryset
|
||||
base_qs = (
|
||||
get_base_park_queryset()
|
||||
.select_related("operator", "property_owner", "location")
|
||||
.prefetch_related("photos", "rides__manufacturer")
|
||||
)
|
||||
|
||||
if not self.is_bound:
|
||||
self._qs = base_qs
|
||||
return self._qs
|
||||
|
||||
if not self.form.is_valid():
|
||||
self._qs = base_qs.none()
|
||||
return self._qs
|
||||
|
||||
self._qs = base_qs
|
||||
for name, value in self.form.cleaned_data.items():
|
||||
if value in [None, "", 0] and name not in [
|
||||
"has_operator",
|
||||
"has_coasters",
|
||||
"big_parks_only",
|
||||
]:
|
||||
continue
|
||||
self._qs = self.filters[name].filter(self._qs, value)
|
||||
self._qs = self._qs.distinct()
|
||||
return self._qs
|
||||
|
||||
def filter_location_search(self, queryset, name, value):
|
||||
"""Filter parks by location fields"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
location_query = (
|
||||
models.Q(location__city__icontains=value)
|
||||
| models.Q(location__state__icontains=value)
|
||||
| models.Q(location__country__icontains=value)
|
||||
| models.Q(location__street_address__icontains=value)
|
||||
)
|
||||
|
||||
return queryset.filter(location_query).distinct()
|
||||
|
||||
def filter_near_location(self, queryset, name, value):
|
||||
"""Filter parks near a specific location using geocoding"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
# Try to geocode the location
|
||||
coordinates = self._geocode_location(value)
|
||||
if not coordinates:
|
||||
return queryset
|
||||
|
||||
lat, lng = coordinates
|
||||
point = Point(lng, lat, srid=4326)
|
||||
|
||||
# Get radius from form data, default to 50km
|
||||
radius = self.data.get("radius_km", 50)
|
||||
try:
|
||||
radius = float(radius)
|
||||
except (ValueError, TypeError):
|
||||
radius = 50
|
||||
|
||||
# Filter by distance
|
||||
distance = Distance(km=radius)
|
||||
return (
|
||||
queryset.filter(location__point__distance_lte=(point, distance))
|
||||
.annotate(distance=models.Value(0, output_field=models.FloatField()))
|
||||
.order_by("distance")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def filter_radius(self, queryset, name, value):
|
||||
"""Radius filter - handled by filter_near_location"""
|
||||
return queryset
|
||||
|
||||
def filter_country(self, queryset, name, value):
|
||||
"""Filter parks by country"""
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(location__country__icontains=value).distinct()
|
||||
|
||||
def filter_state(self, queryset, name, value):
|
||||
"""Filter parks by state/region"""
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(location__state__icontains=value).distinct()
|
||||
|
||||
def filter_park_type(self, queryset, name, value):
|
||||
"""Filter parks by popular company/brand"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
# Map common park types to operator name patterns
|
||||
type_filters = {
|
||||
"disney": models.Q(operator__name__icontains="Disney"),
|
||||
"universal": models.Q(operator__name__icontains="Universal"),
|
||||
"six_flags": models.Q(operator__name__icontains="Six Flags"),
|
||||
"cedar_fair": models.Q(operator__name__icontains="Cedar Fair")
|
||||
| models.Q(operator__name__icontains="Cedar Point")
|
||||
| models.Q(operator__name__icontains="Kings Island")
|
||||
| models.Q(operator__name__icontains="Canada's Wonderland"),
|
||||
"independent": ~(
|
||||
models.Q(operator__name__icontains="Disney")
|
||||
| models.Q(operator__name__icontains="Universal")
|
||||
| models.Q(operator__name__icontains="Six Flags")
|
||||
| models.Q(operator__name__icontains="Cedar Fair")
|
||||
| models.Q(operator__name__icontains="Cedar Point")
|
||||
),
|
||||
}
|
||||
|
||||
if value in type_filters:
|
||||
return queryset.filter(type_filters[value])
|
||||
|
||||
return queryset
|
||||
|
||||
def filter_has_coasters(self, queryset, name, value):
|
||||
"""Filter parks based on whether they have roller coasters"""
|
||||
if value is None:
|
||||
return queryset
|
||||
|
||||
if value:
|
||||
return queryset.filter(coaster_count__gt=0)
|
||||
else:
|
||||
return queryset.filter(
|
||||
models.Q(coaster_count__isnull=True) | models.Q(coaster_count=0)
|
||||
)
|
||||
|
||||
def filter_min_rating(self, queryset, name, value):
|
||||
"""Filter parks by minimum rating"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
try:
|
||||
min_rating = float(value)
|
||||
return queryset.filter(average_rating__gte=min_rating)
|
||||
except (ValueError, TypeError):
|
||||
return queryset
|
||||
|
||||
def filter_big_parks(self, queryset, name, value):
|
||||
"""Filter to show only major parks with many rides"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
return queryset.filter(ride_count__gte=10)
|
||||
|
||||
def _geocode_location(self, location_string):
|
||||
"""
|
||||
Geocode a location string using OpenStreetMap Nominatim.
|
||||
Returns (lat, lng) tuple or None if geocoding fails.
|
||||
"""
|
||||
try:
|
||||
response = requests.get(
|
||||
"https://nominatim.openstreetmap.org/search",
|
||||
params={
|
||||
"q": location_string,
|
||||
"format": "json",
|
||||
"limit": 1,
|
||||
"countrycodes": "us,ca,gb,fr,de,es,it,jp,au", # Popular countries
|
||||
},
|
||||
headers={"User-Agent": "ThrillWiki/1.0"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data:
|
||||
result = data[0]
|
||||
return float(result["lat"]), float(result["lon"])
|
||||
except Exception:
|
||||
# Silently fail geocoding - just return None
|
||||
pass
|
||||
|
||||
return None
|
||||
368
backend/apps/parks/forms.py
Normal file
368
backend/apps/parks/forms.py
Normal file
@@ -0,0 +1,368 @@
|
||||
from django import forms
|
||||
from decimal import Decimal, InvalidOperation, ROUND_DOWN
|
||||
from autocomplete import AutocompleteWidget
|
||||
from .models import Park
|
||||
from .models.location import ParkLocation
|
||||
from .querysets import get_base_park_queryset
|
||||
|
||||
|
||||
class ParkAutocomplete(forms.Form):
|
||||
"""Autocomplete for searching parks.
|
||||
|
||||
Features:
|
||||
- Name-based search with partial matching
|
||||
- Prefetches related owner data
|
||||
- Applies standard park queryset filtering
|
||||
- Includes park status and location in results
|
||||
"""
|
||||
|
||||
model = Park
|
||||
search_attrs = ["name"] # We'll match on park names
|
||||
|
||||
def get_search_results(self, search):
|
||||
"""Return search results with related data."""
|
||||
return (
|
||||
get_base_park_queryset()
|
||||
.filter(name__icontains=search)
|
||||
.select_related("operator", "property_owner")
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
def format_result(self, park):
|
||||
"""Format each park result with status and location."""
|
||||
location = park.formatted_location
|
||||
location_text = f" • {location}" if location else ""
|
||||
return {
|
||||
"key": str(park.pk),
|
||||
"label": park.name,
|
||||
"extra": f"{park.get_status_display()}{location_text}",
|
||||
}
|
||||
|
||||
|
||||
class ParkSearchForm(forms.Form):
|
||||
"""Form for searching parks with autocomplete."""
|
||||
|
||||
park = forms.ModelChoiceField(
|
||||
queryset=Park.objects.all(),
|
||||
required=False,
|
||||
widget=AutocompleteWidget(
|
||||
ac_class=ParkAutocomplete,
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search parks...",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ParkForm(forms.ModelForm):
|
||||
"""Form for creating and updating Park objects with location support"""
|
||||
|
||||
# Location fields
|
||||
latitude = forms.DecimalField(
|
||||
max_digits=9,
|
||||
decimal_places=6,
|
||||
required=False,
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
longitude = forms.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=6,
|
||||
required=False,
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
street_address = forms.CharField(
|
||||
max_length=255,
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
city = forms.CharField(
|
||||
max_length=255,
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
state = forms.CharField(
|
||||
max_length=255,
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
country = forms.CharField(
|
||||
max_length=255,
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
postal_code = forms.CharField(
|
||||
max_length=20,
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Park
|
||||
fields = [
|
||||
"name",
|
||||
"description",
|
||||
"operator",
|
||||
"property_owner",
|
||||
"status",
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"operating_season",
|
||||
"size_acres",
|
||||
"website",
|
||||
# Location fields handled separately
|
||||
"latitude",
|
||||
"longitude",
|
||||
"street_address",
|
||||
"city",
|
||||
"state",
|
||||
"country",
|
||||
"postal_code",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)
|
||||
}
|
||||
),
|
||||
"description": forms.Textarea(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-textarea "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"rows": 2,
|
||||
}
|
||||
),
|
||||
"operator": forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)
|
||||
}
|
||||
),
|
||||
"property_owner": forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)
|
||||
}
|
||||
),
|
||||
"status": forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)
|
||||
}
|
||||
),
|
||||
"opening_date": forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
"closing_date": forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
"operating_season": forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "e.g., Year-round, Summer only, etc.",
|
||||
}
|
||||
),
|
||||
"size_acres": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"step": "0.01",
|
||||
"min": "0",
|
||||
}
|
||||
),
|
||||
"website": forms.URLInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "https://example.com",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Pre-fill location fields if editing existing park
|
||||
if self.instance and self.instance.pk and self.instance.location.exists():
|
||||
location = self.instance.location.first()
|
||||
self.fields["latitude"].initial = location.latitude
|
||||
self.fields["longitude"].initial = location.longitude
|
||||
self.fields["street_address"].initial = location.street_address
|
||||
self.fields["city"].initial = location.city
|
||||
self.fields["state"].initial = location.state
|
||||
self.fields["country"].initial = location.country
|
||||
self.fields["postal_code"].initial = location.postal_code
|
||||
|
||||
def clean_latitude(self):
|
||||
latitude = self.cleaned_data.get("latitude")
|
||||
if latitude is not None:
|
||||
try:
|
||||
# Convert to Decimal for precise handling
|
||||
latitude = Decimal(str(latitude))
|
||||
# Round to exactly 6 decimal places
|
||||
latitude = latitude.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
|
||||
|
||||
# Validate range
|
||||
if latitude < -90 or latitude > 90:
|
||||
raise forms.ValidationError(
|
||||
"Latitude must be between -90 and 90 degrees."
|
||||
)
|
||||
|
||||
# Convert to string to preserve exact decimal places
|
||||
return str(latitude)
|
||||
except (InvalidOperation, TypeError) as e:
|
||||
raise forms.ValidationError("Invalid latitude value.") from e
|
||||
return latitude
|
||||
|
||||
def clean_longitude(self):
|
||||
longitude = self.cleaned_data.get("longitude")
|
||||
if longitude is not None:
|
||||
try:
|
||||
# Convert to Decimal for precise handling
|
||||
longitude = Decimal(str(longitude))
|
||||
# Round to exactly 6 decimal places
|
||||
longitude = longitude.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
|
||||
|
||||
# Validate range
|
||||
if longitude < -180 or longitude > 180:
|
||||
raise forms.ValidationError(
|
||||
"Longitude must be between -180 and 180 degrees."
|
||||
)
|
||||
|
||||
# Convert to string to preserve exact decimal places
|
||||
return str(longitude)
|
||||
except (InvalidOperation, TypeError) as e:
|
||||
raise forms.ValidationError("Invalid longitude value.") from e
|
||||
return longitude
|
||||
|
||||
def save(self, commit=True):
|
||||
park = super().save(commit=False)
|
||||
|
||||
# Prepare location data
|
||||
location_data = {
|
||||
"name": park.name,
|
||||
"location_type": "park",
|
||||
"latitude": self.cleaned_data.get("latitude"),
|
||||
"longitude": self.cleaned_data.get("longitude"),
|
||||
"street_address": self.cleaned_data.get("street_address"),
|
||||
"city": self.cleaned_data.get("city"),
|
||||
"state": self.cleaned_data.get("state"),
|
||||
"country": self.cleaned_data.get("country"),
|
||||
"postal_code": self.cleaned_data.get("postal_code"),
|
||||
}
|
||||
|
||||
# Handle location: update if exists, create if not
|
||||
try:
|
||||
park_location = park.location
|
||||
# Update existing location
|
||||
for key, value in location_data.items():
|
||||
if key in ["latitude", "longitude"] and value:
|
||||
continue # Handle coordinates separately
|
||||
if hasattr(park_location, key):
|
||||
setattr(park_location, key, value)
|
||||
|
||||
# Handle coordinates if provided
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
park_location.set_coordinates(
|
||||
float(location_data["latitude"]),
|
||||
float(location_data["longitude"]),
|
||||
)
|
||||
park_location.save()
|
||||
except ParkLocation.DoesNotExist:
|
||||
# Create new ParkLocation
|
||||
coordinates_data = {}
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
coordinates_data = {
|
||||
"latitude": float(location_data["latitude"]),
|
||||
"longitude": float(location_data["longitude"]),
|
||||
}
|
||||
|
||||
# Remove coordinate fields from location_data for creation
|
||||
creation_data = {
|
||||
k: v
|
||||
for k, v in location_data.items()
|
||||
if k not in ["latitude", "longitude"]
|
||||
}
|
||||
creation_data.setdefault("country", "USA")
|
||||
|
||||
park_location = ParkLocation.objects.create(park=park, **creation_data)
|
||||
|
||||
if coordinates_data:
|
||||
park_location.set_coordinates(
|
||||
coordinates_data["latitude"], coordinates_data["longitude"]
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
if commit:
|
||||
park.save()
|
||||
|
||||
return park
|
||||
55
backend/apps/parks/location_utils.py
Normal file
55
backend/apps/parks/location_utils.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from decimal import Decimal, ROUND_DOWN, InvalidOperation
|
||||
|
||||
|
||||
def normalize_coordinate(value, max_digits, decimal_places):
|
||||
"""Normalize coordinate to have exactly 6 decimal places"""
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
# Convert to Decimal for precise handling
|
||||
value = Decimal(str(value))
|
||||
# Round to exactly 6 decimal places
|
||||
value = value.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
|
||||
|
||||
return float(value)
|
||||
except (TypeError, ValueError, InvalidOperation):
|
||||
return None
|
||||
|
||||
|
||||
def get_english_name(tags):
|
||||
"""Extract English name from OSM tags, falling back to default name"""
|
||||
# Try name:en first
|
||||
if "name:en" in tags:
|
||||
return tags["name:en"]
|
||||
# Then try int_name (international name)
|
||||
if "int_name" in tags:
|
||||
return tags["int_name"]
|
||||
# Fall back to default name
|
||||
return tags.get("name")
|
||||
|
||||
|
||||
def normalize_osm_result(result):
|
||||
"""Normalize OpenStreetMap result to use English names and normalized coordinates"""
|
||||
# Normalize coordinates
|
||||
result["lat"] = normalize_coordinate(float(result["lat"]), 9, 6)
|
||||
result["lon"] = normalize_coordinate(float(result["lon"]), 10, 6)
|
||||
|
||||
# Get address details
|
||||
address = result.get("address", {})
|
||||
|
||||
# Normalize place names to English where possible
|
||||
if "namedetails" in result:
|
||||
# For main display name
|
||||
result["display_name"] = get_english_name(result["namedetails"])
|
||||
|
||||
# For address components
|
||||
if "city" in address and "city_tags" in result:
|
||||
address["city"] = get_english_name(result["city_tags"])
|
||||
if "state" in address and "state_tags" in result:
|
||||
address["state"] = get_english_name(result["state_tags"])
|
||||
if "country" in address and "country_tags" in result:
|
||||
address["country"] = get_english_name(result["country_tags"])
|
||||
|
||||
result["address"] = address
|
||||
return result
|
||||
320
backend/apps/parks/management/commands/create_sample_data.py
Normal file
320
backend/apps/parks/management/commands/create_sample_data.py
Normal file
@@ -0,0 +1,320 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
# Import models from both apps
|
||||
from apps.parks.models import Company as ParkCompany
|
||||
from apps.rides.models.company import Company as RideCompany
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Creates comprehensive sample data for the ThrillWiki theme park application"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.created_companies = {}
|
||||
self.created_parks = {}
|
||||
self.created_rides = {}
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write("Starting sample data creation...")
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
self.create_companies()
|
||||
self.create_parks()
|
||||
self.create_ride_models()
|
||||
self.create_rides()
|
||||
self.create_park_areas()
|
||||
self.create_reviews()
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Successfully created comprehensive sample data!")
|
||||
)
|
||||
self.print_summary()
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"Error creating sample data: {e}"))
|
||||
raise
|
||||
|
||||
def create_companies(self):
|
||||
"""Create companies with different roles following entity relationship rules"""
|
||||
self.stdout.write("Creating companies...")
|
||||
|
||||
# Park operators and property owners (using parks.models.Company)
|
||||
park_operators_data = [
|
||||
{
|
||||
"name": "The Walt Disney Company",
|
||||
"slug": "walt-disney-company",
|
||||
"roles": ["OPERATOR", "PROPERTY_OWNER"],
|
||||
"description": "World's largest entertainment company and theme park operator.",
|
||||
"website": "https://www.disney.com/",
|
||||
"founded_year": 1923,
|
||||
},
|
||||
{
|
||||
"name": "Universal Parks & Resorts",
|
||||
"slug": "universal-parks-resorts",
|
||||
"roles": ["OPERATOR", "PROPERTY_OWNER"],
|
||||
"description": "Division of Comcast NBCUniversal, operating major theme parks worldwide.",
|
||||
"website": "https://www.universalparks.com/",
|
||||
"founded_year": 1964,
|
||||
},
|
||||
{
|
||||
"name": "Six Flags Entertainment Corporation",
|
||||
"slug": "six-flags-entertainment",
|
||||
"roles": ["OPERATOR", "PROPERTY_OWNER"],
|
||||
"description": "World's largest regional theme park company.",
|
||||
"website": "https://www.sixflags.com/",
|
||||
"founded_year": 1961,
|
||||
},
|
||||
{
|
||||
"name": "Cedar Fair Entertainment Company",
|
||||
"slug": "cedar-fair-entertainment",
|
||||
"roles": ["OPERATOR", "PROPERTY_OWNER"],
|
||||
"description": "One of North America's largest operators of regional amusement parks.",
|
||||
"website": "https://www.cedarfair.com/",
|
||||
"founded_year": 1983,
|
||||
},
|
||||
{
|
||||
"name": "Herschend Family Entertainment",
|
||||
"slug": "herschend-family-entertainment",
|
||||
"roles": ["OPERATOR", "PROPERTY_OWNER"],
|
||||
"description": "Largest family-owned themed attractions corporation in the United States.",
|
||||
"website": "https://www.hfecorp.com/",
|
||||
"founded_year": 1950,
|
||||
},
|
||||
{
|
||||
"name": "SeaWorld Parks & Entertainment",
|
||||
"slug": "seaworld-parks-entertainment",
|
||||
"roles": ["OPERATOR", "PROPERTY_OWNER"],
|
||||
"description": "Theme park and entertainment company focusing on nature-based themes.",
|
||||
"website": "https://www.seaworldentertainment.com/",
|
||||
"founded_year": 1959,
|
||||
},
|
||||
{
|
||||
"name": "Merlin Entertainments",
|
||||
"slug": "merlin-entertainments",
|
||||
"roles": ["OPERATOR", "PROPERTY_OWNER"],
|
||||
"description": "European theme park operator with LEGOLAND and Madame Tussauds brands.",
|
||||
"website": "https://www.merlinentertainments.com/",
|
||||
"founded_year": 1998,
|
||||
},
|
||||
]
|
||||
|
||||
for company_data in park_operators_data:
|
||||
company, created = ParkCompany.objects.get_or_create(
|
||||
slug=company_data["slug"], defaults=company_data
|
||||
)
|
||||
self.created_companies[company.slug] = company
|
||||
self.stdout.write(
|
||||
f' {
|
||||
"Created" if created else "Found"} park company: {
|
||||
company.name}'
|
||||
)
|
||||
|
||||
# Ride manufacturers and designers (using rides.models.Company)
|
||||
ride_companies_data = [
|
||||
{
|
||||
"name": "Bolliger & Mabillard",
|
||||
"slug": "bolliger-mabillard",
|
||||
"roles": ["MANUFACTURER", "DESIGNER"],
|
||||
"description": "Swiss roller coaster manufacturer known for inverted and diving coasters.",
|
||||
"website": "https://www.bolliger-mabillard.com/",
|
||||
"founded_date": "1988-01-01",
|
||||
},
|
||||
{
|
||||
"name": "Intamin Amusement Rides",
|
||||
"slug": "intamin-amusement-rides",
|
||||
"roles": ["MANUFACTURER", "DESIGNER"],
|
||||
"description": "Liechtenstein-based manufacturer of roller coasters and thrill rides.",
|
||||
"website": "https://www.intamin.com/",
|
||||
"founded_date": "1967-01-01",
|
||||
},
|
||||
{
|
||||
"name": "Arrow Dynamics",
|
||||
"slug": "arrow-dynamics",
|
||||
"roles": ["MANUFACTURER", "DESIGNER"],
|
||||
"description": "American manufacturer known for corkscrew coasters and mine trains.",
|
||||
"website": "https://en.wikipedia.org/wiki/Arrow_Dynamics",
|
||||
"founded_date": "1946-01-01",
|
||||
},
|
||||
{
|
||||
"name": "Vekoma Rides Manufacturing",
|
||||
"slug": "vekoma-rides-manufacturing",
|
||||
"roles": ["MANUFACTURER", "DESIGNER"],
|
||||
"description": "Dutch manufacturer of roller coasters and family rides.",
|
||||
"website": "https://www.vekoma.com/",
|
||||
"founded_date": "1926-01-01",
|
||||
},
|
||||
{
|
||||
"name": "Rocky Mountain Construction",
|
||||
"slug": "rocky-mountain-construction",
|
||||
"roles": ["MANUFACTURER", "DESIGNER"],
|
||||
"description": "American manufacturer specializing in I-Box track and Raptor track coasters.",
|
||||
"website": "https://www.rockymtnconstruction.com/",
|
||||
"founded_date": "2001-01-01",
|
||||
},
|
||||
{
|
||||
"name": "Mack Rides",
|
||||
"slug": "mack-rides",
|
||||
"roles": ["MANUFACTURER", "DESIGNER"],
|
||||
"description": "German manufacturer known for water rides and powered coasters.",
|
||||
"website": "https://www.mack-rides.com/",
|
||||
"founded_date": "1780-01-01",
|
||||
},
|
||||
{
|
||||
"name": "Chance Rides",
|
||||
"slug": "chance-rides",
|
||||
"roles": ["MANUFACTURER"],
|
||||
"description": "American manufacturer of thrill rides and amusement park equipment.",
|
||||
"website": "https://www.chancerides.com/",
|
||||
"founded_date": "1961-01-01",
|
||||
},
|
||||
{
|
||||
"name": "S&S Worldwide",
|
||||
"slug": "s-s-worldwide",
|
||||
"roles": ["MANUFACTURER", "DESIGNER"],
|
||||
"description": "American manufacturer known for drop towers and 4D free-fly coasters.",
|
||||
"website": "https://www.s-s.com/",
|
||||
"founded_date": "1990-01-01",
|
||||
},
|
||||
{
|
||||
"name": "Zierer Rides",
|
||||
"slug": "zierer-rides",
|
||||
"roles": ["MANUFACTURER"],
|
||||
"description": "German manufacturer of kiddie rides and family coasters.",
|
||||
"website": "https://www.zierer.com/",
|
||||
"founded_date": "1950-01-01",
|
||||
},
|
||||
{
|
||||
"name": "Gerstlauer",
|
||||
"slug": "gerstlauer",
|
||||
"roles": ["MANUFACTURER", "DESIGNER"],
|
||||
"description": "German manufacturer known for Euro-Fighter and spinning coasters.",
|
||||
"website": "https://www.gerstlauer-rides.de/",
|
||||
"founded_date": "1982-01-01",
|
||||
},
|
||||
]
|
||||
|
||||
for company_data in ride_companies_data:
|
||||
company, created = RideCompany.objects.get_or_create(
|
||||
slug=company_data["slug"], defaults=company_data
|
||||
)
|
||||
self.created_companies[company.slug] = company
|
||||
self.stdout.write(
|
||||
f' {
|
||||
"Created" if created else "Found"} ride company: {
|
||||
company.name}'
|
||||
)
|
||||
|
||||
def create_parks(self):
|
||||
"""Create parks with proper operator relationships"""
|
||||
self.stdout.write("Creating parks...")
|
||||
|
||||
# TODO: Implement park creation - parks_data defined but not used yet
|
||||
parks_data = [ # noqa: F841
|
||||
{
|
||||
"name": "Magic Kingdom",
|
||||
"slug": "magic-kingdom",
|
||||
"operator_slug": "walt-disney-company",
|
||||
"property_owner_slug": "walt-disney-company",
|
||||
"description": "The first theme park at Walt Disney World Resort in Florida, opened in 1971.",
|
||||
"opening_date": "1971-10-01",
|
||||
"size_acres": 142,
|
||||
"website": "https://disneyworld.disney.go.com/destinations/magic-kingdom/",
|
||||
"location": {
|
||||
"street_address": "1180 Seven Seas Dr",
|
||||
"city": "Lake Buena Vista",
|
||||
"state_province": "Florida",
|
||||
"country": "USA",
|
||||
"postal_code": "32830",
|
||||
"latitude": 28.4177,
|
||||
"longitude": -81.5812,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Universal Studios Florida",
|
||||
"slug": "universal-studios-florida",
|
||||
"operator_slug": "universal-parks-resorts",
|
||||
"property_owner_slug": "universal-parks-resorts",
|
||||
"description": "Movie and television-based theme park in Orlando, Florida.",
|
||||
"opening_date": "1990-06-07",
|
||||
"size_acres": 108,
|
||||
"website": "https://www.universalorlando.com/web/en/us/theme-parks/universal-studios-florida",
|
||||
"location": {
|
||||
"street_address": "6000 Universal Blvd",
|
||||
"city": "Orlando",
|
||||
"state_province": "Florida",
|
||||
"country": "USA",
|
||||
"postal_code": "32819",
|
||||
"latitude": 28.4749,
|
||||
"longitude": -81.4687,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"operator_slug": "cedar-fair-entertainment",
|
||||
"property_owner_slug": "cedar-fair-entertainment",
|
||||
"description": 'Known as the "Roller Coaster Capital of the World".',
|
||||
"opening_date": "1870-06-01",
|
||||
"size_acres": 364,
|
||||
"website": "https://www.cedarpoint.com/",
|
||||
"location": {
|
||||
"street_address": "1 Cedar Point Dr",
|
||||
"city": "Sandusky",
|
||||
"state_province": "Ohio",
|
||||
"country": "USA",
|
||||
"postal_code": "44870",
|
||||
"latitude": 41.4822,
|
||||
"longitude": -82.6835,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Six Flags Magic Mountain",
|
||||
"slug": "six-flags-magic-mountain",
|
||||
"operator_slug": "six-flags-entertainment",
|
||||
"property_owner_slug": "six-flags-entertainment",
|
||||
"description": "Known for its world-record 19 roller coasters.",
|
||||
"opening_date": "1971-05-29",
|
||||
"size_acres": 262,
|
||||
"website": "https://www.sixflags.com/magicmountain",
|
||||
"location": {
|
||||
"street_address": "26101 Magic Mountain Pkwy",
|
||||
"city": "Valencia",
|
||||
"state_province": "California",
|
||||
"country": "USA",
|
||||
"postal_code": "91355",
|
||||
"latitude": 34.4253,
|
||||
"longitude": -118.5971,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Europa-Park",
|
||||
"slug": "europa-park",
|
||||
"operator_slug": "merlin-entertainments",
|
||||
"property_owner_slug": "merlin-entertainments",
|
||||
"description": "One of the most popular theme parks in Europe, located in Germany.",
|
||||
"opening_date": "1975-07-12",
|
||||
"size_acres": 234,
|
||||
"website": "https://www.europapark.de/",
|
||||
"location": {
|
||||
"street_address": "Europa-Park-Straße 2",
|
||||
"city": "Rust",
|
||||
"state_province": "Baden-Württemberg",
|
||||
"country": "Germany",
|
||||
"postal_code": "77977",
|
||||
"latitude": 48.2667,
|
||||
"longitude": 7.7167,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Alton Towers",
|
||||
"slug": "alton-towers",
|
||||
"operator_slug": "merlin-entertainments",
|
||||
"property_owner_slug": "merlin-entertainments",
|
||||
"description": "Major theme park and former country estate in Staffordshire, England.",
|
||||
"opening_date": "1980-04-23",
|
||||
"size_acres": 500,
|
||||
# Add other fields as needed
|
||||
},
|
||||
]
|
||||
36
backend/apps/parks/management/commands/fix_migrations.py
Normal file
36
backend/apps/parks/management/commands/fix_migrations.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Fix migration history"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
with connection.cursor() as cursor:
|
||||
# Drop existing historical tables
|
||||
cursor.execute(
|
||||
"""
|
||||
DROP TABLE IF EXISTS parks_historicalpark CASCADE;
|
||||
DROP TABLE IF EXISTS parks_historicalparkarea CASCADE;
|
||||
"""
|
||||
)
|
||||
|
||||
# Delete all existing parks migrations
|
||||
cursor.execute(
|
||||
"""
|
||||
DELETE FROM django_migrations
|
||||
WHERE app = 'parks';
|
||||
"""
|
||||
)
|
||||
|
||||
# Insert the new initial migration
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO django_migrations (app, name, applied)
|
||||
VALUES ('parks', '0001_initial', NOW());
|
||||
"""
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Successfully fixed migration history")
|
||||
)
|
||||
351
backend/apps/parks/management/commands/seed_data.json
Normal file
351
backend/apps/parks/management/commands/seed_data.json
Normal file
@@ -0,0 +1,351 @@
|
||||
{
|
||||
"parks": [
|
||||
{
|
||||
"name": "Walt Disney World Magic Kingdom",
|
||||
"location": "Orlando, Florida",
|
||||
"country": "US",
|
||||
"opening_date": "1971-10-01",
|
||||
"status": "OPERATING",
|
||||
"description": "The most visited theme park in the world, Magic Kingdom is Walt Disney World's first theme park.",
|
||||
"website": "https://disneyworld.disney.go.com/destinations/magic-kingdom/",
|
||||
"owner": "The Walt Disney Company",
|
||||
"size_acres": "142.00",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1524008279394-3aed4643b30b"
|
||||
],
|
||||
"rides": [
|
||||
{
|
||||
"name": "Space Mountain",
|
||||
"category": "RC",
|
||||
"opening_date": "1975-01-15",
|
||||
"status": "OPERATING",
|
||||
"manufacturer": "Walt Disney Imagineering",
|
||||
"description": "A high-speed roller coaster in the dark through space.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1536768139911-e290a59011e4"
|
||||
],
|
||||
"stats": {
|
||||
"height_ft": "183.00",
|
||||
"length_ft": "3196.00",
|
||||
"speed_mph": "27.00",
|
||||
"inversions": 0,
|
||||
"ride_time_seconds": 180
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Big Thunder Mountain Railroad",
|
||||
"category": "RC",
|
||||
"opening_date": "1980-09-23",
|
||||
"status": "OPERATING",
|
||||
"manufacturer": "Walt Disney Imagineering",
|
||||
"description": "A mine train roller coaster through the Old West.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1513889961551-628c1e5e2ee9"
|
||||
],
|
||||
"stats": {
|
||||
"height_ft": "104.00",
|
||||
"length_ft": "2671.00",
|
||||
"speed_mph": "30.00",
|
||||
"inversions": 0,
|
||||
"ride_time_seconds": 197
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Seven Dwarfs Mine Train",
|
||||
"category": "RC",
|
||||
"opening_date": "2014-05-28",
|
||||
"status": "OPERATING",
|
||||
"manufacturer": "Vekoma",
|
||||
"description": "A family roller coaster featuring unique swinging cars.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1590144662036-33bf0ebd2c7f"
|
||||
],
|
||||
"stats": {
|
||||
"height_ft": "112.00",
|
||||
"length_ft": "2000.00",
|
||||
"speed_mph": "34.00",
|
||||
"inversions": 0,
|
||||
"ride_time_seconds": 180
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Haunted Mansion",
|
||||
"category": "DR",
|
||||
"opening_date": "1971-10-01",
|
||||
"status": "OPERATING",
|
||||
"manufacturer": "Walt Disney Imagineering",
|
||||
"description": "A dark ride through a haunted estate.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1597466599360-3b9775841aec"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Pirates of the Caribbean",
|
||||
"category": "DR",
|
||||
"opening_date": "1973-12-15",
|
||||
"status": "OPERATING",
|
||||
"manufacturer": "Walt Disney Imagineering",
|
||||
"description": "A boat ride through pirate-filled Caribbean waters.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1506126799754-92bc47fc5d78"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Cedar Point",
|
||||
"location": "Sandusky, Ohio",
|
||||
"country": "US",
|
||||
"opening_date": "1870-06-01",
|
||||
"status": "OPERATING",
|
||||
"description": "Known as the Roller Coaster Capital of the World.",
|
||||
"website": "https://www.cedarpoint.com",
|
||||
"owner": "Cedar Fair",
|
||||
"size_acres": "364.00",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1536768139911-e290a59011e4"
|
||||
],
|
||||
"rides": [
|
||||
{
|
||||
"name": "Steel Vengeance",
|
||||
"category": "RC",
|
||||
"opening_date": "2018-05-05",
|
||||
"status": "OPERATING",
|
||||
"manufacturer": "Rocky Mountain Construction",
|
||||
"description": "A hybrid roller coaster featuring multiple inversions.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1543674892-7d64d45df18b"
|
||||
],
|
||||
"stats": {
|
||||
"height_ft": "205.00",
|
||||
"length_ft": "5740.00",
|
||||
"speed_mph": "74.00",
|
||||
"inversions": 4,
|
||||
"ride_time_seconds": 150
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Millennium Force",
|
||||
"category": "RC",
|
||||
"opening_date": "2000-05-13",
|
||||
"status": "OPERATING",
|
||||
"manufacturer": "Intamin",
|
||||
"description": "A giga coaster with stunning views of Lake Erie.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1605559911160-a3d95d213904"
|
||||
],
|
||||
"stats": {
|
||||
"height_ft": "310.00",
|
||||
"length_ft": "6595.00",
|
||||
"speed_mph": "93.00",
|
||||
"inversions": 0,
|
||||
"ride_time_seconds": 120
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Top Thrill Dragster",
|
||||
"category": "RC",
|
||||
"opening_date": "2003-05-04",
|
||||
"status": "SBNO",
|
||||
"manufacturer": "Intamin",
|
||||
"description": "A strata coaster featuring a 420-foot top hat element.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1578912996078-305d92249aa6"
|
||||
],
|
||||
"stats": {
|
||||
"height_ft": "420.00",
|
||||
"length_ft": "2800.00",
|
||||
"speed_mph": "120.00",
|
||||
"inversions": 0,
|
||||
"ride_time_seconds": 50
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Maverick",
|
||||
"category": "RC",
|
||||
"opening_date": "2007-05-26",
|
||||
"status": "OPERATING",
|
||||
"manufacturer": "Intamin",
|
||||
"description": "A launched roller coaster with multiple inversions.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1581309638082-877cb8132535"
|
||||
],
|
||||
"stats": {
|
||||
"height_ft": "105.00",
|
||||
"length_ft": "4450.00",
|
||||
"speed_mph": "70.00",
|
||||
"inversions": 2,
|
||||
"ride_time_seconds": 150
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Universal's Islands of Adventure",
|
||||
"location": "Orlando, Florida",
|
||||
"country": "US",
|
||||
"opening_date": "1999-05-28",
|
||||
"status": "OPERATING",
|
||||
"description": "A theme park featuring cutting-edge technology and thrilling attractions.",
|
||||
"website": "https://www.universalorlando.com/web/en/us/theme-parks/islands-of-adventure",
|
||||
"owner": "NBCUniversal",
|
||||
"size_acres": "110.00",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1597466599360-3b9775841aec"
|
||||
],
|
||||
"rides": [
|
||||
{
|
||||
"name": "Jurassic World VelociCoaster",
|
||||
"category": "RC",
|
||||
"opening_date": "2021-06-10",
|
||||
"status": "OPERATING",
|
||||
"manufacturer": "Intamin",
|
||||
"description": "A high-speed launch coaster featuring velociraptors.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1536768139911-e290a59011e4"
|
||||
],
|
||||
"stats": {
|
||||
"height_ft": "155.00",
|
||||
"length_ft": "4700.00",
|
||||
"speed_mph": "70.00",
|
||||
"inversions": 4,
|
||||
"ride_time_seconds": 145
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Hagrid's Magical Creatures Motorbike Adventure",
|
||||
"category": "RC",
|
||||
"opening_date": "2019-06-13",
|
||||
"status": "OPERATING",
|
||||
"manufacturer": "Intamin",
|
||||
"description": "A story coaster through the Forbidden Forest.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1513889961551-628c1e5e2ee9"
|
||||
],
|
||||
"stats": {
|
||||
"height_ft": "65.00",
|
||||
"length_ft": "5053.00",
|
||||
"speed_mph": "50.00",
|
||||
"inversions": 0,
|
||||
"ride_time_seconds": 180
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The Amazing Adventures of Spider-Man",
|
||||
"category": "DR",
|
||||
"opening_date": "1999-05-28",
|
||||
"status": "OPERATING",
|
||||
"manufacturer": "Oceaneering International",
|
||||
"description": "A 3D dark ride featuring Spider-Man.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1590144662036-33bf0ebd2c7f"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Alton Towers",
|
||||
"location": "Alton, England",
|
||||
"country": "GB",
|
||||
"opening_date": "1980-04-04",
|
||||
"status": "OPERATING",
|
||||
"description": "The UK's largest theme park, built around a historic stately home.",
|
||||
"website": "https://www.altontowers.com",
|
||||
"owner": "Merlin Entertainments",
|
||||
"size_acres": "910.00",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1506126799754-92bc47fc5d78"
|
||||
],
|
||||
"rides": [
|
||||
{
|
||||
"name": "Nemesis",
|
||||
"category": "RC",
|
||||
"opening_date": "1994-03-19",
|
||||
"status": "CLOSED",
|
||||
"manufacturer": "Bolliger & Mabillard",
|
||||
"description": "An inverted roller coaster through ravines.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1543674892-7d64d45df18b"
|
||||
],
|
||||
"stats": {
|
||||
"height_ft": "43.00",
|
||||
"length_ft": "2349.00",
|
||||
"speed_mph": "50.00",
|
||||
"inversions": 4,
|
||||
"ride_time_seconds": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Oblivion",
|
||||
"category": "RC",
|
||||
"opening_date": "1998-03-14",
|
||||
"status": "OPERATING",
|
||||
"manufacturer": "Bolliger & Mabillard",
|
||||
"description": "The world's first vertical drop roller coaster.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1605559911160-a3d95d213904"
|
||||
],
|
||||
"stats": {
|
||||
"height_ft": "65.00",
|
||||
"length_ft": "1804.00",
|
||||
"speed_mph": "68.00",
|
||||
"inversions": 0,
|
||||
"ride_time_seconds": 100
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Europa-Park",
|
||||
"location": "Rust, Germany",
|
||||
"country": "DE",
|
||||
"opening_date": "1975-07-12",
|
||||
"status": "OPERATING",
|
||||
"description": "Germany's largest theme park, featuring European-themed areas.",
|
||||
"website": "https://www.europapark.de",
|
||||
"owner": "Mack Rides",
|
||||
"size_acres": "235.00",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1536768139911-e290a59011e4"
|
||||
],
|
||||
"rides": [
|
||||
{
|
||||
"name": "Silver Star",
|
||||
"category": "RC",
|
||||
"opening_date": "2002-03-23",
|
||||
"status": "OPERATING",
|
||||
"manufacturer": "Bolliger & Mabillard",
|
||||
"description": "A hypercoaster with stunning views.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1536768139911-e290a59011e4"
|
||||
],
|
||||
"stats": {
|
||||
"height_ft": "239.00",
|
||||
"length_ft": "4003.00",
|
||||
"speed_mph": "79.00",
|
||||
"inversions": 0,
|
||||
"ride_time_seconds": 180
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Blue Fire",
|
||||
"category": "RC",
|
||||
"opening_date": "2009-04-04",
|
||||
"status": "OPERATING",
|
||||
"manufacturer": "Mack Rides",
|
||||
"description": "A launched roller coaster with multiple inversions.",
|
||||
"photos": [
|
||||
"https://images.unsplash.com/photo-1513889961551-628c1e5e2ee9"
|
||||
],
|
||||
"stats": {
|
||||
"height_ft": "125.00",
|
||||
"length_ft": "3465.00",
|
||||
"speed_mph": "62.00",
|
||||
"inversions": 4,
|
||||
"ride_time_seconds": 150
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
334
backend/apps/parks/management/commands/seed_initial_data.py
Normal file
334
backend/apps/parks/management/commands/seed_initial_data.py
Normal file
@@ -0,0 +1,334 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from apps.parks.models import Park, ParkArea, ParkLocation, Company as Operator
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seeds initial park data with major theme parks worldwide"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Create major theme park companies
|
||||
companies_data = [
|
||||
{
|
||||
"name": "The Walt Disney Company",
|
||||
"website": "https://www.disney.com/",
|
||||
"headquarters": "Burbank, California",
|
||||
"description": "The world's largest entertainment company and theme park operator.",
|
||||
},
|
||||
{
|
||||
"name": "Universal Parks & Resorts",
|
||||
"website": "https://www.universalparks.com/",
|
||||
"headquarters": "Orlando, Florida",
|
||||
"description": "A division of Comcast NBCUniversal, operating major theme parks worldwide.",
|
||||
},
|
||||
{
|
||||
"name": "Six Flags Entertainment Corporation",
|
||||
"website": "https://www.sixflags.com/",
|
||||
"headquarters": "Arlington, Texas",
|
||||
"description": "The world's largest regional theme park company.",
|
||||
},
|
||||
{
|
||||
"name": "Cedar Fair Entertainment Company",
|
||||
"website": "https://www.cedarfair.com/",
|
||||
"headquarters": "Sandusky, Ohio",
|
||||
"description": "One of North America's largest operators of regional amusement parks.",
|
||||
},
|
||||
{
|
||||
"name": "Herschend Family Entertainment",
|
||||
"website": "https://www.hfecorp.com/",
|
||||
"headquarters": "Atlanta, Georgia",
|
||||
"description": "The largest family-owned themed attractions corporation in the United States.",
|
||||
},
|
||||
{
|
||||
"name": "SeaWorld Parks & Entertainment",
|
||||
"website": "https://www.seaworldentertainment.com/",
|
||||
"headquarters": "Orlando, Florida",
|
||||
"description": "Theme park and entertainment company focusing on nature-based themes.",
|
||||
},
|
||||
]
|
||||
|
||||
companies = {}
|
||||
for company_data in companies_data:
|
||||
operator, created = Operator.objects.get_or_create(
|
||||
name=company_data["name"], defaults=company_data
|
||||
)
|
||||
companies[operator.name] = operator
|
||||
self.stdout.write(
|
||||
f'{"Created" if created else "Found"} company: {operator.name}'
|
||||
)
|
||||
|
||||
# Create parks with their locations
|
||||
parks_data = [
|
||||
{
|
||||
"name": "Magic Kingdom",
|
||||
"company": "The Walt Disney Company",
|
||||
"description": "The first theme park at Walt Disney World Resort in Florida, opened in 1971.",
|
||||
"opening_date": "1971-10-01",
|
||||
"size_acres": 142,
|
||||
"location": {
|
||||
"street_address": "1180 Seven Seas Dr",
|
||||
"city": "Lake Buena Vista",
|
||||
"state": "Florida",
|
||||
"country": "United States",
|
||||
"postal_code": "32830",
|
||||
"latitude": 28.4177,
|
||||
"longitude": -81.5812,
|
||||
},
|
||||
"areas": [
|
||||
{
|
||||
"name": "Main Street, U.S.A.",
|
||||
"description": "Victorian-era themed entrance corridor",
|
||||
},
|
||||
{
|
||||
"name": "Adventureland",
|
||||
"description": "Exotic tropical places themed area",
|
||||
},
|
||||
{
|
||||
"name": "Frontierland",
|
||||
"description": "American Old West themed area",
|
||||
},
|
||||
{
|
||||
"name": "Liberty Square",
|
||||
"description": "Colonial America themed area",
|
||||
},
|
||||
{
|
||||
"name": "Fantasyland",
|
||||
"description": "Fairy tale themed area",
|
||||
},
|
||||
{
|
||||
"name": "Tomorrowland",
|
||||
"description": "Future themed area",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Universal Studios Florida",
|
||||
"company": "Universal Parks & Resorts",
|
||||
"description": "Movie and television-based theme park in Orlando, Florida.",
|
||||
"opening_date": "1990-06-07",
|
||||
"size_acres": 108,
|
||||
"location": {
|
||||
"street_address": "6000 Universal Blvd",
|
||||
"city": "Orlando",
|
||||
"state": "Florida",
|
||||
"country": "United States",
|
||||
"postal_code": "32819",
|
||||
"latitude": 28.4749,
|
||||
"longitude": -81.4687,
|
||||
},
|
||||
"areas": [
|
||||
{
|
||||
"name": "Production Central",
|
||||
"description": "Main entrance area with movie-themed attractions",
|
||||
},
|
||||
{
|
||||
"name": "New York",
|
||||
"description": "Themed after New York City streets",
|
||||
},
|
||||
{
|
||||
"name": "San Francisco",
|
||||
"description": "Themed after San Francisco's waterfront",
|
||||
},
|
||||
{
|
||||
"name": "The Wizarding World of Harry Potter - Diagon Alley",
|
||||
"description": "Themed after the Harry Potter series",
|
||||
},
|
||||
{
|
||||
"name": "Springfield",
|
||||
"description": "Themed after The Simpsons hometown",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Cedar Point",
|
||||
"company": "Cedar Fair Entertainment Company",
|
||||
"description": 'Known as the "Roller Coaster Capital of the World".',
|
||||
"opening_date": "1870-06-01",
|
||||
"size_acres": 364,
|
||||
"location": {
|
||||
"street_address": "1 Cedar Point Dr",
|
||||
"city": "Sandusky",
|
||||
"state": "Ohio",
|
||||
"country": "United States",
|
||||
"postal_code": "44870",
|
||||
"latitude": 41.4822,
|
||||
"longitude": -82.6835,
|
||||
},
|
||||
"areas": [
|
||||
{
|
||||
"name": "Frontiertown",
|
||||
"description": "Western-themed area with multiple roller coasters",
|
||||
},
|
||||
{
|
||||
"name": "Millennium Island",
|
||||
"description": "Home to the Millennium Force roller coaster",
|
||||
},
|
||||
{
|
||||
"name": "Cedar Point Shores",
|
||||
"description": "Waterpark area",
|
||||
},
|
||||
{
|
||||
"name": "Top Thrill Dragster",
|
||||
"description": "Area surrounding the iconic launched coaster",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Silver Dollar City",
|
||||
"company": "Herschend Family Entertainment",
|
||||
"description": "An 1880s-themed park featuring over 40 rides and attractions.",
|
||||
"opening_date": "1960-05-01",
|
||||
"size_acres": 61,
|
||||
"location": {
|
||||
"street_address": "399 Silver Dollar City Parkway",
|
||||
"city": "Branson",
|
||||
"state": "Missouri",
|
||||
"country": "United States",
|
||||
"postal_code": "65616",
|
||||
"latitude": 36.668497,
|
||||
"longitude": -93.339074,
|
||||
},
|
||||
"areas": [
|
||||
{
|
||||
"name": "Grand Exposition",
|
||||
"description": "Home to many family rides and attractions",
|
||||
},
|
||||
{
|
||||
"name": "Wildfire",
|
||||
"description": "Named after the famous B&M coaster",
|
||||
},
|
||||
{
|
||||
"name": "Wilson's Farm",
|
||||
"description": "Farm-themed attractions and dining",
|
||||
},
|
||||
{
|
||||
"name": "Riverfront",
|
||||
"description": "Water-themed attractions area",
|
||||
},
|
||||
{
|
||||
"name": "The Valley",
|
||||
"description": "Home to Time Traveler and other major attractions",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Six Flags Magic Mountain",
|
||||
"company": "Six Flags Entertainment Corporation",
|
||||
"description": "Known for its world-record 19 roller coasters.",
|
||||
"opening_date": "1971-05-29",
|
||||
"size_acres": 262,
|
||||
"location": {
|
||||
"street_address": "26101 Magic Mountain Pkwy",
|
||||
"city": "Valencia",
|
||||
"state": "California",
|
||||
"country": "United States",
|
||||
"postal_code": "91355",
|
||||
"latitude": 34.4253,
|
||||
"longitude": -118.5971,
|
||||
},
|
||||
"areas": [
|
||||
{
|
||||
"name": "Six Flags Plaza",
|
||||
"description": "Main entrance area",
|
||||
},
|
||||
{
|
||||
"name": "DC Universe",
|
||||
"description": "DC Comics themed area",
|
||||
},
|
||||
{
|
||||
"name": "Screampunk District",
|
||||
"description": "Steampunk themed area",
|
||||
},
|
||||
{
|
||||
"name": "The Underground",
|
||||
"description": "Urban themed area",
|
||||
},
|
||||
{
|
||||
"name": "Goliath Territory",
|
||||
"description": "Area surrounding the Goliath hypercoaster",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "SeaWorld Orlando",
|
||||
"company": "SeaWorld Parks & Entertainment",
|
||||
"description": "Marine zoological park combined with thrill rides and shows.",
|
||||
"opening_date": "1973-12-15",
|
||||
"size_acres": 200,
|
||||
"location": {
|
||||
"street_address": "7007 Sea World Dr",
|
||||
"city": "Orlando",
|
||||
"state": "Florida",
|
||||
"country": "United States",
|
||||
"postal_code": "32821",
|
||||
"latitude": 28.4115,
|
||||
"longitude": -81.4617,
|
||||
},
|
||||
"areas": [
|
||||
{
|
||||
"name": "Sea Harbor",
|
||||
"description": "Main entrance and shopping area",
|
||||
},
|
||||
{
|
||||
"name": "Shark Encounter",
|
||||
"description": "Shark exhibit and themed area",
|
||||
},
|
||||
{
|
||||
"name": "Antarctica: Empire of the Penguin",
|
||||
"description": "Penguin-themed area",
|
||||
},
|
||||
{
|
||||
"name": "Manta",
|
||||
"description": "Area themed around the Manta flying roller coaster",
|
||||
},
|
||||
{
|
||||
"name": "Sesame Street Land",
|
||||
"description": "Kid-friendly area based on Sesame Street",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# Create parks and their areas
|
||||
for park_data in parks_data:
|
||||
company = companies[park_data["company"]]
|
||||
park, created = Park.objects.get_or_create(
|
||||
name=park_data["name"],
|
||||
defaults={
|
||||
"description": park_data["description"],
|
||||
"status": "OPERATING",
|
||||
"opening_date": park_data["opening_date"],
|
||||
"size_acres": park_data["size_acres"],
|
||||
"owner": company,
|
||||
},
|
||||
)
|
||||
self.stdout.write(f'{"Created" if created else "Found"} park: {park.name}')
|
||||
|
||||
# Create location for park
|
||||
if created:
|
||||
loc_data = park_data["location"]
|
||||
park_location = ParkLocation.objects.create(
|
||||
park=park,
|
||||
street_address=loc_data["street_address"],
|
||||
city=loc_data["city"],
|
||||
state=loc_data["state"],
|
||||
country=loc_data["country"],
|
||||
postal_code=loc_data["postal_code"],
|
||||
)
|
||||
# Set coordinates using the helper method
|
||||
park_location.set_coordinates(
|
||||
loc_data["latitude"], loc_data["longitude"]
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
# Create areas for park
|
||||
for area_data in park_data["areas"]:
|
||||
area, created = ParkArea.objects.get_or_create(
|
||||
name=area_data["name"],
|
||||
park=park,
|
||||
defaults={"description": area_data["description"]},
|
||||
)
|
||||
self.stdout.write(
|
||||
f'{"Created" if created else "Found"} area: {area.name} in {park.name}'
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully seeded initial park data"))
|
||||
1173
backend/apps/parks/management/commands/seed_sample_data.py
Normal file
1173
backend/apps/parks/management/commands/seed_sample_data.py
Normal file
File diff suppressed because it is too large
Load Diff
124
backend/apps/parks/management/commands/test_location.py
Normal file
124
backend/apps/parks/management/commands/test_location.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from apps.parks.models import Park, ParkLocation, Company
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Test ParkLocation model functionality"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write("🧪 Testing ParkLocation Model Functionality")
|
||||
self.stdout.write("=" * 50)
|
||||
|
||||
# Create a test company (operator)
|
||||
operator, created = Company.objects.get_or_create(
|
||||
name="Test Theme Parks Inc",
|
||||
defaults={"slug": "test-theme-parks-inc", "roles": ["OPERATOR"]},
|
||||
)
|
||||
self.stdout.write(f"✅ Created operator: {operator.name}")
|
||||
|
||||
# Create a test park
|
||||
park, created = Park.objects.get_or_create(
|
||||
name="Test Magic Kingdom",
|
||||
defaults={
|
||||
"slug": "test-magic-kingdom",
|
||||
"description": "A test theme park for location testing",
|
||||
"operator": operator,
|
||||
},
|
||||
)
|
||||
self.stdout.write(f"✅ Created park: {park.name}")
|
||||
|
||||
# Create a park location
|
||||
location, created = ParkLocation.objects.get_or_create(
|
||||
park=park,
|
||||
defaults={
|
||||
"street_address": "1313 Disneyland Dr",
|
||||
"city": "Anaheim",
|
||||
"state": "California",
|
||||
"country": "USA",
|
||||
"postal_code": "92802",
|
||||
"highway_exit": "I-5 Exit 110B",
|
||||
"parking_notes": "Large parking structure available",
|
||||
"seasonal_notes": "Open year-round",
|
||||
},
|
||||
)
|
||||
self.stdout.write(f"✅ Created location: {location}")
|
||||
|
||||
# Test coordinate setting
|
||||
self.stdout.write("\n🔍 Testing coordinate functionality:")
|
||||
location.set_coordinates(33.8121, -117.9190) # Disneyland coordinates
|
||||
location.save()
|
||||
|
||||
self.stdout.write(f" Latitude: {location.latitude}")
|
||||
self.stdout.write(f" Longitude: {location.longitude}")
|
||||
self.stdout.write(f" Coordinates: {location.coordinates}")
|
||||
self.stdout.write(f" Formatted Address: {location.formatted_address}")
|
||||
|
||||
# Test Park model integration
|
||||
self.stdout.write("\n🔍 Testing Park model integration:")
|
||||
self.stdout.write(
|
||||
f" Park formatted location: {
|
||||
park.formatted_location}"
|
||||
)
|
||||
self.stdout.write(f" Park coordinates: {park.coordinates}")
|
||||
|
||||
# Create another location for distance testing
|
||||
operator2, created = Company.objects.get_or_create(
|
||||
name="Six Flags Entertainment",
|
||||
defaults={
|
||||
"slug": "six-flags-entertainment",
|
||||
"roles": ["OPERATOR"],
|
||||
},
|
||||
)
|
||||
|
||||
park2, created = Park.objects.get_or_create(
|
||||
name="Six Flags Magic Mountain",
|
||||
defaults={
|
||||
"slug": "six-flags-magic-mountain",
|
||||
"description": "Another test theme park",
|
||||
"operator": operator2,
|
||||
},
|
||||
)
|
||||
|
||||
location2, created = ParkLocation.objects.get_or_create(
|
||||
park=park2,
|
||||
defaults={
|
||||
"city": "Valencia",
|
||||
"state": "California",
|
||||
"country": "USA",
|
||||
},
|
||||
)
|
||||
location2.set_coordinates(
|
||||
34.4244, -118.5971
|
||||
) # Six Flags Magic Mountain coordinates
|
||||
location2.save()
|
||||
|
||||
# Test distance calculation
|
||||
self.stdout.write("\n🔍 Testing distance calculation:")
|
||||
distance = location.distance_to(location2)
|
||||
if distance:
|
||||
self.stdout.write(f" Distance between parks: {distance:.2f} km")
|
||||
else:
|
||||
self.stdout.write(" ❌ Distance calculation failed")
|
||||
|
||||
# Test spatial indexing
|
||||
self.stdout.write("\n🔍 Testing spatial queries:")
|
||||
try:
|
||||
from django.contrib.gis.measure import D
|
||||
from django.contrib.gis.geos import Point
|
||||
|
||||
# Find parks within 100km of a point
|
||||
# Same as Disneyland
|
||||
search_point = Point(-117.9190, 33.8121, srid=4326)
|
||||
nearby_locations = ParkLocation.objects.filter(
|
||||
point__distance_lte=(search_point, D(km=100))
|
||||
)
|
||||
self.stdout.write(
|
||||
f" Found {
|
||||
nearby_locations.count()} parks within 100km"
|
||||
)
|
||||
for loc in nearby_locations:
|
||||
self.stdout.write(f" - {loc.park.name} in {loc.city}, {loc.state}")
|
||||
except Exception as e:
|
||||
self.stdout.write(f" ⚠️ Spatial queries not fully functional: {e}")
|
||||
|
||||
self.stdout.write("\n✅ ParkLocation model tests completed successfully!")
|
||||
29
backend/apps/parks/management/commands/update_park_counts.py
Normal file
29
backend/apps/parks/management/commands/update_park_counts.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Q
|
||||
from apps.parks.models import Park
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Update total_rides and total_roller_coasters counts for all parks"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
parks = Park.objects.all()
|
||||
operating_rides = Q(status="OPERATING")
|
||||
updated = 0
|
||||
|
||||
for park in parks:
|
||||
# Count total operating rides
|
||||
total_rides = park.rides.filter(operating_rides).count()
|
||||
|
||||
# Count total operating roller coasters
|
||||
total_coasters = park.rides.filter(operating_rides, category="RC").count()
|
||||
|
||||
# Update park counts
|
||||
Park.objects.filter(id=park.id).update(
|
||||
total_rides=total_rides, total_roller_coasters=total_coasters
|
||||
)
|
||||
updated += 1
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Successfully updated counts for {updated} parks")
|
||||
)
|
||||
302
backend/apps/parks/managers.py
Normal file
302
backend/apps/parks/managers.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Custom managers and QuerySets for Parks models.
|
||||
Optimized queries following Django styleguide patterns.
|
||||
"""
|
||||
|
||||
from django.db.models import Q, Count, Avg, Max, Min, Prefetch
|
||||
|
||||
from apps.core.managers import (
|
||||
BaseQuerySet,
|
||||
BaseManager,
|
||||
LocationQuerySet,
|
||||
LocationManager,
|
||||
ReviewableQuerySet,
|
||||
ReviewableManager,
|
||||
StatusQuerySet,
|
||||
StatusManager,
|
||||
)
|
||||
|
||||
|
||||
class ParkQuerySet(StatusQuerySet, ReviewableQuerySet, LocationQuerySet):
|
||||
"""Optimized QuerySet for Park model."""
|
||||
|
||||
def with_complete_stats(self):
|
||||
"""Add comprehensive park statistics."""
|
||||
return self.annotate(
|
||||
ride_count_calculated=Count("rides", distinct=True),
|
||||
coaster_count_calculated=Count(
|
||||
"rides",
|
||||
filter=Q(rides__category__in=["RC", "WC"]),
|
||||
distinct=True,
|
||||
),
|
||||
area_count=Count("areas", distinct=True),
|
||||
review_count=Count(
|
||||
"reviews", filter=Q(reviews__is_published=True), distinct=True
|
||||
),
|
||||
average_rating_calculated=Avg(
|
||||
"reviews__rating", filter=Q(reviews__is_published=True)
|
||||
),
|
||||
latest_ride_opening=Max("rides__opening_date"),
|
||||
oldest_ride_opening=Min("rides__opening_date"),
|
||||
)
|
||||
|
||||
def optimized_for_list(self):
|
||||
"""Optimize for park list display."""
|
||||
return (
|
||||
self.select_related("operator", "property_owner")
|
||||
.prefetch_related("location")
|
||||
.with_complete_stats()
|
||||
)
|
||||
|
||||
def optimized_for_detail(self):
|
||||
"""Optimize for park detail display."""
|
||||
from apps.rides.models import Ride
|
||||
from .models import ParkReview
|
||||
|
||||
return self.select_related("operator", "property_owner").prefetch_related(
|
||||
"location",
|
||||
"areas",
|
||||
Prefetch(
|
||||
"rides",
|
||||
queryset=Ride.objects.select_related(
|
||||
"manufacturer", "designer", "ride_model", "park_area"
|
||||
).order_by("name"),
|
||||
),
|
||||
Prefetch(
|
||||
"reviews",
|
||||
queryset=ParkReview.objects.select_related("user")
|
||||
.filter(is_published=True)
|
||||
.order_by("-created_at")[:10],
|
||||
),
|
||||
"photos",
|
||||
)
|
||||
|
||||
def by_operator(self, *, operator_id: int):
|
||||
"""Filter parks by operator."""
|
||||
return self.filter(operator_id=operator_id)
|
||||
|
||||
def by_property_owner(self, *, owner_id: int):
|
||||
"""Filter parks by property owner."""
|
||||
return self.filter(property_owner_id=owner_id)
|
||||
|
||||
def with_minimum_coasters(self, *, min_coasters: int = 5):
|
||||
"""Filter parks with minimum number of coasters."""
|
||||
return self.with_complete_stats().filter(
|
||||
coaster_count_calculated__gte=min_coasters
|
||||
)
|
||||
|
||||
def large_parks(self, *, min_acres: float = 100.0):
|
||||
"""Filter for large parks."""
|
||||
return self.filter(size_acres__gte=min_acres)
|
||||
|
||||
def seasonal_parks(self):
|
||||
"""Filter for parks with seasonal operation."""
|
||||
return self.exclude(operating_season__exact="")
|
||||
|
||||
def for_map_display(self, *, bounds=None):
|
||||
"""Optimize for map display with minimal data."""
|
||||
queryset = self.select_related("operator").prefetch_related("location")
|
||||
|
||||
if bounds:
|
||||
queryset = queryset.within_bounds(
|
||||
north=bounds.north,
|
||||
south=bounds.south,
|
||||
east=bounds.east,
|
||||
west=bounds.west,
|
||||
)
|
||||
|
||||
return queryset.values(
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"status",
|
||||
"location__latitude",
|
||||
"location__longitude",
|
||||
"location__city",
|
||||
"location__state",
|
||||
"location__country",
|
||||
"operator__name",
|
||||
)
|
||||
|
||||
def search_autocomplete(self, *, query: str, limit: int = 10):
|
||||
"""Optimized search for autocomplete."""
|
||||
return (
|
||||
self.filter(
|
||||
Q(name__icontains=query)
|
||||
| Q(location__city__icontains=query)
|
||||
| Q(location__state__icontains=query)
|
||||
)
|
||||
.select_related("operator", "location")
|
||||
.values(
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"location__city",
|
||||
"location__state",
|
||||
"operator__name",
|
||||
)[:limit]
|
||||
)
|
||||
|
||||
|
||||
class ParkManager(StatusManager, ReviewableManager, LocationManager):
|
||||
"""Custom manager for Park model."""
|
||||
|
||||
def get_queryset(self):
|
||||
return ParkQuerySet(self.model, using=self._db)
|
||||
|
||||
def with_complete_stats(self):
|
||||
return self.get_queryset().with_complete_stats()
|
||||
|
||||
def optimized_for_list(self):
|
||||
return self.get_queryset().optimized_for_list()
|
||||
|
||||
def optimized_for_detail(self):
|
||||
return self.get_queryset().optimized_for_detail()
|
||||
|
||||
def by_operator(self, *, operator_id: int):
|
||||
return self.get_queryset().by_operator(operator_id=operator_id)
|
||||
|
||||
def large_parks(self, *, min_acres: float = 100.0):
|
||||
return self.get_queryset().large_parks(min_acres=min_acres)
|
||||
|
||||
def for_map_display(self, *, bounds=None):
|
||||
return self.get_queryset().for_map_display(bounds=bounds)
|
||||
|
||||
|
||||
class ParkAreaQuerySet(BaseQuerySet):
|
||||
"""QuerySet for ParkArea model."""
|
||||
|
||||
def with_ride_counts(self):
|
||||
"""Add ride count annotations."""
|
||||
return self.annotate(
|
||||
ride_count=Count("rides", distinct=True),
|
||||
coaster_count=Count(
|
||||
"rides",
|
||||
filter=Q(rides__category__in=["RC", "WC"]),
|
||||
distinct=True,
|
||||
),
|
||||
)
|
||||
|
||||
def optimized_for_list(self):
|
||||
"""Optimize for area list display."""
|
||||
return self.select_related("park").with_ride_counts()
|
||||
|
||||
def by_park(self, *, park_id: int):
|
||||
"""Filter areas by park."""
|
||||
return self.filter(park_id=park_id)
|
||||
|
||||
def with_rides(self):
|
||||
"""Filter areas that have rides."""
|
||||
return self.filter(rides__isnull=False).distinct()
|
||||
|
||||
|
||||
class ParkAreaManager(BaseManager):
|
||||
"""Manager for ParkArea model."""
|
||||
|
||||
def get_queryset(self):
|
||||
return ParkAreaQuerySet(self.model, using=self._db)
|
||||
|
||||
def with_ride_counts(self):
|
||||
return self.get_queryset().with_ride_counts()
|
||||
|
||||
def by_park(self, *, park_id: int):
|
||||
return self.get_queryset().by_park(park_id=park_id)
|
||||
|
||||
|
||||
class ParkReviewQuerySet(ReviewableQuerySet):
|
||||
"""QuerySet for ParkReview model."""
|
||||
|
||||
def for_park(self, *, park_id: int):
|
||||
"""Filter reviews for a specific park."""
|
||||
return self.filter(park_id=park_id)
|
||||
|
||||
def by_user(self, *, user_id: int):
|
||||
"""Filter reviews by user."""
|
||||
return self.filter(user_id=user_id)
|
||||
|
||||
def by_rating_range(self, *, min_rating: int = 1, max_rating: int = 10):
|
||||
"""Filter reviews by rating range."""
|
||||
return self.filter(rating__gte=min_rating, rating__lte=max_rating)
|
||||
|
||||
def optimized_for_display(self):
|
||||
"""Optimize for review display."""
|
||||
return self.select_related("user", "park", "moderated_by")
|
||||
|
||||
def recent_reviews(self, *, days: int = 30):
|
||||
"""Get recent reviews."""
|
||||
return self.recent(days=days)
|
||||
|
||||
def moderation_required(self):
|
||||
"""Filter reviews requiring moderation."""
|
||||
return self.filter(Q(is_published=False) | Q(moderated_at__isnull=True))
|
||||
|
||||
|
||||
class ParkReviewManager(BaseManager):
|
||||
"""Manager for ParkReview model."""
|
||||
|
||||
def get_queryset(self):
|
||||
return ParkReviewQuerySet(self.model, using=self._db)
|
||||
|
||||
def for_park(self, *, park_id: int):
|
||||
return self.get_queryset().for_park(park_id=park_id)
|
||||
|
||||
def by_rating_range(self, *, min_rating: int = 1, max_rating: int = 10):
|
||||
return self.get_queryset().by_rating_range(
|
||||
min_rating=min_rating, max_rating=max_rating
|
||||
)
|
||||
|
||||
def moderation_required(self):
|
||||
return self.get_queryset().moderation_required()
|
||||
|
||||
|
||||
class CompanyQuerySet(BaseQuerySet):
|
||||
"""QuerySet for Company model."""
|
||||
|
||||
def operators(self):
|
||||
"""Filter for companies that operate parks."""
|
||||
return self.filter(roles__contains=["OPERATOR"])
|
||||
|
||||
def property_owners(self):
|
||||
"""Filter for companies that own park properties."""
|
||||
return self.filter(roles__contains=["PROPERTY_OWNER"])
|
||||
|
||||
def manufacturers(self):
|
||||
"""Filter for ride manufacturers."""
|
||||
return self.filter(roles__contains=["MANUFACTURER"])
|
||||
|
||||
def with_park_counts(self):
|
||||
"""Add park count annotations."""
|
||||
return self.annotate(
|
||||
operated_parks_count=Count("operated_parks", distinct=True),
|
||||
owned_parks_count=Count("owned_parks", distinct=True),
|
||||
total_parks_involvement=Count("operated_parks", distinct=True)
|
||||
+ Count("owned_parks", distinct=True),
|
||||
)
|
||||
|
||||
def major_operators(self, *, min_parks: int = 5):
|
||||
"""Filter for major park operators."""
|
||||
return (
|
||||
self.operators()
|
||||
.with_park_counts()
|
||||
.filter(operated_parks_count__gte=min_parks)
|
||||
)
|
||||
|
||||
def optimized_for_list(self):
|
||||
"""Optimize for company list display."""
|
||||
return self.with_park_counts()
|
||||
|
||||
|
||||
class CompanyManager(BaseManager):
|
||||
"""Manager for Company model."""
|
||||
|
||||
def get_queryset(self):
|
||||
return CompanyQuerySet(self.model, using=self._db)
|
||||
|
||||
def operators(self):
|
||||
return self.get_queryset().operators()
|
||||
|
||||
def manufacturers(self):
|
||||
return self.get_queryset().manufacturers()
|
||||
|
||||
def major_operators(self, *, min_parks: int = 5):
|
||||
return self.get_queryset().major_operators(min_parks=min_parks)
|
||||
62
backend/apps/parks/migrations/0001_add_filter_indexes.py
Normal file
62
backend/apps/parks/migrations/0001_add_filter_indexes.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# Generated manually for enhanced filtering performance
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False # Required for CREATE INDEX CONCURRENTLY
|
||||
|
||||
dependencies = [
|
||||
("parks", "0001_initial"), # Adjust this to the latest migration
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Add indexes for commonly filtered fields
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_status_idx ON parks_park (status);",
|
||||
reverse_sql="DROP INDEX IF EXISTS parks_park_status_idx;",
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_operator_id_idx ON parks_park (operator_id);",
|
||||
reverse_sql="DROP INDEX IF EXISTS parks_park_operator_id_idx;",
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_average_rating_idx ON parks_park (average_rating);",
|
||||
reverse_sql="DROP INDEX IF EXISTS parks_park_average_rating_idx;",
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_size_acres_idx ON parks_park (size_acres);",
|
||||
reverse_sql="DROP INDEX IF EXISTS parks_park_size_acres_idx;",
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_coaster_count_idx ON parks_park (coaster_count);",
|
||||
reverse_sql="DROP INDEX IF EXISTS parks_park_coaster_count_idx;",
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_ride_count_idx ON parks_park (ride_count);",
|
||||
reverse_sql="DROP INDEX IF EXISTS parks_park_ride_count_idx;",
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_updated_at_idx ON parks_park (updated_at);",
|
||||
reverse_sql="DROP INDEX IF EXISTS parks_park_updated_at_idx;",
|
||||
),
|
||||
# Composite indexes for common filter combinations
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_status_rating_idx ON parks_park (status, average_rating);",
|
||||
reverse_sql="DROP INDEX IF EXISTS parks_park_status_rating_idx;",
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_operator_status_idx ON parks_park (operator_id, status);",
|
||||
reverse_sql="DROP INDEX IF EXISTS parks_park_operator_status_idx;",
|
||||
),
|
||||
# Index for parks with coasters (coaster_count > 0)
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_has_coasters_idx ON parks_park (coaster_count) WHERE coaster_count > 0;",
|
||||
reverse_sql="DROP INDEX IF EXISTS parks_park_has_coasters_idx;",
|
||||
),
|
||||
# Index for big parks (ride_count >= 10)
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_big_parks_idx ON parks_park (ride_count) WHERE ride_count >= 10;",
|
||||
reverse_sql="DROP INDEX IF EXISTS parks_park_big_parks_idx;",
|
||||
),
|
||||
]
|
||||
719
backend/apps/parks/migrations/0001_initial.py
Normal file
719
backend/apps/parks/migrations/0001_initial.py
Normal file
@@ -0,0 +1,719 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-15 22:01
|
||||
|
||||
import django.contrib.gis.db.models.fields
|
||||
import django.contrib.postgres.fields
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Company",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(max_length=255, unique=True)),
|
||||
(
|
||||
"roles",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("OPERATOR", "Park Operator"),
|
||||
("PROPERTY_OWNER", "Property Owner"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
("description", models.TextField(blank=True)),
|
||||
("website", models.URLField(blank=True)),
|
||||
(
|
||||
"founded_year",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
("parks_count", models.IntegerField(default=0)),
|
||||
("rides_count", models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
"verbose_name_plural": "Companies",
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Park",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(max_length=255, unique=True)),
|
||||
("description", models.TextField(blank=True)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
],
|
||||
default="OPERATING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("opening_date", models.DateField(blank=True, null=True)),
|
||||
("closing_date", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"operating_season",
|
||||
models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
(
|
||||
"size_acres",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=10, null=True
|
||||
),
|
||||
),
|
||||
("website", models.URLField(blank=True)),
|
||||
(
|
||||
"average_rating",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=3, null=True
|
||||
),
|
||||
),
|
||||
("ride_count", models.IntegerField(blank=True, null=True)),
|
||||
("coaster_count", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, null=True),
|
||||
),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"operator",
|
||||
models.ForeignKey(
|
||||
help_text="Company that operates this park",
|
||||
limit_choices_to={"roles__contains": ["OPERATOR"]},
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="operated_parks",
|
||||
to="parks.company",
|
||||
),
|
||||
),
|
||||
(
|
||||
"property_owner",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Company that owns the property (if different from operator)",
|
||||
limit_choices_to={"roles__contains": ["PROPERTY_OWNER"]},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="owned_parks",
|
||||
to="parks.company",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ParkArea",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(max_length=255)),
|
||||
("description", models.TextField(blank=True)),
|
||||
("opening_date", models.DateField(blank=True, null=True)),
|
||||
("closing_date", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"park",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="areas",
|
||||
to="parks.park",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ParkAreaEvent",
|
||||
fields=[
|
||||
(
|
||||
"pgh_id",
|
||||
models.AutoField(primary_key=True, serialize=False),
|
||||
),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(db_index=False, max_length=255)),
|
||||
("description", models.TextField(blank=True)),
|
||||
("opening_date", models.DateField(blank=True, null=True)),
|
||||
("closing_date", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"park",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="parks.park",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="parks.parkarea",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ParkEvent",
|
||||
fields=[
|
||||
(
|
||||
"pgh_id",
|
||||
models.AutoField(primary_key=True, serialize=False),
|
||||
),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(db_index=False, max_length=255)),
|
||||
("description", models.TextField(blank=True)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
],
|
||||
default="OPERATING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("opening_date", models.DateField(blank=True, null=True)),
|
||||
("closing_date", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"operating_season",
|
||||
models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
(
|
||||
"size_acres",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=10, null=True
|
||||
),
|
||||
),
|
||||
("website", models.URLField(blank=True)),
|
||||
(
|
||||
"average_rating",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=3, null=True
|
||||
),
|
||||
),
|
||||
("ride_count", models.IntegerField(blank=True, null=True)),
|
||||
("coaster_count", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, null=True),
|
||||
),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"operator",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
help_text="Company that operates this park",
|
||||
limit_choices_to={"roles__contains": ["OPERATOR"]},
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="parks.company",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="parks.park",
|
||||
),
|
||||
),
|
||||
(
|
||||
"property_owner",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="Company that owns the property (if different from operator)",
|
||||
limit_choices_to={"roles__contains": ["PROPERTY_OWNER"]},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="parks.company",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ParkLocation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"point",
|
||||
django.contrib.gis.db.models.fields.PointField(
|
||||
blank=True,
|
||||
help_text="Geographic coordinates (longitude, latitude)",
|
||||
null=True,
|
||||
srid=4326,
|
||||
),
|
||||
),
|
||||
(
|
||||
"street_address",
|
||||
models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
("city", models.CharField(db_index=True, max_length=100)),
|
||||
("state", models.CharField(db_index=True, max_length=100)),
|
||||
("country", models.CharField(default="USA", max_length=100)),
|
||||
("postal_code", models.CharField(blank=True, max_length=20)),
|
||||
("highway_exit", models.CharField(blank=True, max_length=100)),
|
||||
("parking_notes", models.TextField(blank=True)),
|
||||
("best_arrival_time", models.TimeField(blank=True, null=True)),
|
||||
("seasonal_notes", models.TextField(blank=True)),
|
||||
("osm_id", models.BigIntegerField(blank=True, null=True)),
|
||||
(
|
||||
"osm_type",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Type of OpenStreetMap object (node, way, or relation)",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"park",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="location",
|
||||
to="parks.park",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Park Location",
|
||||
"verbose_name_plural": "Park Locations",
|
||||
"ordering": ["park__name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ParkReview",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"rating",
|
||||
models.PositiveSmallIntegerField(
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(10),
|
||||
]
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("content", models.TextField()),
|
||||
("visit_date", models.DateField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("is_published", models.BooleanField(default=True)),
|
||||
("moderation_notes", models.TextField(blank=True)),
|
||||
("moderated_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"moderated_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="moderated_park_reviews",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"park",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="reviews",
|
||||
to="parks.park",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="park_reviews",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ParkReviewEvent",
|
||||
fields=[
|
||||
(
|
||||
"pgh_id",
|
||||
models.AutoField(primary_key=True, serialize=False),
|
||||
),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
(
|
||||
"rating",
|
||||
models.PositiveSmallIntegerField(
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(10),
|
||||
]
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("content", models.TextField()),
|
||||
("visit_date", models.DateField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("is_published", models.BooleanField(default=True)),
|
||||
("moderation_notes", models.TextField(blank=True)),
|
||||
("moderated_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"moderated_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"park",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="parks.park",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="parks.parkreview",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CompanyHeadquarters",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"street_address",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Mailing address if publicly available",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"city",
|
||||
models.CharField(
|
||||
db_index=True,
|
||||
help_text="Headquarters city",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"state_province",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="State/Province/Region",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"country",
|
||||
models.CharField(
|
||||
db_index=True,
|
||||
default="USA",
|
||||
help_text="Country where headquarters is located",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"postal_code",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="ZIP or postal code",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"mailing_address",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Complete mailing address if different from basic address",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"company",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="headquarters",
|
||||
to="parks.company",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Company Headquarters",
|
||||
"verbose_name_plural": "Company Headquarters",
|
||||
"ordering": ["company__name"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["city", "country"],
|
||||
name="parks_compa_city_cf9a4e_idx",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="park",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "parks_parkevent" ("average_rating", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_66883",
|
||||
table="parks_park",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="park",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "parks_parkevent" ("average_rating", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_19f56",
|
||||
table="parks_park",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="parkarea",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "parks_parkareaevent" ("closing_date", "created_at", "description", "id", "name", "opening_date", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at") VALUES (NEW."closing_date", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."updated_at"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_13457",
|
||||
table="parks_parkarea",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="parkarea",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "parks_parkareaevent" ("closing_date", "created_at", "description", "id", "name", "opening_date", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at") VALUES (NEW."closing_date", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."updated_at"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_6e5aa",
|
||||
table="parks_parkarea",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="parklocation",
|
||||
index=models.Index(
|
||||
fields=["city", "state"], name="parks_parkl_city_7cc873_idx"
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="parkreview",
|
||||
unique_together={("park", "user")},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="parkreview",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "parks_parkreviewevent" ("content", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_a99bc",
|
||||
table="parks_parkreview",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="parkreview",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "parks_parkreviewevent" ("content", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_0e40d",
|
||||
table="parks_parkreview",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-15 22:05
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="parkarea",
|
||||
unique_together={("park", "slug")},
|
||||
),
|
||||
]
|
||||
129
backend/apps/parks/migrations/0003_add_business_constraints.py
Normal file
129
backend/apps/parks/migrations/0003_add_business_constraints.py
Normal file
@@ -0,0 +1,129 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-16 17:42
|
||||
|
||||
import django.db.models.functions.datetime
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0002_alter_parkarea_unique_together"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name="park",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("closing_date__isnull", True),
|
||||
("opening_date__isnull", True),
|
||||
("closing_date__gte", models.F("opening_date")),
|
||||
_connector="OR",
|
||||
),
|
||||
name="park_closing_after_opening",
|
||||
violation_error_message="Closing date must be after opening date",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="park",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("size_acres__isnull", True),
|
||||
("size_acres__gt", 0),
|
||||
_connector="OR",
|
||||
),
|
||||
name="park_size_positive",
|
||||
violation_error_message="Park size must be positive",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="park",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("average_rating__isnull", True),
|
||||
models.Q(("average_rating__gte", 1), ("average_rating__lte", 10)),
|
||||
_connector="OR",
|
||||
),
|
||||
name="park_rating_range",
|
||||
violation_error_message="Average rating must be between 1 and 10",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="park",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("ride_count__isnull", True),
|
||||
("ride_count__gte", 0),
|
||||
_connector="OR",
|
||||
),
|
||||
name="park_ride_count_non_negative",
|
||||
violation_error_message="Ride count must be non-negative",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="park",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("coaster_count__isnull", True),
|
||||
("coaster_count__gte", 0),
|
||||
_connector="OR",
|
||||
),
|
||||
name="park_coaster_count_non_negative",
|
||||
violation_error_message="Coaster count must be non-negative",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="park",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("coaster_count__isnull", True),
|
||||
("ride_count__isnull", True),
|
||||
("coaster_count__lte", models.F("ride_count")),
|
||||
_connector="OR",
|
||||
),
|
||||
name="park_coaster_count_lte_ride_count",
|
||||
violation_error_message="Coaster count cannot exceed total ride count",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="parkreview",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(("rating__gte", 1), ("rating__lte", 10)),
|
||||
name="park_review_rating_range",
|
||||
violation_error_message="Rating must be between 1 and 10",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="parkreview",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
(
|
||||
"visit_date__lte",
|
||||
django.db.models.functions.datetime.Now(),
|
||||
)
|
||||
),
|
||||
name="park_review_visit_date_not_future",
|
||||
violation_error_message="Visit date cannot be in the future",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="parkreview",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
models.Q(
|
||||
("moderated_at__isnull", True),
|
||||
("moderated_by__isnull", True),
|
||||
),
|
||||
models.Q(
|
||||
("moderated_at__isnull", False),
|
||||
("moderated_by__isnull", False),
|
||||
),
|
||||
_connector="OR",
|
||||
),
|
||||
name="park_review_moderation_consistency",
|
||||
violation_error_message="Moderated reviews must have both moderator and moderation timestamp",
|
||||
),
|
||||
),
|
||||
]
|
||||
110
backend/apps/parks/migrations/0004_fix_pghistory_triggers.py
Normal file
110
backend/apps/parks/migrations/0004_fix_pghistory_triggers.py
Normal file
@@ -0,0 +1,110 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-16 17:46
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0003_add_business_constraints"),
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="CompanyEvent",
|
||||
fields=[
|
||||
(
|
||||
"pgh_id",
|
||||
models.AutoField(primary_key=True, serialize=False),
|
||||
),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(db_index=False, max_length=255)),
|
||||
(
|
||||
"roles",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("OPERATOR", "Park Operator"),
|
||||
("PROPERTY_OWNER", "Property Owner"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
("description", models.TextField(blank=True)),
|
||||
("website", models.URLField(blank=True)),
|
||||
(
|
||||
"founded_year",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
("parks_count", models.IntegerField(default=0)),
|
||||
("rides_count", models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="company",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "parks_companyevent" ("created_at", "description", "founded_year", "id", "name", "parks_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."founded_year", NEW."id", NEW."name", NEW."parks_count", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_35b57",
|
||||
table="parks_company",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="company",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "parks_companyevent" ("created_at", "description", "founded_year", "id", "name", "parks_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."founded_year", NEW."id", NEW."name", NEW."parks_count", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_d3286",
|
||||
table="parks_company",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="companyevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="companyevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="parks.company",
|
||||
),
|
||||
),
|
||||
]
|
||||
13
backend/apps/parks/migrations/0005_merge_20250820_2020.py
Normal file
13
backend/apps/parks/migrations/0005_merge_20250820_2020.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-21 00:20
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0001_add_filter_indexes"),
|
||||
("parks", "0004_fix_pghistory_triggers"),
|
||||
]
|
||||
|
||||
operations = []
|
||||
0
backend/apps/parks/migrations/__init__.py
Normal file
0
backend/apps/parks/migrations/__init__.py
Normal file
31
backend/apps/parks/models/__init__.py
Normal file
31
backend/apps/parks/models/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Parks app models with clean import interface.
|
||||
|
||||
This module provides a clean import interface for all parks-related models,
|
||||
enabling imports like: from parks.models import Park, Operator
|
||||
|
||||
The Company model is aliased as Operator to clarify its role as park operators,
|
||||
while maintaining backward compatibility through the Company alias.
|
||||
"""
|
||||
|
||||
from .parks import Park
|
||||
from .areas import ParkArea
|
||||
from .location import ParkLocation
|
||||
from .reviews import ParkReview
|
||||
from .companies import Company, CompanyHeadquarters
|
||||
|
||||
# Alias Company as Operator for clarity
|
||||
Operator = Company
|
||||
|
||||
__all__ = [
|
||||
# Primary models
|
||||
"Park",
|
||||
"ParkArea",
|
||||
"ParkLocation",
|
||||
"ParkReview",
|
||||
# Company models with clear naming
|
||||
"Operator",
|
||||
"CompanyHeadquarters",
|
||||
# Backward compatibility
|
||||
"Company", # Alias to Operator
|
||||
]
|
||||
33
backend/apps/parks/models/areas.py
Normal file
33
backend/apps/parks/models/areas.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
import pghistory
|
||||
|
||||
from apps.core.history import TrackedModel
|
||||
from .parks import Park
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class ParkArea(TrackedModel):
|
||||
|
||||
# Import managers
|
||||
from ..managers import ParkAreaManager
|
||||
|
||||
objects = ParkAreaManager()
|
||||
id: int # Type hint for Django's automatic id field
|
||||
park = models.ForeignKey(Park, on_delete=models.CASCADE, related_name="areas")
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
opening_date = models.DateField(null=True, blank=True)
|
||||
closing_date = models.DateField(null=True, blank=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
unique_together = ("park", "slug")
|
||||
136
backend/apps/parks/models/companies.py
Normal file
136
backend/apps/parks/models/companies.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from apps.core.models import TrackedModel
|
||||
import pghistory
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class Company(TrackedModel):
|
||||
|
||||
# Import managers
|
||||
from ..managers import CompanyManager
|
||||
|
||||
objects = CompanyManager()
|
||||
|
||||
class CompanyRole(models.TextChoices):
|
||||
OPERATOR = "OPERATOR", "Park Operator"
|
||||
PROPERTY_OWNER = "PROPERTY_OWNER", "Property Owner"
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
roles = ArrayField(
|
||||
models.CharField(max_length=20, choices=CompanyRole.choices),
|
||||
default=list,
|
||||
blank=True,
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
website = models.URLField(blank=True)
|
||||
|
||||
# Operator-specific fields
|
||||
founded_year = models.PositiveIntegerField(blank=True, null=True)
|
||||
parks_count = models.IntegerField(default=0)
|
||||
rides_count = models.IntegerField(default=0)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
app_label = "parks"
|
||||
ordering = ["name"]
|
||||
verbose_name_plural = "Companies"
|
||||
|
||||
|
||||
class CompanyHeadquarters(models.Model):
|
||||
"""
|
||||
Simple address storage for company headquarters without coordinate tracking.
|
||||
Focus on human-readable location information for display purposes.
|
||||
"""
|
||||
|
||||
# Relationships
|
||||
company = models.OneToOneField(
|
||||
"Company", on_delete=models.CASCADE, related_name="headquarters"
|
||||
)
|
||||
|
||||
# Address Fields (No coordinates needed)
|
||||
street_address = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="Mailing address if publicly available",
|
||||
)
|
||||
city = models.CharField(
|
||||
max_length=100, db_index=True, help_text="Headquarters city"
|
||||
)
|
||||
state_province = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="State/Province/Region",
|
||||
)
|
||||
country = models.CharField(
|
||||
max_length=100,
|
||||
default="USA",
|
||||
db_index=True,
|
||||
help_text="Country where headquarters is located",
|
||||
)
|
||||
postal_code = models.CharField(
|
||||
max_length=20, blank=True, help_text="ZIP or postal code"
|
||||
)
|
||||
|
||||
# Optional mailing address if different or more complete
|
||||
mailing_address = models.TextField(
|
||||
blank=True,
|
||||
help_text="Complete mailing address if different from basic address",
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def formatted_location(self):
|
||||
"""Returns a formatted address string for display."""
|
||||
components = []
|
||||
if self.street_address:
|
||||
components.append(self.street_address)
|
||||
if self.city:
|
||||
components.append(self.city)
|
||||
if self.state_province:
|
||||
components.append(self.state_province)
|
||||
if self.postal_code:
|
||||
components.append(self.postal_code)
|
||||
if self.country and self.country != "USA":
|
||||
components.append(self.country)
|
||||
return (
|
||||
", ".join(components)
|
||||
if components
|
||||
else f"{
|
||||
self.city}, {
|
||||
self.country}"
|
||||
)
|
||||
|
||||
@property
|
||||
def location_display(self):
|
||||
"""Simple city, state/country display for compact views."""
|
||||
parts = [self.city]
|
||||
if self.state_province:
|
||||
parts.append(self.state_province)
|
||||
elif self.country != "USA":
|
||||
parts.append(self.country)
|
||||
return ", ".join(parts) if parts else "Unknown Location"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.company.name} Headquarters - {self.location_display}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Company Headquarters"
|
||||
verbose_name_plural = "Company Headquarters"
|
||||
ordering = ["company__name"]
|
||||
indexes = [
|
||||
models.Index(fields=["city", "country"]),
|
||||
]
|
||||
112
backend/apps/parks/models/location.py
Normal file
112
backend/apps/parks/models/location.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from django.contrib.gis.db import models
|
||||
from django.contrib.gis.geos import Point
|
||||
|
||||
|
||||
class ParkLocation(models.Model):
|
||||
"""
|
||||
Represents the geographic location and address of a park, with PostGIS support.
|
||||
"""
|
||||
|
||||
park = models.OneToOneField(
|
||||
"parks.Park", on_delete=models.CASCADE, related_name="location"
|
||||
)
|
||||
|
||||
# Spatial Data
|
||||
point = models.PointField(
|
||||
srid=4326,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Geographic coordinates (longitude, latitude)",
|
||||
)
|
||||
|
||||
# Address Fields
|
||||
street_address = models.CharField(max_length=255, blank=True)
|
||||
city = models.CharField(max_length=100, db_index=True)
|
||||
state = models.CharField(max_length=100, db_index=True)
|
||||
country = models.CharField(max_length=100, default="USA")
|
||||
postal_code = models.CharField(max_length=20, blank=True)
|
||||
|
||||
# Road Trip Metadata
|
||||
highway_exit = models.CharField(max_length=100, blank=True)
|
||||
parking_notes = models.TextField(blank=True)
|
||||
best_arrival_time = models.TimeField(null=True, blank=True)
|
||||
seasonal_notes = models.TextField(blank=True)
|
||||
|
||||
# OSM Integration
|
||||
osm_id = models.BigIntegerField(null=True, blank=True)
|
||||
osm_type = models.CharField(
|
||||
max_length=10,
|
||||
blank=True,
|
||||
help_text="Type of OpenStreetMap object (node, way, or relation)",
|
||||
)
|
||||
|
||||
@property
|
||||
def latitude(self):
|
||||
"""Return latitude from point field."""
|
||||
if self.point:
|
||||
return self.point.y
|
||||
return None
|
||||
|
||||
@property
|
||||
def longitude(self):
|
||||
"""Return longitude from point field."""
|
||||
if self.point:
|
||||
return self.point.x
|
||||
return None
|
||||
|
||||
@property
|
||||
def coordinates(self):
|
||||
"""Return (latitude, longitude) tuple."""
|
||||
if self.point:
|
||||
return (self.latitude, self.longitude)
|
||||
return (None, None)
|
||||
|
||||
@property
|
||||
def formatted_address(self):
|
||||
"""Return a nicely formatted address string."""
|
||||
address_parts = [
|
||||
self.street_address,
|
||||
self.city,
|
||||
self.state,
|
||||
self.postal_code,
|
||||
self.country,
|
||||
]
|
||||
return ", ".join(part for part in address_parts if part)
|
||||
|
||||
def set_coordinates(self, latitude, longitude):
|
||||
"""
|
||||
Set the location's point from latitude and longitude coordinates.
|
||||
Validates coordinate ranges.
|
||||
"""
|
||||
if latitude is None or longitude is None:
|
||||
self.point = None
|
||||
return
|
||||
|
||||
if not -90 <= latitude <= 90:
|
||||
raise ValueError("Latitude must be between -90 and 90.")
|
||||
if not -180 <= longitude <= 180:
|
||||
raise ValueError("Longitude must be between -180 and 180.")
|
||||
|
||||
self.point = Point(longitude, latitude, srid=4326)
|
||||
|
||||
def distance_to(self, other_location):
|
||||
"""
|
||||
Calculate the distance to another ParkLocation instance.
|
||||
Returns distance in kilometers.
|
||||
"""
|
||||
if not self.point or not other_location.point:
|
||||
return None
|
||||
# Use geodetic distance calculation which returns meters, convert to km
|
||||
distance_m = self.point.distance(other_location.point)
|
||||
return distance_m / 1000.0
|
||||
|
||||
def __str__(self):
|
||||
return f"Location for {self.park.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Park Location"
|
||||
verbose_name_plural = "Park Locations"
|
||||
ordering = ["park__name"]
|
||||
indexes = [
|
||||
models.Index(fields=["city", "state"]),
|
||||
]
|
||||
267
backend/apps/parks/models/parks.py
Normal file
267
backend/apps/parks/models/parks.py
Normal file
@@ -0,0 +1,267 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from typing import Tuple, Optional, Any, TYPE_CHECKING
|
||||
import pghistory
|
||||
from apps.media.models import Photo
|
||||
from apps.core.history import TrackedModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from apps.rides.models import Ride
|
||||
from . import ParkArea
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class Park(TrackedModel):
|
||||
|
||||
# Import managers
|
||||
from ..managers import ParkManager
|
||||
|
||||
objects = ParkManager()
|
||||
id: int # Type hint for Django's automatic id field
|
||||
STATUS_CHOICES = [
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
status = models.CharField(
|
||||
max_length=20, choices=STATUS_CHOICES, default="OPERATING"
|
||||
)
|
||||
|
||||
# Location relationship - reverse relation from ParkLocation
|
||||
# location will be available via the 'location' related_name on
|
||||
# ParkLocation
|
||||
|
||||
# Details
|
||||
opening_date = models.DateField(null=True, blank=True)
|
||||
closing_date = models.DateField(null=True, blank=True)
|
||||
operating_season = models.CharField(max_length=255, blank=True)
|
||||
size_acres = models.DecimalField(
|
||||
max_digits=10, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
website = models.URLField(blank=True)
|
||||
|
||||
# Statistics
|
||||
average_rating = models.DecimalField(
|
||||
max_digits=3, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
ride_count = models.IntegerField(null=True, blank=True)
|
||||
coaster_count = models.IntegerField(null=True, blank=True)
|
||||
|
||||
# Relationships
|
||||
operator = models.ForeignKey(
|
||||
"Company",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="operated_parks",
|
||||
help_text="Company that operates this park",
|
||||
limit_choices_to={"roles__contains": ["OPERATOR"]},
|
||||
)
|
||||
property_owner = models.ForeignKey(
|
||||
"Company",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="owned_parks",
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Company that owns the property (if different from operator)",
|
||||
limit_choices_to={"roles__contains": ["PROPERTY_OWNER"]},
|
||||
)
|
||||
photos = GenericRelation(Photo, related_query_name="park")
|
||||
areas: models.Manager["ParkArea"] # Type hint for reverse relation
|
||||
# Type hint for reverse relation from rides app
|
||||
rides: models.Manager["Ride"]
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
constraints = [
|
||||
# Business rule: Closing date must be after opening date
|
||||
models.CheckConstraint(
|
||||
name="park_closing_after_opening",
|
||||
check=models.Q(closing_date__isnull=True)
|
||||
| models.Q(opening_date__isnull=True)
|
||||
| models.Q(closing_date__gte=models.F("opening_date")),
|
||||
violation_error_message="Closing date must be after opening date",
|
||||
),
|
||||
# Business rule: Size must be positive
|
||||
models.CheckConstraint(
|
||||
name="park_size_positive",
|
||||
check=models.Q(size_acres__isnull=True) | models.Q(size_acres__gt=0),
|
||||
violation_error_message="Park size must be positive",
|
||||
),
|
||||
# Business rule: Rating must be between 1 and 10
|
||||
models.CheckConstraint(
|
||||
name="park_rating_range",
|
||||
check=models.Q(average_rating__isnull=True)
|
||||
| (models.Q(average_rating__gte=1) & models.Q(average_rating__lte=10)),
|
||||
violation_error_message="Average rating must be between 1 and 10",
|
||||
),
|
||||
# Business rule: Counts must be non-negative
|
||||
models.CheckConstraint(
|
||||
name="park_ride_count_non_negative",
|
||||
check=models.Q(ride_count__isnull=True) | models.Q(ride_count__gte=0),
|
||||
violation_error_message="Ride count must be non-negative",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
name="park_coaster_count_non_negative",
|
||||
check=models.Q(coaster_count__isnull=True)
|
||||
| models.Q(coaster_count__gte=0),
|
||||
violation_error_message="Coaster count must be non-negative",
|
||||
),
|
||||
# Business rule: Coaster count cannot exceed ride count
|
||||
models.CheckConstraint(
|
||||
name="park_coaster_count_lte_ride_count",
|
||||
check=models.Q(coaster_count__isnull=True)
|
||||
| models.Q(ride_count__isnull=True)
|
||||
| models.Q(coaster_count__lte=models.F("ride_count")),
|
||||
violation_error_message="Coaster count cannot exceed total ride count",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from apps.core.history import HistoricalSlug
|
||||
|
||||
# Get old instance if it exists
|
||||
if self.pk:
|
||||
try:
|
||||
old_instance = type(self).objects.get(pk=self.pk)
|
||||
old_name = old_instance.name
|
||||
old_slug = old_instance.slug
|
||||
except type(self).DoesNotExist:
|
||||
old_name = None
|
||||
old_slug = None
|
||||
else:
|
||||
old_name = None
|
||||
old_slug = None
|
||||
|
||||
# Generate new slug if name has changed or slug is missing
|
||||
if not self.slug or (old_name and old_name != self.name):
|
||||
self.slug = slugify(self.name)
|
||||
|
||||
# Save the model
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# If slug has changed, save historical record
|
||||
if old_slug and old_slug != self.slug:
|
||||
HistoricalSlug.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(self),
|
||||
object_id=self.pk,
|
||||
slug=old_slug,
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.operator and "OPERATOR" not in self.operator.roles:
|
||||
raise ValidationError({"operator": "Company must have the OPERATOR role."})
|
||||
if self.property_owner and "PROPERTY_OWNER" not in self.property_owner.roles:
|
||||
raise ValidationError(
|
||||
{"property_owner": "Company must have the PROPERTY_OWNER role."}
|
||||
)
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("parks:park_detail", kwargs={"slug": self.slug})
|
||||
|
||||
def get_status_color(self) -> str:
|
||||
"""Get Tailwind color classes for park status"""
|
||||
status_colors = {
|
||||
"OPERATING": "bg-green-100 text-green-800",
|
||||
"CLOSED_TEMP": "bg-yellow-100 text-yellow-800",
|
||||
"CLOSED_PERM": "bg-red-100 text-red-800",
|
||||
"UNDER_CONSTRUCTION": "bg-blue-100 text-blue-800",
|
||||
"DEMOLISHED": "bg-gray-100 text-gray-800",
|
||||
"RELOCATED": "bg-purple-100 text-purple-800",
|
||||
}
|
||||
return status_colors.get(self.status, "bg-gray-100 text-gray-500")
|
||||
|
||||
@property
|
||||
def formatted_location(self) -> str:
|
||||
"""Get formatted address from ParkLocation if it exists"""
|
||||
if hasattr(self, "location") and self.location:
|
||||
return self.location.formatted_address
|
||||
return ""
|
||||
|
||||
@property
|
||||
def coordinates(self) -> Optional[Tuple[float, float]]:
|
||||
"""Returns coordinates as a tuple (latitude, longitude)"""
|
||||
if hasattr(self, "location") and self.location:
|
||||
return self.location.coordinates
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_by_slug(cls, slug: str) -> Tuple["Park", bool]:
|
||||
"""Get park by current or historical slug"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from apps.core.history import HistoricalSlug
|
||||
|
||||
print(f"\nLooking up slug: {slug}")
|
||||
|
||||
try:
|
||||
park = cls.objects.get(slug=slug)
|
||||
print(f"Found current park with slug: {slug}")
|
||||
return park, False
|
||||
except cls.DoesNotExist:
|
||||
print(f"No current park found with slug: {slug}")
|
||||
|
||||
# Try historical slugs in HistoricalSlug model
|
||||
content_type = ContentType.objects.get_for_model(cls)
|
||||
print(f"Searching HistoricalSlug with content_type: {content_type}")
|
||||
historical = (
|
||||
HistoricalSlug.objects.filter(content_type=content_type, slug=slug)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if historical:
|
||||
print(
|
||||
f"Found historical slug record for object_id: {
|
||||
historical.object_id}"
|
||||
)
|
||||
try:
|
||||
park = cls.objects.get(pk=historical.object_id)
|
||||
print(f"Found park from historical slug: {park.name}")
|
||||
return park, True
|
||||
except cls.DoesNotExist:
|
||||
print("Park not found for historical slug record")
|
||||
else:
|
||||
print("No historical slug record found")
|
||||
|
||||
# Try pghistory events
|
||||
print("Searching pghistory events")
|
||||
event_model = getattr(cls, "event_model", None)
|
||||
if event_model:
|
||||
historical_event = (
|
||||
event_model.objects.filter(slug=slug)
|
||||
.order_by("-pgh_created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if historical_event:
|
||||
print(
|
||||
f"Found pghistory event for pgh_obj_id: {
|
||||
historical_event.pgh_obj_id}"
|
||||
)
|
||||
try:
|
||||
park = cls.objects.get(pk=historical_event.pgh_obj_id)
|
||||
print(f"Found park from pghistory: {park.name}")
|
||||
return park, True
|
||||
except cls.DoesNotExist:
|
||||
print("Park not found for pghistory event")
|
||||
else:
|
||||
print("No pghistory event found")
|
||||
|
||||
raise cls.DoesNotExist("No park found with this slug")
|
||||
77
backend/apps/parks/models/reviews.py
Normal file
77
backend/apps/parks/models/reviews.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from django.db import models
|
||||
from django.db.models import functions
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from apps.core.history import TrackedModel
|
||||
import pghistory
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class ParkReview(TrackedModel):
|
||||
|
||||
# Import managers
|
||||
from ..managers import ParkReviewManager
|
||||
|
||||
objects = ParkReviewManager()
|
||||
"""
|
||||
A review of a park.
|
||||
"""
|
||||
park = models.ForeignKey(
|
||||
"parks.Park", on_delete=models.CASCADE, related_name="reviews"
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
"accounts.User", on_delete=models.CASCADE, related_name="park_reviews"
|
||||
)
|
||||
rating = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1), MaxValueValidator(10)]
|
||||
)
|
||||
title = models.CharField(max_length=200)
|
||||
content = models.TextField()
|
||||
visit_date = models.DateField()
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Moderation
|
||||
is_published = models.BooleanField(default=True)
|
||||
moderation_notes = models.TextField(blank=True)
|
||||
moderated_by = models.ForeignKey(
|
||||
"accounts.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="moderated_park_reviews",
|
||||
)
|
||||
moderated_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
unique_together = ["park", "user"]
|
||||
constraints = [
|
||||
# Business rule: Rating must be between 1 and 10 (database level
|
||||
# enforcement)
|
||||
models.CheckConstraint(
|
||||
name="park_review_rating_range",
|
||||
check=models.Q(rating__gte=1) & models.Q(rating__lte=10),
|
||||
violation_error_message="Rating must be between 1 and 10",
|
||||
),
|
||||
# Business rule: Visit date cannot be in the future
|
||||
models.CheckConstraint(
|
||||
name="park_review_visit_date_not_future",
|
||||
check=models.Q(visit_date__lte=functions.Now()),
|
||||
violation_error_message="Visit date cannot be in the future",
|
||||
),
|
||||
# Business rule: If moderated, must have moderator and timestamp
|
||||
models.CheckConstraint(
|
||||
name="park_review_moderation_consistency",
|
||||
check=models.Q(moderated_by__isnull=True, moderated_at__isnull=True)
|
||||
| models.Q(moderated_by__isnull=False, moderated_at__isnull=False),
|
||||
violation_error_message=(
|
||||
"Moderated reviews must have both moderator and moderation "
|
||||
"timestamp"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Review of {self.park.name} by {self.user.username}"
|
||||
17
backend/apps/parks/querysets.py
Normal file
17
backend/apps/parks/querysets.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.db.models import QuerySet, Count, Q
|
||||
from .models import Park
|
||||
|
||||
|
||||
def get_base_park_queryset() -> QuerySet[Park]:
|
||||
"""Get base queryset with all needed annotations and prefetches"""
|
||||
return (
|
||||
Park.objects.select_related("operator", "property_owner", "location")
|
||||
.prefetch_related("photos", "rides")
|
||||
.annotate(
|
||||
current_ride_count=Count("rides", distinct=True),
|
||||
current_coaster_count=Count(
|
||||
"rides", filter=Q(rides__category="RC"), distinct=True
|
||||
),
|
||||
)
|
||||
.order_by("name")
|
||||
)
|
||||
248
backend/apps/parks/selectors.py
Normal file
248
backend/apps/parks/selectors.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
Selectors for park-related data retrieval.
|
||||
Following Django styleguide pattern for separating data access from business logic.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from django.db.models import QuerySet, Q, Count, Avg, Prefetch
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import Distance
|
||||
|
||||
from .models import Park, ParkArea, ParkReview
|
||||
from apps.rides.models import Ride
|
||||
|
||||
|
||||
def park_list_with_stats(*, filters: Optional[Dict[str, Any]] = None) -> QuerySet[Park]:
|
||||
"""
|
||||
Get parks optimized for list display with basic stats.
|
||||
|
||||
Args:
|
||||
filters: Optional dictionary of filter parameters
|
||||
|
||||
Returns:
|
||||
QuerySet of parks with optimized queries
|
||||
"""
|
||||
queryset = (
|
||||
Park.objects.select_related("operator", "property_owner")
|
||||
.prefetch_related("location")
|
||||
.annotate(
|
||||
ride_count_calculated=Count("rides", distinct=True),
|
||||
coaster_count_calculated=Count(
|
||||
"rides",
|
||||
filter=Q(rides__category__in=["RC", "WC"]),
|
||||
distinct=True,
|
||||
),
|
||||
average_rating_calculated=Avg("reviews__rating"),
|
||||
)
|
||||
)
|
||||
|
||||
if filters:
|
||||
if "status" in filters:
|
||||
queryset = queryset.filter(status=filters["status"])
|
||||
if "operator" in filters:
|
||||
queryset = queryset.filter(operator=filters["operator"])
|
||||
if "country" in filters:
|
||||
queryset = queryset.filter(location__country=filters["country"])
|
||||
if "search" in filters:
|
||||
search_term = filters["search"]
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=search_term) | Q(description__icontains=search_term)
|
||||
)
|
||||
|
||||
return queryset.order_by("name")
|
||||
|
||||
|
||||
def park_detail_optimized(*, slug: str) -> Park:
|
||||
"""
|
||||
Get a single park with all related data optimized for detail view.
|
||||
|
||||
Args:
|
||||
slug: Park slug identifier
|
||||
|
||||
Returns:
|
||||
Park instance with optimized prefetches
|
||||
|
||||
Raises:
|
||||
Park.DoesNotExist: If park with slug doesn't exist
|
||||
"""
|
||||
return (
|
||||
Park.objects.select_related("operator", "property_owner")
|
||||
.prefetch_related(
|
||||
"location",
|
||||
"areas",
|
||||
Prefetch(
|
||||
"rides",
|
||||
queryset=Ride.objects.select_related(
|
||||
"manufacturer", "designer", "ride_model"
|
||||
),
|
||||
),
|
||||
Prefetch(
|
||||
"reviews",
|
||||
queryset=ParkReview.objects.select_related("user").filter(
|
||||
is_published=True
|
||||
),
|
||||
),
|
||||
"photos",
|
||||
)
|
||||
.get(slug=slug)
|
||||
)
|
||||
|
||||
|
||||
def parks_near_location(
|
||||
*, point: Point, distance_km: float = 50, limit: int = 10
|
||||
) -> QuerySet[Park]:
|
||||
"""
|
||||
Get parks near a specific geographic location.
|
||||
|
||||
Args:
|
||||
point: Geographic point (longitude, latitude)
|
||||
distance_km: Maximum distance in kilometers
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
QuerySet of nearby parks ordered by distance
|
||||
"""
|
||||
return (
|
||||
Park.objects.filter(
|
||||
location__coordinates__distance_lte=(
|
||||
point,
|
||||
Distance(km=distance_km),
|
||||
)
|
||||
)
|
||||
.select_related("operator")
|
||||
.prefetch_related("location")
|
||||
.distance(point)
|
||||
.order_by("distance")[:limit]
|
||||
)
|
||||
|
||||
|
||||
def park_statistics() -> Dict[str, Any]:
|
||||
"""
|
||||
Get overall park statistics for dashboard/analytics.
|
||||
|
||||
Returns:
|
||||
Dictionary containing park statistics
|
||||
"""
|
||||
total_parks = Park.objects.count()
|
||||
operating_parks = Park.objects.filter(status="OPERATING").count()
|
||||
total_rides = Ride.objects.count()
|
||||
total_coasters = Ride.objects.filter(category__in=["RC", "WC"]).count()
|
||||
|
||||
return {
|
||||
"total_parks": total_parks,
|
||||
"operating_parks": operating_parks,
|
||||
"closed_parks": total_parks - operating_parks,
|
||||
"total_rides": total_rides,
|
||||
"total_coasters": total_coasters,
|
||||
"average_rides_per_park": (total_rides / total_parks if total_parks > 0 else 0),
|
||||
}
|
||||
|
||||
|
||||
def parks_by_operator(*, operator_id: int) -> QuerySet[Park]:
|
||||
"""
|
||||
Get all parks operated by a specific company.
|
||||
|
||||
Args:
|
||||
operator_id: Company ID of the operator
|
||||
|
||||
Returns:
|
||||
QuerySet of parks operated by the company
|
||||
"""
|
||||
return (
|
||||
Park.objects.filter(operator_id=operator_id)
|
||||
.select_related("operator")
|
||||
.prefetch_related("location")
|
||||
.annotate(ride_count_calculated=Count("rides"))
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
|
||||
def parks_with_recent_reviews(*, days: int = 30) -> QuerySet[Park]:
|
||||
"""
|
||||
Get parks that have received reviews in the last N days.
|
||||
|
||||
Args:
|
||||
days: Number of days to look back for reviews
|
||||
|
||||
Returns:
|
||||
QuerySet of parks with recent reviews
|
||||
"""
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
return (
|
||||
Park.objects.filter(
|
||||
reviews__created_at__gte=cutoff_date, reviews__is_published=True
|
||||
)
|
||||
.select_related("operator")
|
||||
.prefetch_related("location")
|
||||
.annotate(
|
||||
recent_review_count=Count(
|
||||
"reviews", filter=Q(reviews__created_at__gte=cutoff_date)
|
||||
)
|
||||
)
|
||||
.order_by("-recent_review_count")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
def park_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet[Park]:
|
||||
"""
|
||||
Get parks matching a search query for autocomplete functionality.
|
||||
|
||||
Args:
|
||||
query: Search string
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
QuerySet of matching parks for autocomplete
|
||||
"""
|
||||
return (
|
||||
Park.objects.filter(
|
||||
Q(name__icontains=query)
|
||||
| Q(location__city__icontains=query)
|
||||
| Q(location__region__icontains=query)
|
||||
)
|
||||
.select_related("operator")
|
||||
.prefetch_related("location")
|
||||
.order_by("name")[:limit]
|
||||
)
|
||||
|
||||
|
||||
def park_areas_for_park(*, park_slug: str) -> QuerySet[ParkArea]:
|
||||
"""
|
||||
Get all areas for a specific park.
|
||||
|
||||
Args:
|
||||
park_slug: Slug of the park
|
||||
|
||||
Returns:
|
||||
QuerySet of park areas with related data
|
||||
"""
|
||||
return (
|
||||
ParkArea.objects.filter(park__slug=park_slug)
|
||||
.select_related("park")
|
||||
.prefetch_related("rides")
|
||||
.annotate(ride_count=Count("rides"))
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
|
||||
def park_reviews_for_park(*, park_id: int, limit: int = 20) -> QuerySet[ParkReview]:
|
||||
"""
|
||||
Get reviews for a specific park.
|
||||
|
||||
Args:
|
||||
park_id: Park ID
|
||||
limit: Maximum number of reviews to return
|
||||
|
||||
Returns:
|
||||
QuerySet of park reviews
|
||||
"""
|
||||
return (
|
||||
ParkReview.objects.filter(park_id=park_id, is_published=True)
|
||||
.select_related("user", "park")
|
||||
.order_by("-created_at")[:limit]
|
||||
)
|
||||
323
backend/apps/parks/services.py
Normal file
323
backend/apps/parks/services.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
Services for park-related business logic.
|
||||
Following Django styleguide pattern for business logic encapsulation.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
|
||||
from .models import Park, ParkArea
|
||||
from apps.location.models import Location
|
||||
|
||||
# Use AbstractBaseUser for type hinting
|
||||
UserType = AbstractBaseUser
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ParkService:
|
||||
"""Service for managing park operations."""
|
||||
|
||||
@staticmethod
|
||||
def create_park(
|
||||
*,
|
||||
name: str,
|
||||
description: str = "",
|
||||
status: str = "OPERATING",
|
||||
operator_id: Optional[int] = None,
|
||||
property_owner_id: Optional[int] = None,
|
||||
opening_date: Optional[str] = None,
|
||||
closing_date: Optional[str] = None,
|
||||
operating_season: str = "",
|
||||
size_acres: Optional[float] = None,
|
||||
website: str = "",
|
||||
location_data: Optional[Dict[str, Any]] = None,
|
||||
created_by: Optional[UserType] = None,
|
||||
) -> Park:
|
||||
"""
|
||||
Create a new park with validation and location handling.
|
||||
|
||||
Args:
|
||||
name: Park name
|
||||
description: Park description
|
||||
status: Operating status
|
||||
operator_id: ID of operating company
|
||||
property_owner_id: ID of property owner company
|
||||
opening_date: Opening date
|
||||
closing_date: Closing date
|
||||
operating_season: Operating season description
|
||||
size_acres: Park size in acres
|
||||
website: Park website URL
|
||||
location_data: Dictionary containing location information
|
||||
created_by: User creating the park
|
||||
|
||||
Returns:
|
||||
Created Park instance
|
||||
|
||||
Raises:
|
||||
ValidationError: If park data is invalid
|
||||
"""
|
||||
with transaction.atomic():
|
||||
# Create park instance
|
||||
park = Park(
|
||||
name=name,
|
||||
description=description,
|
||||
status=status,
|
||||
opening_date=opening_date,
|
||||
closing_date=closing_date,
|
||||
operating_season=operating_season,
|
||||
size_acres=size_acres,
|
||||
website=website,
|
||||
)
|
||||
|
||||
# Set foreign key relationships if provided
|
||||
if operator_id:
|
||||
from .models import Company
|
||||
|
||||
park.operator = Company.objects.get(id=operator_id)
|
||||
|
||||
if property_owner_id:
|
||||
from .models import Company
|
||||
|
||||
park.property_owner = Company.objects.get(id=property_owner_id)
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
park.full_clean()
|
||||
park.save()
|
||||
|
||||
# Handle location if provided
|
||||
if location_data:
|
||||
LocationService.create_park_location(park=park, **location_data)
|
||||
|
||||
return park
|
||||
|
||||
@staticmethod
|
||||
def update_park(
|
||||
*,
|
||||
park_id: int,
|
||||
updates: Dict[str, Any],
|
||||
updated_by: Optional[UserType] = None,
|
||||
) -> Park:
|
||||
"""
|
||||
Update an existing park with validation.
|
||||
|
||||
Args:
|
||||
park_id: ID of park to update
|
||||
updates: Dictionary of field updates
|
||||
updated_by: User performing the update
|
||||
|
||||
Returns:
|
||||
Updated Park instance
|
||||
|
||||
Raises:
|
||||
Park.DoesNotExist: If park doesn't exist
|
||||
ValidationError: If update data is invalid
|
||||
"""
|
||||
with transaction.atomic():
|
||||
park = Park.objects.select_for_update().get(id=park_id)
|
||||
|
||||
# Apply updates
|
||||
for field, value in updates.items():
|
||||
if hasattr(park, field):
|
||||
setattr(park, field, value)
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
park.full_clean()
|
||||
park.save()
|
||||
|
||||
return park
|
||||
|
||||
@staticmethod
|
||||
def delete_park(*, park_id: int, deleted_by: Optional[UserType] = None) -> bool:
|
||||
"""
|
||||
Soft delete a park by setting status to DEMOLISHED.
|
||||
|
||||
Args:
|
||||
park_id: ID of park to delete
|
||||
deleted_by: User performing the deletion
|
||||
|
||||
Returns:
|
||||
True if successfully deleted
|
||||
|
||||
Raises:
|
||||
Park.DoesNotExist: If park doesn't exist
|
||||
"""
|
||||
with transaction.atomic():
|
||||
park = Park.objects.select_for_update().get(id=park_id)
|
||||
park.status = "DEMOLISHED"
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
park.full_clean()
|
||||
park.save()
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def create_park_area(
|
||||
*,
|
||||
park_id: int,
|
||||
name: str,
|
||||
description: str = "",
|
||||
created_by: Optional[UserType] = None,
|
||||
) -> ParkArea:
|
||||
"""
|
||||
Create a new area within a park.
|
||||
|
||||
Args:
|
||||
park_id: ID of the parent park
|
||||
name: Area name
|
||||
description: Area description
|
||||
created_by: User creating the area
|
||||
|
||||
Returns:
|
||||
Created ParkArea instance
|
||||
|
||||
Raises:
|
||||
Park.DoesNotExist: If park doesn't exist
|
||||
ValidationError: If area data is invalid
|
||||
"""
|
||||
park = Park.objects.get(id=park_id)
|
||||
|
||||
area = ParkArea(park=park, name=name, description=description)
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
area.full_clean()
|
||||
area.save()
|
||||
|
||||
return area
|
||||
|
||||
@staticmethod
|
||||
def update_park_statistics(*, park_id: int) -> Park:
|
||||
"""
|
||||
Recalculate and update park statistics (ride counts, ratings).
|
||||
|
||||
Args:
|
||||
park_id: ID of park to update statistics for
|
||||
|
||||
Returns:
|
||||
Updated Park instance with fresh statistics
|
||||
"""
|
||||
from apps.rides.models import Ride
|
||||
from .models import ParkReview
|
||||
from django.db.models import Count, Avg
|
||||
|
||||
with transaction.atomic():
|
||||
park = Park.objects.select_for_update().get(id=park_id)
|
||||
|
||||
# Calculate ride counts
|
||||
ride_stats = Ride.objects.filter(park=park).aggregate(
|
||||
total_rides=Count("id"),
|
||||
coaster_count=Count("id", filter=Q(category__in=["RC", "WC"])),
|
||||
)
|
||||
|
||||
# Calculate average rating
|
||||
avg_rating = ParkReview.objects.filter(
|
||||
park=park, is_published=True
|
||||
).aggregate(avg_rating=Avg("rating"))["avg_rating"]
|
||||
|
||||
# Update park fields
|
||||
park.ride_count = ride_stats["total_rides"] or 0
|
||||
park.coaster_count = ride_stats["coaster_count"] or 0
|
||||
park.average_rating = avg_rating
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
park.full_clean()
|
||||
park.save()
|
||||
|
||||
return park
|
||||
|
||||
|
||||
class LocationService:
|
||||
"""Service for managing location operations."""
|
||||
|
||||
@staticmethod
|
||||
def create_park_location(
|
||||
*,
|
||||
park: Park,
|
||||
latitude: Optional[float] = None,
|
||||
longitude: Optional[float] = None,
|
||||
street_address: str = "",
|
||||
city: str = "",
|
||||
state: str = "",
|
||||
country: str = "",
|
||||
postal_code: str = "",
|
||||
) -> Location:
|
||||
"""
|
||||
Create a location for a park.
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
latitude: Latitude coordinate
|
||||
longitude: Longitude coordinate
|
||||
street_address: Street address
|
||||
city: City name
|
||||
state: State/region name
|
||||
country: Country name
|
||||
postal_code: Postal/ZIP code
|
||||
|
||||
Returns:
|
||||
Created Location instance
|
||||
|
||||
Raises:
|
||||
ValidationError: If location data is invalid
|
||||
"""
|
||||
location = Location(
|
||||
content_object=park,
|
||||
name=park.name,
|
||||
location_type="park",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
street_address=street_address,
|
||||
city=city,
|
||||
state=state,
|
||||
country=country,
|
||||
postal_code=postal_code,
|
||||
)
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
location.full_clean()
|
||||
location.save()
|
||||
|
||||
return location
|
||||
|
||||
@staticmethod
|
||||
def update_park_location(
|
||||
*, park_id: int, location_updates: Dict[str, Any]
|
||||
) -> Location:
|
||||
"""
|
||||
Update location information for a park.
|
||||
|
||||
Args:
|
||||
park_id: ID of the park
|
||||
location_updates: Dictionary of location field updates
|
||||
|
||||
Returns:
|
||||
Updated Location instance
|
||||
|
||||
Raises:
|
||||
Location.DoesNotExist: If location doesn't exist
|
||||
ValidationError: If location data is invalid
|
||||
"""
|
||||
with transaction.atomic():
|
||||
park = Park.objects.get(id=park_id)
|
||||
|
||||
try:
|
||||
location = park.location
|
||||
except Location.DoesNotExist:
|
||||
# Create location if it doesn't exist
|
||||
return LocationService.create_park_location(
|
||||
park=park, **location_updates
|
||||
)
|
||||
|
||||
# Apply updates
|
||||
for field, value in location_updates.items():
|
||||
if hasattr(location, field):
|
||||
setattr(location, field, value)
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
location.full_clean()
|
||||
location.save()
|
||||
|
||||
return location
|
||||
5
backend/apps/parks/services/__init__.py
Normal file
5
backend/apps/parks/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .roadtrip import RoadTripService
|
||||
from .park_management import ParkService, LocationService
|
||||
from .filter_service import ParkFilterService
|
||||
|
||||
__all__ = ["RoadTripService", "ParkService", "LocationService", "ParkFilterService"]
|
||||
304
backend/apps/parks/services/filter_service.py
Normal file
304
backend/apps/parks/services/filter_service.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Park Filter Service
|
||||
|
||||
Provides filtering functionality, aggregations, and caching for park filters.
|
||||
This service handles complex filter logic and provides useful filter statistics.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
from django.db.models import QuerySet, Count, Q
|
||||
from django.core.cache import cache
|
||||
from django.conf import settings
|
||||
from ..models import Park, Company
|
||||
from ..querysets import get_base_park_queryset
|
||||
|
||||
|
||||
class ParkFilterService:
|
||||
"""
|
||||
Service class for handling park filtering operations, aggregations,
|
||||
and providing filter suggestions based on available data.
|
||||
"""
|
||||
|
||||
CACHE_TIMEOUT = getattr(settings, "PARK_FILTER_CACHE_TIMEOUT", 300) # 5 minutes
|
||||
|
||||
def __init__(self):
|
||||
self.cache_prefix = "park_filter"
|
||||
|
||||
def get_filter_counts(
|
||||
self, base_queryset: Optional[QuerySet] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get counts for various filter options to show users what's available.
|
||||
|
||||
Args:
|
||||
base_queryset: Optional base queryset to use for calculations
|
||||
|
||||
Returns:
|
||||
Dictionary containing counts for different filter categories
|
||||
"""
|
||||
cache_key = f"{self.cache_prefix}:filter_counts"
|
||||
cached_result = cache.get(cache_key)
|
||||
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
if base_queryset is None:
|
||||
base_queryset = get_base_park_queryset()
|
||||
|
||||
# Calculate filter counts
|
||||
filter_counts = {
|
||||
"total_parks": base_queryset.count(),
|
||||
"operating_parks": base_queryset.filter(status="OPERATING").count(),
|
||||
"parks_with_coasters": base_queryset.filter(coaster_count__gt=0).count(),
|
||||
"big_parks": base_queryset.filter(ride_count__gte=10).count(),
|
||||
"highly_rated": base_queryset.filter(average_rating__gte=4.0).count(),
|
||||
"park_types": self._get_park_type_counts(base_queryset),
|
||||
"top_operators": self._get_top_operators(base_queryset),
|
||||
"countries": self._get_country_counts(base_queryset),
|
||||
}
|
||||
|
||||
# Cache the result
|
||||
cache.set(cache_key, filter_counts, self.CACHE_TIMEOUT)
|
||||
return filter_counts
|
||||
|
||||
def _get_park_type_counts(self, queryset: QuerySet) -> Dict[str, int]:
|
||||
"""Get counts for different park types based on operator names."""
|
||||
return {
|
||||
"disney": queryset.filter(operator__name__icontains="Disney").count(),
|
||||
"universal": queryset.filter(operator__name__icontains="Universal").count(),
|
||||
"six_flags": queryset.filter(operator__name__icontains="Six Flags").count(),
|
||||
"cedar_fair": queryset.filter(
|
||||
Q(operator__name__icontains="Cedar Fair")
|
||||
| Q(operator__name__icontains="Cedar Point")
|
||||
| Q(operator__name__icontains="Kings Island")
|
||||
).count(),
|
||||
}
|
||||
|
||||
def _get_top_operators(
|
||||
self, queryset: QuerySet, limit: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get the top operators by number of parks."""
|
||||
return list(
|
||||
queryset.values("operator__name", "operator__id")
|
||||
.annotate(park_count=Count("id"))
|
||||
.filter(park_count__gt=0)
|
||||
.order_by("-park_count")[:limit]
|
||||
)
|
||||
|
||||
def _get_country_counts(
|
||||
self, queryset: QuerySet, limit: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get countries with the most parks."""
|
||||
return list(
|
||||
queryset.filter(location__country__isnull=False)
|
||||
.values("location__country")
|
||||
.annotate(park_count=Count("id"))
|
||||
.filter(park_count__gt=0)
|
||||
.order_by("-park_count")[:limit]
|
||||
)
|
||||
|
||||
def get_filter_suggestions(self, query: str) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Get filter suggestions based on a search query.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
|
||||
Returns:
|
||||
Dictionary with suggestion categories
|
||||
"""
|
||||
cache_key = f"{self.cache_prefix}:suggestions:{query.lower()}"
|
||||
cached_result = cache.get(cache_key)
|
||||
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
suggestions = {
|
||||
"parks": [],
|
||||
"operators": [],
|
||||
"locations": [],
|
||||
}
|
||||
|
||||
if len(query) >= 2: # Only search for queries of 2+ characters
|
||||
# Park name suggestions
|
||||
park_names = Park.objects.filter(name__icontains=query).values_list(
|
||||
"name", flat=True
|
||||
)[:5]
|
||||
suggestions["parks"] = list(park_names)
|
||||
|
||||
# Operator suggestions
|
||||
operator_names = Company.objects.filter(
|
||||
roles__contains=["OPERATOR"], name__icontains=query
|
||||
).values_list("name", flat=True)[:5]
|
||||
suggestions["operators"] = list(operator_names)
|
||||
|
||||
# Location suggestions (cities and countries)
|
||||
locations = Park.objects.filter(
|
||||
Q(location__city__icontains=query)
|
||||
| Q(location__country__icontains=query)
|
||||
).values_list("location__city", "location__country")[:5]
|
||||
|
||||
location_suggestions = []
|
||||
for city, country in locations:
|
||||
if city and city.lower().startswith(query.lower()):
|
||||
location_suggestions.append(city)
|
||||
elif country and country.lower().startswith(query.lower()):
|
||||
location_suggestions.append(country)
|
||||
|
||||
suggestions["locations"] = list(set(location_suggestions))[:5]
|
||||
|
||||
# Cache suggestions for a shorter time
|
||||
cache.set(cache_key, suggestions, 60) # 1 minute cache
|
||||
return suggestions
|
||||
|
||||
def get_popular_filters(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get commonly used filter combinations and popular filter values.
|
||||
|
||||
Returns:
|
||||
Dictionary containing popular filter configurations
|
||||
"""
|
||||
cache_key = f"{self.cache_prefix}:popular_filters"
|
||||
cached_result = cache.get(cache_key)
|
||||
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
base_qs = get_base_park_queryset()
|
||||
|
||||
popular_filters = {
|
||||
"quick_filters": [
|
||||
{
|
||||
"label": "Disney Parks",
|
||||
"filters": {"park_type": "disney"},
|
||||
"count": base_qs.filter(operator__name__icontains="Disney").count(),
|
||||
},
|
||||
{
|
||||
"label": "Parks with Coasters",
|
||||
"filters": {"has_coasters": True},
|
||||
"count": base_qs.filter(coaster_count__gt=0).count(),
|
||||
},
|
||||
{
|
||||
"label": "Highly Rated",
|
||||
"filters": {"min_rating": "4"},
|
||||
"count": base_qs.filter(average_rating__gte=4.0).count(),
|
||||
},
|
||||
{
|
||||
"label": "Major Parks",
|
||||
"filters": {"big_parks_only": True},
|
||||
"count": base_qs.filter(ride_count__gte=10).count(),
|
||||
},
|
||||
],
|
||||
"recommended_sorts": [
|
||||
{"value": "-average_rating", "label": "Highest Rated"},
|
||||
{"value": "-coaster_count", "label": "Most Coasters"},
|
||||
{"value": "name", "label": "A-Z"},
|
||||
],
|
||||
}
|
||||
|
||||
# Cache for longer since these don't change often
|
||||
cache.set(cache_key, popular_filters, self.CACHE_TIMEOUT * 2)
|
||||
return popular_filters
|
||||
|
||||
def clear_filter_cache(self) -> None:
|
||||
"""Clear all cached filter data."""
|
||||
# Simple cache clearing - delete known keys
|
||||
cache_keys = [
|
||||
f"{self.cache_prefix}:filter_counts",
|
||||
f"{self.cache_prefix}:popular_filters",
|
||||
]
|
||||
for key in cache_keys:
|
||||
cache.delete(key)
|
||||
|
||||
def get_filtered_queryset(self, filters: Dict[str, Any]) -> QuerySet: # noqa: C901
|
||||
"""
|
||||
Apply filters to get a filtered queryset with optimizations.
|
||||
|
||||
Args:
|
||||
filters: Dictionary of filter parameters
|
||||
|
||||
Returns:
|
||||
Filtered and optimized QuerySet
|
||||
"""
|
||||
queryset = (
|
||||
get_base_park_queryset()
|
||||
.select_related("operator", "property_owner", "location")
|
||||
.prefetch_related("photos", "rides__manufacturer")
|
||||
)
|
||||
|
||||
# Apply status filter
|
||||
if filters.get("status"):
|
||||
queryset = queryset.filter(status=filters["status"])
|
||||
|
||||
# Apply park type filter
|
||||
if filters.get("park_type"):
|
||||
queryset = self._apply_park_type_filter(queryset, filters["park_type"])
|
||||
|
||||
# Apply coaster filter
|
||||
if filters.get("has_coasters"):
|
||||
queryset = queryset.filter(coaster_count__gt=0)
|
||||
|
||||
# Apply rating filter
|
||||
if filters.get("min_rating"):
|
||||
try:
|
||||
min_rating = float(filters["min_rating"])
|
||||
queryset = queryset.filter(average_rating__gte=min_rating)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Apply big parks filter
|
||||
if filters.get("big_parks_only"):
|
||||
queryset = queryset.filter(ride_count__gte=10)
|
||||
|
||||
# Apply search
|
||||
if filters.get("search"):
|
||||
search_query = filters["search"]
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=search_query)
|
||||
| Q(description__icontains=search_query)
|
||||
| Q(location__city__icontains=search_query)
|
||||
| Q(location__country__icontains=search_query)
|
||||
)
|
||||
|
||||
# Apply location filters
|
||||
if filters.get("country_filter"):
|
||||
queryset = queryset.filter(
|
||||
location__country__icontains=filters["country_filter"]
|
||||
)
|
||||
|
||||
if filters.get("state_filter"):
|
||||
queryset = queryset.filter(
|
||||
location__state__icontains=filters["state_filter"]
|
||||
)
|
||||
|
||||
# Apply ordering
|
||||
if filters.get("ordering"):
|
||||
queryset = queryset.order_by(filters["ordering"])
|
||||
|
||||
return queryset.distinct()
|
||||
|
||||
def _apply_park_type_filter(self, queryset: QuerySet, park_type: str) -> QuerySet:
|
||||
"""Apply park type filter logic."""
|
||||
type_filters = {
|
||||
"disney": Q(operator__name__icontains="Disney"),
|
||||
"universal": Q(operator__name__icontains="Universal"),
|
||||
"six_flags": Q(operator__name__icontains="Six Flags"),
|
||||
"cedar_fair": (
|
||||
Q(operator__name__icontains="Cedar Fair")
|
||||
| Q(operator__name__icontains="Cedar Point")
|
||||
| Q(operator__name__icontains="Kings Island")
|
||||
| Q(operator__name__icontains="Canada's Wonderland")
|
||||
),
|
||||
"independent": ~(
|
||||
Q(operator__name__icontains="Disney")
|
||||
| Q(operator__name__icontains="Universal")
|
||||
| Q(operator__name__icontains="Six Flags")
|
||||
| Q(operator__name__icontains="Cedar Fair")
|
||||
| Q(operator__name__icontains="Cedar Point")
|
||||
),
|
||||
}
|
||||
|
||||
if park_type in type_filters:
|
||||
return queryset.filter(type_filters[park_type])
|
||||
|
||||
return queryset
|
||||
322
backend/apps/parks/services/park_management.py
Normal file
322
backend/apps/parks/services/park_management.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Services for park-related business logic.
|
||||
Following Django styleguide pattern for business logic encapsulation.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any, TYPE_CHECKING
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
|
||||
from ..models import Park, ParkArea
|
||||
from apps.location.models import Location
|
||||
|
||||
|
||||
class ParkService:
|
||||
"""Service for managing park operations."""
|
||||
|
||||
@staticmethod
|
||||
def create_park(
|
||||
*,
|
||||
name: str,
|
||||
description: str = "",
|
||||
status: str = "OPERATING",
|
||||
operator_id: Optional[int] = None,
|
||||
property_owner_id: Optional[int] = None,
|
||||
opening_date: Optional[str] = None,
|
||||
closing_date: Optional[str] = None,
|
||||
operating_season: str = "",
|
||||
size_acres: Optional[float] = None,
|
||||
website: str = "",
|
||||
location_data: Optional[Dict[str, Any]] = None,
|
||||
created_by: Optional["AbstractUser"] = None,
|
||||
) -> Park:
|
||||
"""
|
||||
Create a new park with validation and location handling.
|
||||
|
||||
Args:
|
||||
name: Park name
|
||||
description: Park description
|
||||
status: Operating status
|
||||
operator_id: ID of operating company
|
||||
property_owner_id: ID of property owner company
|
||||
opening_date: Opening date
|
||||
closing_date: Closing date
|
||||
operating_season: Operating season description
|
||||
size_acres: Park size in acres
|
||||
website: Park website URL
|
||||
location_data: Dictionary containing location information
|
||||
created_by: User creating the park
|
||||
|
||||
Returns:
|
||||
Created Park instance
|
||||
|
||||
Raises:
|
||||
ValidationError: If park data is invalid
|
||||
"""
|
||||
with transaction.atomic():
|
||||
# Create park instance
|
||||
park = Park(
|
||||
name=name,
|
||||
description=description,
|
||||
status=status,
|
||||
opening_date=opening_date,
|
||||
closing_date=closing_date,
|
||||
operating_season=operating_season,
|
||||
size_acres=size_acres,
|
||||
website=website,
|
||||
)
|
||||
|
||||
# Set foreign key relationships if provided
|
||||
if operator_id:
|
||||
from apps.parks.models import Company
|
||||
|
||||
park.operator = Company.objects.get(id=operator_id)
|
||||
|
||||
if property_owner_id:
|
||||
from apps.parks.models import Company
|
||||
|
||||
park.property_owner = Company.objects.get(id=property_owner_id)
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
park.full_clean()
|
||||
park.save()
|
||||
|
||||
# Handle location if provided
|
||||
if location_data:
|
||||
LocationService.create_park_location(park=park, **location_data)
|
||||
|
||||
return park
|
||||
|
||||
@staticmethod
|
||||
def update_park(
|
||||
*,
|
||||
park_id: int,
|
||||
updates: Dict[str, Any],
|
||||
updated_by: Optional["AbstractUser"] = None,
|
||||
) -> Park:
|
||||
"""
|
||||
Update an existing park with validation.
|
||||
|
||||
Args:
|
||||
park_id: ID of park to update
|
||||
updates: Dictionary of field updates
|
||||
updated_by: User performing the update
|
||||
|
||||
Returns:
|
||||
Updated Park instance
|
||||
|
||||
Raises:
|
||||
Park.DoesNotExist: If park doesn't exist
|
||||
ValidationError: If update data is invalid
|
||||
"""
|
||||
with transaction.atomic():
|
||||
park = Park.objects.select_for_update().get(id=park_id)
|
||||
|
||||
# Apply updates
|
||||
for field, value in updates.items():
|
||||
if hasattr(park, field):
|
||||
setattr(park, field, value)
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
park.full_clean()
|
||||
park.save()
|
||||
|
||||
return park
|
||||
|
||||
@staticmethod
|
||||
def delete_park(
|
||||
*, park_id: int, deleted_by: Optional["AbstractUser"] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Soft delete a park by setting status to DEMOLISHED.
|
||||
|
||||
Args:
|
||||
park_id: ID of park to delete
|
||||
deleted_by: User performing the deletion
|
||||
|
||||
Returns:
|
||||
True if successfully deleted
|
||||
|
||||
Raises:
|
||||
Park.DoesNotExist: If park doesn't exist
|
||||
"""
|
||||
with transaction.atomic():
|
||||
park = Park.objects.select_for_update().get(id=park_id)
|
||||
park.status = "DEMOLISHED"
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
park.full_clean()
|
||||
park.save()
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def create_park_area(
|
||||
*,
|
||||
park_id: int,
|
||||
name: str,
|
||||
description: str = "",
|
||||
created_by: Optional["AbstractUser"] = None,
|
||||
) -> ParkArea:
|
||||
"""
|
||||
Create a new area within a park.
|
||||
|
||||
Args:
|
||||
park_id: ID of the parent park
|
||||
name: Area name
|
||||
description: Area description
|
||||
created_by: User creating the area
|
||||
|
||||
Returns:
|
||||
Created ParkArea instance
|
||||
|
||||
Raises:
|
||||
Park.DoesNotExist: If park doesn't exist
|
||||
ValidationError: If area data is invalid
|
||||
"""
|
||||
park = Park.objects.get(id=park_id)
|
||||
|
||||
area = ParkArea(park=park, name=name, description=description)
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
area.full_clean()
|
||||
area.save()
|
||||
|
||||
return area
|
||||
|
||||
@staticmethod
|
||||
def update_park_statistics(*, park_id: int) -> Park:
|
||||
"""
|
||||
Recalculate and update park statistics (ride counts, ratings).
|
||||
|
||||
Args:
|
||||
park_id: ID of park to update statistics for
|
||||
|
||||
Returns:
|
||||
Updated Park instance with fresh statistics
|
||||
"""
|
||||
from apps.rides.models import Ride
|
||||
from apps.parks.models import ParkReview
|
||||
from django.db.models import Count, Avg
|
||||
|
||||
with transaction.atomic():
|
||||
park = Park.objects.select_for_update().get(id=park_id)
|
||||
|
||||
# Calculate ride counts
|
||||
ride_stats = Ride.objects.filter(park=park).aggregate(
|
||||
total_rides=Count("id"),
|
||||
coaster_count=Count("id", filter=Q(category__in=["RC", "WC"])),
|
||||
)
|
||||
|
||||
# Calculate average rating
|
||||
avg_rating = ParkReview.objects.filter(
|
||||
park=park, is_published=True
|
||||
).aggregate(avg_rating=Avg("rating"))["avg_rating"]
|
||||
|
||||
# Update park fields
|
||||
park.ride_count = ride_stats["total_rides"] or 0
|
||||
park.coaster_count = ride_stats["coaster_count"] or 0
|
||||
park.average_rating = avg_rating
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
park.full_clean()
|
||||
park.save()
|
||||
|
||||
return park
|
||||
|
||||
|
||||
class LocationService:
|
||||
"""Service for managing location operations."""
|
||||
|
||||
@staticmethod
|
||||
def create_park_location(
|
||||
*,
|
||||
park: Park,
|
||||
latitude: Optional[float] = None,
|
||||
longitude: Optional[float] = None,
|
||||
street_address: str = "",
|
||||
city: str = "",
|
||||
state: str = "",
|
||||
country: str = "",
|
||||
postal_code: str = "",
|
||||
) -> Location:
|
||||
"""
|
||||
Create a location for a park.
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
latitude: Latitude coordinate
|
||||
longitude: Longitude coordinate
|
||||
street_address: Street address
|
||||
city: City name
|
||||
state: State/region name
|
||||
country: Country name
|
||||
postal_code: Postal/ZIP code
|
||||
|
||||
Returns:
|
||||
Created Location instance
|
||||
|
||||
Raises:
|
||||
ValidationError: If location data is invalid
|
||||
"""
|
||||
location = Location(
|
||||
content_object=park,
|
||||
name=park.name,
|
||||
location_type="park",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
street_address=street_address,
|
||||
city=city,
|
||||
state=state,
|
||||
country=country,
|
||||
postal_code=postal_code,
|
||||
)
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
location.full_clean()
|
||||
location.save()
|
||||
|
||||
return location
|
||||
|
||||
@staticmethod
|
||||
def update_park_location(
|
||||
*, park_id: int, location_updates: Dict[str, Any]
|
||||
) -> Location:
|
||||
"""
|
||||
Update location information for a park.
|
||||
|
||||
Args:
|
||||
park_id: ID of the park
|
||||
location_updates: Dictionary of location field updates
|
||||
|
||||
Returns:
|
||||
Updated Location instance
|
||||
|
||||
Raises:
|
||||
Location.DoesNotExist: If location doesn't exist
|
||||
ValidationError: If location data is invalid
|
||||
"""
|
||||
with transaction.atomic():
|
||||
park = Park.objects.get(id=park_id)
|
||||
|
||||
try:
|
||||
location = park.location
|
||||
except Location.DoesNotExist:
|
||||
# Create location if it doesn't exist
|
||||
return LocationService.create_park_location(
|
||||
park=park, **location_updates
|
||||
)
|
||||
|
||||
# Apply updates
|
||||
for field, value in location_updates.items():
|
||||
if hasattr(location, field):
|
||||
setattr(location, field, value)
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
location.full_clean()
|
||||
location.save()
|
||||
|
||||
return location
|
||||
706
backend/apps/parks/services/roadtrip.py
Normal file
706
backend/apps/parks/services/roadtrip.py
Normal file
@@ -0,0 +1,706 @@
|
||||
"""
|
||||
Road Trip Service for theme park planning using OpenStreetMap APIs.
|
||||
|
||||
This service provides functionality for:
|
||||
- Geocoding addresses using Nominatim
|
||||
- Route calculation using OSRM
|
||||
- Park discovery along routes
|
||||
- Multi-park trip planning
|
||||
- Proper rate limiting and caching
|
||||
"""
|
||||
|
||||
import time
|
||||
import math
|
||||
import logging
|
||||
import requests
|
||||
from typing import Dict, List, Tuple, Optional, Any
|
||||
from dataclasses import dataclass
|
||||
from itertools import permutations
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import Distance
|
||||
from apps.parks.models import Park
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Coordinates:
|
||||
"""Represents latitude and longitude coordinates."""
|
||||
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
def to_tuple(self) -> Tuple[float, float]:
|
||||
"""Return as (lat, lon) tuple."""
|
||||
return (self.latitude, self.longitude)
|
||||
|
||||
def to_point(self) -> Point:
|
||||
"""Convert to Django Point object."""
|
||||
return Point(self.longitude, self.latitude, srid=4326)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteInfo:
|
||||
"""Information about a calculated route."""
|
||||
|
||||
distance_km: float
|
||||
duration_minutes: int
|
||||
geometry: Optional[str] = None # Encoded polyline
|
||||
|
||||
@property
|
||||
def formatted_distance(self) -> str:
|
||||
"""Return formatted distance string."""
|
||||
if self.distance_km < 1:
|
||||
return f"{self.distance_km * 1000:.0f}m"
|
||||
return f"{self.distance_km:.1f}km"
|
||||
|
||||
@property
|
||||
def formatted_duration(self) -> str:
|
||||
"""Return formatted duration string."""
|
||||
hours = self.duration_minutes // 60
|
||||
minutes = self.duration_minutes % 60
|
||||
if hours == 0:
|
||||
return f"{minutes}min"
|
||||
elif minutes == 0:
|
||||
return f"{hours}h"
|
||||
else:
|
||||
return f"{hours}h {minutes}min"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TripLeg:
|
||||
"""Represents one leg of a multi-park trip."""
|
||||
|
||||
from_park: "Park"
|
||||
to_park: "Park"
|
||||
route: RouteInfo
|
||||
|
||||
@property
|
||||
def parks_along_route(self) -> List["Park"]:
|
||||
"""Get parks along this route segment."""
|
||||
# This would be populated by find_parks_along_route
|
||||
return []
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoadTrip:
|
||||
"""Complete road trip with multiple parks."""
|
||||
|
||||
parks: List["Park"]
|
||||
legs: List[TripLeg]
|
||||
total_distance_km: float
|
||||
total_duration_minutes: int
|
||||
|
||||
@property
|
||||
def formatted_total_distance(self) -> str:
|
||||
"""Return formatted total distance."""
|
||||
return f"{self.total_distance_km:.1f}km"
|
||||
|
||||
@property
|
||||
def formatted_total_duration(self) -> str:
|
||||
"""Return formatted total duration."""
|
||||
hours = self.total_duration_minutes // 60
|
||||
minutes = self.total_duration_minutes % 60
|
||||
if hours == 0:
|
||||
return f"{minutes}min"
|
||||
elif minutes == 0:
|
||||
return f"{hours}h"
|
||||
else:
|
||||
return f"{hours}h {minutes}min"
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Simple rate limiter for API requests."""
|
||||
|
||||
def __init__(self, max_requests_per_second: float = 1.0):
|
||||
self.max_requests_per_second = max_requests_per_second
|
||||
self.min_interval = 1.0 / max_requests_per_second
|
||||
self.last_request_time = 0.0
|
||||
|
||||
def wait_if_needed(self):
|
||||
"""Wait if necessary to respect rate limits."""
|
||||
current_time = time.time()
|
||||
time_since_last = current_time - self.last_request_time
|
||||
|
||||
if time_since_last < self.min_interval:
|
||||
wait_time = self.min_interval - time_since_last
|
||||
time.sleep(wait_time)
|
||||
|
||||
self.last_request_time = time.time()
|
||||
|
||||
|
||||
class OSMAPIException(Exception):
|
||||
"""Exception for OSM API related errors."""
|
||||
|
||||
|
||||
class RoadTripService:
|
||||
"""
|
||||
Service for planning road trips between theme parks using OpenStreetMap APIs.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.nominatim_base_url = "https://nominatim.openstreetmap.org"
|
||||
self.osrm_base_url = "http://router.project-osrm.org/route/v1/driving"
|
||||
|
||||
# Configuration from Django settings
|
||||
self.cache_timeout = getattr(settings, "ROADTRIP_CACHE_TIMEOUT", 3600 * 24)
|
||||
self.route_cache_timeout = getattr(
|
||||
settings, "ROADTRIP_ROUTE_CACHE_TIMEOUT", 3600 * 6
|
||||
)
|
||||
self.user_agent = getattr(
|
||||
settings, "ROADTRIP_USER_AGENT", "ThrillWiki Road Trip Planner"
|
||||
)
|
||||
self.request_timeout = getattr(settings, "ROADTRIP_REQUEST_TIMEOUT", 10)
|
||||
self.max_retries = getattr(settings, "ROADTRIP_MAX_RETRIES", 3)
|
||||
self.backoff_factor = getattr(settings, "ROADTRIP_BACKOFF_FACTOR", 2)
|
||||
|
||||
# Rate limiter
|
||||
max_rps = getattr(settings, "ROADTRIP_MAX_REQUESTS_PER_SECOND", 1)
|
||||
self.rate_limiter = RateLimiter(max_rps)
|
||||
|
||||
# Request session with proper headers
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"User-Agent": self.user_agent,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def _make_request(self, url: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Make HTTP request with rate limiting, retries, and error handling.
|
||||
"""
|
||||
self.rate_limiter.wait_if_needed()
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
response = self.session.get(
|
||||
url, params=params, timeout=self.request_timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"Request attempt {attempt + 1} failed: {e}")
|
||||
|
||||
if attempt < self.max_retries - 1:
|
||||
wait_time = self.backoff_factor**attempt
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
raise OSMAPIException(
|
||||
f"Failed to make request after {
|
||||
self.max_retries} attempts: {e}"
|
||||
)
|
||||
|
||||
def geocode_address(self, address: str) -> Optional[Coordinates]:
|
||||
"""
|
||||
Convert address to coordinates using Nominatim geocoding service.
|
||||
|
||||
Args:
|
||||
address: Address string to geocode
|
||||
|
||||
Returns:
|
||||
Coordinates object or None if geocoding fails
|
||||
"""
|
||||
if not address or not address.strip():
|
||||
return None
|
||||
|
||||
# Check cache first
|
||||
cache_key = f"roadtrip:geocode:{hash(address.lower().strip())}"
|
||||
cached_result = cache.get(cache_key)
|
||||
if cached_result:
|
||||
return Coordinates(**cached_result)
|
||||
|
||||
try:
|
||||
params = {
|
||||
"q": address.strip(),
|
||||
"format": "json",
|
||||
"limit": 1,
|
||||
"addressdetails": 1,
|
||||
}
|
||||
|
||||
url = f"{self.nominatim_base_url}/search"
|
||||
response = self._make_request(url, params)
|
||||
|
||||
if response and len(response) > 0:
|
||||
result = response[0]
|
||||
coords = Coordinates(
|
||||
latitude=float(result["lat"]),
|
||||
longitude=float(result["lon"]),
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
cache.set(
|
||||
cache_key,
|
||||
{
|
||||
"latitude": coords.latitude,
|
||||
"longitude": coords.longitude,
|
||||
},
|
||||
self.cache_timeout,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Geocoded '{address}' to {
|
||||
coords.latitude}, {
|
||||
coords.longitude}"
|
||||
)
|
||||
return coords
|
||||
else:
|
||||
logger.warning(f"No geocoding results for address: {address}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Geocoding failed for '{address}': {e}")
|
||||
return None
|
||||
|
||||
def calculate_route(
|
||||
self, start_coords: Coordinates, end_coords: Coordinates
|
||||
) -> Optional[RouteInfo]:
|
||||
"""
|
||||
Calculate route between two coordinate points using OSRM.
|
||||
|
||||
Args:
|
||||
start_coords: Starting coordinates
|
||||
end_coords: Ending coordinates
|
||||
|
||||
Returns:
|
||||
RouteInfo object or None if routing fails
|
||||
"""
|
||||
if not start_coords or not end_coords:
|
||||
return None
|
||||
|
||||
# Check cache first
|
||||
cache_key = f"roadtrip:route:{
|
||||
start_coords.latitude},{
|
||||
start_coords.longitude}:{
|
||||
end_coords.latitude},{
|
||||
end_coords.longitude}"
|
||||
cached_result = cache.get(cache_key)
|
||||
if cached_result:
|
||||
return RouteInfo(**cached_result)
|
||||
|
||||
try:
|
||||
# Format coordinates for OSRM (lon,lat format)
|
||||
coords_string = f"{
|
||||
start_coords.longitude},{
|
||||
start_coords.latitude};{
|
||||
end_coords.longitude},{
|
||||
end_coords.latitude}"
|
||||
url = f"{self.osrm_base_url}/{coords_string}"
|
||||
|
||||
params = {
|
||||
"overview": "full",
|
||||
"geometries": "polyline",
|
||||
"steps": "false",
|
||||
}
|
||||
|
||||
response = self._make_request(url, params)
|
||||
|
||||
if response.get("code") == "Ok" and response.get("routes"):
|
||||
route_data = response["routes"][0]
|
||||
|
||||
# Distance is in meters, convert to km
|
||||
distance_km = route_data["distance"] / 1000.0
|
||||
# Duration is in seconds, convert to minutes
|
||||
duration_minutes = int(route_data["duration"] / 60)
|
||||
|
||||
route_info = RouteInfo(
|
||||
distance_km=distance_km,
|
||||
duration_minutes=duration_minutes,
|
||||
geometry=route_data.get("geometry"),
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
cache.set(
|
||||
cache_key,
|
||||
{
|
||||
"distance_km": route_info.distance_km,
|
||||
"duration_minutes": route_info.duration_minutes,
|
||||
"geometry": route_info.geometry,
|
||||
},
|
||||
self.route_cache_timeout,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Route calculated: {
|
||||
route_info.formatted_distance}, {
|
||||
route_info.formatted_duration}"
|
||||
)
|
||||
return route_info
|
||||
else:
|
||||
# Fallback to straight-line distance calculation
|
||||
logger.warning(
|
||||
"OSRM routing failed, falling back to straight-line distance"
|
||||
)
|
||||
return self._calculate_straight_line_route(start_coords, end_coords)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Route calculation failed: {e}")
|
||||
# Fallback to straight-line distance
|
||||
return self._calculate_straight_line_route(start_coords, end_coords)
|
||||
|
||||
def _calculate_straight_line_route(
|
||||
self, start_coords: Coordinates, end_coords: Coordinates
|
||||
) -> RouteInfo:
|
||||
"""
|
||||
Calculate straight-line distance as fallback when routing fails.
|
||||
"""
|
||||
# Haversine formula for great-circle distance
|
||||
lat1, lon1 = math.radians(start_coords.latitude), math.radians(
|
||||
start_coords.longitude
|
||||
)
|
||||
lat2, lon2 = math.radians(end_coords.latitude), math.radians(
|
||||
end_coords.longitude
|
||||
)
|
||||
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
|
||||
a = (
|
||||
math.sin(dlat / 2) ** 2
|
||||
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
|
||||
)
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
|
||||
# Earth's radius in kilometers
|
||||
earth_radius_km = 6371.0
|
||||
distance_km = earth_radius_km * c
|
||||
|
||||
# Estimate driving time (assume average 80 km/h with 25% extra for
|
||||
# roads)
|
||||
estimated_duration_minutes = int((distance_km * 1.25 / 80.0) * 60)
|
||||
|
||||
return RouteInfo(
|
||||
distance_km=distance_km,
|
||||
duration_minutes=estimated_duration_minutes,
|
||||
geometry=None,
|
||||
)
|
||||
|
||||
def find_parks_along_route(
|
||||
self, start_park: "Park", end_park: "Park", max_detour_km: float = 50
|
||||
) -> List["Park"]:
|
||||
"""
|
||||
Find parks along a route within specified detour distance.
|
||||
|
||||
Args:
|
||||
start_park: Starting park
|
||||
end_park: Ending park
|
||||
max_detour_km: Maximum detour distance in kilometers
|
||||
|
||||
Returns:
|
||||
List of parks along the route
|
||||
"""
|
||||
from apps.parks.models import Park
|
||||
|
||||
if not hasattr(start_park, "location") or not hasattr(end_park, "location"):
|
||||
return []
|
||||
|
||||
if not start_park.location or not end_park.location:
|
||||
return []
|
||||
|
||||
start_coords = start_park.coordinates
|
||||
end_coords = end_park.coordinates
|
||||
|
||||
if not start_coords or not end_coords:
|
||||
return []
|
||||
|
||||
start_point = Point(start_coords[1], start_coords[0], srid=4326) # lon, lat
|
||||
# end_point is not used in this method - we use coordinates directly
|
||||
|
||||
# Find all parks within a reasonable distance from both start and end
|
||||
max_search_distance = Distance(km=max_detour_km * 2)
|
||||
|
||||
candidate_parks = (
|
||||
Park.objects.filter(
|
||||
location__point__distance_lte=(
|
||||
start_point,
|
||||
max_search_distance,
|
||||
)
|
||||
)
|
||||
.exclude(id__in=[start_park.id, end_park.id])
|
||||
.select_related("location")
|
||||
)
|
||||
|
||||
parks_along_route = []
|
||||
|
||||
for park in candidate_parks:
|
||||
if not park.location or not park.location.point:
|
||||
continue
|
||||
|
||||
park_coords = park.coordinates
|
||||
if not park_coords:
|
||||
continue
|
||||
|
||||
# Calculate detour distance
|
||||
detour_distance = self._calculate_detour_distance(
|
||||
Coordinates(*start_coords),
|
||||
Coordinates(*end_coords),
|
||||
Coordinates(*park_coords),
|
||||
)
|
||||
|
||||
if detour_distance and detour_distance <= max_detour_km:
|
||||
parks_along_route.append(park)
|
||||
|
||||
return parks_along_route
|
||||
|
||||
def _calculate_detour_distance(
|
||||
self, start: Coordinates, end: Coordinates, waypoint: Coordinates
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Calculate the detour distance when visiting a waypoint.
|
||||
"""
|
||||
try:
|
||||
# Direct route distance
|
||||
direct_route = self.calculate_route(start, end)
|
||||
if not direct_route:
|
||||
return None
|
||||
|
||||
# Route via waypoint
|
||||
route_to_waypoint = self.calculate_route(start, waypoint)
|
||||
route_from_waypoint = self.calculate_route(waypoint, end)
|
||||
|
||||
if not route_to_waypoint or not route_from_waypoint:
|
||||
return None
|
||||
|
||||
detour_distance = (
|
||||
route_to_waypoint.distance_km + route_from_waypoint.distance_km
|
||||
) - direct_route.distance_km
|
||||
return max(0, detour_distance) # Don't return negative detours
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to calculate detour distance: {e}")
|
||||
return None
|
||||
|
||||
def create_multi_park_trip(self, park_list: List["Park"]) -> Optional[RoadTrip]:
|
||||
"""
|
||||
Create optimized multi-park road trip using simple nearest neighbor heuristic.
|
||||
|
||||
Args:
|
||||
park_list: List of parks to visit
|
||||
|
||||
Returns:
|
||||
RoadTrip object with optimized route
|
||||
"""
|
||||
if len(park_list) < 2:
|
||||
return None
|
||||
|
||||
# For small numbers of parks, try all permutations
|
||||
if len(park_list) <= 6:
|
||||
return self._optimize_trip_exhaustive(park_list)
|
||||
else:
|
||||
return self._optimize_trip_nearest_neighbor(park_list)
|
||||
|
||||
def _optimize_trip_exhaustive(self, park_list: List["Park"]) -> Optional[RoadTrip]:
|
||||
"""
|
||||
Find optimal route by testing all permutations (for small lists).
|
||||
"""
|
||||
best_trip = None
|
||||
best_distance = float("inf")
|
||||
|
||||
# Try all possible orders (excluding the first park as starting point)
|
||||
for perm in permutations(park_list[1:]):
|
||||
ordered_parks = [park_list[0]] + list(perm)
|
||||
trip = self._create_trip_from_order(ordered_parks)
|
||||
|
||||
if trip and trip.total_distance_km < best_distance:
|
||||
best_distance = trip.total_distance_km
|
||||
best_trip = trip
|
||||
|
||||
return best_trip
|
||||
|
||||
def _optimize_trip_nearest_neighbor(
|
||||
self, park_list: List["Park"]
|
||||
) -> Optional[RoadTrip]:
|
||||
"""
|
||||
Optimize trip using nearest neighbor heuristic (for larger lists).
|
||||
"""
|
||||
if not park_list:
|
||||
return None
|
||||
|
||||
# Start with the first park
|
||||
current_park = park_list[0]
|
||||
ordered_parks = [current_park]
|
||||
remaining_parks = park_list[1:]
|
||||
|
||||
while remaining_parks:
|
||||
# Find nearest unvisited park
|
||||
nearest_park = None
|
||||
min_distance = float("inf")
|
||||
|
||||
current_coords = current_park.coordinates
|
||||
if not current_coords:
|
||||
break
|
||||
|
||||
for park in remaining_parks:
|
||||
park_coords = park.coordinates
|
||||
if not park_coords:
|
||||
continue
|
||||
|
||||
route = self.calculate_route(
|
||||
Coordinates(*current_coords), Coordinates(*park_coords)
|
||||
)
|
||||
|
||||
if route and route.distance_km < min_distance:
|
||||
min_distance = route.distance_km
|
||||
nearest_park = park
|
||||
|
||||
if nearest_park:
|
||||
ordered_parks.append(nearest_park)
|
||||
remaining_parks.remove(nearest_park)
|
||||
current_park = nearest_park
|
||||
else:
|
||||
break
|
||||
|
||||
return self._create_trip_from_order(ordered_parks)
|
||||
|
||||
def _create_trip_from_order(
|
||||
self, ordered_parks: List["Park"]
|
||||
) -> Optional[RoadTrip]:
|
||||
"""
|
||||
Create a RoadTrip object from an ordered list of parks.
|
||||
"""
|
||||
if len(ordered_parks) < 2:
|
||||
return None
|
||||
|
||||
legs = []
|
||||
total_distance = 0
|
||||
total_duration = 0
|
||||
|
||||
for i in range(len(ordered_parks) - 1):
|
||||
from_park = ordered_parks[i]
|
||||
to_park = ordered_parks[i + 1]
|
||||
|
||||
from_coords = from_park.coordinates
|
||||
to_coords = to_park.coordinates
|
||||
|
||||
if not from_coords or not to_coords:
|
||||
continue
|
||||
|
||||
route = self.calculate_route(
|
||||
Coordinates(*from_coords), Coordinates(*to_coords)
|
||||
)
|
||||
|
||||
if route:
|
||||
legs.append(TripLeg(from_park=from_park, to_park=to_park, route=route))
|
||||
total_distance += route.distance_km
|
||||
total_duration += route.duration_minutes
|
||||
|
||||
if not legs:
|
||||
return None
|
||||
|
||||
return RoadTrip(
|
||||
parks=ordered_parks,
|
||||
legs=legs,
|
||||
total_distance_km=total_distance,
|
||||
total_duration_minutes=total_duration,
|
||||
)
|
||||
|
||||
def get_park_distances(
|
||||
self, center_park: "Park", radius_km: float = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all parks within radius of a center park with distances.
|
||||
|
||||
Args:
|
||||
center_park: Center park for search
|
||||
radius_km: Search radius in kilometers
|
||||
|
||||
Returns:
|
||||
List of dictionaries with park and distance information
|
||||
"""
|
||||
from apps.parks.models import Park
|
||||
|
||||
if not hasattr(center_park, "location") or not center_park.location:
|
||||
return []
|
||||
|
||||
center_coords = center_park.coordinates
|
||||
if not center_coords:
|
||||
return []
|
||||
|
||||
center_point = Point(center_coords[1], center_coords[0], srid=4326) # lon, lat
|
||||
search_distance = Distance(km=radius_km)
|
||||
|
||||
nearby_parks = (
|
||||
Park.objects.filter(
|
||||
location__point__distance_lte=(center_point, search_distance)
|
||||
)
|
||||
.exclude(id=center_park.id)
|
||||
.select_related("location")
|
||||
)
|
||||
|
||||
results = []
|
||||
|
||||
for park in nearby_parks:
|
||||
park_coords = park.coordinates
|
||||
if not park_coords:
|
||||
continue
|
||||
|
||||
route = self.calculate_route(
|
||||
Coordinates(*center_coords), Coordinates(*park_coords)
|
||||
)
|
||||
|
||||
if route:
|
||||
results.append(
|
||||
{
|
||||
"park": park,
|
||||
"distance_km": route.distance_km,
|
||||
"duration_minutes": route.duration_minutes,
|
||||
"formatted_distance": route.formatted_distance,
|
||||
"formatted_duration": route.formatted_duration,
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by distance
|
||||
results.sort(key=lambda x: x["distance_km"])
|
||||
|
||||
return results
|
||||
|
||||
def geocode_park_if_needed(self, park: "Park") -> bool:
|
||||
"""
|
||||
Geocode park location if coordinates are missing.
|
||||
|
||||
Args:
|
||||
park: Park to geocode
|
||||
|
||||
Returns:
|
||||
True if geocoding succeeded or wasn't needed, False otherwise
|
||||
"""
|
||||
if not hasattr(park, "location") or not park.location:
|
||||
return False
|
||||
|
||||
location = park.location
|
||||
|
||||
# If we already have coordinates, no need to geocode
|
||||
if location.point:
|
||||
return True
|
||||
|
||||
# Build address string for geocoding
|
||||
address_parts = [
|
||||
park.name,
|
||||
location.street_address,
|
||||
location.city,
|
||||
location.state,
|
||||
location.country,
|
||||
]
|
||||
address = ", ".join(part for part in address_parts if part)
|
||||
|
||||
if not address:
|
||||
return False
|
||||
|
||||
coords = self.geocode_address(address)
|
||||
if coords:
|
||||
location.set_coordinates(coords.latitude, coords.longitude)
|
||||
location.save()
|
||||
logger.info(
|
||||
f"Geocoded park '{
|
||||
park.name}' to {
|
||||
coords.latitude}, {
|
||||
coords.longitude}"
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
34
backend/apps/parks/signals.py
Normal file
34
backend/apps/parks/signals.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.rides.models import Ride
|
||||
from .models import Park
|
||||
|
||||
|
||||
def update_park_ride_counts(park):
|
||||
"""Update ride_count and coaster_count for a park"""
|
||||
operating_rides = Q(status="OPERATING")
|
||||
|
||||
# Count total operating rides
|
||||
ride_count = park.rides.filter(operating_rides).count()
|
||||
|
||||
# Count total operating roller coasters
|
||||
coaster_count = park.rides.filter(operating_rides, category="RC").count()
|
||||
|
||||
# Update park counts
|
||||
Park.objects.filter(id=park.id).update(
|
||||
ride_count=ride_count, coaster_count=coaster_count
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Ride)
|
||||
def ride_saved(sender, instance, **kwargs):
|
||||
"""Update park counts when a ride is saved"""
|
||||
update_park_ride_counts(instance.park)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Ride)
|
||||
def ride_deleted(sender, instance, **kwargs):
|
||||
"""Update park counts when a ride is deleted"""
|
||||
update_park_ride_counts(instance.park)
|
||||
1244
backend/apps/parks/static/parks/css/search.css
Normal file
1244
backend/apps/parks/static/parks/css/search.css
Normal file
File diff suppressed because it is too large
Load Diff
550
backend/apps/parks/static/parks/js/search.js
Normal file
550
backend/apps/parks/static/parks/js/search.js
Normal file
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* Enhanced Parks Search and Filter Management
|
||||
* Provides comprehensive UX improvements for the parks listing page
|
||||
*/
|
||||
|
||||
class ParkSearchManager {
|
||||
constructor() {
|
||||
this.debounceTimers = new Map();
|
||||
this.filterState = new Map();
|
||||
this.requestCount = 0;
|
||||
this.lastRequestTime = 0;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.initializeLazyLoading();
|
||||
this.setupKeyboardNavigation();
|
||||
this.restoreFilterState();
|
||||
this.setupPerformanceOptimizations();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Enhanced HTMX request handling
|
||||
document.addEventListener('htmx:configRequest', (evt) => this.handleConfigRequest(evt));
|
||||
document.addEventListener('htmx:beforeRequest', (evt) => this.handleBeforeRequest(evt));
|
||||
document.addEventListener('htmx:afterRequest', (evt) => this.handleAfterRequest(evt));
|
||||
document.addEventListener('htmx:responseError', (evt) => this.handleResponseError(evt));
|
||||
document.addEventListener('htmx:historyRestore', (evt) => this.handleHistoryRestore(evt));
|
||||
document.addEventListener('htmx:afterSwap', (evt) => this.handleAfterSwap(evt));
|
||||
|
||||
// Enhanced form interactions
|
||||
document.addEventListener('input', (evt) => this.handleInput(evt));
|
||||
document.addEventListener('change', (evt) => this.handleChange(evt));
|
||||
document.addEventListener('focus', (evt) => this.handleFocus(evt));
|
||||
document.addEventListener('blur', (evt) => this.handleBlur(evt));
|
||||
|
||||
// Search suggestions
|
||||
document.addEventListener('keydown', (evt) => this.handleKeydown(evt));
|
||||
|
||||
// Window events
|
||||
window.addEventListener('beforeunload', () => this.saveFilterState());
|
||||
window.addEventListener('resize', this.debounce(() => this.handleResize(), 250));
|
||||
}
|
||||
|
||||
handleConfigRequest(evt) {
|
||||
// Preserve view mode
|
||||
const parkResults = document.getElementById('park-results');
|
||||
if (parkResults) {
|
||||
const viewMode = parkResults.getAttribute('data-view-mode');
|
||||
if (viewMode) {
|
||||
evt.detail.parameters['view_mode'] = viewMode;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve search terms
|
||||
const searchInput = document.querySelector('input[name="search"]');
|
||||
if (searchInput && searchInput.value) {
|
||||
evt.detail.parameters['search'] = searchInput.value;
|
||||
}
|
||||
|
||||
// Add request tracking
|
||||
evt.detail.parameters['_req_id'] = ++this.requestCount;
|
||||
this.lastRequestTime = Date.now();
|
||||
}
|
||||
|
||||
handleBeforeRequest(evt) {
|
||||
const target = evt.detail.target;
|
||||
if (target) {
|
||||
target.classList.add('htmx-requesting');
|
||||
this.showLoadingIndicator(target);
|
||||
}
|
||||
|
||||
// Disable form elements during request
|
||||
this.toggleFormElements(false);
|
||||
|
||||
// Track request analytics
|
||||
this.trackFilterUsage(evt);
|
||||
}
|
||||
|
||||
handleAfterRequest(evt) {
|
||||
const target = evt.detail.target;
|
||||
if (target) {
|
||||
target.classList.remove('htmx-requesting');
|
||||
this.hideLoadingIndicator(target);
|
||||
}
|
||||
|
||||
// Re-enable form elements
|
||||
this.toggleFormElements(true);
|
||||
|
||||
// Handle response timing
|
||||
const responseTime = Date.now() - this.lastRequestTime;
|
||||
if (responseTime > 3000) {
|
||||
this.showPerformanceWarning();
|
||||
}
|
||||
}
|
||||
|
||||
handleResponseError(evt) {
|
||||
this.hideLoadingIndicator(evt.detail.target);
|
||||
this.toggleFormElements(true);
|
||||
this.showErrorMessage('Failed to load results. Please try again.');
|
||||
}
|
||||
|
||||
handleAfterSwap(evt) {
|
||||
if (evt.detail.target.id === 'results-container') {
|
||||
this.initializeLazyLoading(evt.detail.target);
|
||||
this.updateResultsInfo(evt.detail.target);
|
||||
this.animateResults(evt.detail.target);
|
||||
}
|
||||
}
|
||||
|
||||
handleHistoryRestore(evt) {
|
||||
const parkResults = document.getElementById('park-results');
|
||||
if (parkResults && evt.detail.path) {
|
||||
const url = new URL(evt.detail.path, window.location.origin);
|
||||
const viewMode = url.searchParams.get('view_mode');
|
||||
if (viewMode) {
|
||||
parkResults.setAttribute('data-view-mode', viewMode);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore filter state from URL
|
||||
this.restoreFiltersFromURL(evt.detail.path);
|
||||
}
|
||||
|
||||
handleInput(evt) {
|
||||
if (evt.target.type === 'search' || evt.target.type === 'text') {
|
||||
this.debounceInput(evt.target);
|
||||
}
|
||||
}
|
||||
|
||||
handleChange(evt) {
|
||||
if (evt.target.closest('#filter-form')) {
|
||||
this.updateFilterState();
|
||||
this.saveFilterState();
|
||||
}
|
||||
}
|
||||
|
||||
handleFocus(evt) {
|
||||
if (evt.target.type === 'search') {
|
||||
this.highlightSearchSuggestions(evt.target);
|
||||
}
|
||||
}
|
||||
|
||||
handleBlur(evt) {
|
||||
if (evt.target.type === 'search') {
|
||||
// Delay hiding suggestions to allow for clicks
|
||||
setTimeout(() => this.hideSearchSuggestions(), 150);
|
||||
}
|
||||
}
|
||||
|
||||
handleKeydown(evt) {
|
||||
if (evt.target.type === 'search') {
|
||||
this.handleSearchKeyboard(evt);
|
||||
}
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
// Responsive adjustments
|
||||
this.adjustLayoutForViewport();
|
||||
}
|
||||
|
||||
debounceInput(input) {
|
||||
const key = input.name || input.id;
|
||||
if (this.debounceTimers.has(key)) {
|
||||
clearTimeout(this.debounceTimers.get(key));
|
||||
}
|
||||
|
||||
const delay = input.type === 'search' ? 300 : 500;
|
||||
const timer = setTimeout(() => {
|
||||
if (input.form) {
|
||||
htmx.trigger(input.form, 'change');
|
||||
}
|
||||
this.debounceTimers.delete(key);
|
||||
}, delay);
|
||||
|
||||
this.debounceTimers.set(key, timer);
|
||||
}
|
||||
|
||||
handleSearchKeyboard(evt) {
|
||||
const suggestions = document.getElementById('search-results');
|
||||
if (!suggestions) return;
|
||||
|
||||
const items = suggestions.querySelectorAll('[role="option"]');
|
||||
let activeIndex = Array.from(items).findIndex(item =>
|
||||
item.classList.contains('active') || item.classList.contains('highlighted')
|
||||
);
|
||||
|
||||
switch (evt.key) {
|
||||
case 'ArrowDown':
|
||||
evt.preventDefault();
|
||||
activeIndex = Math.min(activeIndex + 1, items.length - 1);
|
||||
this.highlightSuggestion(items, activeIndex);
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
evt.preventDefault();
|
||||
activeIndex = Math.max(activeIndex - 1, -1);
|
||||
this.highlightSuggestion(items, activeIndex);
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
if (activeIndex >= 0 && items[activeIndex]) {
|
||||
evt.preventDefault();
|
||||
items[activeIndex].click();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
this.hideSearchSuggestions();
|
||||
evt.target.blur();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
highlightSuggestion(items, activeIndex) {
|
||||
items.forEach((item, index) => {
|
||||
item.classList.toggle('active', index === activeIndex);
|
||||
item.classList.toggle('highlighted', index === activeIndex);
|
||||
});
|
||||
}
|
||||
|
||||
highlightSearchSuggestions(input) {
|
||||
const suggestions = document.getElementById('search-results');
|
||||
if (suggestions && input.value) {
|
||||
suggestions.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
hideSearchSuggestions() {
|
||||
const suggestions = document.getElementById('search-results');
|
||||
if (suggestions) {
|
||||
suggestions.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
initializeLazyLoading(container = document) {
|
||||
if (!('IntersectionObserver' in window)) return;
|
||||
|
||||
const imageObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
if (img.dataset.src) {
|
||||
img.src = img.dataset.src;
|
||||
img.removeAttribute('data-src');
|
||||
img.classList.add('loaded');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, {
|
||||
threshold: 0.1,
|
||||
rootMargin: '50px'
|
||||
});
|
||||
|
||||
container.querySelectorAll('img[data-src]').forEach(img => {
|
||||
imageObserver.observe(img);
|
||||
});
|
||||
}
|
||||
|
||||
setupKeyboardNavigation() {
|
||||
// Tab navigation for filter cards
|
||||
document.addEventListener('keydown', (evt) => {
|
||||
if (evt.key === 'Tab' && evt.target.closest('.park-card')) {
|
||||
this.handleCardNavigation(evt);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupPerformanceOptimizations() {
|
||||
// Prefetch next page if pagination exists
|
||||
this.setupPrefetching();
|
||||
|
||||
// Optimize scroll performance
|
||||
this.setupScrollOptimization();
|
||||
}
|
||||
|
||||
setupPrefetching() {
|
||||
const nextPageLink = document.querySelector('a[rel="next"]');
|
||||
if (nextPageLink && 'IntersectionObserver' in window) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.prefetchPage(nextPageLink.href);
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const trigger = document.querySelector('.pagination');
|
||||
if (trigger) {
|
||||
observer.observe(trigger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupScrollOptimization() {
|
||||
let ticking = false;
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(() => {
|
||||
this.handleScroll();
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleScroll() {
|
||||
// Show/hide back to top button
|
||||
const backToTop = document.getElementById('back-to-top');
|
||||
if (backToTop) {
|
||||
backToTop.style.display = window.scrollY > 500 ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
prefetchPage(url) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'prefetch';
|
||||
link.href = url;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
showLoadingIndicator(target) {
|
||||
// Add subtle loading animation
|
||||
target.style.transition = 'opacity 0.3s ease-in-out';
|
||||
target.style.opacity = '0.7';
|
||||
}
|
||||
|
||||
hideLoadingIndicator(target) {
|
||||
target.style.opacity = '1';
|
||||
}
|
||||
|
||||
toggleFormElements(enabled) {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (form) {
|
||||
const elements = form.querySelectorAll('input, select, button');
|
||||
elements.forEach(el => {
|
||||
el.disabled = !enabled;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateFilterState() {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
const formData = new FormData(form);
|
||||
this.filterState.clear();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && value !== '') {
|
||||
this.filterState.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveFilterState() {
|
||||
try {
|
||||
const state = Object.fromEntries(this.filterState);
|
||||
localStorage.setItem('parkFilters', JSON.stringify(state));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save filter state:', e);
|
||||
}
|
||||
}
|
||||
|
||||
restoreFilterState() {
|
||||
try {
|
||||
const saved = localStorage.getItem('parkFilters');
|
||||
if (saved) {
|
||||
const state = JSON.parse(saved);
|
||||
this.applyFilterState(state);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to restore filter state:', e);
|
||||
}
|
||||
}
|
||||
|
||||
restoreFiltersFromURL(path) {
|
||||
const url = new URL(path, window.location.origin);
|
||||
const params = new URLSearchParams(url.search);
|
||||
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
// Clear existing values
|
||||
form.reset();
|
||||
|
||||
// Apply URL parameters
|
||||
for (const [key, value] of params.entries()) {
|
||||
const input = form.querySelector(`[name="${key}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = value === 'on' || value === 'true';
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyFilterState(state) {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
Object.entries(state).forEach(([key, value]) => {
|
||||
const input = form.querySelector(`[name="${key}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = value === 'on' || value === 'true';
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateResultsInfo(container) {
|
||||
// Update any result count displays
|
||||
const countElements = container.querySelectorAll('[data-result-count]');
|
||||
countElements.forEach(el => {
|
||||
const count = container.querySelectorAll('.park-card').length;
|
||||
el.textContent = count;
|
||||
});
|
||||
}
|
||||
|
||||
animateResults(container) {
|
||||
// Subtle animation for new results
|
||||
const cards = container.querySelectorAll('.park-card');
|
||||
cards.forEach((card, index) => {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'translateY(20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
card.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out';
|
||||
card.style.opacity = '1';
|
||||
card.style.transform = 'translateY(0)';
|
||||
}, index * 50);
|
||||
});
|
||||
}
|
||||
|
||||
adjustLayoutForViewport() {
|
||||
const viewport = window.innerWidth;
|
||||
|
||||
// Adjust grid columns based on viewport
|
||||
const grid = document.querySelector('.park-card-grid');
|
||||
if (grid) {
|
||||
if (viewport < 768) {
|
||||
grid.style.gridTemplateColumns = '1fr';
|
||||
} else if (viewport < 1024) {
|
||||
grid.style.gridTemplateColumns = 'repeat(2, 1fr)';
|
||||
} else {
|
||||
grid.style.gridTemplateColumns = 'repeat(3, 1fr)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trackFilterUsage(evt) {
|
||||
// Track which filters are being used for analytics
|
||||
if (window.gtag) {
|
||||
const formData = new FormData(evt.detail.elt);
|
||||
const activeFilters = [];
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && value !== '' && key !== 'csrfmiddlewaretoken') {
|
||||
activeFilters.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
window.gtag('event', 'filter_usage', {
|
||||
'filters_used': activeFilters.join(','),
|
||||
'filter_count': activeFilters.length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
showPerformanceWarning() {
|
||||
// Show a subtle warning for slow responses
|
||||
const warning = document.createElement('div');
|
||||
warning.className = 'fixed top-4 right-4 bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded z-50';
|
||||
warning.innerHTML = `
|
||||
<span class="block sm:inline">Search is taking longer than expected...</span>
|
||||
<span class="absolute top-0 bottom-0 right-0 px-4 py-3 cursor-pointer" onclick="this.parentElement.remove()">
|
||||
<svg class="fill-current h-6 w-6 text-yellow-500" role="button" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651 3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1.2 1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1 1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1.698z"/>
|
||||
</svg>
|
||||
</span>
|
||||
`;
|
||||
|
||||
document.body.appendChild(warning);
|
||||
|
||||
setTimeout(() => {
|
||||
if (warning.parentElement) {
|
||||
warning.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
showErrorMessage(message) {
|
||||
// Show error message with retry option
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'fixed top-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50';
|
||||
errorDiv.innerHTML = `
|
||||
<span class="block sm:inline">${message}</span>
|
||||
<button class="ml-4 bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded text-sm" onclick="location.reload()">
|
||||
Retry
|
||||
</button>
|
||||
<span class="absolute top-0 bottom-0 right-0 px-4 py-3 cursor-pointer" onclick="this.parentElement.remove()">
|
||||
<svg class="fill-current h-6 w-6 text-red-500" role="button" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651 3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1.2 1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1 1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1.698z"/>
|
||||
</svg>
|
||||
</span>
|
||||
`;
|
||||
|
||||
document.body.appendChild(errorDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
if (errorDiv.parentElement) {
|
||||
errorDiv.remove();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Utility method for debouncing
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the enhanced search manager
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.parkSearchManager = new ParkSearchManager();
|
||||
});
|
||||
|
||||
// Export for potential module usage
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = ParkSearchManager;
|
||||
}
|
||||
553
backend/apps/parks/templates/parks/park_list.html
Normal file
553
backend/apps/parks/templates/parks/park_list.html
Normal file
@@ -0,0 +1,553 @@
|
||||
{% extends "core/search/layouts/filtered_list.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Parks - ThrillWiki{% endblock %}
|
||||
|
||||
{% block list_actions %}
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
{# Enhanced View Mode Toggle with Modern Design #}
|
||||
<fieldset class="flex items-center space-x-1 bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600 rounded-xl p-1 shadow-inner">
|
||||
<legend class="sr-only">View mode selection</legend>
|
||||
|
||||
{# Grid View Button #}
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% for key, value in request.GET.items %}{% if key != 'view_mode' and key != 'search' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#view-mode-indicator"
|
||||
class="group relative p-3 rounded-lg transition-all duration-300 {% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}bg-white dark:bg-gray-800 shadow-lg text-blue-600 dark:text-blue-400 scale-105{% else %}text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-white/50 dark:hover:bg-gray-800/50{% endif %}"
|
||||
aria-label="Grid view"
|
||||
aria-pressed="{% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}true{% else %}false{% endif %}">
|
||||
<svg class="w-5 h-5 transition-transform duration-200 group-hover:scale-110" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||
</svg>
|
||||
{% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}
|
||||
<div class="absolute inset-0 rounded-lg bg-gradient-to-r from-blue-500/20 to-purple-500/20 animate-pulse"></div>
|
||||
{% endif %}
|
||||
</button>
|
||||
|
||||
{# List View Button #}
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=list{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% for key, value in request.GET.items %}{% if key != 'view_mode' and key != 'search' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#view-mode-indicator"
|
||||
class="group relative p-3 rounded-lg transition-all duration-300 {% if request.GET.view_mode == 'list' %}bg-white dark:bg-gray-800 shadow-lg text-blue-600 dark:text-blue-400 scale-105{% else %}text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-white/50 dark:hover:bg-gray-800/50{% endif %}"
|
||||
aria-label="List view"
|
||||
aria-pressed="{% if request.GET.view_mode == 'list' %}true{% else %}false{% endif %}">
|
||||
<svg class="w-5 h-5 transition-transform duration-200 group-hover:scale-110" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/>
|
||||
</svg>
|
||||
{% if request.GET.view_mode == 'list' %}
|
||||
<div class="absolute inset-0 rounded-lg bg-gradient-to-r from-blue-500/20 to-purple-500/20 animate-pulse"></div>
|
||||
{% endif %}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
{# View Mode Loading Indicator #}
|
||||
<div id="view-mode-indicator" class="htmx-indicator">
|
||||
<div class="flex items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-blue-700 dark:text-blue-300">Switching view...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Add Park Button #}
|
||||
{% if user.is_authenticated %}
|
||||
<a href="{% url 'parks:park_create' %}"
|
||||
class="group inline-flex items-center px-6 py-3 border border-transparent text-sm font-semibold rounded-xl shadow-lg text-white bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-300 transform hover:scale-105 hover:shadow-xl"
|
||||
data-testid="add-park-button">
|
||||
<svg class="w-5 h-5 mr-2 transition-transform duration-200 group-hover:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
Add Park
|
||||
<div class="absolute inset-0 rounded-xl bg-gradient-to-r from-white/20 to-white/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block filter_section %}
|
||||
<!-- DEBUG: park_list.html filter_section block is being rendered - timestamp: 2025-08-21 -->
|
||||
<div class="mb-6" x-data="parkListManager()" x-init="init()">
|
||||
{# Enhanced Search Section #}
|
||||
<div class="relative mb-8">
|
||||
<div class="w-full relative"
|
||||
x-data="{ query: '', selectedId: null }"
|
||||
@search-selected.window="
|
||||
query = $event.detail;
|
||||
selectedId = $event.target.value;
|
||||
$refs.filterForm.querySelector('input[name=search]').value = query;
|
||||
$refs.filterForm.submit();
|
||||
query = '';
|
||||
">
|
||||
<form hx-get="{% url 'parks:suggest_parks' %}"
|
||||
hx-target="#search-results"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-indicator="#search-indicator"
|
||||
x-ref="searchForm">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search"
|
||||
name="search"
|
||||
placeholder="Search parks by name, location, or description..."
|
||||
class="w-full pl-10 pr-12 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 focus:ring-blue-500 transition-colors duration-200"
|
||||
aria-label="Search parks"
|
||||
aria-controls="search-results"
|
||||
:aria-expanded="query !== ''"
|
||||
x-model="query"
|
||||
@keydown.escape="query = ''"
|
||||
@focus="$event.target.select()">
|
||||
|
||||
<!-- Clear search button -->
|
||||
<button type="button"
|
||||
x-show="query"
|
||||
@click="query = ''; $refs.searchForm.querySelector('input').value = ''; $refs.filterForm.submit();"
|
||||
class="absolute inset-y-0 right-8 flex items-center pr-1 text-gray-400 hover:text-gray-600 focus:outline-none">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="search-indicator"
|
||||
class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2"
|
||||
role="status"
|
||||
aria-label="Loading search results">
|
||||
<svg class="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
<span class="sr-only">Searching...</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="search-results"
|
||||
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-600"
|
||||
role="listbox"
|
||||
x-show="query"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95">
|
||||
<!-- Search suggestions will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Active Filter Chips Section #}
|
||||
<div id="active-filters-section"
|
||||
x-show="hasActiveFilters"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
class="mb-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Active Filters</h3>
|
||||
<button type="button"
|
||||
@click="clearAllFilters()"
|
||||
class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 font-medium focus:outline-none focus:underline">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2" id="filter-chips-container">
|
||||
<!-- Filter chips will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Filter Panel #}
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">Filters</h3>
|
||||
<button type="button"
|
||||
x-data="{ collapsed: false }"
|
||||
@click="collapsed = !collapsed; toggleFilterCollapse()"
|
||||
class="lg:hidden text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 font-medium focus:outline-none">
|
||||
<span x-text="collapsed ? 'Show Filters' : 'Hide Filters'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="filter-form"
|
||||
x-ref="filterForm"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
hx-trigger="change, submit"
|
||||
hx-indicator="#main-loading-indicator"
|
||||
class="mt-4"
|
||||
@htmx:beforeRequest="onFilterRequest()"
|
||||
@htmx:afterRequest="onFilterResponse($event)">
|
||||
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
||||
{% include "core/search/components/filter_form.html" with filter=filter %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Main Loading Indicator #}
|
||||
<div id="main-loading-indicator" class="htmx-indicator fixed top-4 left-1/2 transform -translate-x-1/2 z-50">
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white px-6 py-3 rounded-lg shadow-lg flex items-center backdrop-blur-sm">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Updating results...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function parkListManager() {
|
||||
return {
|
||||
hasActiveFilters: false,
|
||||
filterCollapsed: false,
|
||||
lastResultCount: 0,
|
||||
|
||||
init() {
|
||||
this.updateActiveFilters();
|
||||
this.setupFilterChips();
|
||||
|
||||
// Listen for form changes to update filter chips
|
||||
document.addEventListener('change', (e) => {
|
||||
if (e.target.closest('#filter-form')) {
|
||||
setTimeout(() => this.updateActiveFilters(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for HTMX responses to update result counts
|
||||
document.addEventListener('htmx:afterSwap', (e) => {
|
||||
if (e.detail.target.id === 'results-container') {
|
||||
this.updateResultInfo();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updateActiveFilters() {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
const activeFilters = [];
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (this.isFilterActive(input)) {
|
||||
activeFilters.push(this.createFilterChip(input));
|
||||
}
|
||||
});
|
||||
|
||||
this.hasActiveFilters = activeFilters.length > 0;
|
||||
this.renderFilterChips(activeFilters);
|
||||
},
|
||||
|
||||
isFilterActive(input) {
|
||||
if (!input.name || input.type === 'hidden') return false;
|
||||
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
return input.checked;
|
||||
}
|
||||
|
||||
return input.value && input.value !== '' && input.value !== 'all' && input.value !== '0';
|
||||
},
|
||||
|
||||
createFilterChip(input) {
|
||||
let label = input.name;
|
||||
let value = input.value;
|
||||
|
||||
// Get human readable label from associated label element
|
||||
const labelElement = document.querySelector(`label[for="${input.id}"]`);
|
||||
if (labelElement) {
|
||||
label = labelElement.textContent.trim();
|
||||
}
|
||||
|
||||
// Format value for display
|
||||
if (input.type === 'checkbox') {
|
||||
value = 'Yes';
|
||||
} else if (input.tagName === 'SELECT') {
|
||||
const selectedOption = input.querySelector(`option[value="${input.value}"]`);
|
||||
if (selectedOption) {
|
||||
value = selectedOption.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: input.name,
|
||||
label: label,
|
||||
value: value,
|
||||
displayText: `${label}: ${value}`
|
||||
};
|
||||
},
|
||||
|
||||
renderFilterChips(chips) {
|
||||
const container = document.getElementById('filter-chips-container');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = chips.map(chip => `
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100">
|
||||
${chip.displayText}
|
||||
<button type="button"
|
||||
onclick="removeFilter('${chip.name}')"
|
||||
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100 focus:outline-none"
|
||||
aria-label="Remove ${chip.label} filter">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
`).join('');
|
||||
},
|
||||
|
||||
clearAllFilters() {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = false;
|
||||
} else if (input.type !== 'hidden') {
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
this.hasActiveFilters = false;
|
||||
htmx.trigger(form, 'submit');
|
||||
},
|
||||
|
||||
toggleFilterCollapse() {
|
||||
this.filterCollapsed = !this.filterCollapsed;
|
||||
},
|
||||
|
||||
onFilterRequest() {
|
||||
// Add loading state to results
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.style.opacity = '0.6';
|
||||
resultsContainer.style.pointerEvents = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
onFilterResponse(event) {
|
||||
// Remove loading state
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.style.opacity = '1';
|
||||
resultsContainer.style.pointerEvents = 'auto';
|
||||
}
|
||||
|
||||
// Update active filters after response
|
||||
setTimeout(() => this.updateActiveFilters(), 100);
|
||||
},
|
||||
|
||||
updateResultInfo() {
|
||||
// This would update any result count information
|
||||
// Implementation depends on how results are structured
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global function to remove individual filters
|
||||
function removeFilter(filterName) {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
const input = form.querySelector(`[name="${filterName}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = false;
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
htmx.trigger(form, 'submit');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block results_list %}
|
||||
<div id="park-results"
|
||||
class="overflow-hidden transition-all duration-300"
|
||||
data-view-mode="{{ view_mode|default:'grid' }}">
|
||||
|
||||
{# Enhanced Results Header with Modern Design #}
|
||||
<div class="bg-gradient-to-r from-gray-50 to-white dark:from-gray-800 dark:to-gray-700 border-b border-gray-200/50 dark:border-gray-600/50 px-6 py-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h2 class="text-xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 bg-clip-text text-transparent">
|
||||
Parks
|
||||
{% if parks %}
|
||||
<span class="text-base font-normal text-gray-500 dark:text-gray-400">
|
||||
({{ parks|length }}{% if parks.has_other_pages %} of {{ parks.paginator.count }}{% endif %} found)
|
||||
</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
{# Enhanced Results Status Indicator #}
|
||||
{% if request.GET.search or request.GET.park_type or request.GET.has_coasters or request.GET.min_rating or request.GET.big_parks_only %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-blue-100 to-purple-100 text-blue-800 dark:from-blue-900/30 dark:to-purple-900/30 dark:text-blue-300 border border-blue-200 dark:border-blue-700/50">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.414A1 1 0 013 6.707V4z"/>
|
||||
</svg>
|
||||
Filtered Results
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Enhanced Sort Options #}
|
||||
<div class="flex items-center space-x-3">
|
||||
<label for="sort-select" class="text-sm font-medium text-gray-700 dark:text-gray-300">Sort by:</label>
|
||||
<div class="relative">
|
||||
<select id="sort-select"
|
||||
name="ordering"
|
||||
form="filter-form"
|
||||
class="appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white text-sm rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 pl-3 pr-10 py-2 transition-colors duration-200"
|
||||
onchange="document.getElementById('filter-form').submit()">
|
||||
<option value="">Default</option>
|
||||
<option value="name" {% if request.GET.ordering == 'name' %}selected{% endif %}>Name (A-Z)</option>
|
||||
<option value="-name" {% if request.GET.ordering == '-name' %}selected{% endif %}>Name (Z-A)</option>
|
||||
<option value="-average_rating" {% if request.GET.ordering == '-average_rating' %}selected{% endif %}>Highest Rated</option>
|
||||
<option value="-coaster_count" {% if request.GET.ordering == '-coaster_count' %}selected{% endif %}>Most Coasters</option>
|
||||
<option value="-ride_count" {% if request.GET.ordering == '-ride_count' %}selected{% endif %}>Most Rides</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Results Content with Adaptive Grid #}
|
||||
<div class="p-6">
|
||||
{% if parks %}
|
||||
{# Enhanced Responsive Grid Container #}
|
||||
<div class="{% if view_mode == 'list' %}space-y-4{% else %}grid gap-6 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4{% endif %} transition-all duration-300">
|
||||
{% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
|
||||
</div>
|
||||
{% else %}
|
||||
{# Enhanced No Results Found Section #}
|
||||
<div class="text-center py-16 px-4">
|
||||
<div class="mx-auto w-32 h-32 text-gray-300 dark:text-gray-600 mb-8">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-full h-full">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">No parks found</h3>
|
||||
|
||||
{% if request.GET.search %}
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
|
||||
No parks match your search for <span class="font-semibold text-gray-900 dark:text-white">"{{ request.GET.search }}"</span>.
|
||||
<br>Try searching with different keywords or check your spelling.
|
||||
</p>
|
||||
{% elif request.GET.park_type or request.GET.has_coasters or request.GET.min_rating or request.GET.big_parks_only %}
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
|
||||
No parks match your current filter criteria.
|
||||
<br>Try adjusting your filters or removing some restrictions.
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
|
||||
No parks are currently available in the database.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
{% if request.GET.search or request.GET.park_type or request.GET.has_coasters or request.GET.min_rating or request.GET.big_parks_only %}
|
||||
<button type="button"
|
||||
onclick="clearAllFilters()"
|
||||
class="inline-flex items-center px-6 py-3 border border-transparent text-sm font-semibold rounded-xl text-white bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-300 transform hover:scale-105">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
Clear All Filters
|
||||
</button>
|
||||
|
||||
<a href="{% url 'parks:park_list' %}"
|
||||
class="inline-flex items-center px-6 py-3 text-sm font-semibold text-blue-700 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-200 transition-colors duration-200">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
||||
</svg>
|
||||
View all parks
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<a href="{% url 'parks:park_create' %}"
|
||||
class="inline-flex items-center px-6 py-3 text-sm font-semibold text-green-700 dark:text-green-300 hover:text-green-800 dark:hover:text-green-200 transition-colors duration-200">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Add a new park
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Enhanced clear all filters function
|
||||
function clearAllFilters() {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
// Clear all form inputs
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = false;
|
||||
} else if (input.type !== 'hidden') {
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Clear search input as well
|
||||
const searchInput = document.querySelector('input[name="search"]');
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
}
|
||||
|
||||
// Submit form to reload without filters
|
||||
htmx.trigger(form, 'submit');
|
||||
}
|
||||
|
||||
// Smooth scroll to results after filter changes
|
||||
document.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'results-container') {
|
||||
// Add a subtle scroll to results with a small delay for content to settle
|
||||
setTimeout(() => {
|
||||
const resultsElement = document.getElementById('park-results');
|
||||
if (resultsElement) {
|
||||
resultsElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Flash effect to indicate content update
|
||||
const resultsContainer = event.detail.target;
|
||||
resultsContainer.style.opacity = '0.8';
|
||||
setTimeout(() => {
|
||||
resultsContainer.style.opacity = '1';
|
||||
}, 150);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
168
backend/apps/parks/templates/parks/partials/park_list_item.html
Normal file
168
backend/apps/parks/templates/parks/partials/park_list_item.html
Normal file
@@ -0,0 +1,168 @@
|
||||
{% load static %}
|
||||
|
||||
{% if error %}
|
||||
<div class="p-4" data-testid="park-list-error">
|
||||
<div class="inline-flex items-center px-4 py-2 rounded-md bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% for park in object_list|default:parks %}
|
||||
{% if view_mode == 'list' %}
|
||||
{# Enhanced List View Item #}
|
||||
<article class="group bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-[1.02] overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
|
||||
{# Main Content Section #}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<h2 class="text-xl lg:text-2xl font-bold">
|
||||
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||
class="bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 bg-clip-text text-transparent hover:from-blue-600 hover:to-purple-600 dark:hover:from-blue-400 dark:hover:to-purple-400 transition-all duration-300">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
{# Status Badge #}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border
|
||||
{% if park.status == 'operating' %}bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800
|
||||
{% elif park.status == 'closed' %}bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800
|
||||
{% elif park.status == 'seasonal' %}bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800
|
||||
{% else %}bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if park.operator %}
|
||||
<div class="text-base font-medium text-gray-600 dark:text-gray-400 mb-3">
|
||||
{{ park.operator.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if park.description %}
|
||||
<p class="text-gray-600 dark:text-gray-400 line-clamp-2 mb-4">
|
||||
{{ park.description|truncatewords:30 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Stats Section #}
|
||||
{% if park.ride_count or park.coaster_count %}
|
||||
<div class="flex items-center space-x-6 text-sm">
|
||||
{% if park.ride_count %}
|
||||
<div class="flex items-center space-x-2 px-4 py-2 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 rounded-lg border border-blue-200/50 dark:border-blue-800/50">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
|
||||
</svg>
|
||||
<span class="font-semibold text-blue-700 dark:text-blue-300">{{ park.ride_count }}</span>
|
||||
<span class="text-blue-600 dark:text-blue-400">rides</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if park.coaster_count %}
|
||||
<div class="flex items-center space-x-2 px-4 py-2 bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 rounded-lg border border-purple-200/50 dark:border-purple-800/50">
|
||||
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span class="font-semibold text-purple-700 dark:text-purple-300">{{ park.coaster_count }}</span>
|
||||
<span class="text-purple-600 dark:text-purple-400">coasters</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
{# Enhanced Grid View Item #}
|
||||
<article class="group bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105 hover:-rotate-1 overflow-hidden">
|
||||
{# Card Header with Gradient #}
|
||||
<div class="h-2 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500"></div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<h2 class="text-xl font-bold line-clamp-2 flex-1 mr-3">
|
||||
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||
class="bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 bg-clip-text text-transparent hover:from-blue-600 hover:to-purple-600 dark:hover:from-blue-400 dark:hover:to-purple-400 transition-all duration-300">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
{# Status Badge #}
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold border shrink-0
|
||||
{% if park.status == 'operating' %}bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800
|
||||
{% elif park.status == 'closed' %}bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800
|
||||
{% elif park.status == 'seasonal' %}bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800
|
||||
{% else %}bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if park.operator %}
|
||||
<div class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3 truncate">
|
||||
{{ park.operator.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if park.description %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-3 mb-4">
|
||||
{{ park.description|truncatewords:15 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{# Stats Footer #}
|
||||
{% if park.ride_count or park.coaster_count %}
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200/50 dark:border-gray-600/50">
|
||||
<div class="flex items-center space-x-4 text-sm">
|
||||
{% if park.ride_count %}
|
||||
<div class="flex items-center space-x-1 text-blue-600 dark:text-blue-400">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
|
||||
</svg>
|
||||
<span class="font-semibold">{{ park.ride_count }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if park.coaster_count %}
|
||||
<div class="flex items-center space-x-1 text-purple-600 dark:text-purple-400">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span class="font-semibold">{{ park.coaster_count }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# View Details Arrow #}
|
||||
<div class="text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-200">
|
||||
<svg class="w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% empty %}
|
||||
<div class="{% if view_mode == 'list' %}w-full{% else %}col-span-full{% endif %} p-12 text-center" data-testid="no-parks-found">
|
||||
<div class="mx-auto w-24 h-24 text-gray-300 dark:text-gray-600 mb-6">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-full h-full">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">No parks found</h3>
|
||||
<div class="text-gray-600 dark:text-gray-400">
|
||||
{% if search_query %}
|
||||
<p class="mb-4">No parks found matching "{{ search_query }}". Try adjusting your search terms.</p>
|
||||
{% else %}
|
||||
<p class="mb-4">No parks found matching your criteria. Try adjusting your filters.</p>
|
||||
{% endif %}
|
||||
{% if user.is_authenticated %}
|
||||
<p>You can also <a href="{% url 'parks:park_create' %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 font-semibold">add a new park</a>.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% load static %}
|
||||
|
||||
{% if parks %}
|
||||
<div class="py-2">
|
||||
{% for park in parks %}
|
||||
<button class="w-full text-left px-4 py-2 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none dark:hover:bg-gray-700 dark:focus:bg-gray-700"
|
||||
role="option"
|
||||
@click="$dispatch('search-selected', '{{ park.name }}')"
|
||||
value="{{ park.id }}">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div class="font-medium">{{ park.name }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if park.formatted_location %}
|
||||
{{ park.formatted_location }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ park.get_status_display }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
No parks found matching "{{ query }}"
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% if suggestions %}
|
||||
<div id="search-suggestions-results"
|
||||
class="w-full bg-white rounded-md shadow-lg border border-gray-200 max-h-96 overflow-y-auto"
|
||||
x-show="open"
|
||||
x-cloak
|
||||
@keydown.escape.window="open = false"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95">
|
||||
{% for park in suggestions %}
|
||||
{% with location=park.location.first %}
|
||||
<button type="button"
|
||||
class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer flex items-center justify-between gap-2 transition duration-150"
|
||||
:class="{ 'bg-blue-50': focusedIndex === {{ forloop.counter0 }} }"
|
||||
@mousedown.prevent="query = '{{ park.name }}'; $refs.search.value = '{{ park.name }}'"
|
||||
@mousedown.prevent="query = '{{ park.name }}'; $dispatch('search-selected', '{{ park.name }}'); open = false;"
|
||||
role="option"
|
||||
:aria-selected="focusedIndex === {{ forloop.counter0 }}"
|
||||
tabindex="-1"
|
||||
x-effect="if(focusedIndex === {{ forloop.counter0 }}) $el.scrollIntoView({block: 'nearest'})"
|
||||
aria-label="{{ park.name }}{% if location.city %} in {{ location.city }}{% endif %}{% if location.state %}, {{ location.state }}{% endif %}">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium" x-text="focusedIndex === {{ forloop.counter0 }} ? '▶ {{ park.name }}' : '{{ park.name }}'"></span>
|
||||
<span class="text-gray-500">
|
||||
{% if location.city %}{{ location.city }}, {% endif %}
|
||||
{% if location.state %}{{ location.state }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
11
backend/apps/parks/templatetags/park_tags.py
Normal file
11
backend/apps/parks/templatetags/park_tags.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def has_reviewed_park(user, park):
|
||||
"""Check if a user has reviewed a park"""
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
return park.reviews.filter(user=user).exists()
|
||||
117
backend/apps/parks/tests.py
Normal file
117
backend/apps/parks/tests.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.parks.models import Park, ParkArea, ParkLocation, Company as Operator
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def create_test_location(park: Park) -> ParkLocation:
|
||||
"""Helper function to create a test location"""
|
||||
park_location = ParkLocation.objects.create(
|
||||
park=park,
|
||||
street_address="123 Test St",
|
||||
city="Test City",
|
||||
state="TS",
|
||||
country="Test Country",
|
||||
postal_code="12345",
|
||||
)
|
||||
# Set coordinates using the helper method
|
||||
park_location.set_coordinates(34.0522, -118.2437) # latitude, longitude
|
||||
park_location.save()
|
||||
return park_location
|
||||
|
||||
|
||||
class ParkModelTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls) -> None:
|
||||
# Create test user
|
||||
cls.user = User.objects.create_user(
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
password="testpass123",
|
||||
)
|
||||
|
||||
# Create test company
|
||||
cls.operator = Operator.objects.create(
|
||||
name="Test Company", website="http://example.com"
|
||||
)
|
||||
|
||||
# Create test park
|
||||
cls.park = Park.objects.create(
|
||||
name="Test Park",
|
||||
operator=cls.operator,
|
||||
status="OPERATING",
|
||||
website="http://testpark.com",
|
||||
)
|
||||
|
||||
# Create test location
|
||||
cls.location = create_test_location(cls.park)
|
||||
|
||||
def test_park_creation(self) -> None:
|
||||
"""Test park instance creation and field values"""
|
||||
self.assertEqual(self.park.name, "Test Park")
|
||||
self.assertEqual(self.park.operator, self.operator)
|
||||
self.assertEqual(self.park.status, "OPERATING")
|
||||
self.assertEqual(self.park.website, "http://testpark.com")
|
||||
self.assertTrue(self.park.slug)
|
||||
|
||||
def test_park_str_representation(self) -> None:
|
||||
"""Test string representation of park"""
|
||||
self.assertEqual(str(self.park), "Test Park")
|
||||
|
||||
def test_park_coordinates(self) -> None:
|
||||
"""Test park coordinates property"""
|
||||
coords = self.park.coordinates
|
||||
self.assertIsNotNone(coords)
|
||||
if coords:
|
||||
self.assertAlmostEqual(coords[0], 34.0522, places=4) # latitude
|
||||
self.assertAlmostEqual(coords[1], -118.2437, places=4) # longitude
|
||||
|
||||
def test_park_formatted_location(self) -> None:
|
||||
"""Test park formatted_location property"""
|
||||
expected = "123 Test St, Test City, TS, 12345, Test Country"
|
||||
self.assertEqual(self.park.formatted_location, expected)
|
||||
|
||||
|
||||
class ParkAreaTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
# Create test company
|
||||
self.operator = Operator.objects.create(
|
||||
name="Test Company", website="http://example.com"
|
||||
)
|
||||
|
||||
# Create test park
|
||||
self.park = Park.objects.create(
|
||||
name="Test Park", operator=self.operator, status="OPERATING"
|
||||
)
|
||||
|
||||
# Create test location
|
||||
self.location = create_test_location(self.park)
|
||||
|
||||
# Create test area
|
||||
self.area = ParkArea.objects.create(
|
||||
park=self.park, name="Test Area", description="Test Description"
|
||||
)
|
||||
|
||||
def test_area_creation(self) -> None:
|
||||
"""Test park area creation"""
|
||||
self.assertEqual(self.area.name, "Test Area")
|
||||
self.assertEqual(self.area.park, self.park)
|
||||
self.assertTrue(self.area.slug)
|
||||
|
||||
|
||||
class ParkViewTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.client = Client()
|
||||
self.user = User.objects.create_user(
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
password="testpass123",
|
||||
)
|
||||
self.operator = Operator.objects.create(
|
||||
name="Test Company", website="http://example.com"
|
||||
)
|
||||
self.park = Park.objects.create(
|
||||
name="Test Park", operator=self.operator, status="OPERATING"
|
||||
)
|
||||
self.location = create_test_location(self.park)
|
||||
127
backend/apps/parks/tests_disabled/README.md
Normal file
127
backend/apps/parks/tests_disabled/README.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Park Search Tests
|
||||
|
||||
## Overview
|
||||
|
||||
Test suite for the park search functionality including:
|
||||
- Autocomplete widget integration
|
||||
- Search form validation
|
||||
- Filter integration
|
||||
- HTMX interaction
|
||||
- View mode persistence
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all park tests
|
||||
uv run pytest parks/tests/
|
||||
|
||||
# Run specific search tests
|
||||
uv run pytest parks/tests/test_search.py
|
||||
|
||||
# Run with coverage
|
||||
uv run pytest --cov=parks parks/tests/
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Search API Tests
|
||||
- `test_search_json_format`: Validates API response structure
|
||||
- `test_empty_search_json`: Tests empty search handling
|
||||
- `test_search_format_validation`: Verifies all required fields and types
|
||||
- `test_suggestion_limit`: Confirms 8-item result limit
|
||||
|
||||
### Search Functionality Tests
|
||||
- `test_autocomplete_results`: Validates real-time suggestion filtering
|
||||
- `test_search_with_filters`: Tests filter integration with search
|
||||
- `test_partial_match_search`: Verifies partial text matching works
|
||||
|
||||
### UI Integration Tests
|
||||
- `test_view_mode_persistence`: Ensures view mode is maintained
|
||||
- `test_empty_search`: Tests default state behavior
|
||||
- `test_htmx_request_handling`: Validates HTMX interactions
|
||||
|
||||
### Data Format Tests
|
||||
- Field types validation
|
||||
- Location formatting
|
||||
- Status display formatting
|
||||
- URL generation
|
||||
- Response structure
|
||||
|
||||
### Frontend Integration
|
||||
- HTMX partial updates
|
||||
- Alpine.js state management
|
||||
- Loading indicators
|
||||
- View mode persistence
|
||||
- Keyboard navigation
|
||||
|
||||
### Test Commands
|
||||
```bash
|
||||
# Run all park tests
|
||||
uv run pytest parks/tests/
|
||||
|
||||
# Run search tests specifically
|
||||
uv run pytest parks/tests/test_search.py
|
||||
|
||||
# Run with coverage
|
||||
uv run pytest --cov=parks parks/tests/
|
||||
```
|
||||
|
||||
### Coverage Areas
|
||||
1. Search Functionality:
|
||||
- Suggestion generation
|
||||
- Result filtering
|
||||
- Partial matching
|
||||
- Empty state handling
|
||||
|
||||
2. UI Integration:
|
||||
- HTMX requests
|
||||
- View mode switching
|
||||
- Loading states
|
||||
- Error handling
|
||||
|
||||
3. Performance:
|
||||
- Result limiting
|
||||
- Debouncing
|
||||
- Query optimization
|
||||
|
||||
4. Accessibility:
|
||||
- ARIA attributes
|
||||
- Keyboard controls
|
||||
- Screen reader support
|
||||
|
||||
## Configuration
|
||||
|
||||
Tests use pytest-django and require:
|
||||
- PostgreSQL database
|
||||
- HTMX middleware
|
||||
- Autocomplete app configuration
|
||||
|
||||
## Fixtures
|
||||
|
||||
The test suite uses standard Django test fixtures. No additional fixtures required.
|
||||
|
||||
## Common Issues
|
||||
|
||||
1. Database Errors
|
||||
- Ensure PostGIS extensions are installed
|
||||
- Verify database permissions
|
||||
|
||||
2. HTMX Tests
|
||||
- Use `HTTP_HX_REQUEST` header for HTMX requests
|
||||
- Check response content for HTMX attributes
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
When adding tests, ensure:
|
||||
1. Database isolation using `@pytest.mark.django_db`
|
||||
2. Proper test naming following `test_*` convention
|
||||
3. Clear test descriptions in docstrings
|
||||
4. Coverage for both success and failure cases
|
||||
5. HTMX interaction testing where applicable
|
||||
|
||||
## Future Improvements
|
||||
|
||||
- Add performance benchmarks
|
||||
- Include accessibility tests
|
||||
- Add Playwright e2e tests
|
||||
- Implement geographic search tests
|
||||
1
backend/apps/parks/tests_disabled/__init__.py
Normal file
1
backend/apps/parks/tests_disabled/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Parks app test suite
|
||||
223
backend/apps/parks/tests_disabled/test_filters.py
Normal file
223
backend/apps/parks/tests_disabled/test_filters.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
Tests for park filtering functionality including search, status filtering,
|
||||
date ranges, and numeric validations.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from datetime import date
|
||||
|
||||
from apps.parks.models import Park, ParkLocation, Company
|
||||
from apps.parks.filters import ParkFilter
|
||||
|
||||
# NOTE: These tests need to be updated to work with the new ParkLocation model
|
||||
# instead of the generic Location model
|
||||
|
||||
|
||||
class ParkFilterTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Set up test data for all filter tests"""
|
||||
# Create operators
|
||||
cls.operator1 = Company.objects.create(
|
||||
name="Thrilling Adventures Inc", slug="thrilling-adventures"
|
||||
)
|
||||
cls.operator2 = Company.objects.create(
|
||||
name="Family Fun Corp", slug="family-fun"
|
||||
)
|
||||
|
||||
# Create parks with various attributes for testing all filters
|
||||
cls.park1 = Park.objects.create(
|
||||
name="Thrilling Adventures Park",
|
||||
description="A thrilling park with lots of roller coasters",
|
||||
status="OPERATING",
|
||||
operator=cls.operator1,
|
||||
opening_date=date(2020, 1, 1),
|
||||
size_acres=100,
|
||||
ride_count=20,
|
||||
coaster_count=5,
|
||||
average_rating=4.5,
|
||||
)
|
||||
ParkLocation.objects.create(
|
||||
park=cls.park1,
|
||||
street_address="123 Thrill St",
|
||||
city="Thrill City",
|
||||
state="Thrill State",
|
||||
country="USA",
|
||||
postal_code="12345",
|
||||
)
|
||||
|
||||
cls.park2 = Park.objects.create(
|
||||
name="Family Fun Park",
|
||||
description="Family-friendly entertainment and attractions",
|
||||
status="CLOSED_TEMP",
|
||||
operator=cls.operator2,
|
||||
opening_date=date(2015, 6, 15),
|
||||
size_acres=50,
|
||||
ride_count=15,
|
||||
coaster_count=2,
|
||||
average_rating=4.0,
|
||||
)
|
||||
ParkLocation.objects.create(
|
||||
park=cls.park2,
|
||||
street_address="456 Fun St",
|
||||
city="Fun City",
|
||||
state="Fun State",
|
||||
country="Canada",
|
||||
postal_code="54321",
|
||||
)
|
||||
|
||||
# Park with minimal data for edge case testing
|
||||
cls.park3 = Park.objects.create(
|
||||
name="Incomplete Park",
|
||||
status="UNDER_CONSTRUCTION",
|
||||
operator=cls.operator1,
|
||||
)
|
||||
|
||||
def test_text_search(self):
|
||||
"""Test search functionality across different fields"""
|
||||
# Test name search
|
||||
queryset = ParkFilter(data={"search": "Thrilling"}).qs
|
||||
self.assertEqual(queryset.count(), 1)
|
||||
self.assertIn(self.park1, queryset)
|
||||
|
||||
# Test description search
|
||||
queryset = ParkFilter(data={"search": "family-friendly"}).qs
|
||||
self.assertEqual(queryset.count(), 1)
|
||||
self.assertIn(self.park2, queryset)
|
||||
|
||||
# Test location search
|
||||
queryset = ParkFilter(data={"search": "Thrill City"}).qs
|
||||
self.assertEqual(queryset.count(), 1)
|
||||
self.assertIn(self.park1, queryset)
|
||||
|
||||
# Test combined field search
|
||||
queryset = ParkFilter(data={"search": "Park"}).qs
|
||||
self.assertEqual(queryset.count(), 3)
|
||||
|
||||
# Test empty search
|
||||
queryset = ParkFilter(data={}).qs
|
||||
self.assertEqual(queryset.count(), 3)
|
||||
|
||||
def test_status_filtering(self):
|
||||
"""Test status filter with various values"""
|
||||
# Test each status
|
||||
status_tests = {
|
||||
"OPERATING": [self.park1],
|
||||
"CLOSED_TEMP": [self.park2],
|
||||
"UNDER_CONSTRUCTION": [self.park3],
|
||||
}
|
||||
|
||||
for status, expected_parks in status_tests.items():
|
||||
queryset = ParkFilter(data={"status": status}).qs
|
||||
self.assertEqual(queryset.count(), len(expected_parks))
|
||||
for park in expected_parks:
|
||||
self.assertIn(park, queryset)
|
||||
|
||||
# Test empty status (should return all)
|
||||
queryset = ParkFilter(data={}).qs
|
||||
self.assertEqual(queryset.count(), 3)
|
||||
|
||||
# Test empty string status (should return all)
|
||||
queryset = ParkFilter(data={"status": ""}).qs
|
||||
self.assertEqual(queryset.count(), 3)
|
||||
|
||||
# Test invalid status (should return no results)
|
||||
queryset = ParkFilter(data={"status": "INVALID"}).qs
|
||||
self.assertEqual(queryset.count(), 0)
|
||||
|
||||
def test_date_range_filtering(self):
|
||||
"""Test date range filter functionality"""
|
||||
# Test various date range scenarios
|
||||
test_cases = [
|
||||
# Start date only
|
||||
({"opening_date_after": "2019-01-01"}, [self.park1]),
|
||||
# End date only
|
||||
({"opening_date_before": "2016-01-01"}, [self.park2]),
|
||||
# Date range including one park
|
||||
(
|
||||
{
|
||||
"opening_date_after": "2014-01-01",
|
||||
"opening_date_before": "2016-01-01",
|
||||
},
|
||||
[self.park2],
|
||||
),
|
||||
# Date range including multiple parks
|
||||
(
|
||||
{
|
||||
"opening_date_after": "2014-01-01",
|
||||
"opening_date_before": "2022-01-01",
|
||||
},
|
||||
[self.park1, self.park2],
|
||||
),
|
||||
# Empty filter (should return all)
|
||||
({}, [self.park1, self.park2, self.park3]),
|
||||
# Future date (should return none)
|
||||
({"opening_date_after": "2030-01-01"}, []),
|
||||
]
|
||||
|
||||
for filter_data, expected_parks in test_cases:
|
||||
queryset = ParkFilter(data=filter_data).qs
|
||||
self.assertEqual(
|
||||
set(queryset),
|
||||
set(expected_parks),
|
||||
f"Failed for filter: {filter_data}",
|
||||
)
|
||||
|
||||
# Test invalid date formats
|
||||
invalid_dates = [
|
||||
{"opening_date_after": "invalid-date"},
|
||||
{"opening_date_before": "2023-13-01"}, # Invalid month
|
||||
{"opening_date_after": "2023-01-32"}, # Invalid day
|
||||
{"opening_date_before": "not-a-date"},
|
||||
]
|
||||
|
||||
for invalid_data in invalid_dates:
|
||||
filter_instance = ParkFilter(data=invalid_data)
|
||||
self.assertFalse(
|
||||
filter_instance.is_valid(),
|
||||
f"Filter should be invalid for data: {invalid_data}",
|
||||
)
|
||||
|
||||
def test_numeric_filtering(self):
|
||||
"""Test numeric filters with validation"""
|
||||
# Test minimum rides filter
|
||||
test_cases = [
|
||||
({"min_rides": "18"}, [self.park1]), # Only park1 has >= 18 rides
|
||||
(
|
||||
{"min_rides": "10"},
|
||||
[self.park1, self.park2],
|
||||
), # Both park1 and park2 have >= 10 rides
|
||||
(
|
||||
{"min_rides": "0"},
|
||||
[self.park1, self.park2, self.park3],
|
||||
), # All parks have >= 0 rides
|
||||
# No filter should return all
|
||||
({}, [self.park1, self.park2, self.park3]),
|
||||
]
|
||||
|
||||
for filter_data, expected_parks in test_cases:
|
||||
queryset = ParkFilter(data=filter_data).qs
|
||||
self.assertEqual(
|
||||
set(queryset),
|
||||
set(expected_parks),
|
||||
f"Failed for filter: {filter_data}",
|
||||
)
|
||||
|
||||
# Test coaster count filter
|
||||
queryset = ParkFilter(data={"min_coasters": "3"}).qs
|
||||
self.assertEqual(queryset.count(), 1)
|
||||
self.assertIn(self.park1, queryset)
|
||||
|
||||
# Test size filter
|
||||
queryset = ParkFilter(data={"min_size": "75"}).qs
|
||||
self.assertEqual(queryset.count(), 1)
|
||||
self.assertIn(self.park1, queryset)
|
||||
|
||||
# Test validation
|
||||
invalid_values = ["-1", "invalid", "0.5"]
|
||||
for value in invalid_values:
|
||||
filter_instance = ParkFilter(data={"min_rides": value})
|
||||
self.assertFalse(
|
||||
filter_instance.is_valid(),
|
||||
f"Filter should be invalid for value: {value}",
|
||||
)
|
||||
174
backend/apps/parks/tests_disabled/test_models.py
Normal file
174
backend/apps/parks/tests_disabled/test_models.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Tests for park models functionality including CRUD operations,
|
||||
slug handling, status management, and location integration.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from django.db import IntegrityError
|
||||
|
||||
from apps.parks.models import Park, ParkArea, ParkLocation, Company
|
||||
|
||||
# NOTE: These tests need to be updated to work with the new ParkLocation model
|
||||
# instead of the generic Location model
|
||||
|
||||
|
||||
class ParkModelTests(TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
self.operator = Company.objects.create(name="Test Company", slug="test-company")
|
||||
|
||||
# Create a basic park
|
||||
self.park = Park.objects.create(
|
||||
name="Test Park",
|
||||
description="A test park",
|
||||
status="OPERATING",
|
||||
operator=self.operator,
|
||||
)
|
||||
|
||||
# Create location for the park
|
||||
self.location = ParkLocation.objects.create(
|
||||
park=self.park,
|
||||
street_address="123 Test St",
|
||||
city="Test City",
|
||||
state="Test State",
|
||||
country="Test Country",
|
||||
postal_code="12345",
|
||||
)
|
||||
self.location.set_coordinates(40.7128, -74.0060)
|
||||
self.location.save()
|
||||
|
||||
def test_park_creation(self):
|
||||
"""Test basic park creation and fields"""
|
||||
self.assertEqual(self.park.name, "Test Park")
|
||||
self.assertEqual(self.park.slug, "test-park")
|
||||
self.assertEqual(self.park.status, "OPERATING")
|
||||
self.assertEqual(self.park.operator, self.operator)
|
||||
|
||||
def test_slug_generation(self):
|
||||
"""Test automatic slug generation"""
|
||||
park = Park.objects.create(
|
||||
name="Another Test Park",
|
||||
status="OPERATING",
|
||||
operator=self.operator,
|
||||
)
|
||||
self.assertEqual(park.slug, "another-test-park")
|
||||
|
||||
def test_historical_slug_lookup(self):
|
||||
"""Test finding park by historical slug"""
|
||||
from django.db import transaction
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from core.history import HistoricalSlug
|
||||
|
||||
with transaction.atomic():
|
||||
# Create initial park with a specific name/slug
|
||||
park = Park.objects.create(
|
||||
name="Original Park Name",
|
||||
description="Test description",
|
||||
status="OPERATING",
|
||||
operator=self.operator,
|
||||
)
|
||||
original_slug = park.slug
|
||||
print(f"\nInitial park created with slug: {original_slug}")
|
||||
|
||||
# Ensure we have a save to trigger history
|
||||
park.save()
|
||||
|
||||
# Modify name to trigger slug change
|
||||
park.name = "Updated Park Name"
|
||||
park.save()
|
||||
new_slug = park.slug
|
||||
print(f"Park updated with new slug: {new_slug}")
|
||||
|
||||
# Check HistoricalSlug records
|
||||
historical_slugs = HistoricalSlug.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=park.id,
|
||||
)
|
||||
print(f"Historical slug records: {[h.slug for h in historical_slugs]}")
|
||||
|
||||
# Check pghistory records
|
||||
event_model = getattr(Park, "event_model", None)
|
||||
if event_model:
|
||||
historical_records = event_model.objects.filter(
|
||||
pgh_obj_id=park.id
|
||||
).order_by("-pgh_created_at")
|
||||
print("\nPG History records:")
|
||||
for record in historical_records:
|
||||
print(f"- Event ID: {record.pgh_id}")
|
||||
print(f" Name: {record.name}")
|
||||
print(f" Slug: {record.slug}")
|
||||
print(f" Created At: {record.pgh_created_at}")
|
||||
else:
|
||||
print("\nNo pghistory event model available")
|
||||
|
||||
# Try to find by old slug
|
||||
found_park, is_historical = Park.get_by_slug(original_slug)
|
||||
self.assertEqual(found_park.id, park.id)
|
||||
print(
|
||||
f"Found park by old slug: {found_park.slug}, is_historical: {is_historical}"
|
||||
)
|
||||
self.assertTrue(is_historical)
|
||||
|
||||
# Try current slug
|
||||
found_park, is_historical = Park.get_by_slug(new_slug)
|
||||
self.assertEqual(found_park.id, park.id)
|
||||
print(
|
||||
f"Found park by new slug: {found_park.slug}, is_historical: {is_historical}"
|
||||
)
|
||||
self.assertFalse(is_historical)
|
||||
|
||||
def test_status_color_mapping(self):
|
||||
"""Test status color class mapping"""
|
||||
status_tests = {
|
||||
"OPERATING": "bg-green-100 text-green-800",
|
||||
"CLOSED_TEMP": "bg-yellow-100 text-yellow-800",
|
||||
"CLOSED_PERM": "bg-red-100 text-red-800",
|
||||
"UNDER_CONSTRUCTION": "bg-blue-100 text-blue-800",
|
||||
"DEMOLISHED": "bg-gray-100 text-gray-800",
|
||||
"RELOCATED": "bg-purple-100 text-purple-800",
|
||||
}
|
||||
|
||||
for status, expected_color in status_tests.items():
|
||||
self.park.status = status
|
||||
self.assertEqual(self.park.get_status_color(), expected_color)
|
||||
|
||||
def test_absolute_url(self):
|
||||
"""Test get_absolute_url method"""
|
||||
expected_url = f"/parks/{self.park.slug}/"
|
||||
self.assertEqual(self.park.get_absolute_url(), expected_url)
|
||||
|
||||
|
||||
class ParkAreaModelTests(TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
self.operator = Company.objects.create(
|
||||
name="Test Company 2", slug="test-company-2"
|
||||
)
|
||||
self.park = Park.objects.create(
|
||||
name="Test Park", status="OPERATING", operator=self.operator
|
||||
)
|
||||
self.area = ParkArea.objects.create(
|
||||
park=self.park, name="Test Area", description="A test area"
|
||||
)
|
||||
|
||||
def test_area_creation(self):
|
||||
"""Test basic area creation and fields"""
|
||||
self.assertEqual(self.area.name, "Test Area")
|
||||
self.assertEqual(self.area.slug, "test-area")
|
||||
self.assertEqual(self.area.park, self.park)
|
||||
|
||||
def test_unique_together_constraint(self):
|
||||
"""Test unique_together constraint for park and slug"""
|
||||
from django.db import transaction
|
||||
|
||||
# Try to create area with same slug in same park
|
||||
with transaction.atomic():
|
||||
with self.assertRaises(IntegrityError):
|
||||
ParkArea.objects.create(
|
||||
park=self.park, name="Test Area" # Will generate same slug
|
||||
)
|
||||
|
||||
# Should be able to use same name in different park
|
||||
other_park = Park.objects.create(name="Other Park", operator=self.operator)
|
||||
area = ParkArea.objects.create(park=other_park, name="Test Area")
|
||||
self.assertEqual(area.slug, "test-area")
|
||||
173
backend/apps/parks/tests_disabled/test_search.py
Normal file
173
backend/apps/parks/tests_disabled/test_search.py
Normal file
@@ -0,0 +1,173 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.test import Client
|
||||
|
||||
from apps.parks.models import Park
|
||||
from apps.parks.forms import ParkAutocomplete, ParkSearchForm
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkSearch:
|
||||
def test_autocomplete_results(self, client: Client):
|
||||
"""Test that autocomplete returns correct results"""
|
||||
# Create test parks
|
||||
park1 = Park.objects.create(name="Test Park")
|
||||
park2 = Park.objects.create(name="Another Park")
|
||||
park3 = Park.objects.create(name="Test Garden")
|
||||
|
||||
# Get autocomplete results
|
||||
url = reverse("parks:suggest_parks")
|
||||
response = client.get(url, {"search": "Test"})
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert park1.name in content
|
||||
assert park3.name in content
|
||||
assert park2.name not in content
|
||||
|
||||
def test_search_form_valid(self):
|
||||
"""Test ParkSearchForm validation"""
|
||||
form = ParkSearchForm(data={})
|
||||
assert form.is_valid()
|
||||
|
||||
def test_autocomplete_class(self):
|
||||
"""Test ParkAutocomplete configuration"""
|
||||
ac = ParkAutocomplete()
|
||||
assert ac.model == Park
|
||||
assert "name" in ac.search_attrs
|
||||
|
||||
def test_search_with_filters(self, client: Client):
|
||||
"""Test search works with filters"""
|
||||
park = Park.objects.create(name="Test Park", status="OPERATING")
|
||||
|
||||
# Search with status filter
|
||||
url = reverse("parks:park_list")
|
||||
response = client.get(url, {"park": str(park.pk), "status": "OPERATING"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert park.name in response.content.decode()
|
||||
|
||||
def test_empty_search(self, client: Client):
|
||||
"""Test empty search returns all parks"""
|
||||
Park.objects.create(name="Test Park")
|
||||
Park.objects.create(name="Another Park")
|
||||
|
||||
url = reverse("parks:park_list")
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "Test Park" in content
|
||||
assert "Another Park" in content
|
||||
|
||||
def test_partial_match_search(self, client: Client):
|
||||
"""Test partial matching in search"""
|
||||
Park.objects.create(name="Adventure World")
|
||||
Park.objects.create(name="Water Adventure")
|
||||
|
||||
url = reverse("parks:suggest_parks")
|
||||
response = client.get(url, {"search": "Adv"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "Adventure World" in content
|
||||
assert "Water Adventure" in content
|
||||
|
||||
def test_htmx_request_handling(self, client: Client):
|
||||
"""Test HTMX-specific request handling"""
|
||||
Park.objects.create(name="Test Park")
|
||||
|
||||
url = reverse("parks:suggest_parks")
|
||||
response = client.get(url, {"search": "Test"}, HTTP_HX_REQUEST="true")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Test Park" in response.content.decode()
|
||||
|
||||
def test_view_mode_persistence(self, client: Client):
|
||||
"""Test view mode is maintained during search"""
|
||||
Park.objects.create(name="Test Park")
|
||||
|
||||
url = reverse("parks:park_list")
|
||||
response = client.get(url, {"park": "Test", "view_mode": "list"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'data-view-mode="list"' in response.content.decode()
|
||||
|
||||
def test_suggestion_limit(self, client: Client):
|
||||
"""Test that suggestions are limited to 8 items"""
|
||||
# Create 10 parks
|
||||
for i in range(10):
|
||||
Park.objects.create(name=f"Test Park {i}")
|
||||
|
||||
url = reverse("parks:suggest_parks")
|
||||
response = client.get(url, {"search": "Test"})
|
||||
|
||||
content = response.content.decode()
|
||||
result_count = content.count("Test Park")
|
||||
assert result_count == 8 # Verify limit is enforced
|
||||
|
||||
def test_search_json_format(self, client: Client):
|
||||
"""Test that search returns properly formatted JSON"""
|
||||
park = Park.objects.create(
|
||||
name="Test Park",
|
||||
status="OPERATING",
|
||||
city="Test City",
|
||||
state="Test State",
|
||||
)
|
||||
|
||||
url = reverse("parks:suggest_parks")
|
||||
response = client.get(url, {"search": "Test"})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "results" in data
|
||||
assert len(data["results"]) == 1
|
||||
|
||||
result = data["results"][0]
|
||||
assert result["id"] == str(park.pk)
|
||||
assert result["name"] == "Test Park"
|
||||
assert result["status"] == "Operating"
|
||||
assert result["location"] == park.formatted_location
|
||||
assert result["url"] == reverse("parks:park_detail", kwargs={"slug": park.slug})
|
||||
|
||||
def test_empty_search_json(self, client: Client):
|
||||
"""Test empty search returns empty results array"""
|
||||
url = reverse("parks:suggest_parks")
|
||||
response = client.get(url, {"search": ""})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "results" in data
|
||||
assert len(data["results"]) == 0
|
||||
|
||||
def test_search_format_validation(self, client: Client):
|
||||
"""Test that all fields are properly formatted in search results"""
|
||||
Park.objects.create(
|
||||
name="Test Park",
|
||||
status="OPERATING",
|
||||
city="Test City",
|
||||
state="Test State",
|
||||
country="Test Country",
|
||||
)
|
||||
|
||||
expected_fields = {"id", "name", "status", "location", "url"}
|
||||
|
||||
url = reverse("parks:suggest_parks")
|
||||
response = client.get(url, {"search": "Test"})
|
||||
data = response.json()
|
||||
result = data["results"][0]
|
||||
|
||||
# Check all expected fields are present
|
||||
assert set(result.keys()) == expected_fields
|
||||
|
||||
# Check field types
|
||||
assert isinstance(result["id"], str)
|
||||
assert isinstance(result["name"], str)
|
||||
assert isinstance(result["status"], str)
|
||||
assert isinstance(result["location"], str)
|
||||
assert isinstance(result["url"], str)
|
||||
|
||||
# Check formatted location includes city and state
|
||||
assert "Test City" in result["location"]
|
||||
assert "Test State" in result["location"]
|
||||
108
backend/apps/parks/urls.py
Normal file
108
backend/apps/parks/urls.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from django.urls import path, include
|
||||
from . import views, views_search
|
||||
from apps.rides.views import ParkSingleCategoryListView
|
||||
from .views_roadtrip import (
|
||||
RoadTripPlannerView,
|
||||
CreateTripView,
|
||||
TripDetailView,
|
||||
FindParksAlongRouteView,
|
||||
GeocodeAddressView,
|
||||
ParkDistanceCalculatorView,
|
||||
)
|
||||
|
||||
app_name = "parks"
|
||||
|
||||
urlpatterns = [
|
||||
# Park views with autocomplete search
|
||||
path("", views.ParkListView.as_view(), name="park_list"),
|
||||
path("create/", views.ParkCreateView.as_view(), name="park_create"),
|
||||
# Add park button endpoint (moved before park detail pattern)
|
||||
path("add-park-button/", views.add_park_button, name="add_park_button"),
|
||||
# Location search endpoints
|
||||
path("search/location/", views.location_search, name="location_search"),
|
||||
path(
|
||||
"search/reverse-geocode/",
|
||||
views.reverse_geocode,
|
||||
name="reverse_geocode",
|
||||
),
|
||||
# Areas and search endpoints for HTMX
|
||||
path("areas/", views.get_park_areas, name="get_park_areas"),
|
||||
path("suggest_parks/", views_search.suggest_parks, name="suggest_parks"),
|
||||
path("search/", views.search_parks, name="search_parks"),
|
||||
# Road trip planning URLs
|
||||
path("roadtrip/", RoadTripPlannerView.as_view(), name="roadtrip_planner"),
|
||||
path("roadtrip/create/", CreateTripView.as_view(), name="roadtrip_create"),
|
||||
path(
|
||||
"roadtrip/<str:trip_id>/",
|
||||
TripDetailView.as_view(),
|
||||
name="roadtrip_detail",
|
||||
),
|
||||
# Road trip HTMX endpoints
|
||||
path(
|
||||
"roadtrip/htmx/parks-along-route/",
|
||||
FindParksAlongRouteView.as_view(),
|
||||
name="roadtrip_htmx_parks_along_route",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/geocode/",
|
||||
GeocodeAddressView.as_view(),
|
||||
name="roadtrip_htmx_geocode",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/distance/",
|
||||
ParkDistanceCalculatorView.as_view(),
|
||||
name="roadtrip_htmx_distance",
|
||||
),
|
||||
# Park detail and related views
|
||||
path("<slug:slug>/", views.ParkDetailView.as_view(), name="park_detail"),
|
||||
path("<slug:slug>/edit/", views.ParkUpdateView.as_view(), name="park_update"),
|
||||
path("<slug:slug>/actions/", views.park_actions, name="park_actions"),
|
||||
# Area views
|
||||
path(
|
||||
"<slug:park_slug>/areas/<slug:area_slug>/",
|
||||
views.ParkAreaDetailView.as_view(),
|
||||
name="area_detail",
|
||||
),
|
||||
# Park-specific category URLs
|
||||
path(
|
||||
"<slug:park_slug>/roller_coasters/",
|
||||
ParkSingleCategoryListView.as_view(),
|
||||
{"category": "RC"},
|
||||
name="park_roller_coasters",
|
||||
),
|
||||
path(
|
||||
"<slug:park_slug>/dark_rides/",
|
||||
ParkSingleCategoryListView.as_view(),
|
||||
{"category": "DR"},
|
||||
name="park_dark_rides",
|
||||
),
|
||||
path(
|
||||
"<slug:park_slug>/flat_rides/",
|
||||
ParkSingleCategoryListView.as_view(),
|
||||
{"category": "FR"},
|
||||
name="park_flat_rides",
|
||||
),
|
||||
path(
|
||||
"<slug:park_slug>/water_rides/",
|
||||
ParkSingleCategoryListView.as_view(),
|
||||
{"category": "WR"},
|
||||
name="park_water_rides",
|
||||
),
|
||||
path(
|
||||
"<slug:park_slug>/transports/",
|
||||
ParkSingleCategoryListView.as_view(),
|
||||
{"category": "TR"},
|
||||
name="park_transports",
|
||||
),
|
||||
path(
|
||||
"<slug:park_slug>/others/",
|
||||
ParkSingleCategoryListView.as_view(),
|
||||
{"category": "OT"},
|
||||
name="park_others",
|
||||
),
|
||||
# Include park-specific rides URLs
|
||||
path(
|
||||
"<slug:park_slug>/rides/",
|
||||
include("apps.rides.park_urls", namespace="rides"),
|
||||
),
|
||||
]
|
||||
853
backend/apps/parks/views.py
Normal file
853
backend/apps/parks/views.py
Normal file
@@ -0,0 +1,853 @@
|
||||
from .querysets import get_base_park_queryset
|
||||
from apps.core.mixins import HTMXFilterableMixin
|
||||
from .models.location import ParkLocation
|
||||
from apps.media.models import Photo
|
||||
from apps.moderation.models import EditSubmission
|
||||
from apps.moderation.mixins import (
|
||||
EditSubmissionMixin,
|
||||
PhotoSubmissionMixin,
|
||||
HistoryMixin,
|
||||
)
|
||||
from apps.core.views.views import SlugRedirectMixin
|
||||
from .filters import ParkFilter
|
||||
from .forms import ParkForm
|
||||
from .models import Park, ParkArea, ParkReview as Review
|
||||
from .services import ParkFilterService
|
||||
from django.http import (
|
||||
HttpResponseRedirect,
|
||||
HttpResponse,
|
||||
HttpRequest,
|
||||
JsonResponse,
|
||||
)
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db.models import QuerySet
|
||||
from django.urls import reverse
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from decimal import InvalidOperation
|
||||
from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
||||
import requests
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from typing import Any, Optional, cast, Literal, Dict
|
||||
|
||||
# Constants
|
||||
PARK_DETAIL_URL = "parks:park_detail"
|
||||
PARK_LIST_ITEM_TEMPLATE = "parks/partials/park_list_item.html"
|
||||
REQUIRED_FIELDS_ERROR = (
|
||||
"Please correct the errors below. Required fields are marked with an asterisk (*)."
|
||||
)
|
||||
ALLOWED_ROLES = ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
|
||||
ViewMode = Literal["grid", "list"]
|
||||
|
||||
|
||||
def normalize_osm_result(result: dict) -> dict:
|
||||
"""Normalize OpenStreetMap result to a consistent format with enhanced address details""" # noqa: E501
|
||||
from .location_utils import get_english_name, normalize_coordinate
|
||||
|
||||
# Get address details
|
||||
address = result.get("address", {})
|
||||
|
||||
# Normalize coordinates
|
||||
lat = normalize_coordinate(float(result.get("lat")), 9, 6)
|
||||
lon = normalize_coordinate(float(result.get("lon")), 10, 6)
|
||||
|
||||
# Get English names where possible
|
||||
name = ""
|
||||
if "namedetails" in result:
|
||||
name = get_english_name(result["namedetails"])
|
||||
|
||||
# Build street address from available components
|
||||
street_parts = []
|
||||
if address.get("house_number"):
|
||||
street_parts.append(address["house_number"])
|
||||
if address.get("road") or address.get("street"):
|
||||
street_parts.append(address.get("road") or address.get("street"))
|
||||
elif address.get("pedestrian"):
|
||||
street_parts.append(address["pedestrian"])
|
||||
elif address.get("footway"):
|
||||
street_parts.append(address["footway"])
|
||||
|
||||
# Handle additional address components
|
||||
suburb = address.get("suburb", "")
|
||||
district = address.get("district", "")
|
||||
neighborhood = address.get("neighbourhood", "")
|
||||
|
||||
# Build city from available components
|
||||
city = (
|
||||
address.get("city")
|
||||
or address.get("town")
|
||||
or address.get("village")
|
||||
or address.get("municipality")
|
||||
or ""
|
||||
)
|
||||
|
||||
# Get detailed state/region information
|
||||
state = (
|
||||
address.get("state") or address.get("province") or address.get("region") or ""
|
||||
)
|
||||
|
||||
# Get postal code with fallbacks
|
||||
postal_code = address.get("postcode") or address.get("postal_code") or ""
|
||||
|
||||
return {
|
||||
"display_name": name or result.get("display_name", ""),
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"street": " ".join(street_parts).strip(),
|
||||
"suburb": suburb,
|
||||
"district": district,
|
||||
"neighborhood": neighborhood,
|
||||
"city": city,
|
||||
"state": state,
|
||||
"country": address.get("country", ""),
|
||||
"postal_code": postal_code,
|
||||
}
|
||||
|
||||
|
||||
def get_view_mode(request: HttpRequest) -> ViewMode:
|
||||
"""Get the current view mode from request, defaulting to grid"""
|
||||
view_mode = request.GET.get("view_mode", "grid")
|
||||
return cast(ViewMode, "list" if view_mode == "list" else "grid")
|
||||
|
||||
|
||||
def add_park_button(request: HttpRequest) -> HttpResponse:
|
||||
"""Return the add park button partial template"""
|
||||
return render(request, "parks/partials/add_park_button.html")
|
||||
|
||||
|
||||
def park_actions(request: HttpRequest, slug: str) -> HttpResponse:
|
||||
"""Return the park actions partial template"""
|
||||
park = get_object_or_404(Park, slug=slug)
|
||||
return render(request, "parks/partials/park_actions.html", {"park": park})
|
||||
|
||||
|
||||
def get_park_areas(request: HttpRequest) -> HttpResponse:
|
||||
"""Return park areas as options for a select element"""
|
||||
park_id = request.GET.get("park")
|
||||
if not park_id:
|
||||
return HttpResponse('<option value="">Select a park first</option>')
|
||||
|
||||
try:
|
||||
park = Park.objects.get(id=park_id)
|
||||
areas = park.areas.all()
|
||||
options = ['<option value="">No specific area</option>']
|
||||
options.extend(
|
||||
[f'<option value="{area.id}">{area.name}</option>' for area in areas]
|
||||
)
|
||||
return HttpResponse("\n".join(options))
|
||||
except Park.DoesNotExist:
|
||||
return HttpResponse('<option value="">Invalid park selected</option>')
|
||||
|
||||
|
||||
def location_search(request: HttpRequest) -> JsonResponse:
|
||||
"""Search for locations using OpenStreetMap Nominatim API"""
|
||||
query = request.GET.get("q", "")
|
||||
if not query:
|
||||
return JsonResponse({"results": []})
|
||||
|
||||
response = requests.get(
|
||||
"https://nominatim.openstreetmap.org/search",
|
||||
params={
|
||||
"q": query,
|
||||
"format": "json",
|
||||
"addressdetails": 1,
|
||||
"namedetails": 1,
|
||||
"accept-language": "en",
|
||||
"limit": 10,
|
||||
},
|
||||
headers={"User-Agent": "ThrillWiki/1.0"},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
results = response.json()
|
||||
normalized_results = [normalize_osm_result(result) for result in results]
|
||||
valid_results = [
|
||||
r
|
||||
for r in normalized_results
|
||||
if r["lat"] is not None and r["lon"] is not None
|
||||
]
|
||||
return JsonResponse({"results": valid_results})
|
||||
|
||||
return JsonResponse({"results": []})
|
||||
|
||||
|
||||
def reverse_geocode(request: HttpRequest) -> JsonResponse:
|
||||
"""Reverse geocode coordinates using OpenStreetMap Nominatim API"""
|
||||
try:
|
||||
lat = Decimal(request.GET.get("lat", ""))
|
||||
lon = Decimal(request.GET.get("lon", ""))
|
||||
except (TypeError, ValueError, InvalidOperation):
|
||||
return JsonResponse({"error": "Invalid coordinates"}, status=400)
|
||||
|
||||
if not lat or not lon:
|
||||
return JsonResponse({"error": "Missing coordinates"}, status=400)
|
||||
|
||||
lat = lat.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
|
||||
lon = lon.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
|
||||
|
||||
if lat < -90 or lat > 90:
|
||||
return JsonResponse(
|
||||
{"error": "Latitude must be between -90 and 90"}, status=400
|
||||
)
|
||||
if lon < -180 or lon > 180:
|
||||
return JsonResponse(
|
||||
{"error": "Longitude must be between -180 and 180"}, status=400
|
||||
)
|
||||
|
||||
response = requests.get(
|
||||
"https://nominatim.openstreetmap.org/reverse",
|
||||
params={
|
||||
"lat": str(lat),
|
||||
"lon": str(lon),
|
||||
"format": "json",
|
||||
"addressdetails": 1,
|
||||
"namedetails": 1,
|
||||
"accept-language": "en",
|
||||
},
|
||||
headers={"User-Agent": "ThrillWiki/1.0"},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
normalized_result = normalize_osm_result(result)
|
||||
if normalized_result["lat"] is None or normalized_result["lon"] is None:
|
||||
return JsonResponse({"error": "Invalid coordinates"}, status=400)
|
||||
return JsonResponse(normalized_result)
|
||||
|
||||
return JsonResponse({"error": "Geocoding failed"}, status=500)
|
||||
|
||||
|
||||
class ParkListView(HTMXFilterableMixin, ListView):
|
||||
model = Park
|
||||
template_name = "parks/park_list.html"
|
||||
context_object_name = "parks"
|
||||
filter_class = ParkFilter
|
||||
paginate_by = 20
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.filter_service = ParkFilterService()
|
||||
|
||||
def get_template_names(self) -> list[str]:
|
||||
"""Return park_list_item.html for HTMX requests"""
|
||||
if self.request.htmx:
|
||||
return ["parks/partials/park_list_item.html"]
|
||||
return [self.template_name]
|
||||
|
||||
def get_view_mode(self) -> ViewMode:
|
||||
"""Get the current view mode (grid or list)"""
|
||||
return get_view_mode(self.request)
|
||||
|
||||
def get_queryset(self) -> QuerySet[Park]:
|
||||
"""Get optimized queryset with filter service"""
|
||||
try:
|
||||
# Use filter service for optimized filtering
|
||||
filter_params = dict(self.request.GET.items())
|
||||
queryset = self.filter_service.get_filtered_queryset(filter_params)
|
||||
|
||||
# Also create filterset for form rendering
|
||||
self.filterset = self.filter_class(self.request.GET, queryset=queryset)
|
||||
return self.filterset.qs
|
||||
except Exception as e:
|
||||
messages.error(self.request, f"Error loading parks: {str(e)}")
|
||||
queryset = self.model.objects.none()
|
||||
self.filterset = self.filter_class(self.request.GET, queryset=queryset)
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Add enhanced context with filter stats and suggestions"""
|
||||
try:
|
||||
# Initialize filterset if not exists
|
||||
if not hasattr(self, "filterset"):
|
||||
self.filterset = self.filter_class(
|
||||
self.request.GET, queryset=self.model.objects.none()
|
||||
)
|
||||
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Add filter service data
|
||||
filter_counts = self.filter_service.get_filter_counts()
|
||||
popular_filters = self.filter_service.get_popular_filters()
|
||||
|
||||
context.update(
|
||||
{
|
||||
"view_mode": self.get_view_mode(),
|
||||
"is_search": bool(self.request.GET.get("search")),
|
||||
"search_query": self.request.GET.get("search", ""),
|
||||
"filter_counts": filter_counts,
|
||||
"popular_filters": popular_filters,
|
||||
"total_results": (
|
||||
context.get("paginator").count
|
||||
if context.get("paginator")
|
||||
else 0
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Add filter suggestions for search queries
|
||||
search_query = self.request.GET.get("search", "")
|
||||
if search_query:
|
||||
context["filter_suggestions"] = (
|
||||
self.filter_service.get_filter_suggestions(search_query)
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
except Exception as e:
|
||||
messages.error(self.request, f"Error applying filters: {str(e)}")
|
||||
# Ensure filterset exists in error case
|
||||
if not hasattr(self, "filterset"):
|
||||
self.filterset = self.filter_class(
|
||||
self.request.GET, queryset=self.model.objects.none()
|
||||
)
|
||||
return {
|
||||
"filter": self.filterset,
|
||||
"error": "Unable to apply filters. Please try adjusting your criteria.",
|
||||
"view_mode": self.get_view_mode(),
|
||||
"is_search": bool(self.request.GET.get("search")),
|
||||
"search_query": self.request.GET.get("search", ""),
|
||||
}
|
||||
|
||||
def _get_clean_filter_params(self) -> Dict[str, Any]:
|
||||
"""Extract and clean filter parameters from request."""
|
||||
filter_params = {}
|
||||
|
||||
# Define valid filter fields
|
||||
valid_filters = {
|
||||
"status",
|
||||
"operator",
|
||||
"park_type",
|
||||
"has_coasters",
|
||||
"min_rating",
|
||||
"big_parks_only",
|
||||
"ordering",
|
||||
"search",
|
||||
}
|
||||
|
||||
for param, value in self.request.GET.items():
|
||||
if param in valid_filters and value:
|
||||
# Skip pagination parameter
|
||||
if param == "page":
|
||||
continue
|
||||
|
||||
# Clean and validate the value
|
||||
filter_params[param] = self._clean_filter_value(param, value)
|
||||
|
||||
return {k: v for k, v in filter_params.items() if v is not None}
|
||||
|
||||
def _clean_filter_value(self, param: str, value: str) -> Optional[Any]:
|
||||
"""Clean and validate a single filter value."""
|
||||
if param in ("has_coasters", "big_parks_only"):
|
||||
# Boolean filters
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
elif param == "min_rating":
|
||||
# Numeric filter
|
||||
try:
|
||||
rating = float(value)
|
||||
if 0 <= rating <= 5:
|
||||
return str(rating)
|
||||
except (ValueError, TypeError):
|
||||
pass # Skip invalid ratings
|
||||
return None
|
||||
elif param == "search":
|
||||
# Search filter
|
||||
clean_search = value.strip()
|
||||
return clean_search if clean_search else None
|
||||
else:
|
||||
# String filters
|
||||
return value.strip()
|
||||
|
||||
def _build_filter_query_string(self, filter_params: Dict[str, Any]) -> str:
|
||||
"""Build query string from filter parameters."""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Convert boolean values to strings for URL
|
||||
url_params = {}
|
||||
for key, value in filter_params.items():
|
||||
if isinstance(value, bool):
|
||||
url_params[key] = "true" if value else "false"
|
||||
else:
|
||||
url_params[key] = str(value)
|
||||
|
||||
return urlencode(url_params)
|
||||
|
||||
def _get_pagination_urls(
|
||||
self, page_obj, filter_params: Dict[str, Any]
|
||||
) -> Dict[str, str]:
|
||||
"""Generate pagination URLs that preserve filter state."""
|
||||
|
||||
base_query = self._build_filter_query_string(filter_params)
|
||||
pagination_urls = {}
|
||||
|
||||
if page_obj.has_previous():
|
||||
prev_params = (
|
||||
f"{base_query}&page={page_obj.previous_page_number()}"
|
||||
if base_query
|
||||
else f"page={page_obj.previous_page_number()}"
|
||||
)
|
||||
pagination_urls["previous_url"] = f"?{prev_params}"
|
||||
|
||||
if page_obj.has_next():
|
||||
next_params = (
|
||||
f"{base_query}&page={page_obj.next_page_number()}"
|
||||
if base_query
|
||||
else f"page={page_obj.next_page_number()}"
|
||||
)
|
||||
pagination_urls["next_url"] = f"?{next_params}"
|
||||
|
||||
# First and last page URLs
|
||||
if page_obj.number > 1:
|
||||
first_params = f"{base_query}&page=1" if base_query else "page=1"
|
||||
pagination_urls["first_url"] = f"?{first_params}"
|
||||
|
||||
if page_obj.number < page_obj.paginator.num_pages:
|
||||
last_params = (
|
||||
f"{base_query}&page={page_obj.paginator.num_pages}"
|
||||
if base_query
|
||||
else f"page={page_obj.paginator.num_pages}"
|
||||
)
|
||||
pagination_urls["last_url"] = f"?{last_params}"
|
||||
|
||||
return pagination_urls
|
||||
|
||||
|
||||
def search_parks(request: HttpRequest) -> HttpResponse:
|
||||
"""Search parks and return results using park_list_item.html"""
|
||||
try:
|
||||
search_query = request.GET.get("search", "").strip()
|
||||
if not search_query:
|
||||
return HttpResponse("")
|
||||
|
||||
# Get current view mode from request
|
||||
current_view_mode = request.GET.get("view_mode", "grid")
|
||||
park_filter = ParkFilter(
|
||||
{"search": search_query}, queryset=get_base_park_queryset()
|
||||
)
|
||||
|
||||
parks = park_filter.qs
|
||||
if request.GET.get("quick_search"):
|
||||
parks = parks[:8] # Limit quick search results
|
||||
|
||||
response = render(
|
||||
request,
|
||||
PARK_LIST_ITEM_TEMPLATE,
|
||||
{
|
||||
"parks": parks,
|
||||
"view_mode": current_view_mode,
|
||||
"search_query": search_query,
|
||||
"is_search": True,
|
||||
},
|
||||
)
|
||||
response["HX-Trigger"] = "searchComplete"
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
response = render(
|
||||
request,
|
||||
PARK_LIST_ITEM_TEMPLATE,
|
||||
{
|
||||
"parks": [],
|
||||
"error": f"Error performing search: {str(e)}",
|
||||
"is_search": True,
|
||||
},
|
||||
)
|
||||
response["HX-Trigger"] = "searchError"
|
||||
return response
|
||||
|
||||
|
||||
class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
model = Park
|
||||
form_class = ParkForm
|
||||
template_name = "parks/park_form.html"
|
||||
|
||||
def prepare_changes_data(self, cleaned_data: dict[str, Any]) -> dict[str, Any]:
|
||||
data = cleaned_data.copy()
|
||||
if data.get("owner"):
|
||||
data["owner"] = data["owner"].id
|
||||
if data.get("opening_date"):
|
||||
data["opening_date"] = data["opening_date"].isoformat()
|
||||
if data.get("closing_date"):
|
||||
data["closing_date"] = data["closing_date"].isoformat()
|
||||
decimal_fields = [
|
||||
"latitude",
|
||||
"longitude",
|
||||
"size_acres",
|
||||
"average_rating",
|
||||
]
|
||||
for field in decimal_fields:
|
||||
if data.get(field):
|
||||
data[field] = str(data[field])
|
||||
return data
|
||||
|
||||
def normalize_coordinates(self, form: ParkForm) -> None:
|
||||
if form.cleaned_data.get("latitude"):
|
||||
lat = Decimal(str(form.cleaned_data["latitude"]))
|
||||
form.cleaned_data["latitude"] = lat.quantize(
|
||||
Decimal("0.000001"), rounding=ROUND_DOWN
|
||||
)
|
||||
if form.cleaned_data.get("longitude"):
|
||||
lon = Decimal(str(form.cleaned_data["longitude"]))
|
||||
form.cleaned_data["longitude"] = lon.quantize(
|
||||
Decimal("0.000001"), rounding=ROUND_DOWN
|
||||
)
|
||||
|
||||
def form_valid(self, form: ParkForm) -> HttpResponse:
|
||||
self.normalize_coordinates(form)
|
||||
changes = self.prepare_changes_data(form.cleaned_data)
|
||||
|
||||
submission = EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
submission_type="CREATE",
|
||||
changes=changes,
|
||||
reason=self.request.POST.get("reason", ""),
|
||||
source=self.request.POST.get("source", ""),
|
||||
)
|
||||
|
||||
if (
|
||||
hasattr(self.request.user, "role")
|
||||
and getattr(self.request.user, "role", None) in ALLOWED_ROLES
|
||||
):
|
||||
try:
|
||||
self.object = form.save()
|
||||
submission.object_id = self.object.id
|
||||
submission.status = "APPROVED"
|
||||
submission.handled_by = self.request.user
|
||||
submission.save()
|
||||
|
||||
if form.cleaned_data.get("latitude") and form.cleaned_data.get(
|
||||
"longitude"
|
||||
):
|
||||
# Create or update ParkLocation
|
||||
park_location, created = ParkLocation.objects.get_or_create(
|
||||
park=self.object,
|
||||
defaults={
|
||||
"street_address": form.cleaned_data.get(
|
||||
"street_address", ""
|
||||
),
|
||||
"city": form.cleaned_data.get("city", ""),
|
||||
"state": form.cleaned_data.get("state", ""),
|
||||
"country": form.cleaned_data.get("country", "USA"),
|
||||
"postal_code": form.cleaned_data.get("postal_code", ""),
|
||||
},
|
||||
)
|
||||
park_location.set_coordinates(
|
||||
form.cleaned_data["latitude"],
|
||||
form.cleaned_data["longitude"],
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
Photo.objects.create(
|
||||
image=photo_file,
|
||||
uploaded_by=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=self.object.id,
|
||||
)
|
||||
uploaded_count += 1
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error uploading photo {
|
||||
photo_file.name}: {
|
||||
str(e)}",
|
||||
)
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Successfully created {self.object.name}. "
|
||||
f"Added {uploaded_count} photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error creating park: {
|
||||
str(e)}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
"Your park submission has been sent for review. "
|
||||
"You will be notified when it is approved.",
|
||||
)
|
||||
for field, errors in form.errors.items():
|
||||
for error in errors:
|
||||
messages.error(self.request, f"{field}: {error}")
|
||||
return super().form_invalid(form)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
||||
|
||||
|
||||
class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
||||
model = Park
|
||||
form_class = ParkForm
|
||||
template_name = "parks/park_form.html"
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["is_edit"] = True
|
||||
return context
|
||||
|
||||
def prepare_changes_data(self, cleaned_data: dict[str, Any]) -> dict[str, Any]:
|
||||
data = cleaned_data.copy()
|
||||
if data.get("owner"):
|
||||
data["owner"] = data["owner"].id
|
||||
if data.get("opening_date"):
|
||||
data["opening_date"] = data["opening_date"].isoformat()
|
||||
if data.get("closing_date"):
|
||||
data["closing_date"] = data["closing_date"].isoformat()
|
||||
decimal_fields = [
|
||||
"latitude",
|
||||
"longitude",
|
||||
"size_acres",
|
||||
"average_rating",
|
||||
]
|
||||
for field in decimal_fields:
|
||||
if data.get(field):
|
||||
data[field] = str(data[field])
|
||||
return data
|
||||
|
||||
def normalize_coordinates(self, form: ParkForm) -> None:
|
||||
if form.cleaned_data.get("latitude"):
|
||||
lat = Decimal(str(form.cleaned_data["latitude"]))
|
||||
form.cleaned_data["latitude"] = lat.quantize(
|
||||
Decimal("0.000001"), rounding=ROUND_DOWN
|
||||
)
|
||||
if form.cleaned_data.get("longitude"):
|
||||
lon = Decimal(str(form.cleaned_data["longitude"]))
|
||||
form.cleaned_data["longitude"] = lon.quantize(
|
||||
Decimal("0.000001"), rounding=ROUND_DOWN
|
||||
)
|
||||
|
||||
def form_valid(self, form: ParkForm) -> HttpResponse: # noqa: C901
|
||||
self.normalize_coordinates(form)
|
||||
changes = self.prepare_changes_data(form.cleaned_data)
|
||||
|
||||
submission = EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=self.object.id,
|
||||
submission_type="EDIT",
|
||||
changes=changes,
|
||||
reason=self.request.POST.get("reason", ""),
|
||||
source=self.request.POST.get("source", ""),
|
||||
)
|
||||
|
||||
if (
|
||||
hasattr(self.request.user, "role")
|
||||
and getattr(self.request.user, "role", None) in ALLOWED_ROLES
|
||||
):
|
||||
try:
|
||||
self.object = form.save()
|
||||
submission.status = "APPROVED"
|
||||
submission.handled_by = self.request.user
|
||||
submission.save()
|
||||
|
||||
location_data = {
|
||||
"name": self.object.name,
|
||||
"location_type": "park",
|
||||
"latitude": form.cleaned_data.get("latitude"),
|
||||
"longitude": form.cleaned_data.get("longitude"),
|
||||
"street_address": form.cleaned_data.get("street_address", ""),
|
||||
"city": form.cleaned_data.get("city", ""),
|
||||
"state": form.cleaned_data.get("state", ""),
|
||||
"country": form.cleaned_data.get("country", ""),
|
||||
"postal_code": form.cleaned_data.get("postal_code", ""),
|
||||
}
|
||||
|
||||
# Create or update ParkLocation
|
||||
try:
|
||||
park_location = self.object.location
|
||||
# Update existing location
|
||||
for key, value in location_data.items():
|
||||
if key in ["latitude", "longitude"] and value:
|
||||
continue # Handle coordinates separately
|
||||
if hasattr(park_location, key):
|
||||
setattr(park_location, key, value)
|
||||
|
||||
# Handle coordinates if provided
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
park_location.set_coordinates(
|
||||
float(location_data["latitude"]),
|
||||
float(location_data["longitude"]),
|
||||
)
|
||||
park_location.save()
|
||||
except ParkLocation.DoesNotExist:
|
||||
# Create new ParkLocation
|
||||
coordinates_data = {}
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
coordinates_data = {
|
||||
"latitude": float(location_data["latitude"]),
|
||||
"longitude": float(location_data["longitude"]),
|
||||
}
|
||||
|
||||
# Remove coordinate fields from location_data for creation
|
||||
creation_data = {
|
||||
k: v
|
||||
for k, v in location_data.items()
|
||||
if k not in ["latitude", "longitude"]
|
||||
}
|
||||
creation_data.setdefault("country", "USA")
|
||||
|
||||
park_location = ParkLocation.objects.create(
|
||||
park=self.object, **creation_data
|
||||
)
|
||||
|
||||
if coordinates_data:
|
||||
park_location.set_coordinates(
|
||||
coordinates_data["latitude"],
|
||||
coordinates_data["longitude"],
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
Photo.objects.create(
|
||||
image=photo_file,
|
||||
uploaded_by=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=self.object.id,
|
||||
)
|
||||
uploaded_count += 1
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error uploading photo {
|
||||
photo_file.name}: {
|
||||
str(e)}",
|
||||
)
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Successfully updated {self.object.name}. "
|
||||
f"Added {uploaded_count} new photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error updating park: {
|
||||
str(e)}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Your changes to {self.object.name} have been sent for review. "
|
||||
"You will be notified when they are approved.",
|
||||
)
|
||||
return HttpResponseRedirect(
|
||||
reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
||||
)
|
||||
|
||||
def form_invalid(self, form: ParkForm) -> HttpResponse:
|
||||
messages.error(self.request, REQUIRED_FIELDS_ERROR)
|
||||
for field, errors in form.errors.items():
|
||||
for error in errors:
|
||||
messages.error(self.request, f"{field}: {error}")
|
||||
return super().form_invalid(form)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
||||
|
||||
|
||||
class ParkDetailView(
|
||||
SlugRedirectMixin,
|
||||
EditSubmissionMixin,
|
||||
PhotoSubmissionMixin,
|
||||
HistoryMixin,
|
||||
DetailView,
|
||||
):
|
||||
model = Park
|
||||
template_name = "parks/park_detail.html"
|
||||
context_object_name = "park"
|
||||
|
||||
def get_object(self, queryset: Optional[QuerySet[Park]] = None) -> Park:
|
||||
if queryset is None:
|
||||
queryset = self.get_queryset()
|
||||
slug = self.kwargs.get(self.slug_url_kwarg)
|
||||
if slug is None:
|
||||
raise ObjectDoesNotExist("No slug provided")
|
||||
park, _ = Park.get_by_slug(slug)
|
||||
return park
|
||||
|
||||
def get_queryset(self) -> QuerySet[Park]:
|
||||
return cast(
|
||||
QuerySet[Park],
|
||||
super()
|
||||
.get_queryset()
|
||||
.prefetch_related(
|
||||
"rides", "rides__manufacturer", "photos", "areas", "location"
|
||||
),
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
park = cast(Park, self.object)
|
||||
context["areas"] = park.areas.all()
|
||||
context["rides"] = park.rides.all().order_by("-status", "name")
|
||||
|
||||
if self.request.user.is_authenticated:
|
||||
context["has_reviewed"] = Review.objects.filter(
|
||||
user=self.request.user,
|
||||
park=park,
|
||||
).exists()
|
||||
else:
|
||||
context["has_reviewed"] = False
|
||||
|
||||
return context
|
||||
|
||||
def get_redirect_url_pattern(self) -> str:
|
||||
return PARK_DETAIL_URL
|
||||
|
||||
|
||||
class ParkAreaDetailView(
|
||||
SlugRedirectMixin,
|
||||
EditSubmissionMixin,
|
||||
PhotoSubmissionMixin,
|
||||
HistoryMixin,
|
||||
DetailView,
|
||||
):
|
||||
model = ParkArea
|
||||
template_name = "parks/area_detail.html"
|
||||
context_object_name = "area"
|
||||
slug_url_kwarg = "area_slug"
|
||||
|
||||
def get_object(self, queryset: Optional[QuerySet[ParkArea]] = None) -> ParkArea:
|
||||
if queryset is None:
|
||||
queryset = self.get_queryset()
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
area_slug = self.kwargs.get("area_slug")
|
||||
if park_slug is None or area_slug is None:
|
||||
raise ObjectDoesNotExist("Missing slug")
|
||||
area, _ = ParkArea.get_by_slug(area_slug)
|
||||
if area.park.slug != park_slug:
|
||||
raise ObjectDoesNotExist("Park slug doesn't match")
|
||||
return area
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
return context
|
||||
|
||||
def get_redirect_url_pattern(self) -> str:
|
||||
return PARK_DETAIL_URL
|
||||
|
||||
def get_redirect_url_kwargs(self) -> dict[str, str]:
|
||||
area = cast(ParkArea, self.object)
|
||||
return {"park_slug": area.park.slug, "area_slug": area.slug}
|
||||
480
backend/apps/parks/views_roadtrip.py
Normal file
480
backend/apps/parks/views_roadtrip.py
Normal file
@@ -0,0 +1,480 @@
|
||||
"""
|
||||
Road trip planning views for theme parks.
|
||||
Provides interfaces for creating and managing multi-park road trips.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any, List
|
||||
from django.shortcuts import render
|
||||
from django.http import JsonResponse, HttpRequest, HttpResponse
|
||||
from django.views.generic import TemplateView, View
|
||||
from django.urls import reverse
|
||||
|
||||
from .models import Park
|
||||
from .services.roadtrip import RoadTripService
|
||||
from apps.core.services.map_service import unified_map_service
|
||||
from apps.core.services.data_structures import LocationType, MapFilters
|
||||
|
||||
JSON_DECODE_ERROR_MSG = "Invalid JSON data"
|
||||
PARKS_ALONG_ROUTE_HTML = "parks/partials/parks_along_route.html"
|
||||
|
||||
|
||||
class RoadTripViewMixin:
|
||||
"""Mixin providing common functionality for road trip views."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.roadtrip_service = RoadTripService()
|
||||
|
||||
def get_roadtrip_context(self) -> Dict[str, Any]:
|
||||
"""Get common context data for road trip views."""
|
||||
return {
|
||||
"roadtrip_api_urls": {
|
||||
"create_trip": "/roadtrip/create/",
|
||||
"find_parks_along_route": "/roadtrip/htmx/parks-along-route/",
|
||||
"geocode": "/roadtrip/htmx/geocode/",
|
||||
},
|
||||
"max_parks_per_trip": 10,
|
||||
"default_detour_km": 50,
|
||||
"enable_osm_integration": True,
|
||||
}
|
||||
|
||||
|
||||
class RoadTripPlannerView(RoadTripViewMixin, TemplateView):
|
||||
"""
|
||||
Main road trip planning interface.
|
||||
|
||||
URL: /roadtrip/
|
||||
"""
|
||||
|
||||
template_name = "parks/roadtrip_planner.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(self.get_roadtrip_context(self.request))
|
||||
|
||||
# Get popular parks for suggestions
|
||||
popular_parks = (
|
||||
Park.objects.filter(status="OPERATING", location__isnull=False)
|
||||
.select_related("location", "operator")
|
||||
.order_by("-ride_count")[:20]
|
||||
)
|
||||
|
||||
context.update(
|
||||
{
|
||||
"page_title": "Road Trip Planner",
|
||||
"popular_parks": popular_parks,
|
||||
"countries_with_parks": self._get_countries_with_parks(),
|
||||
"enable_route_optimization": True,
|
||||
"show_distance_estimates": True,
|
||||
}
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
def _get_countries_with_parks(self) -> List[str]:
|
||||
"""Get list of countries that have theme parks."""
|
||||
countries = (
|
||||
Park.objects.filter(status="OPERATING", location__country__isnull=False)
|
||||
.values_list("location__country", flat=True)
|
||||
.distinct()
|
||||
.order_by("location__country")
|
||||
)
|
||||
return list(countries)
|
||||
|
||||
|
||||
class CreateTripView(RoadTripViewMixin, View):
|
||||
"""
|
||||
Generate optimized road trip routes.
|
||||
|
||||
URL: /roadtrip/create/
|
||||
"""
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Create a new road trip with optimized routing."""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
|
||||
# Parse park IDs
|
||||
park_ids = data.get("park_ids", [])
|
||||
if not park_ids or len(park_ids) < 2:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "At least 2 parks are required for a road trip",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if len(park_ids) > 10:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Maximum 10 parks allowed per trip",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Get parks
|
||||
parks = list(
|
||||
Park.objects.filter(
|
||||
id__in=park_ids, location__isnull=False
|
||||
).select_related("location", "operator")
|
||||
)
|
||||
|
||||
if len(parks) != len(park_ids):
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Some parks could not be found or do not have location data",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Create optimized trip
|
||||
trip = self.roadtrip_service.create_multi_park_trip(parks)
|
||||
|
||||
if not trip:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Could not create optimized route for the selected parks",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Convert trip to dict for JSON response
|
||||
trip_data = {
|
||||
"parks": [self._park_to_dict(park) for park in trip.parks],
|
||||
"legs": [self._leg_to_dict(leg) for leg in trip.legs],
|
||||
"total_distance_km": trip.total_distance_km,
|
||||
"total_duration_minutes": trip.total_duration_minutes,
|
||||
"formatted_total_distance": trip.formatted_total_distance,
|
||||
"formatted_total_duration": trip.formatted_total_duration,
|
||||
}
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"data": trip_data,
|
||||
"trip_url": reverse(
|
||||
"parks:roadtrip_detail", kwargs={"trip_id": "temp"}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": JSON_DECODE_ERROR_MSG},
|
||||
status=400,
|
||||
)
|
||||
except Exception as e:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": f"Failed to create trip: {str(e)}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
def _park_to_dict(self, park: Park) -> Dict[str, Any]:
|
||||
"""Convert park instance to dictionary."""
|
||||
return {
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"formatted_location": getattr(park, "formatted_location", ""),
|
||||
"coordinates": park.coordinates,
|
||||
"operator": park.operator.name if park.operator else None,
|
||||
"ride_count": getattr(park, "ride_count", 0),
|
||||
"url": reverse("parks:park_detail", kwargs={"slug": park.slug}),
|
||||
}
|
||||
|
||||
def _leg_to_dict(self, leg) -> Dict[str, Any]:
|
||||
"""Convert trip leg to dictionary."""
|
||||
return {
|
||||
"from_park": self._park_to_dict(leg.from_park),
|
||||
"to_park": self._park_to_dict(leg.to_park),
|
||||
"distance_km": leg.route.distance_km,
|
||||
"duration_minutes": leg.route.duration_minutes,
|
||||
"formatted_distance": leg.route.formatted_distance,
|
||||
"formatted_duration": leg.route.formatted_duration,
|
||||
"geometry": leg.route.geometry,
|
||||
}
|
||||
|
||||
|
||||
class TripDetailView(RoadTripViewMixin, TemplateView):
|
||||
"""
|
||||
Show trip details and map.
|
||||
|
||||
URL: /roadtrip/<trip_id>/
|
||||
"""
|
||||
|
||||
template_name = "parks/trip_detail.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(self.get_roadtrip_context(self.request))
|
||||
|
||||
# For now, this is a placeholder since we don't persist trips
|
||||
# In a full implementation, you would retrieve the trip from database
|
||||
trip_id = kwargs.get("trip_id")
|
||||
|
||||
context.update(
|
||||
{
|
||||
"page_title": f"Road Trip #{trip_id}",
|
||||
"trip_id": trip_id,
|
||||
"message": "Trip details would be loaded here. Currently trips are not persisted.",
|
||||
}
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class FindParksAlongRouteView(RoadTripViewMixin, View):
|
||||
"""
|
||||
HTMX endpoint for route-based park discovery.
|
||||
|
||||
URL: /roadtrip/htmx/parks-along-route/
|
||||
"""
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Find parks along a route between two points."""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
|
||||
start_park_id = data.get("start_park_id")
|
||||
end_park_id = data.get("end_park_id")
|
||||
max_detour_km = min(100, max(10, float(data.get("max_detour_km", 50))))
|
||||
|
||||
if not start_park_id or not end_park_id:
|
||||
return render(
|
||||
request,
|
||||
PARKS_ALONG_ROUTE_HTML,
|
||||
{"error": "Start and end parks are required"},
|
||||
)
|
||||
|
||||
# Get start and end parks
|
||||
try:
|
||||
start_park = Park.objects.select_related("location").get(
|
||||
id=start_park_id, location__isnull=False
|
||||
)
|
||||
end_park = Park.objects.select_related("location").get(
|
||||
id=end_park_id, location__isnull=False
|
||||
)
|
||||
except Park.DoesNotExist:
|
||||
return render(
|
||||
request,
|
||||
PARKS_ALONG_ROUTE_HTML,
|
||||
{"error": "One or both parks could not be found"},
|
||||
)
|
||||
|
||||
# Find parks along route
|
||||
parks_along_route = self.roadtrip_service.find_parks_along_route(
|
||||
start_park, end_park, max_detour_km
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
PARKS_ALONG_ROUTE_HTML,
|
||||
{
|
||||
"parks": parks_along_route,
|
||||
"start_park": start_park,
|
||||
"end_park": end_park,
|
||||
"max_detour_km": max_detour_km,
|
||||
"count": len(parks_along_route),
|
||||
},
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return render(
|
||||
request,
|
||||
PARKS_ALONG_ROUTE_HTML,
|
||||
{"error": JSON_DECODE_ERROR_MSG},
|
||||
)
|
||||
except Exception as e:
|
||||
return render(request, PARKS_ALONG_ROUTE_HTML, {"error": str(e)})
|
||||
|
||||
|
||||
class GeocodeAddressView(RoadTripViewMixin, View):
|
||||
"""
|
||||
HTMX endpoint for geocoding addresses.
|
||||
|
||||
URL: /roadtrip/htmx/geocode/
|
||||
"""
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Geocode an address and find nearby parks."""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
address = data.get("address", "").strip()
|
||||
|
||||
if not address:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Address is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Geocode the address
|
||||
coordinates = self.roadtrip_service.geocode_address(address)
|
||||
|
||||
if not coordinates:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Could not geocode the provided address",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Find nearby parks
|
||||
radius_km = min(200, max(10, float(data.get("radius_km", 100))))
|
||||
|
||||
# Use map service to find parks near coordinates
|
||||
from core.services.data_structures import GeoBounds
|
||||
|
||||
# Create a bounding box around the coordinates
|
||||
lat_delta = radius_km / 111.0 # Rough conversion: 1 degree ≈ 111km
|
||||
lng_delta = radius_km / (111.0 * abs(coordinates.latitude / 90.0))
|
||||
|
||||
bounds = GeoBounds(
|
||||
north=coordinates.latitude + lat_delta,
|
||||
south=coordinates.latitude - lat_delta,
|
||||
east=coordinates.longitude + lng_delta,
|
||||
west=coordinates.longitude - lng_delta,
|
||||
)
|
||||
|
||||
map_response = unified_map_service.get_locations_by_bounds(
|
||||
north=bounds.north,
|
||||
south=bounds.south,
|
||||
east=bounds.east,
|
||||
west=bounds.west,
|
||||
location_types={LocationType.PARK},
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"coordinates": {
|
||||
"latitude": coordinates.latitude,
|
||||
"longitude": coordinates.longitude,
|
||||
},
|
||||
"address": address,
|
||||
"nearby_parks": [
|
||||
loc.to_dict() for loc in map_response.locations[:20]
|
||||
],
|
||||
"radius_km": radius_km,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": JSON_DECODE_ERROR_MSG},
|
||||
status=400,
|
||||
)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
class ParkDistanceCalculatorView(RoadTripViewMixin, View):
|
||||
"""
|
||||
HTMX endpoint for calculating distances between parks.
|
||||
|
||||
URL: /roadtrip/htmx/distance/
|
||||
"""
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Calculate distance and duration between two parks."""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
|
||||
park1_id = data.get("park1_id")
|
||||
park2_id = data.get("park2_id")
|
||||
|
||||
if not park1_id or not park2_id:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Both park IDs are required",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Get parks
|
||||
try:
|
||||
park1 = Park.objects.select_related("location").get(
|
||||
id=park1_id, location__isnull=False
|
||||
)
|
||||
park2 = Park.objects.select_related("location").get(
|
||||
id=park2_id, location__isnull=False
|
||||
)
|
||||
except Park.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "One or both parks could not be found",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Calculate route
|
||||
coords1 = park1.coordinates
|
||||
coords2 = park2.coordinates
|
||||
|
||||
if not coords1 or not coords2:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "One or both parks do not have coordinate data",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
from services.roadtrip import Coordinates
|
||||
|
||||
route = self.roadtrip_service.calculate_route(
|
||||
Coordinates(*coords1), Coordinates(*coords2)
|
||||
)
|
||||
|
||||
if not route:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Could not calculate route between parks",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"distance_km": route.distance_km,
|
||||
"duration_minutes": route.duration_minutes,
|
||||
"formatted_distance": route.formatted_distance,
|
||||
"formatted_duration": route.formatted_duration,
|
||||
"park1": {
|
||||
"name": park1.name,
|
||||
"formatted_location": getattr(
|
||||
park1, "formatted_location", ""
|
||||
),
|
||||
},
|
||||
"park2": {
|
||||
"name": park2.name,
|
||||
"formatted_location": getattr(
|
||||
park2, "formatted_location", ""
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": JSON_DECODE_ERROR_MSG},
|
||||
status=400,
|
||||
)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
59
backend/apps/parks/views_search.py
Normal file
59
backend/apps/parks/views_search.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from django.http import HttpRequest, JsonResponse
|
||||
from django.views.generic import TemplateView
|
||||
from django.urls import reverse
|
||||
|
||||
from .filters import ParkFilter
|
||||
from .forms import ParkSearchForm
|
||||
from .querysets import get_base_park_queryset
|
||||
|
||||
|
||||
class ParkSearchView(TemplateView):
|
||||
"""View for handling park search with autocomplete."""
|
||||
|
||||
template_name = "parks/park_list.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["search_form"] = ParkSearchForm(self.request.GET)
|
||||
|
||||
# Initialize filter with current querystring
|
||||
queryset = get_base_park_queryset()
|
||||
filter_instance = ParkFilter(self.request.GET, queryset=queryset)
|
||||
context["filter"] = filter_instance
|
||||
|
||||
# Apply search if park ID selected via autocomplete
|
||||
park_id = self.request.GET.get("park")
|
||||
if park_id:
|
||||
queryset = filter_instance.qs.filter(id=park_id)
|
||||
else:
|
||||
queryset = filter_instance.qs
|
||||
|
||||
# Handle view mode
|
||||
context["view_mode"] = self.request.GET.get("view_mode", "grid")
|
||||
context["parks"] = queryset
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def suggest_parks(request: HttpRequest) -> JsonResponse:
|
||||
"""Return park search suggestions as JSON."""
|
||||
query = request.GET.get("search", "").strip()
|
||||
if not query:
|
||||
return JsonResponse({"results": []})
|
||||
|
||||
queryset = get_base_park_queryset()
|
||||
filter_instance = ParkFilter({"search": query}, queryset=queryset)
|
||||
parks = filter_instance.qs[:8] # Limit to 8 suggestions
|
||||
|
||||
results = [
|
||||
{
|
||||
"id": str(park.pk),
|
||||
"name": park.name,
|
||||
"status": park.get_status_display(),
|
||||
"location": park.formatted_location or "",
|
||||
"url": reverse("parks:park_detail", kwargs={"slug": park.slug}),
|
||||
}
|
||||
for park in parks
|
||||
]
|
||||
|
||||
return JsonResponse({"results": results})
|
||||
16
backend/apps/parks/views_update.py
Normal file
16
backend/apps/parks/views_update.py
Normal file
@@ -0,0 +1,16 @@
|
||||
def prepare_changes_data(self, cleaned_data):
|
||||
data = cleaned_data.copy()
|
||||
# Convert model instances to IDs for JSON serialization
|
||||
if data.get("owner"):
|
||||
data["owner"] = data["owner"].id
|
||||
# Convert dates to ISO format strings
|
||||
if data.get("opening_date"):
|
||||
data["opening_date"] = data["opening_date"].isoformat()
|
||||
if data.get("closing_date"):
|
||||
data["closing_date"] = data["closing_date"].isoformat()
|
||||
# Convert Decimal fields to strings
|
||||
decimal_fields = ["latitude", "longitude", "size_acres", "average_rating"]
|
||||
for field in decimal_fields:
|
||||
if data.get(field):
|
||||
data[field] = str(data[field])
|
||||
return data
|
||||
Reference in New Issue
Block a user