Refactor test utilities and enhance ASGI settings

- Cleaned up and standardized assertions in ApiTestMixin for API response validation.
- Updated ASGI settings to use os.environ for setting the DJANGO_SETTINGS_MODULE.
- Removed unused imports and improved formatting in settings.py.
- Refactored URL patterns in urls.py for better readability and organization.
- Enhanced view functions in views.py for consistency and clarity.
- Added .flake8 configuration for linting and style enforcement.
- Introduced type stubs for django-environ to improve type checking with Pylance.
This commit is contained in:
pacnpal
2025-08-20 19:51:59 -04:00
parent 69c07d1381
commit 66ed4347a9
230 changed files with 15094 additions and 11578 deletions

View File

@@ -1,137 +1,218 @@
from django.contrib import admin
from django.contrib.gis.admin import GISModelAdmin
from django.utils.html import format_html
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'),
("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')
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',)
}),
("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'
latitude.short_description = "Latitude"
def longitude(self, obj):
return obj.longitude
longitude.short_description = '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',)}
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'
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',)}
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',
("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')
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',)
}),
("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',)}
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'
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'):
if hasattr(obj, "headquarters"):
return obj.headquarters.location_display
return 'No headquarters'
headquarters_location.short_description = 'Headquarters'
return "No headquarters"
headquarters_location.short_description = "Headquarters"
# Register the models with their admin classes

View File

@@ -4,52 +4,53 @@ Separates Input and Output serializers for clear boundaries.
"""
from rest_framework import serializers
from django.contrib.gis.geos import Point
from ..models import Park, ParkArea, Company, ParkReview
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:
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:
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:
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:
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:
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:
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()
@@ -58,6 +59,7 @@ class CompanyOutputSerializer(serializers.Serializer):
class ParkAreaOutputSerializer(serializers.Serializer):
"""Output serializer for park area data."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
@@ -66,23 +68,26 @@ class ParkAreaOutputSerializer(serializers.Serializer):
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)
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()
@@ -90,34 +95,39 @@ class ParkListOutputSerializer(serializers.Serializer):
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)
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)
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()
@@ -125,171 +135,170 @@ class ParkDetailOutputSerializer(serializers.Serializer):
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"
)
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)
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
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')
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
)
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)
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
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')
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
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,
max_digits=3,
decimal_places=2,
required=False,
min_value=1,
max_value=10
max_value=10,
)
# Size filter
min_size_acres = serializers.DecimalField(
max_digits=10,
decimal_places=2,
required=False,
min_value=0
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
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'
"name",
"-name",
"opening_date",
"-opening_date",
"average_rating",
"-average_rating",
"coaster_count",
"-coaster_count",
"created_at",
"-created_at",
],
required=False,
default='name'
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
"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)
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

@@ -7,55 +7,59 @@ from rest_framework.routers import DefaultRouter
from .views import (
ParkListApi,
ParkDetailApi,
ParkDetailApi,
ParkCreateApi,
ParkUpdateApi,
ParkDeleteApi,
ParkApi
ParkApi,
)
app_name = 'parks_api'
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')
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')
router_unified.register(r"parks", ParkApi, basename="park")
# Use unified approach for cleaner URLs
urlpatterns = [
path('v1/', include(router_unified.urls)),
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'),
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'),
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'),
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'),
path(
"v1/parks/<slug:slug>/reviews/",
ParkApi.as_view({"get": "reviews"}),
name="park-reviews",
),
]

View File

@@ -3,30 +3,29 @@ Parks API views following Django styleguide patterns.
Uses ClassNameApi naming convention and proper Input/Output serializers.
"""
from typing import Any, Dict
from rest_framework import status
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 rest_framework.permissions import (
IsAuthenticated,
IsAuthenticatedOrReadOnly,
)
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
from core.api.mixins import (
ApiMixin,
CreateApiMixin,
UpdateApiMixin,
ListApiMixin,
CreateApiMixin,
UpdateApiMixin,
ListApiMixin,
RetrieveApiMixin,
DestroyApiMixin
DestroyApiMixin,
)
from ..selectors import (
park_list_with_stats,
park_detail_optimized,
park_reviews_for_park,
park_statistics
park_statistics,
)
from ..services import ParkService
from .serializers import (
@@ -36,165 +35,148 @@ from .serializers import (
ParkUpdateInputSerializer,
ParkFilterInputSerializer,
ParkReviewOutputSerializer,
ParkStatsOutputSerializer
ParkStatsOutputSerializer,
)
class ParkListApi(
ListApiMixin,
GenericViewSet
):
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']
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'])
@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
metadata={"cache_duration": 3600}, # 1 hour cache hint
)
class ParkDetailApi(
RetrieveApiMixin,
GenericViewSet
):
class ParkDetailApi(RetrieveApiMixin, GenericViewSet):
"""
API endpoint for retrieving individual park details.
GET /api/v1/parks/{id}/
"""
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = 'slug'
lookup_field = "slug"
OutputSerializer = ParkDetailOutputSerializer
def get_object(self):
"""Use selector for optimized detail query."""
slug = self.kwargs.get('slug')
slug = self.kwargs.get("slug")
return park_detail_optimized(slug=slug)
@action(detail=True, methods=['get'])
@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
}
metadata={"total_reviews": len(reviews), "park_name": park.name},
)
class ParkCreateApi(
CreateApiMixin,
GenericViewSet
):
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
):
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'
lookup_field = "slug"
InputSerializer = ParkUpdateInputSerializer
OutputSerializer = ParkDetailOutputSerializer
def get_object(self):
"""Use selector for optimized detail query."""
slug = self.kwargs.get('slug')
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
)
return ParkService.update_park(park_id=instance.id, **validated_data)
class ParkDeleteApi(
DestroyApiMixin,
RetrieveApiMixin,
GenericViewSet
):
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'
lookup_field = "slug"
def get_object(self):
"""Use selector for optimized detail query."""
slug = self.kwargs.get('slug')
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)
@@ -207,11 +189,11 @@ class ParkApi(
ListApiMixin,
RetrieveApiMixin,
DestroyApiMixin,
GenericViewSet
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
@@ -219,96 +201,95 @@ class ParkApi(
PATCH /api/v1/parks/{slug}/ - Partial update park
DELETE /api/v1/parks/{slug}/ - Delete park
"""
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = 'slug'
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']
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':
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')
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':
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']:
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
)
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'])
@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}
data=serializer.data, metadata={"cache_duration": 3600}
)
@action(detail=True, methods=['get'])
@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
}
metadata={"total_reviews": len(reviews), "park_name": park.name},
)

View File

@@ -1,8 +1,9 @@
from django.apps import AppConfig
class ParksConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'parks'
default_auto_field = "django.db.models.BigAutoField"
name = "parks"
def ready(self):
import parks.signals # Register signals
import parks.signals # noqa: F401 - Register signals

View File

