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:
pacnpal
2025-08-23 18:40:07 -04:00
parent b0e0678590
commit d504d41de2
762 changed files with 142636 additions and 0 deletions

View File

223
backend/apps/parks/admin.py Normal file
View 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)

View File

@@ -0,0 +1 @@
# Parks API module

View 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()

View 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",
),
]

View 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},
)

View 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

View 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
View 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

View 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

View 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
},
]

View 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")
)

View 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
}
}
]
}
]
}

View 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"))

File diff suppressed because it is too large Load Diff

View 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!")

View 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")
)

View 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)

View 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;",
),
]

View 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",
),
),
),
]

View File

@@ -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")},
),
]

View 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",
),
),
]

View 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",
),
),
]

View 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 = []

View 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
]

View 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")

View 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"]),
]

View 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"]),
]

View 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")

View 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}"

View 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")
)

View 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]
)

View 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

View 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"]

View 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

View 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

View 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

View 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)

File diff suppressed because it is too large Load Diff

View 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;
}

View 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 %}

View 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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View 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
View 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)

View 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

View File

@@ -0,0 +1 @@
# Parks app test suite

View 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}",
)

View 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")

View 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
View 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
View 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}

View 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)

View 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})

View 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