mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 06:31:08 -05:00
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:
233
parks/admin.py
233
parks/admin.py
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
187
parks/filters.py
187
parks/filters.py
@@ -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
|
||||
|
||||
236
parks/forms.py
236
parks/forms.py
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
@@ -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!")
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]),
|
||||
]
|
||||
|
||||
@@ -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"]),
|
||||
]
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .roadtrip import RoadTripService
|
||||
from .park_management import ParkService, LocationService
|
||||
|
||||
__all__ = ['RoadTripService', 'ParkService', 'LocationService']
|
||||
__all__ = ["RoadTripService", "ParkService", "LocationService"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
# Parks app test suite
|
||||
# Parks app test suite
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
104
parks/urls.py
104
parks/urls.py
@@ -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"),
|
||||
),
|
||||
]
|
||||
|
||||
314
parks/views.py
314
parks/views.py
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user