@@ -10,119 +10,122 @@ from django_filters import (
ChoiceFilter,
FilterSet,
CharFilter,
BooleanFilter
BooleanFilter,
)
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'))
raise ValidationError(_("Value must be a positive integer"))
return int(value)
except (TypeError, ValueError):
raise ValidationError(_('Invalid number format'))
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',
method="filter_search",
label=_("Search Parks"),
help_text=_("Search by park name, description, or location")
help_text=_("Search by park name, description, or location"),
)
# Status filter with clearer label
status = ChoiceFilter(
field_name='status',
choices=Park._meta.get_field('status').choices,
empty_label=_('Any status'),
field_name="status",
choices=Park._meta.get_field("status").choices,
empty_label=_("Any status"),
label=_("Operating Status"),
help_text=_("Filter parks by their current operating status")
help_text=_("Filter parks by their current operating status"),
)
# Operator filters with helpful descriptions
operator = ModelChoiceFilter(
field_name='operating_company',
queryset=Company.objects.filter(roles__contains=['OPERATOR']),
empty_label=_('Any operator'),
field_name="operating_company",
queryset=Company.objects.filter(roles__contains=["OPERATOR"]),
empty_label=_("Any operator"),
label=_("Operating Company"),
help_text=_("Filter parks by their operating company")
help_text=_("Filter parks by their operating company"),
)
has_operator = BooleanFilter(
method='filter_has_operator',
method="filter_has_operator",
label=_("Operator Status"),
help_text=_("Show parks with or without an operating company")
help_text=_("Show parks with or without an operating company"),
)
# Ride and attraction filters
min_rides = NumberFilter(
field_name='ride_count',
lookup_expr='gte',
field_name="ride_count",
lookup_expr="gte",
validators=[validate_positive_integer],
label=_("Minimum Rides"),
help_text=_("Show parks with at least this many rides")
help_text=_("Show parks with at least this many rides"),
)
min_coasters = NumberFilter(
field_name='coaster_count',
lookup_expr='gte',
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")
help_text=_("Show parks with at least this many roller coasters"),
)
# Size filter
min_size = NumberFilter(
field_name='size_acres',
lookup_expr='gte',
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")
help_text=_("Show parks of at least this size in acres"),
)
# Opening date filter with better label
opening_date = DateFromToRangeFilter(
field_name='opening_date',
field_name="opening_date",
label=_("Opening Date Range"),
help_text=_("Filter parks by their opening date")
help_text=_("Filter parks by their opening date"),
)
# Location-based filters
location_search = CharFilter(
method='filter_location_search',
method="filter_location_search",
label=_("Location Search"),
help_text=_("Search by city, state, country, or address")
help_text=_("Search by city, state, country, or address"),
)
near_location = CharFilter(
method='filter_near_location',
method="filter_near_location",
label=_("Near Location"),
help_text=_("Find parks near a specific location")
help_text=_("Find parks near a specific location"),
)
radius_km = NumberFilter(
method='filter_radius',
method="filter_radius",
label=_("Radius (km)"),
help_text=_("Search radius in kilometers (use with 'Near Location')")
help_text=_("Search radius in kilometers (use with 'Near Location')"),
)
country_filter = CharFilter(
method='filter_country',
method="filter_country",
label=_("Country"),
help_text=_("Filter parks by country")
help_text=_("Filter parks by country"),
)
state_filter = CharFilter(
method='filter_state',
method="filter_state",
label=_("State/Region"),
help_text=_("Filter parks by state or region")
help_text=_("Filter parks by state or region"),
)
def filter_search(self, queryset, name, value):
@@ -131,109 +134,115 @@ class ParkFilter(FilterSet):
return queryset
search_fields = [
'name__icontains',
'description__icontains',
'location__city__icontains',
'location__state__icontains',
'location__country__icontains'
"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(operating_company__isnull=not value)
@property
def qs(self):
"""Override qs property to ensure we always use base queryset with annotations"""
if not hasattr(self, '_qs'):
if not hasattr(self, "_qs"):
# Start with the base queryset that includes annotations
base_qs = get_base_park_queryset()
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']:
if value in [None, "", 0] and name not in ["has_operator"]:
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)
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)
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.functions.Cast(
models.functions.Extract(
models.F('location__point').distance(point) * 111.32, # Convert degrees to km
'epoch'
),
models.FloatField()
return (
queryset.filter(location__point__distance_lte=(point, distance))
.annotate(
distance=models.functions.Cast(
models.functions.Extract(
models.F("location__point").distance(point)
* 111.32, # Convert degrees to km
"epoch",
),
models.FloatField(),
)
)
).order_by('distance').distinct()
.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 _geocode_location(self, location_string):
"""
Geocode a location string using OpenStreetMap Nominatim.
@@ -243,22 +252,22 @@ class ParkFilter(FilterSet):
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
"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
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'])
return float(result["lat"]), float(result["lon"])
except Exception:
# Silently fail geocoding - just return None
pass
return None
return None

View File

@@ -1,8 +1,6 @@
from django import forms
from decimal import Decimal, InvalidOperation, ROUND_DOWN
from autocomplete import AutocompleteWidget
from django import forms
from .models import Park
from .models.location import ParkLocation
from .querysets import get_base_park_queryset
@@ -10,106 +8,131 @@ 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
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'))
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}"
"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...'}
)
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()
widget=forms.HiddenInput(),
)
longitude = forms.DecimalField(
max_digits=10,
decimal_places=6,
required=False,
widget=forms.HiddenInput()
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"
"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"
"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"
"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"
"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": (
"w-full border-gray-300 rounded-lg form-input "
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
)
}
)
),
)
class Meta:
@@ -137,58 +160,88 @@ class ParkForm(forms.ModelForm):
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"
"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",
"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"
"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"
"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"
"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",
"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",
"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",
"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",
"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",
"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",
}
),
@@ -199,27 +252,29 @@ class ParkForm(forms.ModelForm):
# 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
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')
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)
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.")
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:
@@ -227,18 +282,20 @@ class ParkForm(forms.ModelForm):
return latitude
def clean_longitude(self):
longitude = self.cleaned_data.get('longitude')
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)
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.")
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:
@@ -247,66 +304,65 @@ class ParkForm(forms.ModelForm):
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'),
"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:
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']:
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'])
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']:
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'])
"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
)
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']
coordinates_data["latitude"], coordinates_data["longitude"]
)
park_location.save()
if commit:
park.save()
return park

View File

@@ -6,12 +6,12 @@ def normalize_coordinate(value, max_digits, 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)
value = value.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
return float(value)
except (TypeError, ValueError, InvalidOperation):
return None
@@ -20,36 +20,36 @@ def normalize_coordinate(value, max_digits, decimal_places):
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']
if "name:en" in tags:
return tags["name:en"]
# Then try int_name (international name)
if 'int_name' in tags:
return tags['int_name']
if "int_name" in tags:
return tags["int_name"]
# Fall back to default name
return tags.get('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)
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', {})
address = result.get("address", {})
# Normalize place names to English where possible
if 'namedetails' in result:
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["display_name"] = get_english_name(result["namedetails"])
result['address'] = address
# 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

@@ -1,19 +1,15 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from django.db import transaction
from datetime import date, timedelta
import random
from decimal import Decimal
# Import models from both apps
from parks.models import Company as ParkCompany, Park, ParkArea, ParkReview
from parks.models.location import ParkLocation
from rides.models import Company as RideCompany, Ride, RideModel, RideReview, RollerCoasterStats
from accounts.models import User
from parks.models import Company as ParkCompany
from rides.models import (
Company as RideCompany,
)
class Command(BaseCommand):
help = 'Creates comprehensive sample data for the ThrillWiki theme park application'
help = "Creates comprehensive sample data for the ThrillWiki theme park application"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -22,7 +18,7 @@ class Command(BaseCommand):
self.created_rides = {}
def handle(self, *args, **options):
self.stdout.write('Starting sample data creation...')
self.stdout.write("Starting sample data creation...")
try:
with transaction.atomic():
@@ -33,285 +29,294 @@ class Command(BaseCommand):
self.create_park_areas()
self.create_reviews()
self.stdout.write(self.style.SUCCESS('Successfully created comprehensive sample data!'))
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}'))
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...')
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": "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": "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": "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": "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": "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": "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,
"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
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}')
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": "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": "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": "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": "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": "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": "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": "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": "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": "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',
"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
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}')
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...')
self.stdout.write("Creating parks...")
parks_data = [
# 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": "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": "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": "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": "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": "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,
"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

@@ -3,26 +3,34 @@ from django.db import connection
class Command(BaseCommand):
help = 'Fix migration history'
help = "Fix migration history"
def handle(self, *args, **options):
with connection.cursor() as cursor:
# Drop existing historical tables
cursor.execute("""
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
cursor.execute(
"""
DELETE FROM django_migrations
WHERE app = 'parks';
""")
"""
)
# Insert the new initial migration
cursor.execute("""
cursor.execute(
"""
INSERT INTO django_migrations (app, name, applied)
VALUES ('parks', '0001_initial', NOW());
""")
"""
)
self.stdout.write(self.style.SUCCESS('Successfully fixed migration history'))
self.stdout.write(
self.style.SUCCESS("Successfully fixed migration history")
)

View File

@@ -1,246 +1,334 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from parks.models.companies import Operator
from parks.models import Park, ParkArea
from parks.models.location import ParkLocation
from parks.models import Park, ParkArea, ParkLocation, Company as Operator
class Command(BaseCommand):
help = 'Seeds initial park data with major theme parks worldwide'
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": "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": "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": "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": "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": "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.'
}
"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
name=company_data["name"], defaults=company_data
)
companies[operator.name] = operator
self.stdout.write(f'{"Created" if created else "Found"} company: {operator.name}')
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
"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'}
]
"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
"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'}
]
"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
"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'}
]
"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
"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'}
]
"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
"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'}
]
"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
"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'}
]
}
"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']]
company = companies[park_data["company"]]
park, created = Park.objects.get_or_create(
name=park_data['name'],
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
}
"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']
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']
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']
loc_data["latitude"], loc_data["longitude"]
)
park_location.save()
# Create areas for park
for area_data in park_data['areas']:
for area_data in park_data["areas"]:
area, created = ParkArea.objects.get_or_create(
name=area_data['name'],
name=area_data["name"],
park=park,
defaults={
'description': area_data['description']
}
defaults={"description": area_data["description"]},
)
self.stdout.write(
f'{"Created" if created else "Found"} area: {area.name} in {park.name}'
)
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'))
self.stdout.write(self.style.SUCCESS("Successfully seeded initial park data"))

File diff suppressed because it is too large Load Diff

View File

@@ -1,96 +1,97 @@
from django.core.management.base import BaseCommand
from parks.models import Park, ParkLocation
from parks.models.companies import Company
from parks.models import Park, ParkLocation, Company
class Command(BaseCommand):
help = 'Test ParkLocation model functionality'
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']
}
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
}
"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'
}
"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 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']
}
"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
}
"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'
}
"city": "Valencia",
"state": "California",
"country": "USA",
},
)
location2.set_coordinates(34.4244, -118.5971) # Six Flags Magic Mountain coordinates
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)
@@ -98,22 +99,26 @@ class Command(BaseCommand):
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
search_point = Point(-117.9190, 33.8121, srid=4326) # Same as Disneyland
# 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")
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!")
self.stdout.write("\n✅ ParkLocation model tests completed successfully!")

View File

@@ -1,34 +1,29 @@
from django.core.management.base import BaseCommand
from django.db.models import Count, Q
from django.db.models import Q
from parks.models import Park
class Command(BaseCommand):
help = 'Update total_rides and total_roller_coasters counts for all parks'
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')
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()
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
total_rides=total_rides, total_roller_coasters=total_coasters
)
updated += 1
self.stdout.write(
self.style.SUCCESS(
f'Successfully updated counts for {updated} parks'
)
self.style.SUCCESS(f"Successfully updated counts for {updated} parks")
)

View File

@@ -3,171 +3,188 @@ Custom managers and QuerySets for Parks models.
Optimized queries following Django styleguide patterns.
"""
from typing import Optional, List, Dict, Any, Union
from django.db import models
from django.db.models import Q, F, Count, Avg, Max, Min, Prefetch
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import Distance
from django.db.models import Q, Count, Avg, Max, Min, Prefetch
from core.managers import (
BaseQuerySet, BaseManager, LocationQuerySet, LocationManager,
ReviewableQuerySet, ReviewableManager, StatusQuerySet, StatusManager
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),
ride_count_calculated=Count("rides", distinct=True),
coaster_count_calculated=Count(
'rides',
filter=Q(rides__category__in=['RC', 'WC']),
distinct=True
"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')
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()
return (
self.select_related("operator", "property_owner")
.prefetch_related("location")
.with_complete_stats()
)
def optimized_for_detail(self):
"""Optimize for park detail display."""
from rides.models import Ride
from .models import ParkReview
return self.select_related(
'operator',
'property_owner'
).prefetch_related(
'location',
'areas',
return self.select_related("operator", "property_owner").prefetch_related(
"location",
"areas",
Prefetch(
'rides',
"rides",
queryset=Ride.objects.select_related(
'manufacturer', 'designer', 'ride_model', 'park_area'
).order_by('name')
"manufacturer", "designer", "ride_model", "park_area"
).order_by("name"),
),
Prefetch(
'reviews',
queryset=ParkReview.objects.select_related('user')
"reviews",
queryset=ParkReview.objects.select_related("user")
.filter(is_published=True)
.order_by('-created_at')[:10]
.order_by("-created_at")[:10],
),
'photos'
"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)
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='')
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')
queryset = self.select_related("operator").prefetch_related("location")
if bounds:
queryset = queryset.within_bounds(
north=bounds.north,
south=bounds.south,
south=bounds.south,
east=bounds.east,
west=bounds.west
west=bounds.west,
)
return queryset.values(
'id', 'name', 'slug', 'status',
'location__latitude', 'location__longitude',
'location__city', 'location__state', 'location__country',
'operator__name'
"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]
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),
ride_count=Count("rides", distinct=True),
coaster_count=Count(
'rides',
filter=Q(rides__category__in=['RC', 'WC']),
distinct=True
)
"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()
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()
@@ -175,91 +192,95 @@ class ParkAreaQuerySet(BaseQuerySet):
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')
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)
)
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)
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'])
return self.filter(roles__contains=["OPERATOR"])
def property_owners(self):
"""Filter for companies that own park properties."""
return self.filter(roles__contains=['PROPERTY_OWNER'])
return self.filter(roles__contains=["PROPERTY_OWNER"])
def manufacturers(self):
"""Filter for ride manufacturers."""
return self.filter(roles__contains=['MANUFACTURER'])
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)
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)
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()
@@ -267,15 +288,15 @@ class CompanyQuerySet(BaseQuerySet):
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

