mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 11:31:09 -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:
@@ -4,67 +4,90 @@ from .models.company import Company
|
||||
from .models.rides import Ride
|
||||
from .models.location import RideLocation
|
||||
|
||||
|
||||
class ManufacturerAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'headquarters', 'website', 'rides_count')
|
||||
search_fields = ('name',)
|
||||
|
||||
list_display = ("name", "headquarters", "website", "rides_count")
|
||||
search_fields = ("name",)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).filter(roles__contains=['MANUFACTURER'])
|
||||
return super().get_queryset(request).filter(roles__contains=["MANUFACTURER"])
|
||||
|
||||
|
||||
class DesignerAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'headquarters', 'website')
|
||||
search_fields = ('name',)
|
||||
list_display = ("name", "headquarters", "website")
|
||||
search_fields = ("name",)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).filter(roles__contains=['DESIGNER'])
|
||||
return super().get_queryset(request).filter(roles__contains=["DESIGNER"])
|
||||
|
||||
|
||||
class RideLocationInline(admin.StackedInline):
|
||||
"""Inline admin for RideLocation"""
|
||||
|
||||
model = RideLocation
|
||||
extra = 0
|
||||
fields = (
|
||||
'park_area',
|
||||
'point',
|
||||
'entrance_notes',
|
||||
'accessibility_notes',
|
||||
"park_area",
|
||||
"point",
|
||||
"entrance_notes",
|
||||
"accessibility_notes",
|
||||
)
|
||||
|
||||
|
||||
class RideLocationAdmin(GISModelAdmin):
|
||||
"""Admin for standalone RideLocation management"""
|
||||
list_display = ('ride', 'park_area', 'has_coordinates', 'created_at')
|
||||
list_filter = ('park_area', 'created_at')
|
||||
search_fields = ('ride__name', 'park_area', 'entrance_notes')
|
||||
readonly_fields = ('latitude', 'longitude', 'coordinates', 'created_at', 'updated_at')
|
||||
|
||||
list_display = ("ride", "park_area", "has_coordinates", "created_at")
|
||||
list_filter = ("park_area", "created_at")
|
||||
search_fields = ("ride__name", "park_area", "entrance_notes")
|
||||
readonly_fields = (
|
||||
"latitude",
|
||||
"longitude",
|
||||
"coordinates",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
fieldsets = (
|
||||
('Ride', {
|
||||
'fields': ('ride',)
|
||||
}),
|
||||
('Location Information', {
|
||||
'fields': ('park_area', 'point', 'latitude', 'longitude', 'coordinates'),
|
||||
'description': 'Optional coordinates - not all rides need precise location tracking'
|
||||
}),
|
||||
('Navigation Notes', {
|
||||
'fields': ('entrance_notes', 'accessibility_notes'),
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
("Ride", {"fields": ("ride",)}),
|
||||
(
|
||||
"Location Information",
|
||||
{
|
||||
"fields": (
|
||||
"park_area",
|
||||
"point",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"coordinates",
|
||||
),
|
||||
"description": "Optional coordinates - not all rides need precise location tracking",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Navigation Notes",
|
||||
{
|
||||
"fields": ("entrance_notes", "accessibility_notes"),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{"fields": ("created_at", "updated_at"), "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 RideAdmin(admin.ModelAdmin):
|
||||
"""Enhanced Ride admin with location inline"""
|
||||
|
||||
inlines = [RideLocationInline]
|
||||
|
||||
|
||||
|
||||
@@ -3,29 +3,31 @@ Serializers for Rides API following Django styleguide patterns.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from ..models import Ride, RideModel, Company
|
||||
from ..models import Ride
|
||||
|
||||
|
||||
class RideModelOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride model data."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
manufacturer = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
def get_manufacturer(self, obj):
|
||||
if obj.manufacturer:
|
||||
return {
|
||||
'id': obj.manufacturer.id,
|
||||
'name': obj.manufacturer.name,
|
||||
'slug': obj.manufacturer.slug
|
||||
"id": obj.manufacturer.id,
|
||||
"name": obj.manufacturer.name,
|
||||
"slug": obj.manufacturer.slug,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
class RideParkOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride's park data."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
@@ -33,24 +35,27 @@ class RideParkOutputSerializer(serializers.Serializer):
|
||||
|
||||
class RideListOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride list view."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
|
||||
|
||||
# Park info
|
||||
park = RideParkOutputSerializer()
|
||||
|
||||
|
||||
# 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
|
||||
)
|
||||
capacity_per_hour = serializers.IntegerField(allow_null=True)
|
||||
|
||||
|
||||
# Dates
|
||||
opening_date = serializers.DateField(allow_null=True)
|
||||
closing_date = serializers.DateField(allow_null=True)
|
||||
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
@@ -58,6 +63,7 @@ class RideListOutputSerializer(serializers.Serializer):
|
||||
|
||||
class RideDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride detail view."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
@@ -65,285 +71,275 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
status = serializers.CharField()
|
||||
post_closing_status = serializers.CharField(allow_null=True)
|
||||
description = serializers.CharField()
|
||||
|
||||
|
||||
# Park info
|
||||
park = RideParkOutputSerializer()
|
||||
park_area = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
# Dates
|
||||
opening_date = serializers.DateField(allow_null=True)
|
||||
closing_date = serializers.DateField(allow_null=True)
|
||||
status_since = serializers.DateField(allow_null=True)
|
||||
|
||||
|
||||
# Physical specs
|
||||
min_height_in = serializers.IntegerField(allow_null=True)
|
||||
max_height_in = serializers.IntegerField(allow_null=True)
|
||||
capacity_per_hour = serializers.IntegerField(allow_null=True)
|
||||
ride_duration_seconds = serializers.IntegerField(allow_null=True)
|
||||
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# Companies
|
||||
manufacturer = serializers.SerializerMethodField()
|
||||
designer = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
# Model
|
||||
ride_model = RideModelOutputSerializer(allow_null=True)
|
||||
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
|
||||
def get_park_area(self, obj):
|
||||
if obj.park_area:
|
||||
return {
|
||||
'id': obj.park_area.id,
|
||||
'name': obj.park_area.name,
|
||||
'slug': obj.park_area.slug
|
||||
"id": obj.park_area.id,
|
||||
"name": obj.park_area.name,
|
||||
"slug": obj.park_area.slug,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def get_manufacturer(self, obj):
|
||||
if obj.manufacturer:
|
||||
return {
|
||||
'id': obj.manufacturer.id,
|
||||
'name': obj.manufacturer.name,
|
||||
'slug': obj.manufacturer.slug
|
||||
"id": obj.manufacturer.id,
|
||||
"name": obj.manufacturer.name,
|
||||
"slug": obj.manufacturer.slug,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def get_designer(self, obj):
|
||||
if obj.designer:
|
||||
return {
|
||||
'id': obj.designer.id,
|
||||
'name': obj.designer.name,
|
||||
'slug': obj.designer.slug
|
||||
"id": obj.designer.id,
|
||||
"name": obj.designer.name,
|
||||
"slug": obj.designer.slug,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
class RideCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating rides."""
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
category = serializers.ChoiceField(choices=Ride.CATEGORY_CHOICES)
|
||||
status = serializers.ChoiceField(
|
||||
choices=Ride.STATUS_CHOICES,
|
||||
default="OPERATING"
|
||||
)
|
||||
|
||||
status = serializers.ChoiceField(choices=Ride.STATUS_CHOICES, default="OPERATING")
|
||||
|
||||
# Required park
|
||||
park_id = serializers.IntegerField()
|
||||
|
||||
|
||||
# Optional area
|
||||
park_area_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
# Optional dates
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
status_since = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
|
||||
# Optional specs
|
||||
min_height_in = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=30,
|
||||
max_value=90
|
||||
required=False, allow_null=True, min_value=30, max_value=90
|
||||
)
|
||||
max_height_in = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=30,
|
||||
max_value=90
|
||||
required=False, allow_null=True, min_value=30, max_value=90
|
||||
)
|
||||
capacity_per_hour = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=1
|
||||
required=False, allow_null=True, min_value=1
|
||||
)
|
||||
ride_duration_seconds = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=1
|
||||
required=False, allow_null=True, min_value=1
|
||||
)
|
||||
|
||||
|
||||
# Optional companies
|
||||
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
designer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
# Optional model
|
||||
ride_model_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
def validate(self, data):
|
||||
"""Cross-field validation."""
|
||||
# Date 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"
|
||||
)
|
||||
|
||||
|
||||
# Height validation
|
||||
min_height = data.get('min_height_in')
|
||||
max_height = data.get('max_height_in')
|
||||
|
||||
min_height = data.get("min_height_in")
|
||||
max_height = data.get("max_height_in")
|
||||
|
||||
if min_height and max_height and min_height > max_height:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum height cannot be greater than maximum height"
|
||||
)
|
||||
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class RideUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating rides."""
|
||||
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
category = serializers.ChoiceField(choices=Ride.CATEGORY_CHOICES, required=False)
|
||||
status = serializers.ChoiceField(choices=Ride.STATUS_CHOICES, required=False)
|
||||
post_closing_status = serializers.ChoiceField(
|
||||
choices=Ride.POST_CLOSING_STATUS_CHOICES,
|
||||
choices=Ride.POST_CLOSING_STATUS_CHOICES,
|
||||
required=False,
|
||||
allow_null=True
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
|
||||
# Park and area
|
||||
park_id = serializers.IntegerField(required=False)
|
||||
park_area_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
# Dates
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
status_since = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
|
||||
# Specs
|
||||
min_height_in = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=30,
|
||||
max_value=90
|
||||
required=False, allow_null=True, min_value=30, max_value=90
|
||||
)
|
||||
max_height_in = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=30,
|
||||
max_value=90
|
||||
required=False, allow_null=True, min_value=30, max_value=90
|
||||
)
|
||||
capacity_per_hour = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=1
|
||||
required=False, allow_null=True, min_value=1
|
||||
)
|
||||
ride_duration_seconds = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=1
|
||||
required=False, allow_null=True, min_value=1
|
||||
)
|
||||
|
||||
|
||||
# Companies
|
||||
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
designer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
# Model
|
||||
ride_model_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
def validate(self, data):
|
||||
"""Cross-field validation."""
|
||||
# Date 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"
|
||||
)
|
||||
|
||||
|
||||
# Height validation
|
||||
min_height = data.get('min_height_in')
|
||||
max_height = data.get('max_height_in')
|
||||
|
||||
min_height = data.get("min_height_in")
|
||||
max_height = data.get("max_height_in")
|
||||
|
||||
if min_height and max_height and min_height > max_height:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum height cannot be greater than maximum height"
|
||||
)
|
||||
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class RideFilterInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for ride filtering and search."""
|
||||
|
||||
# Search
|
||||
search = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
# Category filter
|
||||
category = serializers.MultipleChoiceField(
|
||||
choices=Ride.CATEGORY_CHOICES,
|
||||
required=False
|
||||
choices=Ride.CATEGORY_CHOICES, required=False
|
||||
)
|
||||
|
||||
|
||||
# Status filter
|
||||
status = serializers.MultipleChoiceField(
|
||||
choices=Ride.STATUS_CHOICES,
|
||||
required=False
|
||||
choices=Ride.STATUS_CHOICES, required=False
|
||||
)
|
||||
|
||||
|
||||
# Park filter
|
||||
park_id = serializers.IntegerField(required=False)
|
||||
park_slug = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
# Company filters
|
||||
manufacturer_id = serializers.IntegerField(required=False)
|
||||
designer_id = serializers.IntegerField(required=False)
|
||||
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
|
||||
# Height filters
|
||||
min_height_requirement = serializers.IntegerField(required=False)
|
||||
max_height_requirement = serializers.IntegerField(required=False)
|
||||
|
||||
|
||||
# Capacity filter
|
||||
min_capacity = serializers.IntegerField(required=False)
|
||||
|
||||
|
||||
# Ordering
|
||||
ordering = serializers.ChoiceField(
|
||||
choices=[
|
||||
'name', '-name',
|
||||
'opening_date', '-opening_date',
|
||||
'average_rating', '-average_rating',
|
||||
'capacity_per_hour', '-capacity_per_hour',
|
||||
'created_at', '-created_at'
|
||||
"name",
|
||||
"-name",
|
||||
"opening_date",
|
||||
"-opening_date",
|
||||
"average_rating",
|
||||
"-average_rating",
|
||||
"capacity_per_hour",
|
||||
"-capacity_per_hour",
|
||||
"created_at",
|
||||
"-created_at",
|
||||
],
|
||||
required=False,
|
||||
default='name'
|
||||
default="name",
|
||||
)
|
||||
|
||||
|
||||
class RideStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride statistics."""
|
||||
|
||||
total_rides = serializers.IntegerField()
|
||||
operating_rides = serializers.IntegerField()
|
||||
closed_rides = serializers.IntegerField()
|
||||
under_construction = serializers.IntegerField()
|
||||
|
||||
|
||||
# By category
|
||||
rides_by_category = serializers.DictField()
|
||||
|
||||
|
||||
# Averages
|
||||
average_rating = serializers.DecimalField(max_digits=3, decimal_places=2, allow_null=True)
|
||||
average_capacity = serializers.DecimalField(max_digits=8, decimal_places=2, allow_null=True)
|
||||
|
||||
average_rating = serializers.DecimalField(
|
||||
max_digits=3, decimal_places=2, allow_null=True
|
||||
)
|
||||
average_capacity = serializers.DecimalField(
|
||||
max_digits=8, decimal_places=2, allow_null=True
|
||||
)
|
||||
|
||||
# Top manufacturers
|
||||
top_manufacturers = serializers.ListField(child=serializers.DictField())
|
||||
|
||||
|
||||
# Recently added
|
||||
recently_added_count = serializers.IntegerField()
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
URL configuration for Rides API following Django styleguide patterns.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
# Note: We'll create the views file after this
|
||||
# from .views import RideApi
|
||||
|
||||
app_name = 'rides_api'
|
||||
app_name = "rides_api"
|
||||
|
||||
# Placeholder for future implementation
|
||||
urlpatterns = [
|
||||
|
||||
@@ -2,8 +2,8 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class RidesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'rides'
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "rides"
|
||||
|
||||
def ready(self):
|
||||
import rides.signals
|
||||
pass
|
||||
|
||||
@@ -1,70 +1,75 @@
|
||||
from typing import Dict
|
||||
|
||||
|
||||
def get_ride_display_changes(changes: Dict) -> Dict:
|
||||
"""Returns a human-readable version of the ride changes"""
|
||||
field_names = {
|
||||
'name': 'Name',
|
||||
'description': 'Description',
|
||||
'status': 'Status',
|
||||
'post_closing_status': 'Post-Closing Status',
|
||||
'opening_date': 'Opening Date',
|
||||
'closing_date': 'Closing Date',
|
||||
'status_since': 'Status Since',
|
||||
'capacity_per_hour': 'Hourly Capacity',
|
||||
'min_height_in': 'Minimum Height',
|
||||
'max_height_in': 'Maximum Height',
|
||||
'ride_duration_seconds': 'Ride Duration'
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"status": "Status",
|
||||
"post_closing_status": "Post-Closing Status",
|
||||
"opening_date": "Opening Date",
|
||||
"closing_date": "Closing Date",
|
||||
"status_since": "Status Since",
|
||||
"capacity_per_hour": "Hourly Capacity",
|
||||
"min_height_in": "Minimum Height",
|
||||
"max_height_in": "Maximum Height",
|
||||
"ride_duration_seconds": "Ride Duration",
|
||||
}
|
||||
|
||||
|
||||
display_changes = {}
|
||||
for field, change in changes.items():
|
||||
if field in field_names:
|
||||
old_value = change.get('old', '')
|
||||
new_value = change.get('new', '')
|
||||
|
||||
old_value = change.get("old", "")
|
||||
new_value = change.get("new", "")
|
||||
|
||||
# Format specific fields
|
||||
if field == 'status':
|
||||
if field == "status":
|
||||
from .models import Ride
|
||||
|
||||
choices = dict(Ride.STATUS_CHOICES)
|
||||
old_value = choices.get(old_value, old_value)
|
||||
new_value = choices.get(new_value, new_value)
|
||||
elif field == 'post_closing_status':
|
||||
elif field == "post_closing_status":
|
||||
from .models import Ride
|
||||
|
||||
choices = dict(Ride.POST_CLOSING_STATUS_CHOICES)
|
||||
old_value = choices.get(old_value, old_value)
|
||||
new_value = choices.get(new_value, new_value)
|
||||
|
||||
|
||||
display_changes[field_names[field]] = {
|
||||
'old': old_value,
|
||||
'new': new_value
|
||||
"old": old_value,
|
||||
"new": new_value,
|
||||
}
|
||||
|
||||
|
||||
return display_changes
|
||||
|
||||
|
||||
def get_ride_model_display_changes(changes: Dict) -> Dict:
|
||||
"""Returns a human-readable version of the ride model changes"""
|
||||
field_names = {
|
||||
'name': 'Name',
|
||||
'description': 'Description',
|
||||
'category': 'Category'
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"category": "Category",
|
||||
}
|
||||
|
||||
|
||||
display_changes = {}
|
||||
for field, change in changes.items():
|
||||
if field in field_names:
|
||||
old_value = change.get('old', '')
|
||||
new_value = change.get('new', '')
|
||||
|
||||
old_value = change.get("old", "")
|
||||
new_value = change.get("new", "")
|
||||
|
||||
# Format category field
|
||||
if field == 'category':
|
||||
if field == "category":
|
||||
from .models import CATEGORY_CHOICES
|
||||
|
||||
choices = dict(CATEGORY_CHOICES)
|
||||
old_value = choices.get(old_value, old_value)
|
||||
new_value = choices.get(new_value, new_value)
|
||||
|
||||
|
||||
display_changes[field_names[field]] = {
|
||||
'old': old_value,
|
||||
'new': new_value
|
||||
"old": old_value,
|
||||
"new": new_value,
|
||||
}
|
||||
|
||||
return display_changes
|
||||
|
||||
return display_changes
|
||||
|
||||
190
rides/forms.py
190
rides/forms.py
@@ -1,3 +1,4 @@
|
||||
from parks.models import Park, ParkArea
|
||||
from django import forms
|
||||
from django.forms import ModelChoiceField
|
||||
from django.urls import reverse_lazy
|
||||
@@ -6,7 +7,6 @@ from .models.rides import Ride, RideModel
|
||||
|
||||
Manufacturer = Company
|
||||
Designer = Company
|
||||
from parks.models import Park, ParkArea
|
||||
|
||||
|
||||
class RideForm(forms.ModelForm):
|
||||
@@ -15,7 +15,10 @@ class RideForm(forms.ModelForm):
|
||||
required=True,
|
||||
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"
|
||||
),
|
||||
"placeholder": "Search for a park...",
|
||||
"hx-get": "/parks/search/",
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
@@ -25,13 +28,16 @@ class RideForm(forms.ModelForm):
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
manufacturer_search = forms.CharField(
|
||||
label="Manufacturer",
|
||||
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"
|
||||
),
|
||||
"placeholder": "Search for a manufacturer...",
|
||||
"hx-get": reverse_lazy("rides:search_companies"),
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
@@ -41,13 +47,16 @@ class RideForm(forms.ModelForm):
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
designer_search = forms.CharField(
|
||||
label="Designer",
|
||||
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"
|
||||
),
|
||||
"placeholder": "Search for a designer...",
|
||||
"hx-get": reverse_lazy("rides:search_companies"),
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
@@ -63,7 +72,10 @@ class RideForm(forms.ModelForm):
|
||||
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"
|
||||
),
|
||||
"placeholder": "Search for a ride model...",
|
||||
"hx-get": reverse_lazy("rides:search_ride_models"),
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
@@ -79,37 +91,40 @@ class RideForm(forms.ModelForm):
|
||||
queryset=Park.objects.all(),
|
||||
required=True,
|
||||
label="",
|
||||
widget=forms.HiddenInput()
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
|
||||
manufacturer = forms.ModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label="",
|
||||
widget=forms.HiddenInput()
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
|
||||
designer = forms.ModelChoiceField(
|
||||
queryset=Designer.objects.all(),
|
||||
required=False,
|
||||
label="",
|
||||
widget=forms.HiddenInput()
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
ride_model = forms.ModelChoiceField(
|
||||
queryset=RideModel.objects.all(),
|
||||
required=False,
|
||||
label="",
|
||||
widget=forms.HiddenInput()
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
|
||||
park_area = ModelChoiceField(
|
||||
queryset=ParkArea.objects.none(),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": "w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white",
|
||||
"placeholder": "Select an area within the park..."
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Select an area within the park...",
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -136,91 +151,127 @@ class RideForm(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",
|
||||
"placeholder": "Official name of the ride"
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Official name of the ride",
|
||||
}
|
||||
),
|
||||
"category": 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"
|
||||
),
|
||||
"hx-get": reverse_lazy("rides:coaster_fields"),
|
||||
"hx-target": "#coaster-fields",
|
||||
"hx-trigger": "change",
|
||||
"hx-include": "this",
|
||||
"hx-swap": "innerHTML"
|
||||
"hx-swap": "innerHTML",
|
||||
}
|
||||
),
|
||||
"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"
|
||||
),
|
||||
"placeholder": "Current operational status",
|
||||
"x-model": "status",
|
||||
"@change": "handleStatusChange"
|
||||
"@change": "handleStatusChange",
|
||||
}
|
||||
),
|
||||
"post_closing_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"
|
||||
),
|
||||
"placeholder": "Status after closing",
|
||||
"x-show": "status === 'CLOSING'"
|
||||
"x-show": "status === 'CLOSING'",
|
||||
}
|
||||
),
|
||||
"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",
|
||||
"placeholder": "Date when ride first opened"
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Date when ride first opened",
|
||||
}
|
||||
),
|
||||
"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"
|
||||
),
|
||||
"placeholder": "Date when ride will close",
|
||||
"x-show": "['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(status)",
|
||||
":required": "status === 'CLOSING'"
|
||||
":required": "status === 'CLOSING'",
|
||||
}
|
||||
),
|
||||
"status_since": 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",
|
||||
"placeholder": "Date when current status took effect"
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Date when current status took effect",
|
||||
}
|
||||
),
|
||||
"min_height_in": 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"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Minimum height requirement in inches"
|
||||
"placeholder": "Minimum height requirement in inches",
|
||||
}
|
||||
),
|
||||
"max_height_in": 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"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Maximum height limit in inches (if applicable)"
|
||||
"placeholder": "Maximum height limit in inches (if applicable)",
|
||||
}
|
||||
),
|
||||
"capacity_per_hour": 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"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Theoretical hourly ride capacity"
|
||||
"placeholder": "Theoretical hourly ride capacity",
|
||||
}
|
||||
),
|
||||
"ride_duration_seconds": 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"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Total duration of one ride cycle in seconds"
|
||||
"placeholder": "Total duration of one ride cycle in seconds",
|
||||
}
|
||||
),
|
||||
"description": forms.Textarea(
|
||||
attrs={
|
||||
"rows": 4,
|
||||
"class": "w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white",
|
||||
"placeholder": "General description and notable features of the ride"
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-textarea "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "General description and notable features of the ride",
|
||||
}
|
||||
),
|
||||
}
|
||||
@@ -228,10 +279,10 @@ class RideForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
park = kwargs.pop("park", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
# Make category required
|
||||
self.fields['category'].required = True
|
||||
|
||||
self.fields["category"].required = True
|
||||
|
||||
# Clear any default values for date fields
|
||||
self.fields["opening_date"].initial = None
|
||||
self.fields["closing_date"].initial = None
|
||||
@@ -239,13 +290,27 @@ class RideForm(forms.ModelForm):
|
||||
|
||||
# Move fields to the beginning in desired order
|
||||
field_order = [
|
||||
"park_search", "park", "park_area",
|
||||
"name", "manufacturer_search", "manufacturer",
|
||||
"designer_search", "designer", "ride_model_search",
|
||||
"ride_model", "category", "status",
|
||||
"post_closing_status", "opening_date", "closing_date", "status_since",
|
||||
"min_height_in", "max_height_in", "capacity_per_hour",
|
||||
"ride_duration_seconds", "description"
|
||||
"park_search",
|
||||
"park",
|
||||
"park_area",
|
||||
"name",
|
||||
"manufacturer_search",
|
||||
"manufacturer",
|
||||
"designer_search",
|
||||
"designer",
|
||||
"ride_model_search",
|
||||
"ride_model",
|
||||
"category",
|
||||
"status",
|
||||
"post_closing_status",
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"status_since",
|
||||
"min_height_in",
|
||||
"max_height_in",
|
||||
"capacity_per_hour",
|
||||
"ride_duration_seconds",
|
||||
"description",
|
||||
]
|
||||
self.order_fields(field_order)
|
||||
|
||||
@@ -260,23 +325,30 @@ class RideForm(forms.ModelForm):
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": "w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white",
|
||||
"placeholder": "Select an area within the park..."
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Select an area within the park...",
|
||||
}
|
||||
),
|
||||
)
|
||||
else:
|
||||
# If no park provided, show park search and disable park_area until park is selected
|
||||
# If no park provided, show park search and disable park_area until
|
||||
# park is selected
|
||||
self.fields["park_area"].widget.attrs["disabled"] = True
|
||||
# Initialize park search with current park name if editing
|
||||
if self.instance and self.instance.pk and self.instance.park:
|
||||
self.fields["park_search"].initial = self.instance.park.name
|
||||
self.fields["park"].initial = self.instance.park
|
||||
|
||||
# Initialize manufacturer, designer, and ride model search fields if editing
|
||||
|
||||
# Initialize manufacturer, designer, and ride model search fields if
|
||||
# editing
|
||||
if self.instance and self.instance.pk:
|
||||
if self.instance.manufacturer:
|
||||
self.fields["manufacturer_search"].initial = self.instance.manufacturer.name
|
||||
self.fields["manufacturer_search"].initial = (
|
||||
self.instance.manufacturer.name
|
||||
)
|
||||
self.fields["manufacturer"].initial = self.instance.manufacturer
|
||||
if self.instance.designer:
|
||||
self.fields["designer_search"].initial = self.instance.designer.name
|
||||
@@ -288,13 +360,17 @@ class RideForm(forms.ModelForm):
|
||||
|
||||
class RideSearchForm(forms.Form):
|
||||
"""Form for searching rides with HTMX autocomplete."""
|
||||
|
||||
ride = forms.ModelChoiceField(
|
||||
queryset=Ride.objects.all(),
|
||||
label="Find a ride",
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
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"
|
||||
),
|
||||
"hx-get": reverse_lazy("rides:search"),
|
||||
"hx-trigger": "change",
|
||||
"hx-target": "#ride-search-results",
|
||||
|
||||
@@ -3,279 +3,299 @@ Custom managers and QuerySets for Rides 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 typing import Optional, List, Union
|
||||
from django.db.models import Q, F, Count, Prefetch
|
||||
|
||||
from core.managers import (
|
||||
BaseQuerySet, BaseManager, ReviewableQuerySet, ReviewableManager,
|
||||
StatusQuerySet, StatusManager
|
||||
BaseQuerySet,
|
||||
BaseManager,
|
||||
ReviewableQuerySet,
|
||||
ReviewableManager,
|
||||
StatusQuerySet,
|
||||
StatusManager,
|
||||
)
|
||||
|
||||
|
||||
class RideQuerySet(StatusQuerySet, ReviewableQuerySet):
|
||||
"""Optimized QuerySet for Ride model."""
|
||||
|
||||
|
||||
def by_category(self, *, category: Union[str, List[str]]):
|
||||
"""Filter rides by category."""
|
||||
if isinstance(category, list):
|
||||
return self.filter(category__in=category)
|
||||
return self.filter(category=category)
|
||||
|
||||
|
||||
def coasters(self):
|
||||
"""Filter for roller coasters."""
|
||||
return self.filter(category__in=['RC', 'WC'])
|
||||
|
||||
return self.filter(category__in=["RC", "WC"])
|
||||
|
||||
def thrill_rides(self):
|
||||
"""Filter for thrill rides."""
|
||||
return self.filter(category__in=['RC', 'WC', 'FR'])
|
||||
|
||||
return self.filter(category__in=["RC", "WC", "FR"])
|
||||
|
||||
def family_friendly(self, *, max_height_requirement: int = 42):
|
||||
"""Filter for family-friendly rides."""
|
||||
return self.filter(
|
||||
Q(min_height_in__lte=max_height_requirement) |
|
||||
Q(min_height_in__isnull=True)
|
||||
Q(min_height_in__lte=max_height_requirement) | Q(min_height_in__isnull=True)
|
||||
)
|
||||
|
||||
|
||||
def by_park(self, *, park_id: int):
|
||||
"""Filter rides by park."""
|
||||
return self.filter(park_id=park_id)
|
||||
|
||||
|
||||
def by_manufacturer(self, *, manufacturer_id: int):
|
||||
"""Filter rides by manufacturer."""
|
||||
return self.filter(manufacturer_id=manufacturer_id)
|
||||
|
||||
|
||||
def by_designer(self, *, designer_id: int):
|
||||
"""Filter rides by designer."""
|
||||
return self.filter(designer_id=designer_id)
|
||||
|
||||
|
||||
def with_capacity_info(self):
|
||||
"""Add capacity-related annotations."""
|
||||
return self.annotate(
|
||||
estimated_daily_capacity=F('capacity_per_hour') * 10, # Assuming 10 operating hours
|
||||
duration_minutes=F('ride_duration_seconds') / 60.0
|
||||
estimated_daily_capacity=F("capacity_per_hour")
|
||||
* 10, # Assuming 10 operating hours
|
||||
duration_minutes=F("ride_duration_seconds") / 60.0,
|
||||
)
|
||||
|
||||
|
||||
def high_capacity(self, *, min_capacity: int = 1000):
|
||||
"""Filter for high-capacity rides."""
|
||||
return self.filter(capacity_per_hour__gte=min_capacity)
|
||||
|
||||
|
||||
def optimized_for_list(self):
|
||||
"""Optimize for ride list display."""
|
||||
return self.select_related(
|
||||
'park',
|
||||
'park_area',
|
||||
'manufacturer',
|
||||
'designer',
|
||||
'ride_model'
|
||||
"park", "park_area", "manufacturer", "designer", "ride_model"
|
||||
).with_review_stats()
|
||||
|
||||
|
||||
def optimized_for_detail(self):
|
||||
"""Optimize for ride detail display."""
|
||||
from .models import RideReview
|
||||
|
||||
|
||||
return self.select_related(
|
||||
'park',
|
||||
'park_area',
|
||||
'manufacturer',
|
||||
'designer',
|
||||
'ride_model__manufacturer'
|
||||
"park",
|
||||
"park_area",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"ride_model__manufacturer",
|
||||
).prefetch_related(
|
||||
'location',
|
||||
'rollercoaster_stats',
|
||||
"location",
|
||||
"rollercoaster_stats",
|
||||
Prefetch(
|
||||
'reviews',
|
||||
queryset=RideReview.objects.select_related('user')
|
||||
"reviews",
|
||||
queryset=RideReview.objects.select_related("user")
|
||||
.filter(is_published=True)
|
||||
.order_by('-created_at')[:10]
|
||||
.order_by("-created_at")[:10],
|
||||
),
|
||||
'photos'
|
||||
"photos",
|
||||
)
|
||||
|
||||
|
||||
def for_map_display(self):
|
||||
"""Optimize for map display."""
|
||||
return self.select_related('park', 'park_area').prefetch_related('location').values(
|
||||
'id', 'name', 'slug', 'category', 'status',
|
||||
'park__name', 'park__slug',
|
||||
'park_area__name',
|
||||
'location__point'
|
||||
return (
|
||||
self.select_related("park", "park_area")
|
||||
.prefetch_related("location")
|
||||
.values(
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"category",
|
||||
"status",
|
||||
"park__name",
|
||||
"park__slug",
|
||||
"park_area__name",
|
||||
"location__point",
|
||||
)
|
||||
)
|
||||
|
||||
def search_by_specs(self, *, min_height: Optional[int] = None, max_height: Optional[int] = None,
|
||||
min_speed: Optional[float] = None, inversions: Optional[bool] = None):
|
||||
|
||||
def search_by_specs(
|
||||
self,
|
||||
*,
|
||||
min_height: Optional[int] = None,
|
||||
max_height: Optional[int] = None,
|
||||
min_speed: Optional[float] = None,
|
||||
inversions: Optional[bool] = None,
|
||||
):
|
||||
"""Search rides by physical specifications."""
|
||||
queryset = self
|
||||
|
||||
|
||||
if min_height:
|
||||
queryset = queryset.filter(
|
||||
Q(rollercoaster_stats__height_ft__gte=min_height) |
|
||||
Q(min_height_in__gte=min_height)
|
||||
Q(rollercoaster_stats__height_ft__gte=min_height)
|
||||
| Q(min_height_in__gte=min_height)
|
||||
)
|
||||
|
||||
|
||||
if max_height:
|
||||
queryset = queryset.filter(
|
||||
Q(rollercoaster_stats__height_ft__lte=max_height) |
|
||||
Q(max_height_in__lte=max_height)
|
||||
Q(rollercoaster_stats__height_ft__lte=max_height)
|
||||
| Q(max_height_in__lte=max_height)
|
||||
)
|
||||
|
||||
|
||||
if min_speed:
|
||||
queryset = queryset.filter(rollercoaster_stats__speed_mph__gte=min_speed)
|
||||
|
||||
|
||||
if inversions is not None:
|
||||
if inversions:
|
||||
queryset = queryset.filter(rollercoaster_stats__inversions__gt=0)
|
||||
else:
|
||||
queryset = queryset.filter(
|
||||
Q(rollercoaster_stats__inversions=0) |
|
||||
Q(rollercoaster_stats__isnull=True)
|
||||
Q(rollercoaster_stats__inversions=0)
|
||||
| Q(rollercoaster_stats__isnull=True)
|
||||
)
|
||||
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class RideManager(StatusManager, ReviewableManager):
|
||||
"""Custom manager for Ride model."""
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
return RideQuerySet(self.model, using=self._db)
|
||||
|
||||
|
||||
def coasters(self):
|
||||
return self.get_queryset().coasters()
|
||||
|
||||
|
||||
def thrill_rides(self):
|
||||
return self.get_queryset().thrill_rides()
|
||||
|
||||
|
||||
def family_friendly(self, *, max_height_requirement: int = 42):
|
||||
return self.get_queryset().family_friendly(max_height_requirement=max_height_requirement)
|
||||
|
||||
return self.get_queryset().family_friendly(
|
||||
max_height_requirement=max_height_requirement
|
||||
)
|
||||
|
||||
def by_park(self, *, park_id: int):
|
||||
return self.get_queryset().by_park(park_id=park_id)
|
||||
|
||||
|
||||
def high_capacity(self, *, min_capacity: int = 1000):
|
||||
return self.get_queryset().high_capacity(min_capacity=min_capacity)
|
||||
|
||||
|
||||
def optimized_for_list(self):
|
||||
return self.get_queryset().optimized_for_list()
|
||||
|
||||
|
||||
def optimized_for_detail(self):
|
||||
return self.get_queryset().optimized_for_detail()
|
||||
|
||||
|
||||
class RideModelQuerySet(BaseQuerySet):
|
||||
"""QuerySet for RideModel model."""
|
||||
|
||||
|
||||
def by_manufacturer(self, *, manufacturer_id: int):
|
||||
"""Filter ride models by manufacturer."""
|
||||
return self.filter(manufacturer_id=manufacturer_id)
|
||||
|
||||
|
||||
def by_category(self, *, category: str):
|
||||
"""Filter ride models by category."""
|
||||
return self.filter(category=category)
|
||||
|
||||
|
||||
def with_ride_counts(self):
|
||||
"""Add count of rides using this model."""
|
||||
return self.annotate(
|
||||
ride_count=Count('rides', distinct=True),
|
||||
operating_rides_count=Count('rides', filter=Q(rides__status='OPERATING'), distinct=True)
|
||||
ride_count=Count("rides", distinct=True),
|
||||
operating_rides_count=Count(
|
||||
"rides", filter=Q(rides__status="OPERATING"), distinct=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def popular_models(self, *, min_installations: int = 5):
|
||||
"""Filter for popular ride models."""
|
||||
return self.with_ride_counts().filter(ride_count__gte=min_installations)
|
||||
|
||||
|
||||
def optimized_for_list(self):
|
||||
"""Optimize for model list display."""
|
||||
return self.select_related('manufacturer').with_ride_counts()
|
||||
return self.select_related("manufacturer").with_ride_counts()
|
||||
|
||||
|
||||
class RideModelManager(BaseManager):
|
||||
"""Manager for RideModel model."""
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
return RideModelQuerySet(self.model, using=self._db)
|
||||
|
||||
|
||||
def by_manufacturer(self, *, manufacturer_id: int):
|
||||
return self.get_queryset().by_manufacturer(manufacturer_id=manufacturer_id)
|
||||
|
||||
|
||||
def popular_models(self, *, min_installations: int = 5):
|
||||
return self.get_queryset().popular_models(min_installations=min_installations)
|
||||
|
||||
|
||||
class RideReviewQuerySet(ReviewableQuerySet):
|
||||
"""QuerySet for RideReview model."""
|
||||
|
||||
|
||||
def for_ride(self, *, ride_id: int):
|
||||
"""Filter reviews for a specific ride."""
|
||||
return self.filter(ride_id=ride_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', 'ride', 'moderated_by')
|
||||
return self.select_related("user", "ride", "moderated_by")
|
||||
|
||||
|
||||
class RideReviewManager(BaseManager):
|
||||
"""Manager for RideReview model."""
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
return RideReviewQuerySet(self.model, using=self._db)
|
||||
|
||||
|
||||
def for_ride(self, *, ride_id: int):
|
||||
return self.get_queryset().for_ride(ride_id=ride_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
|
||||
)
|
||||
|
||||
|
||||
class RollerCoasterStatsQuerySet(BaseQuerySet):
|
||||
"""QuerySet for RollerCoasterStats model."""
|
||||
|
||||
|
||||
def tall_coasters(self, *, min_height_ft: float = 200):
|
||||
"""Filter for tall roller coasters."""
|
||||
return self.filter(height_ft__gte=min_height_ft)
|
||||
|
||||
|
||||
def fast_coasters(self, *, min_speed_mph: float = 60):
|
||||
"""Filter for fast roller coasters."""
|
||||
return self.filter(speed_mph__gte=min_speed_mph)
|
||||
|
||||
|
||||
def with_inversions(self):
|
||||
"""Filter for coasters with inversions."""
|
||||
return self.filter(inversions__gt=0)
|
||||
|
||||
|
||||
def launched_coasters(self):
|
||||
"""Filter for launched coasters."""
|
||||
return self.exclude(launch_type='NONE')
|
||||
|
||||
return self.exclude(launch_type="NONE")
|
||||
|
||||
def by_track_type(self, *, track_type: str):
|
||||
"""Filter by track type."""
|
||||
return self.filter(track_type=track_type)
|
||||
|
||||
|
||||
def optimized_for_list(self):
|
||||
"""Optimize for stats list display."""
|
||||
return self.select_related('ride', 'ride__park')
|
||||
return self.select_related("ride", "ride__park")
|
||||
|
||||
|
||||
class RollerCoasterStatsManager(BaseManager):
|
||||
"""Manager for RollerCoasterStats model."""
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
return RollerCoasterStatsQuerySet(self.model, using=self._db)
|
||||
|
||||
|
||||
def tall_coasters(self, *, min_height_ft: float = 200):
|
||||
return self.get_queryset().tall_coasters(min_height_ft=min_height_ft)
|
||||
|
||||
|
||||
def fast_coasters(self, *, min_speed_mph: float = 60):
|
||||
return self.get_queryset().fast_coasters(min_speed_mph=min_speed_mph)
|
||||
|
||||
|
||||
def with_inversions(self):
|
||||
return self.get_queryset().with_inversions()
|
||||
|
||||
|
||||
def launched_coasters(self):
|
||||
return self.get_queryset().launched_coasters()
|
||||
|
||||
@@ -67,7 +67,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()),
|
||||
@@ -170,8 +173,14 @@ class Migration(migrations.Migration):
|
||||
("opening_date", models.DateField(blank=True, null=True)),
|
||||
("closing_date", models.DateField(blank=True, null=True)),
|
||||
("status_since", models.DateField(blank=True, null=True)),
|
||||
("min_height_in", models.PositiveIntegerField(blank=True, null=True)),
|
||||
("max_height_in", models.PositiveIntegerField(blank=True, null=True)),
|
||||
(
|
||||
"min_height_in",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"max_height_in",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"capacity_per_hour",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
@@ -323,7 +332,10 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name="RideReviewEvent",
|
||||
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()),
|
||||
@@ -440,9 +452,18 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
),
|
||||
("train_style", models.CharField(blank=True, max_length=255)),
|
||||
("trains_count", models.PositiveIntegerField(blank=True, null=True)),
|
||||
("cars_per_train", models.PositiveIntegerField(blank=True, null=True)),
|
||||
("seats_per_car", models.PositiveIntegerField(blank=True, null=True)),
|
||||
(
|
||||
"trains_count",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"cars_per_train",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"seats_per_car",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Roller Coaster Statistics",
|
||||
|
||||
@@ -112,7 +112,10 @@ class Migration(migrations.Migration):
|
||||
model_name="ridereview",
|
||||
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="ride_review_visit_date_not_future",
|
||||
violation_error_message="Visit date cannot be in the future",
|
||||
@@ -123,10 +126,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",
|
||||
),
|
||||
|
||||
@@ -1,4 +1,32 @@
|
||||
from .company import *
|
||||
from .rides import *
|
||||
from .reviews import *
|
||||
from .location import *
|
||||
"""
|
||||
Rides app models with clean import interface.
|
||||
|
||||
This module provides a clean import interface for all rides-related models,
|
||||
enabling imports like: from rides.models import Ride, Manufacturer
|
||||
|
||||
The Company model is aliased as Manufacturer to clarify its role as ride manufacturers,
|
||||
while maintaining backward compatibility through the Company alias.
|
||||
"""
|
||||
|
||||
from .rides import Ride, RideModel, RollerCoasterStats, CATEGORY_CHOICES
|
||||
from .location import RideLocation
|
||||
from .reviews import RideReview
|
||||
from .company import Company
|
||||
|
||||
# Alias Company as Manufacturer for clarity
|
||||
Manufacturer = Company
|
||||
|
||||
__all__ = [
|
||||
# Primary models
|
||||
"Ride",
|
||||
"RideModel",
|
||||
"RollerCoasterStats",
|
||||
"RideLocation",
|
||||
"RideReview",
|
||||
# Shared constants
|
||||
"CATEGORY_CHOICES",
|
||||
# Company models with clear naming
|
||||
"Manufacturer",
|
||||
# Backward compatibility
|
||||
"Company", # Alias to Manufacturer
|
||||
]
|
||||
|
||||
@@ -11,17 +11,17 @@ from core.models import TrackedModel
|
||||
@pghistory.track()
|
||||
class Company(TrackedModel):
|
||||
class CompanyRole(models.TextChoices):
|
||||
MANUFACTURER = 'MANUFACTURER', 'Ride Manufacturer'
|
||||
DESIGNER = 'DESIGNER', 'Ride Designer'
|
||||
OPERATOR = 'OPERATOR', 'Park Operator'
|
||||
PROPERTY_OWNER = 'PROPERTY_OWNER', 'Property Owner'
|
||||
MANUFACTURER = "MANUFACTURER", "Ride Manufacturer"
|
||||
DESIGNER = "DESIGNER", "Ride Designer"
|
||||
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)
|
||||
@@ -43,8 +43,8 @@ class Company(TrackedModel):
|
||||
|
||||
def get_absolute_url(self):
|
||||
# This will need to be updated to handle different roles
|
||||
return reverse('companies:detail', kwargs={'slug': self.slug})
|
||||
return '#'
|
||||
return reverse("companies:detail", kwargs={"slug": self.slug})
|
||||
return "#"
|
||||
|
||||
@classmethod
|
||||
def get_by_slug(cls, slug):
|
||||
@@ -56,7 +56,7 @@ class Company(TrackedModel):
|
||||
history_model = cls.get_history_model()
|
||||
history_entry = (
|
||||
history_model.objects.filter(slug=slug)
|
||||
.order_by('-pgh_created_at')
|
||||
.order_by("-pgh_created_at")
|
||||
.first()
|
||||
)
|
||||
if history_entry:
|
||||
@@ -65,13 +65,12 @@ class Company(TrackedModel):
|
||||
# Check manual slug history as fallback
|
||||
try:
|
||||
historical = HistoricalSlug.objects.get(
|
||||
content_type__model='company',
|
||||
slug=slug
|
||||
content_type__model="company", slug=slug
|
||||
)
|
||||
return cls.objects.get(pk=historical.object_id), True
|
||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||
raise cls.DoesNotExist("No company found with this slug")
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name_plural = 'Companies'
|
||||
ordering = ["name"]
|
||||
verbose_name_plural = "Companies"
|
||||
|
||||
@@ -8,47 +8,45 @@ class RideLocation(models.Model):
|
||||
Lightweight location tracking for individual rides within parks.
|
||||
Optional coordinates with focus on practical navigation information.
|
||||
"""
|
||||
|
||||
# Relationships
|
||||
ride = models.OneToOneField(
|
||||
'rides.Ride',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='ride_location'
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="ride_location"
|
||||
)
|
||||
|
||||
|
||||
# Optional Spatial Data - keep it simple with single point
|
||||
point = gis_models.PointField(
|
||||
srid=4326,
|
||||
null=True,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Geographic coordinates for ride location (longitude, latitude)"
|
||||
help_text="Geographic coordinates for ride location (longitude, latitude)",
|
||||
)
|
||||
|
||||
|
||||
# Park Area Information
|
||||
park_area = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="Themed area or land within the park (e.g., 'Frontierland', 'Tomorrowland')"
|
||||
help_text=(
|
||||
"Themed area or land within the park (e.g., 'Frontierland', 'Tomorrowland')"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# General notes field to match database schema
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="General location notes"
|
||||
)
|
||||
|
||||
notes = models.TextField(blank=True, help_text="General location notes")
|
||||
|
||||
# Navigation and Entrance Information
|
||||
entrance_notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="Directions to ride entrance, queue location, or navigation tips"
|
||||
help_text="Directions to ride entrance, queue location, or navigation tips",
|
||||
)
|
||||
|
||||
|
||||
# Accessibility Information
|
||||
accessibility_notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="Information about accessible entrances, wheelchair access, etc."
|
||||
help_text="Information about accessible entrances, wheelchair access, etc.",
|
||||
)
|
||||
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@@ -102,11 +100,11 @@ class RideLocation(models.Model):
|
||||
"""
|
||||
if not self.point:
|
||||
return None
|
||||
|
||||
park_location = getattr(self.ride.park, 'location', None)
|
||||
|
||||
park_location = getattr(self.ride.park, "location", None)
|
||||
if not park_location or not park_location.point:
|
||||
return None
|
||||
|
||||
|
||||
# Use geodetic distance calculation which returns meters, convert to km
|
||||
distance_m = self.point.distance(park_location.point)
|
||||
return distance_m / 1000.0
|
||||
@@ -118,8 +116,9 @@ class RideLocation(models.Model):
|
||||
class Meta:
|
||||
verbose_name = "Ride Location"
|
||||
verbose_name_plural = "Ride Locations"
|
||||
ordering = ['ride__name']
|
||||
ordering = ["ride__name"]
|
||||
indexes = [
|
||||
models.Index(fields=['park_area']),
|
||||
# Spatial index will be created automatically for PostGIS PointField
|
||||
]
|
||||
models.Index(fields=["park_area"]),
|
||||
# Spatial index will be created automatically for PostGIS
|
||||
# PointField
|
||||
]
|
||||
|
||||
@@ -4,20 +4,18 @@ from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from core.history import TrackedModel
|
||||
import pghistory
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RideReview(TrackedModel):
|
||||
"""
|
||||
A review of a ride.
|
||||
"""
|
||||
|
||||
ride = models.ForeignKey(
|
||||
'rides.Ride',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='reviews'
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="reviews"
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
'accounts.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='ride_reviews'
|
||||
"accounts.User", on_delete=models.CASCADE, related_name="ride_reviews"
|
||||
)
|
||||
rating = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1), MaxValueValidator(10)]
|
||||
@@ -25,47 +23,53 @@ class RideReview(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_ride_reviews'
|
||||
related_name="moderated_ride_reviews",
|
||||
)
|
||||
moderated_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
unique_together = ['ride', 'user']
|
||||
ordering = ["-created_at"]
|
||||
unique_together = ["ride", "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="ride_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="ride_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="ride_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.ride.name} by {self.user.username}"
|
||||
return f"Review of {self.ride.name} by {self.user.username}"
|
||||
|
||||
@@ -6,119 +6,118 @@ from .company import Company
|
||||
|
||||
# Shared choices that will be used by multiple models
|
||||
CATEGORY_CHOICES = [
|
||||
('', 'Select ride type'),
|
||||
('RC', 'Roller Coaster'),
|
||||
('DR', 'Dark Ride'),
|
||||
('FR', 'Flat Ride'),
|
||||
('WR', 'Water Ride'),
|
||||
('TR', 'Transport'),
|
||||
('OT', 'Other'),
|
||||
("", "Select ride type"),
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
]
|
||||
|
||||
|
||||
class RideModel(TrackedModel):
|
||||
"""
|
||||
Represents a specific model/type of ride that can be manufactured by different companies.
|
||||
Represents a specific model/type of ride that can be manufactured by different
|
||||
companies.
|
||||
For example: B&M Dive Coaster, Vekoma Boomerang, etc.
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
manufacturer = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='ride_models',
|
||||
related_name="ride_models",
|
||||
null=True,
|
||||
blank=True,
|
||||
limit_choices_to={'roles__contains': ['MANUFACTURER']},
|
||||
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
category = models.CharField(
|
||||
max_length=2,
|
||||
choices=CATEGORY_CHOICES,
|
||||
default='',
|
||||
blank=True
|
||||
max_length=2, choices=CATEGORY_CHOICES, default="", blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['manufacturer', 'name']
|
||||
unique_together = ['manufacturer', 'name']
|
||||
ordering = ["manufacturer", "name"]
|
||||
unique_together = ["manufacturer", "name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name if not self.manufacturer else f"{self.manufacturer.name} {self.name}"
|
||||
return (
|
||||
self.name
|
||||
if not self.manufacturer
|
||||
else f"{self.manufacturer.name} {self.name}"
|
||||
)
|
||||
|
||||
|
||||
class Ride(TrackedModel):
|
||||
"""Model for individual ride installations at parks"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('', 'Select status'),
|
||||
('OPERATING', 'Operating'),
|
||||
('CLOSED_TEMP', 'Temporarily Closed'),
|
||||
('SBNO', 'Standing But Not Operating'),
|
||||
('CLOSING', 'Closing'),
|
||||
('CLOSED_PERM', 'Permanently Closed'),
|
||||
('UNDER_CONSTRUCTION', 'Under Construction'),
|
||||
('DEMOLISHED', 'Demolished'),
|
||||
('RELOCATED', 'Relocated'),
|
||||
("", "Select status"),
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSING", "Closing"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
]
|
||||
|
||||
POST_CLOSING_STATUS_CHOICES = [
|
||||
('SBNO', 'Standing But Not Operating'),
|
||||
('CLOSED_PERM', 'Permanently Closed'),
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
park = models.ForeignKey(
|
||||
'parks.Park',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='rides'
|
||||
"parks.Park", on_delete=models.CASCADE, related_name="rides"
|
||||
)
|
||||
park_area = models.ForeignKey(
|
||||
'parks.ParkArea',
|
||||
"parks.ParkArea",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='rides',
|
||||
related_name="rides",
|
||||
null=True,
|
||||
blank=True
|
||||
blank=True,
|
||||
)
|
||||
category = models.CharField(
|
||||
max_length=2,
|
||||
choices=CATEGORY_CHOICES,
|
||||
default='',
|
||||
blank=True
|
||||
max_length=2, choices=CATEGORY_CHOICES, default="", blank=True
|
||||
)
|
||||
manufacturer = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='manufactured_rides',
|
||||
limit_choices_to={'roles__contains': ['MANUFACTURER']},
|
||||
related_name="manufactured_rides",
|
||||
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
|
||||
)
|
||||
designer = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='designed_rides',
|
||||
related_name="designed_rides",
|
||||
null=True,
|
||||
blank=True,
|
||||
limit_choices_to={'roles__contains': ['DESIGNER']},
|
||||
limit_choices_to={"roles__contains": ["DESIGNER"]},
|
||||
)
|
||||
ride_model = models.ForeignKey(
|
||||
'RideModel',
|
||||
"RideModel",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='rides',
|
||||
related_name="rides",
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="The specific model/type of this ride"
|
||||
help_text="The specific model/type of this ride",
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='OPERATING'
|
||||
max_length=20, choices=STATUS_CHOICES, default="OPERATING"
|
||||
)
|
||||
post_closing_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=POST_CLOSING_STATUS_CHOICES,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Status to change to after closing date"
|
||||
help_text="Status to change to after closing date",
|
||||
)
|
||||
opening_date = models.DateField(null=True, blank=True)
|
||||
closing_date = models.DateField(null=True, blank=True)
|
||||
@@ -128,56 +127,67 @@ class Ride(TrackedModel):
|
||||
capacity_per_hour = models.PositiveIntegerField(null=True, blank=True)
|
||||
ride_duration_seconds = models.PositiveIntegerField(null=True, blank=True)
|
||||
average_rating = models.DecimalField(
|
||||
max_digits=3,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True
|
||||
max_digits=3, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
photos = GenericRelation('media.Photo')
|
||||
photos = GenericRelation("media.Photo")
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
unique_together = ['park', 'slug']
|
||||
ordering = ["name"]
|
||||
unique_together = ["park", "slug"]
|
||||
constraints = [
|
||||
# Business rule: Closing date must be after opening date
|
||||
models.CheckConstraint(
|
||||
name="ride_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: Height requirements must be logical
|
||||
models.CheckConstraint(
|
||||
name="ride_height_requirements_logical",
|
||||
check=models.Q(min_height_in__isnull=True) | models.Q(max_height_in__isnull=True) | models.Q(min_height_in__lte=models.F("max_height_in")),
|
||||
violation_error_message="Minimum height cannot exceed maximum height"
|
||||
check=models.Q(min_height_in__isnull=True)
|
||||
| models.Q(max_height_in__isnull=True)
|
||||
| models.Q(min_height_in__lte=models.F("max_height_in")),
|
||||
violation_error_message="Minimum height cannot exceed maximum height",
|
||||
),
|
||||
# Business rule: Height requirements must be reasonable (between 30 and 90 inches)
|
||||
# Business rule: Height requirements must be reasonable (between 30
|
||||
# and 90 inches)
|
||||
models.CheckConstraint(
|
||||
name="ride_min_height_reasonable",
|
||||
check=models.Q(min_height_in__isnull=True) | (models.Q(min_height_in__gte=30) & models.Q(min_height_in__lte=90)),
|
||||
violation_error_message="Minimum height must be between 30 and 90 inches"
|
||||
check=models.Q(min_height_in__isnull=True)
|
||||
| (models.Q(min_height_in__gte=30) & models.Q(min_height_in__lte=90)),
|
||||
violation_error_message=(
|
||||
"Minimum height must be between 30 and 90 inches"
|
||||
),
|
||||
),
|
||||
models.CheckConstraint(
|
||||
name="ride_max_height_reasonable",
|
||||
check=models.Q(max_height_in__isnull=True) | (models.Q(max_height_in__gte=30) & models.Q(max_height_in__lte=90)),
|
||||
violation_error_message="Maximum height must be between 30 and 90 inches"
|
||||
check=models.Q(max_height_in__isnull=True)
|
||||
| (models.Q(max_height_in__gte=30) & models.Q(max_height_in__lte=90)),
|
||||
violation_error_message=(
|
||||
"Maximum height must be between 30 and 90 inches"
|
||||
),
|
||||
),
|
||||
# Business rule: Rating must be between 1 and 10
|
||||
models.CheckConstraint(
|
||||
name="ride_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: Capacity and duration must be positive
|
||||
models.CheckConstraint(
|
||||
name="ride_capacity_positive",
|
||||
check=models.Q(capacity_per_hour__isnull=True) | models.Q(capacity_per_hour__gt=0),
|
||||
violation_error_message="Hourly capacity must be positive"
|
||||
check=models.Q(capacity_per_hour__isnull=True)
|
||||
| models.Q(capacity_per_hour__gt=0),
|
||||
violation_error_message="Hourly capacity must be positive",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
name="ride_duration_positive",
|
||||
check=models.Q(ride_duration_seconds__isnull=True) | models.Q(ride_duration_seconds__gt=0),
|
||||
violation_error_message="Ride duration must be positive"
|
||||
check=models.Q(ride_duration_seconds__isnull=True)
|
||||
| models.Q(ride_duration_seconds__gt=0),
|
||||
violation_error_message="Ride duration must be positive",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -189,58 +199,49 @@ class Ride(TrackedModel):
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class RollerCoasterStats(models.Model):
|
||||
"""Model for tracking roller coaster specific statistics"""
|
||||
|
||||
TRACK_MATERIAL_CHOICES = [
|
||||
('STEEL', 'Steel'),
|
||||
('WOOD', 'Wood'),
|
||||
('HYBRID', 'Hybrid'),
|
||||
("STEEL", "Steel"),
|
||||
("WOOD", "Wood"),
|
||||
("HYBRID", "Hybrid"),
|
||||
]
|
||||
|
||||
COASTER_TYPE_CHOICES = [
|
||||
('SITDOWN', 'Sit Down'),
|
||||
('INVERTED', 'Inverted'),
|
||||
('FLYING', 'Flying'),
|
||||
('STANDUP', 'Stand Up'),
|
||||
('WING', 'Wing'),
|
||||
('DIVE', 'Dive'),
|
||||
('FAMILY', 'Family'),
|
||||
('WILD_MOUSE', 'Wild Mouse'),
|
||||
('SPINNING', 'Spinning'),
|
||||
('FOURTH_DIMENSION', '4th Dimension'),
|
||||
('OTHER', 'Other'),
|
||||
("SITDOWN", "Sit Down"),
|
||||
("INVERTED", "Inverted"),
|
||||
("FLYING", "Flying"),
|
||||
("STANDUP", "Stand Up"),
|
||||
("WING", "Wing"),
|
||||
("DIVE", "Dive"),
|
||||
("FAMILY", "Family"),
|
||||
("WILD_MOUSE", "Wild Mouse"),
|
||||
("SPINNING", "Spinning"),
|
||||
("FOURTH_DIMENSION", "4th Dimension"),
|
||||
("OTHER", "Other"),
|
||||
]
|
||||
|
||||
LAUNCH_CHOICES = [
|
||||
('CHAIN', 'Chain Lift'),
|
||||
('LSM', 'LSM Launch'),
|
||||
('HYDRAULIC', 'Hydraulic Launch'),
|
||||
('GRAVITY', 'Gravity'),
|
||||
('OTHER', 'Other'),
|
||||
("CHAIN", "Chain Lift"),
|
||||
("LSM", "LSM Launch"),
|
||||
("HYDRAULIC", "Hydraulic Launch"),
|
||||
("GRAVITY", "Gravity"),
|
||||
("OTHER", "Other"),
|
||||
]
|
||||
|
||||
ride = models.OneToOneField(
|
||||
Ride,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='coaster_stats'
|
||||
Ride, on_delete=models.CASCADE, related_name="coaster_stats"
|
||||
)
|
||||
height_ft = models.DecimalField(
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True
|
||||
max_digits=6, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
length_ft = models.DecimalField(
|
||||
max_digits=7,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True
|
||||
max_digits=7, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
speed_mph = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True
|
||||
max_digits=5, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
inversions = models.PositiveIntegerField(default=0)
|
||||
ride_time_seconds = models.PositiveIntegerField(null=True, blank=True)
|
||||
@@ -248,25 +249,20 @@ class RollerCoasterStats(models.Model):
|
||||
track_material = models.CharField(
|
||||
max_length=20,
|
||||
choices=TRACK_MATERIAL_CHOICES,
|
||||
default='STEEL',
|
||||
blank=True
|
||||
default="STEEL",
|
||||
blank=True,
|
||||
)
|
||||
roller_coaster_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=COASTER_TYPE_CHOICES,
|
||||
default='SITDOWN',
|
||||
blank=True
|
||||
default="SITDOWN",
|
||||
blank=True,
|
||||
)
|
||||
max_drop_height_ft = models.DecimalField(
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True
|
||||
max_digits=6, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
launch_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=LAUNCH_CHOICES,
|
||||
default='CHAIN'
|
||||
max_length=20, choices=LAUNCH_CHOICES, default="CHAIN"
|
||||
)
|
||||
train_style = models.CharField(max_length=255, blank=True)
|
||||
trains_count = models.PositiveIntegerField(null=True, blank=True)
|
||||
@@ -274,8 +270,8 @@ class RollerCoasterStats(models.Model):
|
||||
seats_per_car = models.PositiveIntegerField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Roller Coaster Statistics'
|
||||
verbose_name_plural = 'Roller Coaster Statistics'
|
||||
verbose_name = "Roller Coaster Statistics"
|
||||
verbose_name_plural = "Roller Coaster Statistics"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Stats for {self.ride.name}"
|
||||
return f"Stats for {self.ride.name}"
|
||||
|
||||
@@ -7,35 +7,16 @@ urlpatterns = [
|
||||
# Park-specific list views
|
||||
path("", views.RideListView.as_view(), name="ride_list"),
|
||||
path("create/", views.RideCreateView.as_view(), name="ride_create"),
|
||||
|
||||
# Park-specific detail views
|
||||
path(
|
||||
"<slug:ride_slug>/",
|
||||
views.RideDetailView.as_view(),
|
||||
name="ride_detail"
|
||||
),
|
||||
path("<slug:ride_slug>/", views.RideDetailView.as_view(), name="ride_detail"),
|
||||
path(
|
||||
"<slug:ride_slug>/update/",
|
||||
views.RideUpdateView.as_view(),
|
||||
name="ride_update"
|
||||
name="ride_update",
|
||||
),
|
||||
path(
|
||||
"search/companies/",
|
||||
views.search_companies,
|
||||
name="search_companies"
|
||||
),
|
||||
|
||||
path("search/companies/", views.search_companies, name="search_companies"),
|
||||
# Search endpoints
|
||||
path(
|
||||
"search/models/",
|
||||
views.search_ride_models,
|
||||
name="search_ride_models"
|
||||
),
|
||||
|
||||
path("search/models/", views.search_ride_models, name="search_ride_models"),
|
||||
# HTMX endpoints
|
||||
path(
|
||||
"coaster-fields/",
|
||||
views.show_coaster_fields,
|
||||
name="coaster_fields"
|
||||
),
|
||||
]
|
||||
path("coaster-fields/", views.show_coaster_fields, name="coaster_fields"),
|
||||
]
|
||||
|
||||
@@ -3,314 +3,301 @@ Selectors for ride-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
|
||||
|
||||
from .models import Ride, RideModel, RideReview
|
||||
from parks.models import Park
|
||||
|
||||
|
||||
def ride_list_for_display(*, filters: Optional[Dict[str, Any]] = None) -> QuerySet[Ride]:
|
||||
def ride_list_for_display(
|
||||
*, filters: Optional[Dict[str, Any]] = None
|
||||
) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get rides optimized for list display with related data.
|
||||
|
||||
|
||||
Args:
|
||||
filters: Optional dictionary of filter parameters
|
||||
|
||||
|
||||
Returns:
|
||||
QuerySet of rides with optimized queries
|
||||
"""
|
||||
queryset = Ride.objects.select_related(
|
||||
'park',
|
||||
'park__operator',
|
||||
'manufacturer',
|
||||
'designer',
|
||||
'ride_model',
|
||||
'park_area'
|
||||
).prefetch_related(
|
||||
'park__location',
|
||||
'location'
|
||||
).annotate(
|
||||
average_rating_calculated=Avg('reviews__rating')
|
||||
queryset = (
|
||||
Ride.objects.select_related(
|
||||
"park",
|
||||
"park__operator",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"ride_model",
|
||||
"park_area",
|
||||
)
|
||||
.prefetch_related("park__location", "location")
|
||||
.annotate(average_rating_calculated=Avg("reviews__rating"))
|
||||
)
|
||||
|
||||
|
||||
if filters:
|
||||
if 'status' in filters:
|
||||
queryset = queryset.filter(status=filters['status'])
|
||||
if 'category' in filters:
|
||||
queryset = queryset.filter(category=filters['category'])
|
||||
if 'manufacturer' in filters:
|
||||
queryset = queryset.filter(manufacturer=filters['manufacturer'])
|
||||
if 'park' in filters:
|
||||
queryset = queryset.filter(park=filters['park'])
|
||||
if 'search' in filters:
|
||||
search_term = filters['search']
|
||||
if "status" in filters:
|
||||
queryset = queryset.filter(status=filters["status"])
|
||||
if "category" in filters:
|
||||
queryset = queryset.filter(category=filters["category"])
|
||||
if "manufacturer" in filters:
|
||||
queryset = queryset.filter(manufacturer=filters["manufacturer"])
|
||||
if "park" in filters:
|
||||
queryset = queryset.filter(park=filters["park"])
|
||||
if "search" in filters:
|
||||
search_term = filters["search"]
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=search_term) |
|
||||
Q(description__icontains=search_term) |
|
||||
Q(park__name__icontains=search_term)
|
||||
Q(name__icontains=search_term)
|
||||
| Q(description__icontains=search_term)
|
||||
| Q(park__name__icontains=search_term)
|
||||
)
|
||||
|
||||
return queryset.order_by('park__name', 'name')
|
||||
|
||||
return queryset.order_by("park__name", "name")
|
||||
|
||||
|
||||
def ride_detail_optimized(*, slug: str, park_slug: str) -> Ride:
|
||||
"""
|
||||
Get a single ride with all related data optimized for detail view.
|
||||
|
||||
|
||||
Args:
|
||||
slug: Ride slug identifier
|
||||
park_slug: Park slug for the ride
|
||||
|
||||
|
||||
Returns:
|
||||
Ride instance with optimized prefetches
|
||||
|
||||
|
||||
Raises:
|
||||
Ride.DoesNotExist: If ride doesn't exist
|
||||
"""
|
||||
return Ride.objects.select_related(
|
||||
'park',
|
||||
'park__operator',
|
||||
'manufacturer',
|
||||
'designer',
|
||||
'ride_model',
|
||||
'park_area'
|
||||
).prefetch_related(
|
||||
'park__location',
|
||||
'location',
|
||||
Prefetch(
|
||||
'reviews',
|
||||
queryset=RideReview.objects.select_related('user').filter(is_published=True)
|
||||
),
|
||||
'photos'
|
||||
).get(slug=slug, park__slug=park_slug)
|
||||
return (
|
||||
Ride.objects.select_related(
|
||||
"park",
|
||||
"park__operator",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"ride_model",
|
||||
"park_area",
|
||||
)
|
||||
.prefetch_related(
|
||||
"park__location",
|
||||
"location",
|
||||
Prefetch(
|
||||
"reviews",
|
||||
queryset=RideReview.objects.select_related("user").filter(
|
||||
is_published=True
|
||||
),
|
||||
),
|
||||
"photos",
|
||||
)
|
||||
.get(slug=slug, park__slug=park_slug)
|
||||
)
|
||||
|
||||
|
||||
def rides_by_category(*, category: str) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get all rides in a specific category.
|
||||
|
||||
|
||||
Args:
|
||||
category: Ride category code
|
||||
|
||||
|
||||
Returns:
|
||||
QuerySet of rides in the category
|
||||
"""
|
||||
return Ride.objects.filter(
|
||||
category=category
|
||||
).select_related(
|
||||
'park',
|
||||
'manufacturer',
|
||||
'designer'
|
||||
).prefetch_related(
|
||||
'park__location'
|
||||
).annotate(
|
||||
average_rating_calculated=Avg('reviews__rating')
|
||||
).order_by('park__name', 'name')
|
||||
return (
|
||||
Ride.objects.filter(category=category)
|
||||
.select_related("park", "manufacturer", "designer")
|
||||
.prefetch_related("park__location")
|
||||
.annotate(average_rating_calculated=Avg("reviews__rating"))
|
||||
.order_by("park__name", "name")
|
||||
)
|
||||
|
||||
|
||||
def rides_by_manufacturer(*, manufacturer_id: int) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get all rides manufactured by a specific company.
|
||||
|
||||
|
||||
Args:
|
||||
manufacturer_id: Company ID of the manufacturer
|
||||
|
||||
|
||||
Returns:
|
||||
QuerySet of rides by the manufacturer
|
||||
"""
|
||||
return Ride.objects.filter(
|
||||
manufacturer_id=manufacturer_id
|
||||
).select_related(
|
||||
'park',
|
||||
'manufacturer',
|
||||
'ride_model'
|
||||
).prefetch_related(
|
||||
'park__location'
|
||||
).annotate(
|
||||
average_rating_calculated=Avg('reviews__rating')
|
||||
).order_by('park__name', 'name')
|
||||
return (
|
||||
Ride.objects.filter(manufacturer_id=manufacturer_id)
|
||||
.select_related("park", "manufacturer", "ride_model")
|
||||
.prefetch_related("park__location")
|
||||
.annotate(average_rating_calculated=Avg("reviews__rating"))
|
||||
.order_by("park__name", "name")
|
||||
)
|
||||
|
||||
|
||||
def rides_by_designer(*, designer_id: int) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get all rides designed by a specific company.
|
||||
|
||||
|
||||
Args:
|
||||
designer_id: Company ID of the designer
|
||||
|
||||
|
||||
Returns:
|
||||
QuerySet of rides by the designer
|
||||
"""
|
||||
return Ride.objects.filter(
|
||||
designer_id=designer_id
|
||||
).select_related(
|
||||
'park',
|
||||
'designer',
|
||||
'ride_model'
|
||||
).prefetch_related(
|
||||
'park__location'
|
||||
).annotate(
|
||||
average_rating_calculated=Avg('reviews__rating')
|
||||
).order_by('park__name', 'name')
|
||||
return (
|
||||
Ride.objects.filter(designer_id=designer_id)
|
||||
.select_related("park", "designer", "ride_model")
|
||||
.prefetch_related("park__location")
|
||||
.annotate(average_rating_calculated=Avg("reviews__rating"))
|
||||
.order_by("park__name", "name")
|
||||
)
|
||||
|
||||
|
||||
def rides_in_park(*, park_slug: str) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get all rides in a specific park.
|
||||
|
||||
|
||||
Args:
|
||||
park_slug: Slug of the park
|
||||
|
||||
|
||||
Returns:
|
||||
QuerySet of rides in the park
|
||||
"""
|
||||
return Ride.objects.filter(
|
||||
park__slug=park_slug
|
||||
).select_related(
|
||||
'manufacturer',
|
||||
'designer',
|
||||
'ride_model',
|
||||
'park_area'
|
||||
).prefetch_related(
|
||||
'location'
|
||||
).annotate(
|
||||
average_rating_calculated=Avg('reviews__rating')
|
||||
).order_by('park_area__name', 'name')
|
||||
return (
|
||||
Ride.objects.filter(park__slug=park_slug)
|
||||
.select_related("manufacturer", "designer", "ride_model", "park_area")
|
||||
.prefetch_related("location")
|
||||
.annotate(average_rating_calculated=Avg("reviews__rating"))
|
||||
.order_by("park_area__name", "name")
|
||||
)
|
||||
|
||||
|
||||
def rides_near_location(
|
||||
*,
|
||||
point: Point,
|
||||
distance_km: float = 50,
|
||||
limit: int = 10
|
||||
*, point: Point, distance_km: float = 50, limit: int = 10
|
||||
) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get rides 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 rides ordered by distance
|
||||
"""
|
||||
return Ride.objects.filter(
|
||||
park__location__coordinates__distance_lte=(point, Distance(km=distance_km))
|
||||
).select_related(
|
||||
'park',
|
||||
'manufacturer'
|
||||
).prefetch_related(
|
||||
'park__location'
|
||||
).distance(point).order_by('distance')[:limit]
|
||||
return (
|
||||
Ride.objects.filter(
|
||||
park__location__coordinates__distance_lte=(
|
||||
point,
|
||||
Distance(km=distance_km),
|
||||
)
|
||||
)
|
||||
.select_related("park", "manufacturer")
|
||||
.prefetch_related("park__location")
|
||||
.distance(point)
|
||||
.order_by("distance")[:limit]
|
||||
)
|
||||
|
||||
|
||||
def ride_models_with_installations() -> QuerySet[RideModel]:
|
||||
"""
|
||||
Get ride models that have installations with counts.
|
||||
|
||||
|
||||
Returns:
|
||||
QuerySet of ride models with installation counts
|
||||
"""
|
||||
return RideModel.objects.annotate(
|
||||
installation_count=Count('rides')
|
||||
).filter(
|
||||
installation_count__gt=0
|
||||
).select_related(
|
||||
'manufacturer'
|
||||
).order_by('-installation_count', 'name')
|
||||
return (
|
||||
RideModel.objects.annotate(installation_count=Count("rides"))
|
||||
.filter(installation_count__gt=0)
|
||||
.select_related("manufacturer")
|
||||
.order_by("-installation_count", "name")
|
||||
)
|
||||
|
||||
|
||||
def ride_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get rides matching a search query for autocomplete functionality.
|
||||
|
||||
|
||||
Args:
|
||||
query: Search string
|
||||
limit: Maximum number of results
|
||||
|
||||
|
||||
Returns:
|
||||
QuerySet of matching rides for autocomplete
|
||||
"""
|
||||
return Ride.objects.filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(park__name__icontains=query) |
|
||||
Q(manufacturer__name__icontains=query)
|
||||
).select_related(
|
||||
'park',
|
||||
'manufacturer'
|
||||
).prefetch_related(
|
||||
'park__location'
|
||||
).order_by('park__name', 'name')[:limit]
|
||||
return (
|
||||
Ride.objects.filter(
|
||||
Q(name__icontains=query)
|
||||
| Q(park__name__icontains=query)
|
||||
| Q(manufacturer__name__icontains=query)
|
||||
)
|
||||
.select_related("park", "manufacturer")
|
||||
.prefetch_related("park__location")
|
||||
.order_by("park__name", "name")[:limit]
|
||||
)
|
||||
|
||||
|
||||
def rides_with_recent_reviews(*, days: int = 30) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get rides that have received reviews in the last N days.
|
||||
|
||||
|
||||
Args:
|
||||
days: Number of days to look back for reviews
|
||||
|
||||
|
||||
Returns:
|
||||
QuerySet of rides with recent reviews
|
||||
"""
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
return Ride.objects.filter(
|
||||
reviews__created_at__gte=cutoff_date,
|
||||
reviews__is_published=True
|
||||
).select_related(
|
||||
'park',
|
||||
'manufacturer'
|
||||
).prefetch_related(
|
||||
'park__location'
|
||||
).annotate(
|
||||
recent_review_count=Count('reviews', filter=Q(reviews__created_at__gte=cutoff_date))
|
||||
).order_by('-recent_review_count').distinct()
|
||||
|
||||
return (
|
||||
Ride.objects.filter(
|
||||
reviews__created_at__gte=cutoff_date, reviews__is_published=True
|
||||
)
|
||||
.select_related("park", "manufacturer")
|
||||
.prefetch_related("park__location")
|
||||
.annotate(
|
||||
recent_review_count=Count(
|
||||
"reviews", filter=Q(reviews__created_at__gte=cutoff_date)
|
||||
)
|
||||
)
|
||||
.order_by("-recent_review_count")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
def ride_statistics_by_category() -> Dict[str, Any]:
|
||||
"""
|
||||
Get ride statistics grouped by category.
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary containing ride statistics by category
|
||||
"""
|
||||
from .models import CATEGORY_CHOICES
|
||||
|
||||
|
||||
stats = {}
|
||||
for category_code, category_name in CATEGORY_CHOICES:
|
||||
if category_code: # Skip empty choice
|
||||
count = Ride.objects.filter(category=category_code).count()
|
||||
stats[category_code] = {
|
||||
'name': category_name,
|
||||
'count': count
|
||||
}
|
||||
|
||||
stats[category_code] = {"name": category_name, "count": count}
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def rides_by_opening_year(*, year: int) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get rides that opened in a specific year.
|
||||
|
||||
|
||||
Args:
|
||||
year: The opening year
|
||||
|
||||
|
||||
Returns:
|
||||
QuerySet of rides that opened in the specified year
|
||||
"""
|
||||
return Ride.objects.filter(
|
||||
opening_date__year=year
|
||||
).select_related(
|
||||
'park',
|
||||
'manufacturer'
|
||||
).prefetch_related(
|
||||
'park__location'
|
||||
).order_by('opening_date', 'park__name', 'name')
|
||||
return (
|
||||
Ride.objects.filter(opening_date__year=year)
|
||||
.select_related("park", "manufacturer")
|
||||
.prefetch_related("park__location")
|
||||
.order_by("opening_date", "park__name", "name")
|
||||
)
|
||||
|
||||
@@ -9,9 +9,9 @@ def handle_ride_status(sender, instance, **kwargs):
|
||||
"""Handle ride status changes based on closing date"""
|
||||
if instance.closing_date:
|
||||
today = timezone.now().date()
|
||||
|
||||
|
||||
# If we've reached the closing date and status is "Closing"
|
||||
if today >= instance.closing_date and instance.status == 'CLOSING':
|
||||
if today >= instance.closing_date and instance.status == "CLOSING":
|
||||
# Change to the selected post-closing status
|
||||
instance.status = instance.post_closing_status or 'SBNO'
|
||||
instance.status = instance.post_closing_status or "SBNO"
|
||||
instance.status_since = instance.closing_date
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
|
||||
@@ -6,7 +6,6 @@ app_name = "rides"
|
||||
urlpatterns = [
|
||||
# Global list views
|
||||
path("", views.RideListView.as_view(), name="global_ride_list"),
|
||||
|
||||
# Global category views
|
||||
path(
|
||||
"roller_coasters/",
|
||||
@@ -44,45 +43,22 @@ urlpatterns = [
|
||||
{"category": "OT"},
|
||||
name="global_others",
|
||||
),
|
||||
|
||||
# Search endpoints (must come before slug patterns)
|
||||
path(
|
||||
"search/models/",
|
||||
views.search_ride_models,
|
||||
name="search_ride_models"
|
||||
),
|
||||
path(
|
||||
"search/companies/",
|
||||
views.search_companies,
|
||||
name="search_companies"
|
||||
),
|
||||
|
||||
path("search/models/", views.search_ride_models, name="search_ride_models"),
|
||||
path("search/companies/", views.search_companies, name="search_companies"),
|
||||
# HTMX endpoints (must come before slug patterns)
|
||||
path(
|
||||
"coaster-fields/",
|
||||
views.show_coaster_fields,
|
||||
name="coaster_fields"
|
||||
),
|
||||
path("coaster-fields/", views.show_coaster_fields, name="coaster_fields"),
|
||||
path(
|
||||
"search-suggestions/",
|
||||
views.get_search_suggestions,
|
||||
name="search_suggestions"
|
||||
name="search_suggestions",
|
||||
),
|
||||
|
||||
# Park-specific URLs
|
||||
path(
|
||||
"create/",
|
||||
views.RideCreateView.as_view(),
|
||||
name="ride_create"
|
||||
),
|
||||
path(
|
||||
"<slug:ride_slug>/",
|
||||
views.RideDetailView.as_view(),
|
||||
name="ride_detail"
|
||||
),
|
||||
path("create/", views.RideCreateView.as_view(), name="ride_create"),
|
||||
path("<slug:ride_slug>/", views.RideDetailView.as_view(), name="ride_detail"),
|
||||
path(
|
||||
"<slug:ride_slug>/update/",
|
||||
views.RideUpdateView.as_view(),
|
||||
name="ride_update"
|
||||
name="ride_update",
|
||||
),
|
||||
]
|
||||
|
||||
331
rides/views.py
331
rides/views.py
@@ -1,21 +1,15 @@
|
||||
from typing import Any, Dict, Optional, Tuple, Union, cast, Type
|
||||
from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q, Model
|
||||
from django.db.models import Q
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib import messages
|
||||
from django.http import HttpRequest, HttpResponse, Http404
|
||||
from django.db.models import Count
|
||||
from .models import (
|
||||
Ride, RollerCoasterStats, RideModel,
|
||||
CATEGORY_CHOICES, Company
|
||||
)
|
||||
from .models import Ride, RideModel, CATEGORY_CHOICES, Company
|
||||
from .forms import RideForm, RideSearchForm
|
||||
from parks.models import Park
|
||||
from core.views.views import SlugRedirectMixin
|
||||
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
|
||||
from moderation.mixins import EditSubmissionMixin, HistoryMixin
|
||||
from moderation.models import EditSubmission
|
||||
|
||||
|
||||
@@ -23,81 +17,86 @@ class ParkContextRequired:
|
||||
"""Mixin to require park context for views"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if 'park_slug' not in self.kwargs:
|
||||
if "park_slug" not in self.kwargs:
|
||||
raise Http404("Park context is required")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
def show_coaster_fields(request: HttpRequest) -> HttpResponse:
|
||||
"""Show roller coaster specific fields based on category selection"""
|
||||
category = request.GET.get('category')
|
||||
if category != 'RC': # Only show for roller coasters
|
||||
return HttpResponse('')
|
||||
category = request.GET.get("category")
|
||||
if category != "RC": # Only show for roller coasters
|
||||
return HttpResponse("")
|
||||
return render(request, "rides/partials/coaster_fields.html")
|
||||
|
||||
|
||||
class RideDetailView(HistoryMixin, DetailView):
|
||||
"""View for displaying ride details"""
|
||||
|
||||
model = Ride
|
||||
template_name = 'rides/ride_detail.html'
|
||||
slug_url_kwarg = 'ride_slug'
|
||||
template_name = "rides/ride_detail.html"
|
||||
slug_url_kwarg = "ride_slug"
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get ride for the specific park if park_slug is provided"""
|
||||
queryset = Ride.objects.all().select_related(
|
||||
'park',
|
||||
'ride_model',
|
||||
'ride_model__manufacturer'
|
||||
).prefetch_related('photos')
|
||||
queryset = (
|
||||
Ride.objects.all()
|
||||
.select_related("park", "ride_model", "ride_model__manufacturer")
|
||||
.prefetch_related("photos")
|
||||
)
|
||||
|
||||
if 'park_slug' in self.kwargs:
|
||||
queryset = queryset.filter(park__slug=self.kwargs['park_slug'])
|
||||
if "park_slug" in self.kwargs:
|
||||
queryset = queryset.filter(park__slug=self.kwargs["park_slug"])
|
||||
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add context data"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
if 'park_slug' in self.kwargs:
|
||||
context['park_slug'] = self.kwargs['park_slug']
|
||||
context['park'] = self.object.park
|
||||
if "park_slug" in self.kwargs:
|
||||
context["park_slug"] = self.kwargs["park_slug"]
|
||||
context["park"] = self.object.park
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class RideCreateView(LoginRequiredMixin, ParkContextRequired, CreateView):
|
||||
"""View for creating a new ride"""
|
||||
|
||||
model = Ride
|
||||
form_class = RideForm
|
||||
template_name = 'rides/ride_form.html'
|
||||
template_name = "rides/ride_form.html"
|
||||
|
||||
def get_success_url(self):
|
||||
"""Get URL to redirect to after successful creation"""
|
||||
return reverse('parks:rides:ride_detail', kwargs={
|
||||
'park_slug': self.park.slug,
|
||||
'ride_slug': self.object.slug
|
||||
})
|
||||
return reverse(
|
||||
"parks:rides:ride_detail",
|
||||
kwargs={
|
||||
"park_slug": self.park.slug,
|
||||
"ride_slug": self.object.slug,
|
||||
},
|
||||
)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
"""Pass park to the form"""
|
||||
kwargs = super().get_form_kwargs()
|
||||
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
|
||||
kwargs['park'] = self.park
|
||||
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
||||
kwargs["park"] = self.park
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add park and park_slug to context"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['park'] = self.park
|
||||
context['park_slug'] = self.park.slug
|
||||
context['is_edit'] = False
|
||||
context["park"] = self.park
|
||||
context["park_slug"] = self.park.slug
|
||||
context["is_edit"] = False
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Handle form submission including new items"""
|
||||
# Check for new manufacturer
|
||||
manufacturer_name = form.cleaned_data.get('manufacturer_search')
|
||||
if manufacturer_name and not form.cleaned_data.get('manufacturer'):
|
||||
manufacturer_name = form.cleaned_data.get("manufacturer_search")
|
||||
if manufacturer_name and not form.cleaned_data.get("manufacturer"):
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
@@ -106,8 +105,8 @@ class RideCreateView(LoginRequiredMixin, ParkContextRequired, CreateView):
|
||||
)
|
||||
|
||||
# Check for new designer
|
||||
designer_name = form.cleaned_data.get('designer_search')
|
||||
if designer_name and not form.cleaned_data.get('designer'):
|
||||
designer_name = form.cleaned_data.get("designer_search")
|
||||
if designer_name and not form.cleaned_data.get("designer"):
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
@@ -116,89 +115,95 @@ class RideCreateView(LoginRequiredMixin, ParkContextRequired, CreateView):
|
||||
)
|
||||
|
||||
# Check for new ride model
|
||||
ride_model_name = form.cleaned_data.get('ride_model_search')
|
||||
manufacturer = form.cleaned_data.get('manufacturer')
|
||||
if ride_model_name and not form.cleaned_data.get('ride_model') and manufacturer:
|
||||
ride_model_name = form.cleaned_data.get("ride_model_search")
|
||||
manufacturer = form.cleaned_data.get("manufacturer")
|
||||
if ride_model_name and not form.cleaned_data.get("ride_model") and manufacturer:
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(RideModel),
|
||||
submission_type="CREATE",
|
||||
changes={
|
||||
"name": ride_model_name,
|
||||
"manufacturer": manufacturer.id
|
||||
"manufacturer": manufacturer.id,
|
||||
},
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class RideUpdateView(LoginRequiredMixin, ParkContextRequired, EditSubmissionMixin, UpdateView):
|
||||
class RideUpdateView(
|
||||
LoginRequiredMixin, ParkContextRequired, EditSubmissionMixin, UpdateView
|
||||
):
|
||||
"""View for updating an existing ride"""
|
||||
|
||||
model = Ride
|
||||
form_class = RideForm
|
||||
template_name = 'rides/ride_form.html'
|
||||
slug_url_kwarg = 'ride_slug'
|
||||
template_name = "rides/ride_form.html"
|
||||
slug_url_kwarg = "ride_slug"
|
||||
|
||||
def get_success_url(self):
|
||||
"""Get URL to redirect to after successful update"""
|
||||
return reverse('parks:rides:ride_detail', kwargs={
|
||||
'park_slug': self.park.slug,
|
||||
'ride_slug': self.object.slug
|
||||
})
|
||||
return reverse(
|
||||
"parks:rides:ride_detail",
|
||||
kwargs={
|
||||
"park_slug": self.park.slug,
|
||||
"ride_slug": self.object.slug,
|
||||
},
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get ride for the specific park"""
|
||||
return Ride.objects.filter(park__slug=self.kwargs['park_slug'])
|
||||
return Ride.objects.filter(park__slug=self.kwargs["park_slug"])
|
||||
|
||||
def get_form_kwargs(self):
|
||||
"""Pass park to the form"""
|
||||
kwargs = super().get_form_kwargs()
|
||||
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
|
||||
kwargs['park'] = self.park
|
||||
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
||||
kwargs["park"] = self.park
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add park and park_slug to context"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['park'] = self.park
|
||||
context['park_slug'] = self.park.slug
|
||||
context['is_edit'] = True
|
||||
context["park"] = self.park
|
||||
context["park_slug"] = self.park.slug
|
||||
context["is_edit"] = True
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Handle form submission including new items"""
|
||||
# Check for new manufacturer
|
||||
manufacturer_name = form.cleaned_data.get('manufacturer_search')
|
||||
if manufacturer_name and not form.cleaned_data.get('manufacturer'):
|
||||
manufacturer_name = form.cleaned_data.get("manufacturer_search")
|
||||
if manufacturer_name and not form.cleaned_data.get("manufacturer"):
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
submission_type="CREATE",
|
||||
changes={"name": manufacturer_name, "roles": ["MANUFACTURER"]}
|
||||
changes={"name": manufacturer_name, "roles": ["MANUFACTURER"]},
|
||||
)
|
||||
|
||||
# Check for new designer
|
||||
designer_name = form.cleaned_data.get('designer_search')
|
||||
if designer_name and not form.cleaned_data.get('designer'):
|
||||
designer_name = form.cleaned_data.get("designer_search")
|
||||
if designer_name and not form.cleaned_data.get("designer"):
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
submission_type="CREATE",
|
||||
changes={"name": designer_name, "roles": ["DESIGNER"]}
|
||||
changes={"name": designer_name, "roles": ["DESIGNER"]},
|
||||
)
|
||||
|
||||
# Check for new ride model
|
||||
ride_model_name = form.cleaned_data.get('ride_model_search')
|
||||
manufacturer = form.cleaned_data.get('manufacturer')
|
||||
if ride_model_name and not form.cleaned_data.get('ride_model') and manufacturer:
|
||||
ride_model_name = form.cleaned_data.get("ride_model_search")
|
||||
manufacturer = form.cleaned_data.get("manufacturer")
|
||||
if ride_model_name and not form.cleaned_data.get("ride_model") and manufacturer:
|
||||
EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(RideModel),
|
||||
submission_type="CREATE",
|
||||
changes={
|
||||
"name": ride_model_name,
|
||||
"manufacturer": manufacturer.id
|
||||
}
|
||||
"manufacturer": manufacturer.id,
|
||||
},
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
@@ -206,50 +211,49 @@ class RideUpdateView(LoginRequiredMixin, ParkContextRequired, EditSubmissionMixi
|
||||
|
||||
class RideListView(ListView):
|
||||
"""View for displaying a list of rides"""
|
||||
|
||||
model = Ride
|
||||
template_name = 'rides/ride_list.html'
|
||||
context_object_name = 'rides'
|
||||
template_name = "rides/ride_list.html"
|
||||
context_object_name = "rides"
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get filtered rides based on search and filters"""
|
||||
queryset = Ride.objects.all().select_related(
|
||||
'park',
|
||||
'ride_model',
|
||||
'ride_model__manufacturer'
|
||||
).prefetch_related('photos')
|
||||
queryset = (
|
||||
Ride.objects.all()
|
||||
.select_related("park", "ride_model", "ride_model__manufacturer")
|
||||
.prefetch_related("photos")
|
||||
)
|
||||
|
||||
# Park filter
|
||||
if 'park_slug' in self.kwargs:
|
||||
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
|
||||
if "park_slug" in self.kwargs:
|
||||
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
||||
queryset = queryset.filter(park=self.park)
|
||||
|
||||
# Search term handling
|
||||
search = self.request.GET.get('q', '').strip()
|
||||
search = self.request.GET.get("q", "").strip()
|
||||
if search:
|
||||
# Split search terms for more flexible matching
|
||||
search_terms = search.split()
|
||||
search_query = Q()
|
||||
|
||||
|
||||
for term in search_terms:
|
||||
term_query = Q(
|
||||
name__icontains=term
|
||||
) | Q(
|
||||
park__name__icontains=term
|
||||
) | Q(
|
||||
description__icontains=term
|
||||
term_query = (
|
||||
Q(name__icontains=term)
|
||||
| Q(park__name__icontains=term)
|
||||
| Q(description__icontains=term)
|
||||
)
|
||||
search_query &= term_query
|
||||
|
||||
|
||||
queryset = queryset.filter(search_query)
|
||||
|
||||
# Category filter
|
||||
category = self.request.GET.get('category')
|
||||
if category and category != 'all':
|
||||
category = self.request.GET.get("category")
|
||||
if category and category != "all":
|
||||
queryset = queryset.filter(category=category)
|
||||
|
||||
# Operating status filter
|
||||
if self.request.GET.get('operating') == 'true':
|
||||
queryset = queryset.filter(status='operating')
|
||||
if self.request.GET.get("operating") == "true":
|
||||
queryset = queryset.filter(status="operating")
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -262,32 +266,29 @@ class RideListView(ListView):
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add park and category choices to context"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
if hasattr(self, 'park'):
|
||||
context['park'] = self.park
|
||||
context['park_slug'] = self.kwargs['park_slug']
|
||||
context['category_choices'] = CATEGORY_CHOICES
|
||||
if hasattr(self, "park"):
|
||||
context["park"] = self.park
|
||||
context["park_slug"] = self.kwargs["park_slug"]
|
||||
context["category_choices"] = CATEGORY_CHOICES
|
||||
return context
|
||||
|
||||
|
||||
class SingleCategoryListView(ListView):
|
||||
"""View for displaying rides of a specific category"""
|
||||
|
||||
model = Ride
|
||||
template_name = 'rides/park_category_list.html'
|
||||
context_object_name = 'rides'
|
||||
template_name = "rides/park_category_list.html"
|
||||
context_object_name = "rides"
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get rides filtered by category and optionally by park"""
|
||||
category = self.kwargs.get('category')
|
||||
queryset = Ride.objects.filter(
|
||||
category=category
|
||||
).select_related(
|
||||
'park',
|
||||
'ride_model',
|
||||
'ride_model__manufacturer'
|
||||
category = self.kwargs.get("category")
|
||||
queryset = Ride.objects.filter(category=category).select_related(
|
||||
"park", "ride_model", "ride_model__manufacturer"
|
||||
)
|
||||
|
||||
if 'park_slug' in self.kwargs:
|
||||
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
|
||||
if "park_slug" in self.kwargs:
|
||||
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
||||
queryset = queryset.filter(park=self.park)
|
||||
|
||||
return queryset
|
||||
@@ -295,11 +296,10 @@ class SingleCategoryListView(ListView):
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add park and category information to context"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
if hasattr(self, 'park'):
|
||||
context['park'] = self.park
|
||||
context['park_slug'] = self.kwargs['park_slug']
|
||||
context['category'] = dict(CATEGORY_CHOICES).get(
|
||||
self.kwargs['category'])
|
||||
if hasattr(self, "park"):
|
||||
context["park"] = self.park
|
||||
context["park_slug"] = self.kwargs["park_slug"]
|
||||
context["category"] = dict(CATEGORY_CHOICES).get(self.kwargs["category"])
|
||||
return context
|
||||
|
||||
|
||||
@@ -307,8 +307,6 @@ class SingleCategoryListView(ListView):
|
||||
ParkSingleCategoryListView = SingleCategoryListView
|
||||
|
||||
|
||||
|
||||
|
||||
def search_companies(request: HttpRequest) -> HttpResponse:
|
||||
"""Search companies and return results for HTMX"""
|
||||
query = request.GET.get("q", "").strip()
|
||||
@@ -327,14 +325,14 @@ def search_companies(request: HttpRequest) -> HttpResponse:
|
||||
{"companies": companies, "search_term": query},
|
||||
)
|
||||
|
||||
|
||||
def search_ride_models(request: HttpRequest) -> HttpResponse:
|
||||
"""Search ride models and return results for HTMX"""
|
||||
query = request.GET.get("q", "").strip()
|
||||
manufacturer_id = request.GET.get("manufacturer")
|
||||
|
||||
# Show all ride models on click, filter on input
|
||||
ride_models = RideModel.objects.select_related(
|
||||
"manufacturer").order_by("name")
|
||||
ride_models = RideModel.objects.select_related("manufacturer").order_by("name")
|
||||
if query:
|
||||
ride_models = ride_models.filter(name__icontains=query)
|
||||
if manufacturer_id:
|
||||
@@ -344,82 +342,89 @@ def search_ride_models(request: HttpRequest) -> HttpResponse:
|
||||
return render(
|
||||
request,
|
||||
"rides/partials/ride_model_search_results.html",
|
||||
{"ride_models": ride_models, "search_term": query,
|
||||
"manufacturer_id": manufacturer_id},
|
||||
{
|
||||
"ride_models": ride_models,
|
||||
"search_term": query,
|
||||
"manufacturer_id": manufacturer_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_search_suggestions(request: HttpRequest) -> HttpResponse:
|
||||
"""Get smart search suggestions for rides
|
||||
|
||||
|
||||
Returns suggestions including:
|
||||
- Common matching ride names
|
||||
- Matching parks
|
||||
- Matching categories
|
||||
"""
|
||||
query = request.GET.get('q', '').strip().lower()
|
||||
query = request.GET.get("q", "").strip().lower()
|
||||
suggestions = []
|
||||
|
||||
|
||||
if query:
|
||||
# Get common ride names
|
||||
matching_names = Ride.objects.filter(
|
||||
name__icontains=query
|
||||
).values('name').annotate(
|
||||
count=Count('id')
|
||||
).order_by('-count')[:3]
|
||||
|
||||
matching_names = (
|
||||
Ride.objects.filter(name__icontains=query)
|
||||
.values("name")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:3]
|
||||
)
|
||||
|
||||
for match in matching_names:
|
||||
suggestions.append({
|
||||
'type': 'ride',
|
||||
'text': match['name'],
|
||||
'count': match['count']
|
||||
})
|
||||
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "ride",
|
||||
"text": match["name"],
|
||||
"count": match["count"],
|
||||
}
|
||||
)
|
||||
|
||||
# Get matching parks
|
||||
matching_parks = Park.objects.filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(location__city__icontains=query)
|
||||
Q(name__icontains=query) | Q(location__city__icontains=query)
|
||||
)[:3]
|
||||
|
||||
|
||||
for park in matching_parks:
|
||||
suggestions.append({
|
||||
'type': 'park',
|
||||
'text': park.name,
|
||||
'location': park.location.city if park.location else None
|
||||
})
|
||||
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "park",
|
||||
"text": park.name,
|
||||
"location": park.location.city if park.location else None,
|
||||
}
|
||||
)
|
||||
|
||||
# Add category matches
|
||||
for code, name in CATEGORY_CHOICES:
|
||||
if query in name.lower():
|
||||
ride_count = Ride.objects.filter(category=code).count()
|
||||
suggestions.append({
|
||||
'type': 'category',
|
||||
'code': code,
|
||||
'text': name,
|
||||
'count': ride_count
|
||||
})
|
||||
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "category",
|
||||
"code": code,
|
||||
"text": name,
|
||||
"count": ride_count,
|
||||
}
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
'rides/partials/search_suggestions.html',
|
||||
{
|
||||
'suggestions': suggestions,
|
||||
'query': query
|
||||
}
|
||||
"rides/partials/search_suggestions.html",
|
||||
{"suggestions": suggestions, "query": query},
|
||||
)
|
||||
|
||||
|
||||
class RideSearchView(ListView):
|
||||
"""View for ride search functionality with HTMX support."""
|
||||
|
||||
model = Ride
|
||||
template_name = 'search/partials/ride_search_results.html'
|
||||
context_object_name = 'rides'
|
||||
template_name = "search/partials/ride_search_results.html"
|
||||
context_object_name = "rides"
|
||||
paginate_by = 20
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get filtered rides based on search form."""
|
||||
queryset = Ride.objects.select_related('park').order_by('name')
|
||||
|
||||
queryset = Ride.objects.select_related("park").order_by("name")
|
||||
|
||||
# Process search form
|
||||
form = RideSearchForm(self.request.GET)
|
||||
if form.is_valid():
|
||||
@@ -429,20 +434,20 @@ class RideSearchView(ListView):
|
||||
queryset = queryset.filter(id=ride.id)
|
||||
else:
|
||||
# If no specific ride, filter by search term
|
||||
search_term = self.request.GET.get('ride', '').strip()
|
||||
search_term = self.request.GET.get("ride", "").strip()
|
||||
if search_term:
|
||||
queryset = queryset.filter(name__icontains=search_term)
|
||||
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
def get_template_names(self):
|
||||
"""Return appropriate template based on request type."""
|
||||
if self.request.htmx:
|
||||
return ['search/partials/ride_search_results.html']
|
||||
return ['search/ride_search.html']
|
||||
|
||||
return ["search/partials/ride_search_results.html"]
|
||||
return ["search/ride_search.html"]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add search form to context."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_form'] = RideSearchForm(self.request.GET)
|
||||
context["search_form"] = RideSearchForm(self.request.GET)
|
||||
return context
|
||||
|
||||
Reference in New Issue
Block a user