Refactor test utilities and enhance ASGI settings

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

View File

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

View File

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

View File

@@ -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 = [

View File

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

View File

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

View File

@@ -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",

View File

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

View File

@@ -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",

View File

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

View File

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

View File

@@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1 @@
from django.test import TestCase
# Create your tests here.

View File

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

View File

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