@@ -53,7 +53,10 @@ class Migration(migrations.Migration):
),
("description", models.TextField(blank=True)),
("website", models.URLField(blank=True)),
("founded_year", models.PositiveIntegerField(blank=True, null=True)),
(
"founded_year",
models.PositiveIntegerField(blank=True, null=True),
),
("parks_count", models.IntegerField(default=0)),
("rides_count", models.IntegerField(default=0)),
],
@@ -94,7 +97,10 @@ class Migration(migrations.Migration):
),
("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)),
(
"operating_season",
models.CharField(blank=True, max_length=255),
),
(
"size_acres",
models.DecimalField(
@@ -110,7 +116,10 @@ class Migration(migrations.Migration):
),
("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)),
(
"created_at",
models.DateTimeField(auto_now_add=True, null=True),
),
("updated_at", models.DateTimeField(auto_now=True)),
(
"operator",
@@ -174,7 +183,10 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="ParkAreaEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
(
"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()),
@@ -222,7 +234,10 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="ParkEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
(
"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()),
@@ -246,7 +261,10 @@ class Migration(migrations.Migration):
),
("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)),
(
"operating_season",
models.CharField(blank=True, max_length=255),
),
(
"size_acres",
models.DecimalField(
@@ -262,7 +280,10 @@ class Migration(migrations.Migration):
),
("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)),
(
"created_at",
models.DateTimeField(auto_now_add=True, null=True),
),
("updated_at", models.DateTimeField(auto_now=True)),
(
"operator",
@@ -335,7 +356,10 @@ class Migration(migrations.Migration):
srid=4326,
),
),
("street_address", models.CharField(blank=True, max_length=255)),
(
"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)),
@@ -431,7 +455,10 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="ParkReviewEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
(
"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()),
@@ -531,7 +558,9 @@ class Migration(migrations.Migration):
(
"city",
models.CharField(
db_index=True, help_text="Headquarters city", max_length=100
db_index=True,
help_text="Headquarters city",
max_length=100,
),
),
(
@@ -555,7 +584,9 @@ class Migration(migrations.Migration):
(
"postal_code",
models.CharField(
blank=True, help_text="ZIP or postal code", max_length=20
blank=True,
help_text="ZIP or postal code",
max_length=20,
),
),
(
@@ -582,7 +613,8 @@ class Migration(migrations.Migration):
"ordering": ["company__name"],
"indexes": [
models.Index(
fields=["city", "country"], name="parks_compa_city_cf9a4e_idx"
fields=["city", "country"],
name="parks_compa_city_cf9a4e_idx",
)
],
},

View File

@@ -30,7 +30,9 @@ class Migration(migrations.Migration):
model_name="park",
constraint=models.CheckConstraint(
condition=models.Q(
("size_acres__isnull", True), ("size_acres__gt", 0), _connector="OR"
("size_acres__isnull", True),
("size_acres__gt", 0),
_connector="OR",
),
name="park_size_positive",
violation_error_message="Park size must be positive",
@@ -97,7 +99,10 @@ class Migration(migrations.Migration):
model_name="parkreview",
constraint=models.CheckConstraint(
condition=models.Q(
("visit_date__lte", django.db.models.functions.datetime.Now())
(
"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",
@@ -108,10 +113,12 @@ class Migration(migrations.Migration):
constraint=models.CheckConstraint(
condition=models.Q(
models.Q(
("moderated_at__isnull", True), ("moderated_by__isnull", True)
("moderated_at__isnull", True),
("moderated_by__isnull", True),
),
models.Q(
("moderated_at__isnull", False), ("moderated_by__isnull", False)
("moderated_at__isnull", False),
("moderated_by__isnull", False),
),
_connector="OR",
),

View File

@@ -18,7 +18,10 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="CompanyEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
(
"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()),
@@ -43,7 +46,10 @@ class Migration(migrations.Migration):
),
("description", models.TextField(blank=True)),
("website", models.URLField(blank=True)),
("founded_year", models.PositiveIntegerField(blank=True, null=True)),
(
"founded_year",
models.PositiveIntegerField(blank=True, null=True),
),
("parks_count", models.IntegerField(default=0)),
("rides_count", models.IntegerField(default=0)),
],

View File

@@ -1,5 +1,31 @@
from .location import *
from .areas import *
from .parks import *
from .reviews import *
from .companies import *
"""
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

@@ -1,18 +1,17 @@
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from typing import Tuple, Any
import pghistory
from 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")
@@ -31,4 +30,4 @@ class ParkArea(TrackedModel):
return self.name
class Meta:
unique_together = ('park', 'slug')
unique_together = ("park", "slug")

View File

@@ -14,15 +14,15 @@ class Company(TrackedModel):
objects = CompanyManager()
class CompanyRole(models.TextChoices):
OPERATOR = 'OPERATOR', 'Park Operator'
PROPERTY_OWNER = 'PROPERTY_OWNER', 'Property Owner'
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
blank=True,
)
description = models.TextField(blank=True)
website = models.URLField(blank=True)
@@ -41,8 +41,8 @@ class Company(TrackedModel):
return self.name
class Meta:
ordering = ['name']
verbose_name_plural = 'Companies'
ordering = ["name"]
verbose_name_plural = "Companies"
class CompanyHeadquarters(models.Model):
@@ -50,46 +50,41 @@ 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'
"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"
help_text="Mailing address if publicly available",
)
city = models.CharField(
max_length=100,
db_index=True,
help_text="Headquarters city"
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"
help_text="State/Province/Region",
)
country = models.CharField(
max_length=100,
default='USA',
default="USA",
db_index=True,
help_text="Country where headquarters is located"
help_text="Country where headquarters is located",
)
postal_code = models.CharField(
max_length=20,
blank=True,
help_text="ZIP or postal code"
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"
help_text="Complete mailing address if different from basic address",
)
# Metadata
@@ -108,9 +103,15 @@ class CompanyHeadquarters(models.Model):
components.append(self.state_province)
if self.postal_code:
components.append(self.postal_code)
if self.country and self.country != 'USA':
if self.country and self.country != "USA":
components.append(self.country)
return ", ".join(components) if components else f"{self.city}, {self.country}"
return (
", ".join(components)
if components
else f"{
self.city}, {
self.country}"
)
@property
def location_display(self):
@@ -118,7 +119,7 @@ class CompanyHeadquarters(models.Model):
parts = [self.city]
if self.state_province:
parts.append(self.state_province)
elif self.country != 'USA':
elif self.country != "USA":
parts.append(self.country)
return ", ".join(parts) if parts else "Unknown Location"
@@ -128,7 +129,7 @@ class CompanyHeadquarters(models.Model):
class Meta:
verbose_name = "Company Headquarters"
verbose_name_plural = "Company Headquarters"
ordering = ['company__name']
ordering = ["company__name"]
indexes = [
models.Index(fields=['city', 'country']),
models.Index(fields=["city", "country"]),
]

View File

@@ -1,17 +1,14 @@
from django.contrib.gis.db import models
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D
from django.core.validators import MinValueValidator, MaxValueValidator
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'
"parks.Park", on_delete=models.CASCADE, related_name="location"
)
# Spatial Data
@@ -19,14 +16,14 @@ class ParkLocation(models.Model):
srid=4326,
null=True,
blank=True,
help_text="Geographic coordinates (longitude, latitude)"
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')
country = models.CharField(max_length=100, default="USA")
postal_code = models.CharField(max_length=20, blank=True)
# Road Trip Metadata
@@ -40,7 +37,7 @@ class ParkLocation(models.Model):
osm_type = models.CharField(
max_length=10,
blank=True,
help_text="Type of OpenStreetMap object (node, way, or relation)"
help_text="Type of OpenStreetMap object (node, way, or relation)",
)
@property
@@ -72,7 +69,7 @@ class ParkLocation(models.Model):
self.city,
self.state,
self.postal_code,
self.country
self.country,
]
return ", ".join(part for part in address_parts if part)
@@ -109,7 +106,7 @@ class ParkLocation(models.Model):
class Meta:
verbose_name = "Park Location"
verbose_name_plural = "Park Locations"
ordering = ['park__name']
ordering = ["park__name"]
indexes = [
models.Index(fields=['city', 'state']),
]
models.Index(fields=["city", "state"]),
]

View File

@@ -3,10 +3,8 @@ 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 decimal import Decimal, ROUND_DOWN, InvalidOperation
from typing import Tuple, Optional, Any, TYPE_CHECKING
import pghistory
from .companies import Company
from media.models import Photo
from core.history import TrackedModel
@@ -17,10 +15,10 @@ if TYPE_CHECKING:
@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 = [
@@ -40,7 +38,8 @@ class Park(TrackedModel):
)
# Location relationship - reverse relation from ParkLocation
# location will be available via the 'location' related_name on ParkLocation
# location will be available via the 'location' related_name on
# ParkLocation
# Details
opening_date = models.DateField(null=True, blank=True)
@@ -60,25 +59,25 @@ class Park(TrackedModel):
# Relationships
operator = models.ForeignKey(
'Company',
"Company",
on_delete=models.PROTECT,
related_name='operated_parks',
help_text='Company that operates this park',
limit_choices_to={'roles__contains': ['OPERATOR']},
related_name="operated_parks",
help_text="Company that operates this park",
limit_choices_to={"roles__contains": ["OPERATOR"]},
)
property_owner = models.ForeignKey(
'Company',
"Company",
on_delete=models.PROTECT,
related_name='owned_parks',
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']},
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
areas: models.Manager["ParkArea"] # Type hint for reverse relation
# Type hint for reverse relation from rides app
rides: models.Manager['Ride']
rides: models.Manager["Ride"]
# Metadata
created_at = models.DateTimeField(auto_now_add=True, null=True)
@@ -90,37 +89,43 @@ class Park(TrackedModel):
# 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"
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"
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"
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"
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"
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"
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",
),
]
@@ -156,17 +161,17 @@ class Park(TrackedModel):
HistoricalSlug.objects.create(
content_type=ContentType.objects.get_for_model(self),
object_id=self.pk,
slug=old_slug
slug=old_slug,
)
def clean(self):
super().clean()
if self.operator and 'OPERATOR' not in self.operator.roles:
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(
{'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.'})
{"property_owner": "Company must have the PROPERTY_OWNER role."}
)
def get_absolute_url(self) -> str:
return reverse("parks:park_detail", kwargs={"slug": self.slug})
@@ -174,31 +179,31 @@ class Park(TrackedModel):
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',
"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')
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:
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:
if hasattr(self, "location") and self.location:
return self.location.coordinates
return None
@classmethod
def get_by_slug(cls, slug: str) -> Tuple['Park', bool]:
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 core.history import HistoricalSlug
@@ -214,16 +219,18 @@ class Park(TrackedModel):
# 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()
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}")
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}")
@@ -235,15 +242,19 @@ class Park(TrackedModel):
# Try pghistory events
print("Searching pghistory events")
event_model = getattr(cls, 'event_model', None)
event_model = getattr(cls, "event_model", None)
if event_model:
historical_event = event_model.objects.filter(
slug=slug
).order_by('-pgh_created_at').first()
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}")
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}")

View File

@@ -4,25 +4,22 @@ from django.core.validators import MinValueValidator, MaxValueValidator
from core.history import TrackedModel
import pghistory
@pghistory.track()
class ParkReview(TrackedModel):
# Import managers
# 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'
"parks.Park", on_delete=models.CASCADE, related_name="reviews"
)
user = models.ForeignKey(
'accounts.User',
on_delete=models.CASCADE,
related_name='park_reviews'
"accounts.User", on_delete=models.CASCADE, related_name="park_reviews"
)
rating = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(10)]
@@ -30,47 +27,53 @@ class ParkReview(TrackedModel):
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',
"accounts.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='moderated_park_reviews'
related_name="moderated_park_reviews",
)
moderated_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
unique_together = ['park', 'user']
ordering = ["-created_at"]
unique_together = ["park", "user"]
constraints = [
# Business rule: Rating must be between 1 and 10 (database level enforcement)
# 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"
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"
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"
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}"
return f"Review of {self.park.name} by {self.user.username}"

View File

@@ -1,17 +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'
)
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)
current_ride_count=Count("rides", distinct=True),
current_coaster_count=Count(
"rides", filter=Q(rides__category="RC"), distinct=True
),
)
.order_by('name')
)
.order_by("name")
)

View File

@@ -3,8 +3,8 @@ Selectors for park-related data retrieval.
Following Django styleguide pattern for separating data access from business logic.
"""
from typing import Optional, Dict, Any, List
from django.db.models import QuerySet, Q, F, Count, Avg, Prefetch
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
@@ -15,230 +15,234 @@ from 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')
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']
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)
Q(name__icontains=search_term) | Q(description__icontains=search_term)
)
return queryset.order_by('name')
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)
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
*, 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]
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()
operating_parks = Park.objects.filter(status="OPERATING").count()
total_rides = Ride.objects.count()
total_coasters = Ride.objects.filter(category__in=['RC', 'WC']).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
"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')
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()
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]
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')
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]
return (
ParkReview.objects.filter(park_id=park_id, is_published=True)
.select_related("user", "park")
.order_by("-created_at")[:limit]
)

View File

@@ -3,10 +3,9 @@ Services for park-related business logic.
Following Django styleguide pattern for business logic encapsulation.
"""
from typing import Optional, Dict, Any, Tuple
from typing import Optional, Dict, Any
from django.db import transaction
from django.db.models import Q
from django.core.exceptions import ValidationError
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractBaseUser
@@ -20,7 +19,7 @@ User = get_user_model()
class ParkService:
"""Service for managing park operations."""
@staticmethod
def create_park(
*,
@@ -35,11 +34,11 @@ class ParkService:
size_acres: Optional[float] = None,
website: str = "",
location_data: Optional[Dict[str, Any]] = None,
created_by: Optional[UserType] = None
created_by: Optional[UserType] = None,
) -> Park:
"""
Create a new park with validation and location handling.
Args:
name: Park name
description: Park description
@@ -53,10 +52,10 @@ class ParkService:
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
"""
@@ -70,175 +69,169 @@ class ParkService:
closing_date=closing_date,
operating_season=operating_season,
size_acres=size_acres,
website=website
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
)
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
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'
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
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
)
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 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']))
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']
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.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(
*,
@@ -249,11 +242,11 @@ class LocationService:
city: str = "",
state: str = "",
country: str = "",
postal_code: str = ""
postal_code: str = "",
) -> Location:
"""
Create a location for a park.
Args:
park: Park instance
latitude: Latitude coordinate
@@ -263,71 +256,68 @@ class LocationService:
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',
location_type="park",
latitude=latitude,
longitude=longitude,
street_address=street_address,
city=city,
state=state,
country=country,
postal_code=postal_code
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]
*, 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
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

@@ -1,4 +1,4 @@
from .roadtrip import RoadTripService
from .park_management import ParkService, LocationService
__all__ = ['RoadTripService', 'ParkService', 'LocationService']
__all__ = ["RoadTripService", "ParkService", "LocationService"]

View File

@@ -6,7 +6,6 @@ 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
from django.core.exceptions import ValidationError
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractUser
@@ -32,11 +31,11 @@ class ParkService:
size_acres: Optional[float] = None,
website: str = "",
location_data: Optional[Dict[str, Any]] = None,
created_by: Optional['AbstractUser'] = None
created_by: Optional["AbstractUser"] = None,
) -> Park:
"""
Create a new park with validation and location handling.
Args:
name: Park name
description: Park description
@@ -50,10 +49,10 @@ class ParkService:
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
"""
@@ -67,16 +66,18 @@ class ParkService:
closing_date=closing_date,
operating_season=operating_season,
size_acres=size_acres,
website=website
website=website,
)
# Set foreign key relationships if provided
if operator_id:
from parks.models import Company
park.operator = Company.objects.get(id=operator_id)
if property_owner_id:
from parks.models import Company
park.property_owner = Company.objects.get(id=property_owner_id)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
@@ -85,10 +86,7 @@ class ParkService:
# Handle location if provided
if location_data:
LocationService.create_park_location(
park=park,
**location_data
)
LocationService.create_park_location(park=park, **location_data)
return park
@@ -97,19 +95,19 @@ class ParkService:
*,
park_id: int,
updates: Dict[str, Any],
updated_by: Optional['AbstractUser'] = None
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
@@ -129,23 +127,25 @@ class ParkService:
return park
@staticmethod
def delete_park(*, park_id: int, deleted_by: Optional['AbstractUser'] = None) -> bool:
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'
park.status = "DEMOLISHED"
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
park.full_clean()
@@ -159,31 +159,27 @@ class ParkService:
park_id: int,
name: str,
description: str = "",
created_by: Optional['AbstractUser'] = None
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
)
area = ParkArea(park=park, name=name, description=description)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
area.full_clean()
@@ -195,10 +191,10 @@ class ParkService:
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
"""
@@ -211,19 +207,18 @@ class ParkService:
# 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']))
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']
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.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
@@ -246,11 +241,11 @@ class LocationService:
city: str = "",
state: str = "",
country: str = "",
postal_code: str = ""
postal_code: str = "",
) -> Location:
"""
Create a location for a park.
Args:
park: Park instance
latitude: Latitude coordinate
@@ -260,24 +255,24 @@ class LocationService:
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',
location_type="park",
latitude=latitude,
longitude=longitude,
street_address=street_address,
city=city,
state=state,
country=country,
postal_code=postal_code
postal_code=postal_code,
)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
@@ -288,20 +283,18 @@ class LocationService:
@staticmethod
def update_park_location(
*,
park_id: int,
location_updates: Dict[str, Any]
*, 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
@@ -314,8 +307,7 @@ class LocationService:
except Location.DoesNotExist:
# Create location if it doesn't exist
return LocationService.create_park_location(
park=park,
**location_updates
park=park, **location_updates
)
# Apply updates

View File

@@ -13,7 +13,7 @@ import time
import math
import logging
import requests
from typing import Dict, List, Tuple, Optional, Any, Union
from typing import Dict, List, Tuple, Optional, Any
from dataclasses import dataclass
from itertools import permutations
@@ -21,7 +21,6 @@ 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 django.db.models import Q
from parks.models import Park
logger = logging.getLogger(__name__)
@@ -30,6 +29,7 @@ logger = logging.getLogger(__name__)
@dataclass
class Coordinates:
"""Represents latitude and longitude coordinates."""
latitude: float
longitude: float
@@ -45,6 +45,7 @@ class Coordinates:
@dataclass
class RouteInfo:
"""Information about a calculated route."""
distance_km: float
duration_minutes: int
geometry: Optional[str] = None # Encoded polyline
@@ -72,12 +73,13 @@ class RouteInfo:
@dataclass
class TripLeg:
"""Represents one leg of a multi-park trip."""
from_park: 'Park'
to_park: 'Park'
from_park: "Park"
to_park: "Park"
route: RouteInfo
@property
def parks_along_route(self) -> List['Park']:
def parks_along_route(self) -> List["Park"]:
"""Get parks along this route segment."""
# This would be populated by find_parks_along_route
return []
@@ -86,7 +88,8 @@ class TripLeg:
@dataclass
class RoadTrip:
"""Complete road trip with multiple parks."""
parks: List['Park']
parks: List["Park"]
legs: List[TripLeg]
total_distance_km: float
total_duration_minutes: int
@@ -131,7 +134,6 @@ class RateLimiter:
class OSMAPIException(Exception):
"""Exception for OSM API related errors."""
pass
class RoadTripService:
@@ -144,27 +146,29 @@ class RoadTripService:
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.cache_timeout = getattr(settings, "ROADTRIP_CACHE_TIMEOUT", 3600 * 24)
self.route_cache_timeout = getattr(
settings, 'ROADTRIP_ROUTE_CACHE_TIMEOUT', 3600 * 6)
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)
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)
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',
})
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]:
"""
@@ -175,9 +179,7 @@ class RoadTripService:
for attempt in range(self.max_retries):
try:
response = self.session.get(
url,
params=params,
timeout=self.request_timeout
url, params=params, timeout=self.request_timeout
)
response.raise_for_status()
return response.json()
@@ -186,11 +188,13 @@ class RoadTripService:
logger.warning(f"Request attempt {attempt + 1} failed: {e}")
if attempt < self.max_retries - 1:
wait_time = self.backoff_factor ** attempt
wait_time = self.backoff_factor**attempt
time.sleep(wait_time)
else:
raise OSMAPIException(
f"Failed to make request after {self.max_retries} attempts: {e}")
f"Failed to make request after {
self.max_retries} attempts: {e}"
)
def geocode_address(self, address: str) -> Optional[Coordinates]:
"""
@@ -213,10 +217,10 @@ class RoadTripService:
try:
params = {
'q': address.strip(),
'format': 'json',
'limit': 1,
'addressdetails': 1,
"q": address.strip(),
"format": "json",
"limit": 1,
"addressdetails": 1,
}
url = f"{self.nominatim_base_url}/search"
@@ -225,18 +229,25 @@ class RoadTripService:
if response and len(response) > 0:
result = response[0]
coords = Coordinates(
latitude=float(result['lat']),
longitude=float(result['lon'])
latitude=float(result["lat"]),
longitude=float(result["lon"]),
)
# Cache the result
cache.set(cache_key, {
'latitude': coords.latitude,
'longitude': coords.longitude
}, self.cache_timeout)
cache.set(
cache_key,
{
"latitude": coords.latitude,
"longitude": coords.longitude,
},
self.cache_timeout,
)
logger.info(
f"Geocoded '{address}' to {coords.latitude}, {coords.longitude}")
f"Geocoded '{address}' to {
coords.latitude}, {
coords.longitude}"
)
return coords
else:
logger.warning(f"No geocoding results for address: {address}")
@@ -246,7 +257,9 @@ class RoadTripService:
logger.error(f"Geocoding failed for '{address}': {e}")
return None
def calculate_route(self, start_coords: Coordinates, end_coords: Coordinates) -> Optional[RouteInfo]:
def calculate_route(
self, start_coords: Coordinates, end_coords: Coordinates
) -> Optional[RouteInfo]:
"""
Calculate route between two coordinate points using OSRM.
@@ -261,52 +274,68 @@ class RoadTripService:
return None
# Check cache first
cache_key = f"roadtrip:route:{start_coords.latitude},{start_coords.longitude}:{end_coords.latitude},{end_coords.longitude}"
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}"
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',
"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]
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
distance_km = route_data["distance"] / 1000.0
# Duration is in seconds, convert to minutes
duration_minutes = int(route_data['duration'] / 60)
duration_minutes = int(route_data["duration"] / 60)
route_info = RouteInfo(
distance_km=distance_km,
duration_minutes=duration_minutes,
geometry=route_data.get('geometry')
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)
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}")
f"Route calculated: {
route_info.formatted_distance}, {
route_info.formatted_duration}"
)
return route_info
else:
# Fallback to straight-line distance calculation
logger.warning(
f"OSRM routing failed, falling back to straight-line distance")
f"OSRM routing failed, falling back to straight-line distance"
)
return self._calculate_straight_line_route(start_coords, end_coords)
except Exception as e:
@@ -314,37 +343,46 @@ class RoadTripService:
# 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:
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)
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
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)
# 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
geometry=None,
)
def find_parks_along_route(self, start_park: 'Park', end_park: 'Park', max_detour_km: float = 50) -> List['Park']:
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.
@@ -358,7 +396,7 @@ class RoadTripService:
"""
from parks.models import Park
if not hasattr(start_park, 'location') or not hasattr(end_park, 'location'):
if not hasattr(start_park, "location") or not hasattr(end_park, "location"):
return []
if not start_park.location or not end_park.location:
@@ -370,18 +408,22 @@ class RoadTripService:
if not start_coords or not end_coords:
return []
start_point = Point(
start_coords[1], start_coords[0], srid=4326) # lon, lat
end_point = Point(end_coords[1], end_coords[0], srid=4326)
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')
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 = []
@@ -397,7 +439,7 @@ class RoadTripService:
detour_distance = self._calculate_detour_distance(
Coordinates(*start_coords),
Coordinates(*end_coords),
Coordinates(*park_coords)
Coordinates(*park_coords),
)
if detour_distance and detour_distance <= max_detour_km:
@@ -405,7 +447,9 @@ class RoadTripService:
return parks_along_route
def _calculate_detour_distance(self, start: Coordinates, end: Coordinates, waypoint: Coordinates) -> Optional[float]:
def _calculate_detour_distance(
self, start: Coordinates, end: Coordinates, waypoint: Coordinates
) -> Optional[float]:
"""
Calculate the detour distance when visiting a waypoint.
"""
@@ -422,15 +466,16 @@ class RoadTripService:
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
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]:
def create_multi_park_trip(self, park_list: List["Park"]) -> Optional[RoadTrip]:
"""
Create optimized multi-park road trip using simple nearest neighbor heuristic.
@@ -449,12 +494,12 @@ class RoadTripService:
else:
return self._optimize_trip_nearest_neighbor(park_list)
def _optimize_trip_exhaustive(self, park_list: List['Park']) -> Optional[RoadTrip]:
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')
best_distance = float("inf")
# Try all possible orders (excluding the first park as starting point)
for perm in permutations(park_list[1:]):
@@ -467,7 +512,9 @@ class RoadTripService:
return best_trip
def _optimize_trip_nearest_neighbor(self, park_list: List['Park']) -> Optional[RoadTrip]:
def _optimize_trip_nearest_neighbor(
self, park_list: List["Park"]
) -> Optional[RoadTrip]:
"""
Optimize trip using nearest neighbor heuristic (for larger lists).
"""
@@ -482,7 +529,7 @@ class RoadTripService:
while remaining_parks:
# Find nearest unvisited park
nearest_park = None
min_distance = float('inf')
min_distance = float("inf")
current_coords = current_park.coordinates
if not current_coords:
@@ -494,8 +541,7 @@ class RoadTripService:
continue
route = self.calculate_route(
Coordinates(*current_coords),
Coordinates(*park_coords)
Coordinates(*current_coords), Coordinates(*park_coords)
)
if route and route.distance_km < min_distance:
@@ -511,7 +557,9 @@ class RoadTripService:
return self._create_trip_from_order(ordered_parks)
def _create_trip_from_order(self, ordered_parks: List['Park']) -> Optional[RoadTrip]:
def _create_trip_from_order(
self, ordered_parks: List["Park"]
) -> Optional[RoadTrip]:
"""
Create a RoadTrip object from an ordered list of parks.
"""
@@ -533,16 +581,11 @@ class RoadTripService:
continue
route = self.calculate_route(
Coordinates(*from_coords),
Coordinates(*to_coords)
Coordinates(*from_coords), Coordinates(*to_coords)
)
if route:
legs.append(TripLeg(
from_park=from_park,
to_park=to_park,
route=route
))
legs.append(TripLeg(from_park=from_park, to_park=to_park, route=route))
total_distance += route.distance_km
total_duration += route.duration_minutes
@@ -553,10 +596,12 @@ class RoadTripService:
parks=ordered_parks,
legs=legs,
total_distance_km=total_distance,
total_duration_minutes=total_duration
total_duration_minutes=total_duration,
)
def get_park_distances(self, center_park: 'Park', radius_km: float = 100) -> List[Dict[str, Any]]:
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.
@@ -569,22 +614,23 @@ class RoadTripService:
"""
from parks.models import Park
if not hasattr(center_park, 'location') or not center_park.location:
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
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')
nearby_parks = (
Park.objects.filter(
location__point__distance_lte=(center_point, search_distance)
)
.exclude(id=center_park.id)
.select_related("location")
)
results = []
@@ -594,25 +640,26 @@ class RoadTripService:
continue
route = self.calculate_route(
Coordinates(*center_coords),
Coordinates(*park_coords)
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,
})
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'])
results.sort(key=lambda x: x["distance_km"])
return results
def geocode_park_if_needed(self, park: 'Park') -> bool:
def geocode_park_if_needed(self, park: "Park") -> bool:
"""
Geocode park location if coordinates are missing.
@@ -622,7 +669,7 @@ class RoadTripService:
Returns:
True if geocoding succeeded or wasn't needed, False otherwise
"""
if not hasattr(park, 'location') or not park.location:
if not hasattr(park, "location") or not park.location:
return False
location = park.location
@@ -637,7 +684,7 @@ class RoadTripService:
location.street_address,
location.city,
location.state,
location.country
location.country,
]
address = ", ".join(part for part in address_parts if part)
@@ -649,7 +696,11 @@ class RoadTripService:
location.set_coordinates(coords.latitude, coords.longitude)
location.save()
logger.info(
f"Geocoded park '{park.name}' to {coords.latitude}, {coords.longitude}")
f"Geocoded park '{
park.name}' to {
coords.latitude}, {
coords.longitude}"
)
return True
return False

View File

@@ -1,34 +1,33 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.db.models import Count, Q
from django.db.models import Q
from 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')
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()
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
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"""

View File

@@ -2,6 +2,7 @@ from django import template
register = template.Library()
@register.filter
def has_reviewed_park(user, park):
"""Check if a user has reviewed a park"""

View File

@@ -1,70 +1,63 @@
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.contrib.gis.geos import Point
from django.http import HttpResponse
from typing import cast, Optional, Tuple
from .models import Park, ParkArea
from parks.models import Company as Operator
from parks.models.location import ParkLocation
from 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'
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'
username="testuser",
email="test@example.com",
password="testpass123",
)
# Create test company
cls.operator = Operator.objects.create(
name='Test Company',
website='http://example.com'
name="Test Company", website="http://example.com"
)
# Create test park
cls.park = Park.objects.create(
name='Test Park',
name="Test Park",
operator=cls.operator,
status='OPERATING',
website='http://testpark.com'
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.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.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')
self.assertEqual(str(self.park), "Test Park")
def test_park_coordinates(self) -> None:
"""Test park coordinates property"""
@@ -76,60 +69,49 @@ class ParkModelTests(TestCase):
def test_park_formatted_location(self) -> None:
"""Test park formatted_location property"""
expected = '123 Test St, Test City, TS, 12345, Test Country'
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'
name="Test Company", website="http://example.com"
)
# Create test park
self.park = Park.objects.create(
name='Test Park',
operator=self.operator,
status='OPERATING'
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'
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.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'
username="testuser",
email="test@example.com",
password="testpass123",
)
self.operator = Operator.objects.create(
name='Test Company',
website='http://example.com'
name="Test Company", website="http://example.com"
)
self.park = Park.objects.create(
name='Test Park',
operator=self.operator,
status='OPERATING'
name="Test Park", operator=self.operator, status="OPERATING"
)
self.location = create_test_location(self.park)

View File

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

View File

@@ -2,31 +2,29 @@
Tests for park filtering functionality including search, status filtering,
date ranges, and numeric validations.
"""
from django.test import TestCase
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from datetime import date, timedelta
from parks.models import Park, ParkLocation
from django.test import TestCase
from datetime import date
from parks.models import Park, ParkLocation, Company
from parks.filters import ParkFilter
from parks.models.companies import Company
# 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"
name="Thrilling Adventures Inc", slug="thrilling-adventures"
)
cls.operator2 = Company.objects.create(
name="Family Fun Corp",
slug="family-fun"
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",
@@ -37,7 +35,7 @@ class ParkFilterTests(TestCase):
size_acres=100,
ride_count=20,
coaster_count=5,
average_rating=4.5
average_rating=4.5,
)
ParkLocation.objects.create(
park=cls.park1,
@@ -45,9 +43,9 @@ class ParkFilterTests(TestCase):
city="Thrill City",
state="Thrill State",
country="USA",
postal_code="12345"
postal_code="12345",
)
cls.park2 = Park.objects.create(
name="Family Fun Park",
description="Family-friendly entertainment and attractions",
@@ -57,7 +55,7 @@ class ParkFilterTests(TestCase):
size_acres=50,
ride_count=15,
coaster_count=2,
average_rating=4.0
average_rating=4.0,
)
ParkLocation.objects.create(
park=cls.park2,
@@ -65,159 +63,161 @@ class ParkFilterTests(TestCase):
city="Fun City",
state="Fun State",
country="Canada",
postal_code="54321"
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
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]
"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]),
({"opening_date_after": "2019-01-01"}, [self.park1]),
# End date only
({
"opening_date_before": "2016-01-01"
}, [self.park2]),
({"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]),
(
{
"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]),
(
{
"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"
}, []),
({"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}"
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_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}"
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
({}, [self.park1, self.park2, self.park3]), # No filter should return all
(
{"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}"
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}"
)
f"Filter should be invalid for value: {value}",
)

View File

@@ -2,33 +2,29 @@
Tests for park models functionality including CRUD operations,
slug handling, status management, and location integration.
"""
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.utils import timezone
from datetime import date
from parks.models import Park, ParkArea, ParkLocation
from parks.models.companies import Company
from django.test import TestCase
from django.db import IntegrityError
from 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"
)
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
operator=self.operator,
)
# Create location for the park
self.location = ParkLocation.objects.create(
park=self.park,
@@ -53,7 +49,7 @@ class ParkModelTests(TestCase):
park = Park.objects.create(
name="Another Test Park",
status="OPERATING",
operator=self.operator
operator=self.operator,
)
self.assertEqual(park.slug, "another-test-park")
@@ -62,40 +58,40 @@ class ParkModelTests(TestCase):
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
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
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)
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')
).order_by("-pgh_created_at")
print(f"\nPG History records:")
for record in historical_records:
print(f"- Event ID: {record.pgh_id}")
@@ -104,56 +100,57 @@ class ParkModelTests(TestCase):
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}")
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}")
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'
"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"
name="Test Company 2", slug="test-company-2"
)
self.park = Park.objects.create(
name="Test Park",
status="OPERATING",
operator=self.operator
name="Test Park", status="OPERATING", operator=self.operator
)
self.area = ParkArea.objects.create(
park=self.park,
name="Test Area",
description="A test area"
park=self.park, name="Test Area", description="A test area"
)
def test_area_creation(self):
@@ -162,23 +159,18 @@ class ParkAreaModelTests(TestCase):
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
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"
)
area = ParkArea.objects.create(park=other_park, name="Test Area")
self.assertEqual(area.slug, "test-area")

View File

@@ -16,8 +16,8 @@ class TestParkSearch:
park3 = Park.objects.create(name="Test Garden")
# Get autocomplete results
url = reverse('parks:suggest_parks')
response = client.get(url, {'search': 'Test'})
url = reverse("parks:suggest_parks")
response = client.get(url, {"search": "Test"})
# Check response
assert response.status_code == 200
@@ -35,18 +35,15 @@ class TestParkSearch:
"""Test ParkAutocomplete configuration"""
ac = ParkAutocomplete()
assert ac.model == Park
assert 'name' in ac.search_attrs
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'
})
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()
@@ -56,7 +53,7 @@ class TestParkSearch:
Park.objects.create(name="Test Park")
Park.objects.create(name="Another Park")
url = reverse('parks:park_list')
url = reverse("parks:park_list")
response = client.get(url)
assert response.status_code == 200
@@ -69,8 +66,8 @@ class TestParkSearch:
Park.objects.create(name="Adventure World")
Park.objects.create(name="Water Adventure")
url = reverse('parks:suggest_parks')
response = client.get(url, {'search': 'Adv'})
url = reverse("parks:suggest_parks")
response = client.get(url, {"search": "Adv"})
assert response.status_code == 200
content = response.content.decode()
@@ -81,12 +78,8 @@ class TestParkSearch:
"""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'
)
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()
@@ -95,11 +88,8 @@ class TestParkSearch:
"""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'
})
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()
@@ -110,11 +100,11 @@ class TestParkSearch:
for i in range(10):
Park.objects.create(name=f"Test Park {i}")
url = reverse('parks:suggest_parks')
response = client.get(url, {'search': 'Test'})
url = reverse("parks:suggest_parks")
response = client.get(url, {"search": "Test"})
content = response.content.decode()
result_count = content.count('Test Park')
result_count = content.count("Test Park")
assert result_count == 8 # Verify limit is enforced
def test_search_json_format(self, client: Client):
@@ -123,61 +113,61 @@ class TestParkSearch:
name="Test Park",
status="OPERATING",
city="Test City",
state="Test State"
state="Test State",
)
url = reverse('parks:suggest_parks')
response = client.get(url, {'search': 'Test'})
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})
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': ''})
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
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 = Park.objects.create(
Park.objects.create(
name="Test Park",
status="OPERATING",
city="Test City",
state="Test State",
country="Test Country"
country="Test Country",
)
expected_fields = {'id', 'name', 'status', 'location', 'url'}
url = reverse('parks:suggest_parks')
response = client.get(url, {'search': 'Test'})
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]
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)
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']
assert "Test City" in result["location"]
assert "Test State" in result["location"]

View File

@@ -16,57 +16,93 @@ 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"),
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"),
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"),
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"),
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"),
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("rides.park_urls", namespace="rides")),
path(
"<slug:park_slug>/rides/",
include("rides.park_urls", namespace="rides"),
),
]

View File

@@ -3,17 +3,26 @@ from core.mixins import HTMXFilterableMixin
from .models.location import ParkLocation
from media.models import Photo
from moderation.models import EditSubmission
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
from moderation.mixins import (
EditSubmissionMixin,
PhotoSubmissionMixin,
HistoryMixin,
)
from core.views.views import SlugRedirectMixin
from .filters import ParkFilter
from .forms import ParkForm
from .models import Park, ParkArea, ParkReview as Review
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest, JsonResponse
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 Q, Count, QuerySet
from django.db.models import QuerySet
from django.urls import reverse
from django.shortcuts import get_object_or_404, render
from decimal import InvalidOperation
@@ -25,7 +34,9 @@ from typing import Any, Optional, cast, Literal
# 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 (*)."
REQUIRED_FIELDS_ERROR = (
"Please correct the errors below. Required fields are marked with an asterisk (*)."
)
ALLOWED_ROLES = ["MODERATOR", "ADMIN", "SUPERUSER"]
@@ -37,70 +48,69 @@ def normalize_osm_result(result: dict) -> dict:
from .location_utils import get_english_name, normalize_coordinate
# Get address details
address = result.get('address', {})
address = result.get("address", {})
# Normalize coordinates
lat = normalize_coordinate(float(result.get('lat')), 9, 6)
lon = normalize_coordinate(float(result.get('lon')), 10, 6)
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'])
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'])
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', '')
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
'')
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
'')
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
'')
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,
"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')
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:
@@ -116,7 +126,7 @@ def park_actions(request: HttpRequest, slug: str) -> HttpResponse:
def get_park_areas(request: HttpRequest) -> HttpResponse:
"""Return park areas as options for a select element"""
park_id = request.GET.get('park')
park_id = request.GET.get("park")
if not park_id:
return HttpResponse('<option value="">Select a park first</option>')
@@ -124,11 +134,10 @@ def get_park_areas(request: HttpRequest) -> HttpResponse:
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))
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>')
@@ -150,15 +159,15 @@ def location_search(request: HttpRequest) -> JsonResponse:
"limit": 10,
},
headers={"User-Agent": "ThrillWiki/1.0"},
timeout=60
timeout=60,
)
if response.status_code == 200:
results = response.json()
normalized_results = [normalize_osm_result(
result) for result in results]
normalized_results = [normalize_osm_result(result) for result in results]
valid_results = [
r for r in normalized_results
r
for r in normalized_results
if r["lat"] is not None and r["lon"] is not None
]
return JsonResponse({"results": valid_results})
@@ -181,9 +190,13 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
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)
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)
return JsonResponse(
{"error": "Longitude must be between -180 and 180"}, status=400
)
response = requests.get(
"https://nominatim.openstreetmap.org/reverse",
@@ -196,7 +209,7 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
"accept-language": "en",
},
headers={"User-Agent": "ThrillWiki/1.0"},
timeout=60
timeout=60,
)
if response.status_code == 200:
@@ -242,51 +255,51 @@ class ParkListView(HTMXFilterableMixin, ListView):
"""Add view_mode and other context data"""
try:
# Initialize filterset even if queryset fails
if not hasattr(self, 'filterset'):
if not hasattr(self, "filterset"):
self.filterset = self.filter_class(
self.request.GET,
queryset=self.model.objects.none()
self.request.GET, queryset=self.model.objects.none()
)
context = super().get_context_data(**kwargs)
context.update({
'view_mode': self.get_view_mode(),
'is_search': bool(self.request.GET.get('search')),
'search_query': self.request.GET.get('search', '')
})
context.update(
{
"view_mode": self.get_view_mode(),
"is_search": bool(self.request.GET.get("search")),
"search_query": self.request.GET.get("search", ""),
}
)
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'):
if not hasattr(self, "filterset"):
self.filterset = self.filter_class(
self.request.GET,
queryset=self.model.objects.none()
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', '')
"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 search_parks(request: HttpRequest) -> HttpResponse:
"""Search parks and return results using park_list_item.html"""
try:
search_query = request.GET.get('search', '').strip()
search_query = request.GET.get("search", "").strip()
if not search_query:
return HttpResponse('')
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())
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'):
if request.GET.get("quick_search"):
parks = parks[:8] # Limit quick search results
response = render(
@@ -296,10 +309,10 @@ def search_parks(request: HttpRequest) -> HttpResponse:
"parks": parks,
"view_mode": current_view_mode,
"search_query": search_query,
"is_search": True
}
"is_search": True,
},
)
response['HX-Trigger'] = 'searchComplete'
response["HX-Trigger"] = "searchComplete"
return response
except Exception as e:
@@ -309,10 +322,10 @@ def search_parks(request: HttpRequest) -> HttpResponse:
{
"parks": [],
"error": f"Error performing search: {str(e)}",
"is_search": True
}
"is_search": True,
},
)
response['HX-Trigger'] = 'searchError'
response["HX-Trigger"] = "searchError"
return response
@@ -329,8 +342,12 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
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"]
decimal_fields = [
"latitude",
"longitude",
"size_acres",
"average_rating",
]
for field in decimal_fields:
if data.get(field):
data[field] = str(data[field])
@@ -361,9 +378,10 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
source=self.request.POST.get("source", ""),
)
if hasattr(self.request.user, "role") and getattr(
self.request.user, "role", None
) in ALLOWED_ROLES:
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
@@ -378,16 +396,18 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
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", ""),
}
"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"]
form.cleaned_data["longitude"],
)
park_location.save()
@@ -398,15 +418,16 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
Photo.objects.create(
image=photo_file,
uploaded_by=self.request.user,
content_type=ContentType.objects.get_for_model(
Park),
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)}",
f"Error uploading photo {
photo_file.name}: {
str(e)}",
)
messages.success(
@@ -418,14 +439,15 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
except Exception as e:
messages.error(
self.request,
f"Error creating park: {str(e)}. Please check your input and try again.",
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."
"You will be notified when it is approved.",
)
for field, errors in form.errors.items():
for error in errors:
@@ -454,8 +476,12 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
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"]
decimal_fields = [
"latitude",
"longitude",
"size_acres",
"average_rating",
]
for field in decimal_fields:
if data.get(field):
data[field] = str(data[field])
@@ -487,9 +513,10 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
source=self.request.POST.get("source", ""),
)
if hasattr(self.request.user, "role") and getattr(
self.request.user, "role", None
) in ALLOWED_ROLES:
if (
hasattr(self.request.user, "role")
and getattr(self.request.user, "role", None) in ALLOWED_ROLES
):
try:
self.object = form.save()
submission.status = "APPROVED"
@@ -513,43 +540,45 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
park_location = self.object.location
# Update existing location
for key, value in location_data.items():
if key in ['latitude', 'longitude'] and value:
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']:
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'])
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']:
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'])
"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')
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
park=self.object, **creation_data
)
if coordinates_data:
park_location.set_coordinates(
coordinates_data['latitude'],
coordinates_data['longitude']
coordinates_data["latitude"],
coordinates_data["longitude"],
)
park_location.save()
@@ -560,15 +589,16 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
Photo.objects.create(
image=photo_file,
uploaded_by=self.request.user,
content_type=ContentType.objects.get_for_model(
Park),
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)}",
f"Error uploading photo {
photo_file.name}: {
str(e)}",
)
messages.success(
@@ -580,7 +610,8 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
except Exception as e:
messages.error(
self.request,
f"Error updating park: {str(e)}. Please check your input and try again.",
f"Error updating park: {
str(e)}. Please check your input and try again.",
)
return self.form_invalid(form)
@@ -594,10 +625,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
)
def form_invalid(self, form: ParkForm) -> HttpResponse:
messages.error(
self.request,
REQUIRED_FIELDS_ERROR
)
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}")
@@ -612,7 +640,7 @@ class ParkDetailView(
EditSubmissionMixin,
PhotoSubmissionMixin,
HistoryMixin,
DetailView
DetailView,
):
model = Park
template_name = "parks/park_detail.html"
@@ -633,11 +661,7 @@ class ParkDetailView(
super()
.get_queryset()
.prefetch_related(
"rides",
"rides__manufacturer",
"photos",
"areas",
"location"
"rides", "rides__manufacturer", "photos", "areas", "location"
),
)
@@ -667,7 +691,7 @@ class ParkAreaDetailView(
EditSubmissionMixin,
PhotoSubmissionMixin,
HistoryMixin,
DetailView
DetailView,
):
model = ParkArea
template_name = "parks/area_detail.html"

View File

@@ -4,24 +4,19 @@ Provides interfaces for creating and managing multi-park road trips.
"""
import json
from typing import Dict, Any, List, Optional
from django.shortcuts import render, get_object_or_404, redirect
from django.http import JsonResponse, HttpRequest, HttpResponse, Http404
from django.views.generic import TemplateView, View, DetailView
from django.views.decorators.http import require_http_methods
from django.utils.decorators import method_decorator
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ValidationError
from django.contrib import messages
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 django.db.models import Q
from .models import Park
from .services.roadtrip import RoadTripService
from core.services.map_service import unified_map_service
from core.services.data_structures import LocationType, MapFilters
JSON_DECODE_ERROR_MSG = 'Invalid JSON data'
PARKS_ALONG_ROUTE_HTML = 'parks/partials/parks_along_route.html'
JSON_DECODE_ERROR_MSG = "Invalid JSON data"
PARKS_ALONG_ROUTE_HTML = "parks/partials/parks_along_route.html"
class RoadTripViewMixin:
@@ -34,14 +29,14 @@ class RoadTripViewMixin:
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/',
"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,
"max_parks_per_trip": 10,
"default_detour_km": 50,
"enable_osm_integration": True,
}
@@ -51,34 +46,40 @@ class RoadTripPlannerView(RoadTripViewMixin, TemplateView):
URL: /roadtrip/
"""
template_name = 'parks/roadtrip_planner.html'
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]
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,
})
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')
countries = (
Park.objects.filter(status="OPERATING", location__country__isnull=False)
.values_list("location__country", flat=True)
.distinct()
.order_by("location__country")
)
return list(countries)
@@ -95,90 +96,110 @@ class CreateTripView(RoadTripViewMixin, View):
data = json.loads(request.body)
# Parse park IDs
park_ids = data.get('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)
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)
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'))
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)
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)
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,
"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'})
})
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)
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)
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}),
"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,
"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,
}
@@ -188,7 +209,8 @@ class TripDetailView(RoadTripViewMixin, TemplateView):
URL: /roadtrip/<trip_id>/
"""
template_name = 'parks/trip_detail.html'
template_name = "parks/trip_detail.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@@ -196,13 +218,15 @@ class TripDetailView(RoadTripViewMixin, TemplateView):
# 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')
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.',
})
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
@@ -219,50 +243,57 @@ class FindParksAlongRouteView(RoadTripViewMixin, View):
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))))
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'
})
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(
start_park = Park.objects.select_related("location").get(
id=start_park_id, location__isnull=False
)
end_park = Park.objects.select_related('location').get(
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'
})
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)
})
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
})
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)
})
return render(request, PARKS_ALONG_ROUTE_HTML, {"error": str(e)})
class GeocodeAddressView(RoadTripViewMixin, View):
@@ -276,25 +307,28 @@ class GeocodeAddressView(RoadTripViewMixin, View):
"""Geocode an address and find nearby parks."""
try:
data = json.loads(request.body)
address = data.get('address', '').strip()
address = data.get("address", "").strip()
if not address:
return JsonResponse({
'status': 'error',
'message': 'Address is required'
}, status=400)
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)
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))))
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
@@ -307,42 +341,41 @@ class GeocodeAddressView(RoadTripViewMixin, View):
north=coordinates.latitude + lat_delta,
south=coordinates.latitude - lat_delta,
east=coordinates.longitude + lng_delta,
west=coordinates.longitude - lng_delta
west=coordinates.longitude - lng_delta,
)
filters = MapFilters(location_types={LocationType.PARK})
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}
location_types={LocationType.PARK},
)
return JsonResponse({
'status': 'success',
'data': {
'coordinates': {
'latitude': coordinates.latitude,
'longitude': coordinates.longitude
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,
},
'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)
return JsonResponse(
{"status": "error", "message": JSON_DECODE_ERROR_MSG},
status=400,
)
except Exception as e:
return JsonResponse({
'status': 'error',
'message': str(e)
}, status=500)
return JsonResponse({"status": "error", "message": str(e)}, status=500)
class ParkDistanceCalculatorView(RoadTripViewMixin, View):
@@ -357,77 +390,91 @@ class ParkDistanceCalculatorView(RoadTripViewMixin, View):
try:
data = json.loads(request.body)
park1_id = data.get('park1_id')
park2_id = data.get('park2_id')
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)
return JsonResponse(
{
"status": "error",
"message": "Both park IDs are required",
},
status=400,
)
# Get parks
try:
park1 = Park.objects.select_related('location').get(
park1 = Park.objects.select_related("location").get(
id=park1_id, location__isnull=False
)
park2 = Park.objects.select_related('location').get(
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)
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)
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)
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', '')
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", ""
),
},
},
'park2': {
'name': park2.name,
'formatted_location': getattr(park2, 'formatted_location', '')
}
}
})
)
except json.JSONDecodeError:
return JsonResponse({
'status': 'error',
'message': JSON_DECODE_ERROR_MSG
}, status=400)
return JsonResponse(
{"status": "error", "message": JSON_DECODE_ERROR_MSG},
status=400,
)
except Exception as e:
return JsonResponse({
'status': 'error',
'message': str(e)
}, status=500)
return JsonResponse({"status": "error", "message": str(e)}, status=500)

View File

@@ -1,5 +1,4 @@
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import render
from django.http import HttpRequest, JsonResponse
from django.views.generic import TemplateView
from django.urls import reverse
@@ -7,48 +6,54 @@ 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)
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
context["filter"] = filter_instance
# Apply search if park ID selected via autocomplete
park_id = self.request.GET.get('park')
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
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()
query = request.GET.get("search", "").strip()
if not query:
return JsonResponse({'results': []})
return JsonResponse({"results": []})
queryset = get_base_park_queryset()
filter_instance = ParkFilter({'search': query}, queryset=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})
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

@@ -9,7 +9,7 @@ def prepare_changes_data(self, cleaned_data):
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']
decimal_fields = ["latitude", "longitude", "size_acres", "average_rating"]
for field in decimal_fields:
if data.get(field):
data[field] = str(data[field])