feat: complete monorepo structure with frontend and shared resources

- Add complete backend/ directory with full Django application
- Add frontend/ directory with Vite + TypeScript setup ready for Next.js
- Add comprehensive shared/ directory with:
  - Complete documentation and memory-bank archives
  - Media files and avatars (letters, park/ride images)
  - Deployment scripts and automation tools
  - Shared types and utilities
- Add architecture/ directory with migration guides
- Configure pnpm workspace for monorepo development
- Update .gitignore to exclude .django_tailwind_cli/ build artifacts
- Preserve all historical documentation in shared/docs/memory-bank/
- Set up proper structure for full-stack development with shared resources
This commit is contained in:
pacnpal
2025-08-23 18:40:07 -04:00
parent b0e0678590
commit d504d41de2
762 changed files with 142636 additions and 0 deletions

View File

View File

@@ -0,0 +1,96 @@
from django.contrib import admin
from django.contrib.gis.admin import GISModelAdmin
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",)
def get_queryset(self, request):
return super().get_queryset(request).filter(roles__contains=["MANUFACTURER"])
class DesignerAdmin(admin.ModelAdmin):
list_display = ("name", "headquarters", "website")
search_fields = ("name",)
def get_queryset(self, request):
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",
)
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",
)
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",)},
),
)
def latitude(self, obj):
return obj.latitude
latitude.short_description = "Latitude"
def longitude(self, obj):
return obj.longitude
longitude.short_description = "Longitude"
class RideAdmin(admin.ModelAdmin):
"""Enhanced Ride admin with location inline"""
inlines = [RideLocationInline]
admin.site.register(Company)
admin.site.register(Ride, RideAdmin)
admin.site.register(RideLocation, RideLocationAdmin)

View File

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

View File

@@ -0,0 +1,345 @@
"""
Serializers for Rides API following Django styleguide patterns.
"""
from rest_framework import serializers
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,
}
return None
class RideParkOutputSerializer(serializers.Serializer):
"""Output serializer for ride's park data."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
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
)
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()
class RideDetailOutputSerializer(serializers.Serializer):
"""Output serializer for ride detail view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = serializers.CharField()
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
)
# 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,
}
return None
def get_manufacturer(self, obj):
if obj.manufacturer:
return {
"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,
}
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")
# 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
)
max_height_in = serializers.IntegerField(
required=False, allow_null=True, min_value=30, max_value=90
)
capacity_per_hour = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
ride_duration_seconds = serializers.IntegerField(
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")
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")
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,
required=False,
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
)
max_height_in = serializers.IntegerField(
required=False, allow_null=True, min_value=30, max_value=90
)
capacity_per_hour = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
ride_duration_seconds = serializers.IntegerField(
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")
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")
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
)
# Status filter
status = serializers.MultipleChoiceField(
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,
required=False,
min_value=1,
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",
],
required=False,
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
)
# Top manufacturers
top_manufacturers = serializers.ListField(child=serializers.DictField())
# Recently added
recently_added_count = serializers.IntegerField()

View File

@@ -0,0 +1,14 @@
"""
URL configuration for Rides API following Django styleguide patterns.
"""
# Note: We'll create the views file after this
# from .views import RideApi
app_name = "rides_api"
# Placeholder for future implementation
urlpatterns = [
# Will be implemented in next phase
# path('v1/', include(router.urls)),
]

View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class RidesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.rides"
def ready(self):
pass

View File

@@ -0,0 +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",
}
display_changes = {}
for field, change in changes.items():
if field in field_names:
old_value = change.get("old", "")
new_value = change.get("new", "")
# Format specific fields
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":
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,
}
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",
}
display_changes = {}
for field, change in changes.items():
if field in field_names:
old_value = change.get("old", "")
new_value = change.get("new", "")
# Format category field
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,
}
return display_changes

379
backend/apps/rides/forms.py Normal file
View File

@@ -0,0 +1,379 @@
from apps.parks.models import Park, ParkArea
from django import forms
from django.forms import ModelChoiceField
from django.urls import reverse_lazy
from .models.company import Company
from .models.rides import Ride, RideModel
Manufacturer = Company
Designer = Company
class RideForm(forms.ModelForm):
park_search = forms.CharField(
label="Park *",
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"
),
"placeholder": "Search for a park...",
"hx-get": "/parks/search/",
"hx-trigger": "click, input delay:200ms",
"hx-target": "#park-search-results",
"name": "q",
"autocomplete": "off",
}
),
)
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"
),
"placeholder": "Search for a manufacturer...",
"hx-get": reverse_lazy("rides:search_companies"),
"hx-trigger": "click, input delay:200ms",
"hx-target": "#manufacturer-search-results",
"name": "q",
"autocomplete": "off",
}
),
)
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"
),
"placeholder": "Search for a designer...",
"hx-get": reverse_lazy("rides:search_companies"),
"hx-trigger": "click, input delay:200ms",
"hx-target": "#designer-search-results",
"name": "q",
"autocomplete": "off",
}
),
)
ride_model_search = forms.CharField(
label="Ride Model",
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"
),
"placeholder": "Search for a ride model...",
"hx-get": reverse_lazy("rides:search_ride_models"),
"hx-trigger": "click, input delay:200ms",
"hx-target": "#ride-model-search-results",
"hx-include": "[name='manufacturer']",
"name": "q",
"autocomplete": "off",
}
),
)
park = forms.ModelChoiceField(
queryset=Park.objects.all(),
required=True,
label="",
widget=forms.HiddenInput(),
)
manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label="",
widget=forms.HiddenInput(),
)
designer = forms.ModelChoiceField(
queryset=Designer.objects.all(),
required=False,
label="",
widget=forms.HiddenInput(),
)
ride_model = forms.ModelChoiceField(
queryset=RideModel.objects.all(),
required=False,
label="",
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 Meta:
model = Ride
fields = [
"name",
"category",
"manufacturer",
"designer",
"ride_model",
"status",
"post_closing_status",
"opening_date",
"closing_date",
"status_since",
"min_height_in",
"max_height_in",
"capacity_per_hour",
"ride_duration_seconds",
"description",
]
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",
}
),
"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"
),
"hx-get": reverse_lazy("rides:coaster_fields"),
"hx-target": "#coaster-fields",
"hx-trigger": "change",
"hx-include": "this",
"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"
),
"placeholder": "Current operational status",
"x-model": "status",
"@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"
),
"placeholder": "Status after 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",
}
),
"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"
),
"placeholder": "Date when ride will close",
"x-show": "['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(status)",
":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",
}
),
"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"
),
"min": "0",
"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"
),
"min": "0",
"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"
),
"min": "0",
"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"
),
"min": "0",
"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",
}
),
}
def __init__(self, *args, **kwargs):
park = kwargs.pop("park", None)
super().__init__(*args, **kwargs)
# Make category required
self.fields["category"].required = True
# Clear any default values for date fields
self.fields["opening_date"].initial = None
self.fields["closing_date"].initial = None
self.fields["status_since"].initial = None
# 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",
]
self.order_fields(field_order)
if park:
# If park is provided, set it as the initial value
self.fields["park"].initial = park
# Hide the park search field since we know the park
del self.fields["park_search"]
# Create new park_area field with park's areas
self.fields["park_area"] = forms.ModelChoiceField(
queryset=park.areas.all(),
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...",
}
),
)
else:
# 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
if self.instance and self.instance.pk:
if self.instance.manufacturer:
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
self.fields["designer"].initial = self.instance.designer
if self.instance.ride_model:
self.fields["ride_model_search"].initial = self.instance.ride_model.name
self.fields["ride_model"].initial = self.instance.ride_model
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"
),
"hx-get": reverse_lazy("rides:search"),
"hx-trigger": "change",
"hx-target": "#ride-search-results",
}
),
)

View File

@@ -0,0 +1,301 @@
"""
Custom managers and QuerySets for Rides models.
Optimized queries following Django styleguide patterns.
"""
from typing import Optional, List, Union
from django.db.models import Q, F, Count, Prefetch
from apps.core.managers import (
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"])
def thrill_rides(self):
"""Filter for thrill rides."""
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)
)
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,
)
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"
).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",
).prefetch_related(
"location",
"rollercoaster_stats",
Prefetch(
"reviews",
queryset=RideReview.objects.select_related("user")
.filter(is_published=True)
.order_by("-created_at")[:10],
),
"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",
)
)
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)
)
if max_height:
queryset = queryset.filter(
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)
)
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
)
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
),
)
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()
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")
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
)
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")
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")
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

@@ -0,0 +1,741 @@
# Generated by Django 5.2.5 on 2025-08-15 21:30
import django.contrib.gis.db.models.fields
import django.contrib.postgres.fields
import django.core.validators
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("pghistory", "0007_auto_20250421_0444"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Company",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255, unique=True)),
(
"roles",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("MANUFACTURER", "Ride Manufacturer"),
("DESIGNER", "Ride Designer"),
("OPERATOR", "Park Operator"),
("PROPERTY_OWNER", "Property Owner"),
],
max_length=20,
),
blank=True,
default=list,
size=None,
),
),
("description", models.TextField(blank=True)),
("website", models.URLField(blank=True)),
("founded_date", models.DateField(blank=True, null=True)),
("rides_count", models.IntegerField(default=0)),
("coasters_count", models.IntegerField(default=0)),
],
options={
"verbose_name_plural": "Companies",
"ordering": ["name"],
},
),
migrations.CreateModel(
name="CompanyEvent",
fields=[
(
"pgh_id",
models.AutoField(primary_key=True, serialize=False),
),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(db_index=False, max_length=255)),
(
"roles",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("MANUFACTURER", "Ride Manufacturer"),
("DESIGNER", "Ride Designer"),
("OPERATOR", "Park Operator"),
("PROPERTY_OWNER", "Property Owner"),
],
max_length=20,
),
blank=True,
default=list,
size=None,
),
),
("description", models.TextField(blank=True)),
("website", models.URLField(blank=True)),
("founded_date", models.DateField(blank=True, null=True)),
("rides_count", models.IntegerField(default=0)),
("coasters_count", models.IntegerField(default=0)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Ride",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255)),
("description", models.TextField(blank=True)),
(
"category",
models.CharField(
blank=True,
choices=[
("", "Select ride type"),
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
default="",
max_length=2,
),
),
(
"status",
models.CharField(
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"),
],
default="OPERATING",
max_length=20,
),
),
(
"post_closing_status",
models.CharField(
blank=True,
choices=[
("SBNO", "Standing But Not Operating"),
("CLOSED_PERM", "Permanently Closed"),
],
help_text="Status to change to after closing date",
max_length=20,
null=True,
),
),
("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),
),
(
"capacity_per_hour",
models.PositiveIntegerField(blank=True, null=True),
),
(
"ride_duration_seconds",
models.PositiveIntegerField(blank=True, null=True),
),
(
"average_rating",
models.DecimalField(
blank=True, decimal_places=2, max_digits=3, null=True
),
),
],
options={
"ordering": ["name"],
},
),
migrations.CreateModel(
name="RideLocation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"point",
django.contrib.gis.db.models.fields.PointField(
blank=True,
help_text="Geographic coordinates for ride location (longitude, latitude)",
null=True,
srid=4326,
),
),
(
"park_area",
models.CharField(
blank=True,
db_index=True,
help_text="Themed area or land within the park (e.g., 'Frontierland', 'Tomorrowland')",
max_length=100,
),
),
(
"notes",
models.TextField(blank=True, help_text="General location notes"),
),
(
"entrance_notes",
models.TextField(
blank=True,
help_text="Directions to ride entrance, queue location, or navigation tips",
),
),
(
"accessibility_notes",
models.TextField(
blank=True,
help_text="Information about accessible entrances, wheelchair access, etc.",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "Ride Location",
"verbose_name_plural": "Ride Locations",
"ordering": ["ride__name"],
},
),
migrations.CreateModel(
name="RideModel",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True)),
(
"category",
models.CharField(
blank=True,
choices=[
("", "Select ride type"),
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
default="",
max_length=2,
),
),
],
options={
"ordering": ["manufacturer", "name"],
},
),
migrations.CreateModel(
name="RideReview",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"rating",
models.PositiveSmallIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(10),
]
),
),
("title", models.CharField(max_length=200)),
("content", models.TextField()),
("visit_date", models.DateField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("is_published", models.BooleanField(default=True)),
("moderation_notes", models.TextField(blank=True)),
("moderated_at", models.DateTimeField(blank=True, null=True)),
],
options={
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="RideReviewEvent",
fields=[
(
"pgh_id",
models.AutoField(primary_key=True, serialize=False),
),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
(
"rating",
models.PositiveSmallIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(10),
]
),
),
("title", models.CharField(max_length=200)),
("content", models.TextField()),
("visit_date", models.DateField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("is_published", models.BooleanField(default=True)),
("moderation_notes", models.TextField(blank=True)),
("moderated_at", models.DateTimeField(blank=True, null=True)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="RollerCoasterStats",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"height_ft",
models.DecimalField(
blank=True, decimal_places=2, max_digits=6, null=True
),
),
(
"length_ft",
models.DecimalField(
blank=True, decimal_places=2, max_digits=7, null=True
),
),
(
"speed_mph",
models.DecimalField(
blank=True, decimal_places=2, max_digits=5, null=True
),
),
("inversions", models.PositiveIntegerField(default=0)),
(
"ride_time_seconds",
models.PositiveIntegerField(blank=True, null=True),
),
("track_type", models.CharField(blank=True, max_length=255)),
(
"track_material",
models.CharField(
blank=True,
choices=[
("STEEL", "Steel"),
("WOOD", "Wood"),
("HYBRID", "Hybrid"),
],
default="STEEL",
max_length=20,
),
),
(
"roller_coaster_type",
models.CharField(
blank=True,
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"),
],
default="SITDOWN",
max_length=20,
),
),
(
"max_drop_height_ft",
models.DecimalField(
blank=True, decimal_places=2, max_digits=6, null=True
),
),
(
"launch_type",
models.CharField(
choices=[
("CHAIN", "Chain Lift"),
("LSM", "LSM Launch"),
("HYDRAULIC", "Hydraulic Launch"),
("GRAVITY", "Gravity"),
("OTHER", "Other"),
],
default="CHAIN",
max_length=20,
),
),
("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),
),
],
options={
"verbose_name": "Roller Coaster Statistics",
"verbose_name_plural": "Roller Coaster Statistics",
},
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_e7194",
table="rides_company",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_456a8",
table="rides_company",
when="AFTER",
),
),
),
migrations.AddField(
model_name="companyevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="companyevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.company",
),
),
migrations.AddField(
model_name="ride",
name="designer",
field=models.ForeignKey(
blank=True,
limit_choices_to={"roles__contains": ["DESIGNER"]},
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="designed_rides",
to="rides.company",
),
),
migrations.AddField(
model_name="ride",
name="manufacturer",
field=models.ForeignKey(
blank=True,
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="manufactured_rides",
to="rides.company",
),
),
migrations.AddField(
model_name="ride",
name="park",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="rides",
to="parks.park",
),
),
migrations.AddField(
model_name="ride",
name="park_area",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rides",
to="parks.parkarea",
),
),
migrations.AddField(
model_name="ridelocation",
name="ride",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="ride_location",
to="rides.ride",
),
),
migrations.AddField(
model_name="ridemodel",
name="manufacturer",
field=models.ForeignKey(
blank=True,
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="ride_models",
to="rides.company",
),
),
migrations.AddField(
model_name="ride",
name="ride_model",
field=models.ForeignKey(
blank=True,
help_text="The specific model/type of this ride",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rides",
to="rides.ridemodel",
),
),
migrations.AddField(
model_name="ridereview",
name="moderated_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderated_ride_reviews",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="ridereview",
name="ride",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="reviews",
to="rides.ride",
),
),
migrations.AddField(
model_name="ridereview",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ride_reviews",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="ridereviewevent",
name="moderated_by",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="ridereviewevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="ridereviewevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridereview",
),
),
migrations.AddField(
model_name="ridereviewevent",
name="ride",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
migrations.AddField(
model_name="ridereviewevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="rollercoasterstats",
name="ride",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="coaster_stats",
to="rides.ride",
),
),
migrations.AddIndex(
model_name="ridelocation",
index=models.Index(
fields=["park_area"], name="rides_ridel_park_ar_26c90c_idx"
),
),
migrations.AlterUniqueTogether(
name="ridemodel",
unique_together={("manufacturer", "name")},
),
migrations.AlterUniqueTogether(
name="ride",
unique_together={("park", "slug")},
),
migrations.AlterUniqueTogether(
name="ridereview",
unique_together={("ride", "user")},
),
pgtrigger.migrations.AddTrigger(
model_name="ridereview",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridereviewevent" ("content", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."ride_id", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_33237",
table="rides_ridereview",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridereview",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridereviewevent" ("content", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."ride_id", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_90298",
table="rides_ridereview",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,142 @@
# Generated by Django 5.2.5 on 2025-08-16 17:42
import django.db.models.functions.datetime
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0003_add_business_constraints"),
("rides", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddConstraint(
model_name="ride",
constraint=models.CheckConstraint(
condition=models.Q(
("closing_date__isnull", True),
("opening_date__isnull", True),
("closing_date__gte", models.F("opening_date")),
_connector="OR",
),
name="ride_closing_after_opening",
violation_error_message="Closing date must be after opening date",
),
),
migrations.AddConstraint(
model_name="ride",
constraint=models.CheckConstraint(
condition=models.Q(
("min_height_in__isnull", True),
("max_height_in__isnull", True),
("min_height_in__lte", models.F("max_height_in")),
_connector="OR",
),
name="ride_height_requirements_logical",
violation_error_message="Minimum height cannot exceed maximum height",
),
),
migrations.AddConstraint(
model_name="ride",
constraint=models.CheckConstraint(
condition=models.Q(
("min_height_in__isnull", True),
models.Q(("min_height_in__gte", 30), ("min_height_in__lte", 90)),
_connector="OR",
),
name="ride_min_height_reasonable",
violation_error_message="Minimum height must be between 30 and 90 inches",
),
),
migrations.AddConstraint(
model_name="ride",
constraint=models.CheckConstraint(
condition=models.Q(
("max_height_in__isnull", True),
models.Q(("max_height_in__gte", 30), ("max_height_in__lte", 90)),
_connector="OR",
),
name="ride_max_height_reasonable",
violation_error_message="Maximum height must be between 30 and 90 inches",
),
),
migrations.AddConstraint(
model_name="ride",
constraint=models.CheckConstraint(
condition=models.Q(
("average_rating__isnull", True),
models.Q(("average_rating__gte", 1), ("average_rating__lte", 10)),
_connector="OR",
),
name="ride_rating_range",
violation_error_message="Average rating must be between 1 and 10",
),
),
migrations.AddConstraint(
model_name="ride",
constraint=models.CheckConstraint(
condition=models.Q(
("capacity_per_hour__isnull", True),
("capacity_per_hour__gt", 0),
_connector="OR",
),
name="ride_capacity_positive",
violation_error_message="Hourly capacity must be positive",
),
),
migrations.AddConstraint(
model_name="ride",
constraint=models.CheckConstraint(
condition=models.Q(
("ride_duration_seconds__isnull", True),
("ride_duration_seconds__gt", 0),
_connector="OR",
),
name="ride_duration_positive",
violation_error_message="Ride duration must be positive",
),
),
migrations.AddConstraint(
model_name="ridereview",
constraint=models.CheckConstraint(
condition=models.Q(("rating__gte", 1), ("rating__lte", 10)),
name="ride_review_rating_range",
violation_error_message="Rating must be between 1 and 10",
),
),
migrations.AddConstraint(
model_name="ridereview",
constraint=models.CheckConstraint(
condition=models.Q(
(
"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",
),
),
migrations.AddConstraint(
model_name="ridereview",
constraint=models.CheckConstraint(
condition=models.Q(
models.Q(
("moderated_at__isnull", True),
("moderated_by__isnull", True),
),
models.Q(
("moderated_at__isnull", False),
("moderated_by__isnull", False),
),
_connector="OR",
),
name="ride_review_moderation_consistency",
violation_error_message="Moderated reviews must have both moderator and moderation timestamp",
),
),
]

View File

@@ -0,0 +1,24 @@
"""
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, Categories
from .location import RideLocation
from .reviews import RideReview
__all__ = [
# Primary models
"Ride",
"RideModel",
"RollerCoasterStats",
"RideLocation",
"RideReview",
# Shared constants
"Categories",
]

View File

@@ -0,0 +1,77 @@
import pghistory
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from apps.core.history import HistoricalSlug
from apps.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"
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
roles = ArrayField(
models.CharField(max_length=20, choices=CompanyRole.choices),
default=list,
blank=True,
)
description = models.TextField(blank=True)
website = models.URLField(blank=True)
# General company info
founded_date = models.DateField(null=True, blank=True)
# Manufacturer-specific fields
rides_count = models.IntegerField(default=0)
coasters_count = models.IntegerField(default=0)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def get_absolute_url(self):
# This will need to be updated to handle different roles
return reverse("companies:detail", kwargs={"slug": self.slug})
return "#"
@classmethod
def get_by_slug(cls, slug):
"""Get company by current or historical slug"""
try:
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check pghistory first
history_model = cls.get_history_model()
history_entry = (
history_model.objects.filter(slug=slug)
.order_by("-pgh_created_at")
.first()
)
if history_entry:
return cls.objects.get(id=history_entry.pgh_obj_id), True
# Check manual slug history as fallback
try:
historical = HistoricalSlug.objects.get(
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:
app_label = "rides"
ordering = ["name"]
verbose_name_plural = "Companies"

View File

@@ -0,0 +1,124 @@
from django.contrib.gis.db import models as gis_models
from django.db import models
from django.contrib.gis.geos import Point
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"
)
# Optional Spatial Data - keep it simple with single point
point = gis_models.PointField(
srid=4326,
null=True,
blank=True,
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')"
),
)
# General notes field to match database schema
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",
)
# Accessibility Information
accessibility_notes = models.TextField(
blank=True,
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)
@property
def latitude(self):
"""Return latitude from point field for backward compatibility."""
if self.point:
return self.point.y
return None
@property
def longitude(self):
"""Return longitude from point field for backward compatibility."""
if self.point:
return self.point.x
return None
@property
def coordinates(self):
"""Return (latitude, longitude) tuple."""
if self.point:
return (self.latitude, self.longitude)
return (None, None)
@property
def has_coordinates(self):
"""Check if coordinates are set."""
return self.point is not None
def set_coordinates(self, latitude, longitude):
"""
Set the location's point from latitude and longitude coordinates.
Validates coordinate ranges.
"""
if latitude is None or longitude is None:
self.point = None
return
if not -90 <= latitude <= 90:
raise ValueError("Latitude must be between -90 and 90.")
if not -180 <= longitude <= 180:
raise ValueError("Longitude must be between -180 and 180.")
self.point = Point(longitude, latitude, srid=4326)
def distance_to_park_location(self):
"""
Calculate distance to parent park's location if both have coordinates.
Returns distance in kilometers.
"""
if not self.point:
return 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
def __str__(self):
area_str = f" in {self.park_area}" if self.park_area else ""
return f"Location for {self.ride.name}{area_str}"
class Meta:
verbose_name = "Ride Location"
verbose_name_plural = "Ride Locations"
ordering = ["ride__name"]
indexes = [
models.Index(fields=["park_area"]),
# Spatial index will be created automatically for PostGIS
# PointField
]

View File

@@ -0,0 +1,73 @@
from django.db import models
from django.db.models import functions
from django.core.validators import MinValueValidator, MaxValueValidator
from apps.core.history import TrackedModel
import pghistory
@pghistory.track()
class RideReview(TrackedModel):
"""
A review of a ride.
"""
ride = models.ForeignKey(
"rides.Ride", on_delete=models.CASCADE, related_name="reviews"
)
user = models.ForeignKey(
"accounts.User", on_delete=models.CASCADE, related_name="ride_reviews"
)
rating = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(10)]
)
title = models.CharField(max_length=200)
content = models.TextField()
visit_date = models.DateField()
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Moderation
is_published = models.BooleanField(default=True)
moderation_notes = models.TextField(blank=True)
moderated_by = models.ForeignKey(
"accounts.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="moderated_ride_reviews",
)
moderated_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
unique_together = ["ride", "user"]
constraints = [
# 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",
),
# 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",
),
# 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"
),
),
]
def __str__(self):
return f"Review of {self.ride.name} by {self.user.username}"

View File

@@ -0,0 +1,280 @@
from django.db import models
from django.utils.text import slugify
from django.contrib.contenttypes.fields import GenericRelation
from apps.core.models import TrackedModel
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"),
]
# Legacy alias for backward compatibility
Categories = CATEGORY_CHOICES
class RideModel(TrackedModel):
"""
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",
null=True,
blank=True,
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
)
description = models.TextField(blank=True)
category = models.CharField(
max_length=2, choices=CATEGORY_CHOICES, default="", blank=True
)
class Meta:
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}"
)
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"),
]
POST_CLOSING_STATUS_CHOICES = [
("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"
)
park_area = models.ForeignKey(
"parks.ParkArea",
on_delete=models.SET_NULL,
related_name="rides",
null=True,
blank=True,
)
category = models.CharField(
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"]},
)
designer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
related_name="designed_rides",
null=True,
blank=True,
limit_choices_to={"roles__contains": ["DESIGNER"]},
)
ride_model = models.ForeignKey(
"RideModel",
on_delete=models.SET_NULL,
related_name="rides",
null=True,
blank=True,
help_text="The specific model/type of this ride",
)
status = models.CharField(
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",
)
opening_date = models.DateField(null=True, blank=True)
closing_date = models.DateField(null=True, blank=True)
status_since = models.DateField(null=True, blank=True)
min_height_in = models.PositiveIntegerField(null=True, blank=True)
max_height_in = models.PositiveIntegerField(null=True, blank=True)
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
)
photos = GenericRelation("media.Photo")
class Meta:
ordering = ["name"]
unique_together = ["park", "slug"]
constraints = [
# Business rule: Closing date must be after opening date
models.CheckConstraint(
name="ride_closing_after_opening",
condition=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",
condition=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)
models.CheckConstraint(
name="ride_min_height_reasonable",
condition=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",
condition=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",
condition=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",
condition=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",
condition=models.Q(ride_duration_seconds__isnull=True)
| models.Q(ride_duration_seconds__gt=0),
violation_error_message="Ride duration must be positive",
),
]
def __str__(self) -> str:
return f"{self.name} at {self.park.name}"
def save(self, *args, **kwargs) -> None:
if not self.slug:
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"),
]
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"),
]
LAUNCH_CHOICES = [
("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"
)
height_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True
)
length_ft = models.DecimalField(
max_digits=7, decimal_places=2, null=True, blank=True
)
speed_mph = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
inversions = models.PositiveIntegerField(default=0)
ride_time_seconds = models.PositiveIntegerField(null=True, blank=True)
track_type = models.CharField(max_length=255, blank=True)
track_material = models.CharField(
max_length=20,
choices=TRACK_MATERIAL_CHOICES,
default="STEEL",
blank=True,
)
roller_coaster_type = models.CharField(
max_length=20,
choices=COASTER_TYPE_CHOICES,
default="SITDOWN",
blank=True,
)
max_drop_height_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True
)
launch_type = models.CharField(
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)
cars_per_train = models.PositiveIntegerField(null=True, blank=True)
seats_per_car = models.PositiveIntegerField(null=True, blank=True)
class Meta:
verbose_name = "Roller Coaster Statistics"
verbose_name_plural = "Roller Coaster Statistics"
def __str__(self) -> str:
return f"Stats for {self.ride.name}"

View File

@@ -0,0 +1,22 @@
from django.urls import path
from . import views
app_name = "rides"
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>/update/",
views.RideUpdateView.as_view(),
name="ride_update",
),
path("search/companies/", views.search_companies, name="search_companies"),
# Search endpoints
path("search/models/", views.search_ride_models, name="search_ride_models"),
# HTMX endpoints
path("coaster-fields/", views.show_coaster_fields, name="coaster_fields"),
]

View File

@@ -0,0 +1,303 @@
"""
Selectors for ride-related data retrieval.
Following Django styleguide pattern for separating data access from business logic.
"""
from typing import Optional, Dict, Any
from django.db.models import QuerySet, Q, Count, Avg, Prefetch
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import Distance
from .models import Ride, RideModel, RideReview
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"))
)
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"]
queryset = queryset.filter(
Q(name__icontains=search_term)
| Q(description__icontains=search_term)
| Q(park__name__icontains=search_term)
)
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)
)
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")
)
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")
)
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")
)
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")
)
def rides_near_location(
*, 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]
)
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")
)
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]
)
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()
)
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}
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")
)

View File

@@ -0,0 +1,17 @@
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.utils import timezone
from .models import Ride
@receiver(pre_save, sender=Ride)
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":
# Change to the selected post-closing status
instance.status = instance.post_closing_status or "SBNO"
instance.status_since = instance.closing_date

View File

@@ -0,0 +1,3 @@
{% for company in companies %}
<div>{{ company.name }}</div>
{% endfor %}

View File

@@ -0,0 +1,26 @@
{% if suggestions %}
<div id="search-suggestions" class="search-suggestions bg-white rounded-lg shadow-lg overflow-hidden">
{% for suggestion in suggestions %}
<div class="suggestion px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer flex items-center gap-2"
data-type="{{ suggestion.type }}"
data-suggestion="{{ suggestion.text|escape }}"
role="option">
{% if suggestion.type == 'ride' %}
<span class="icon text-xl">🎢</span>
<span class="text flex-grow text-gray-900 dark:text-white">{{ suggestion.text }}</span>
<span class="count text-sm text-gray-500 dark:text-gray-400">({{ suggestion.count }} rides)</span>
{% elif suggestion.type == 'park' %}
<span class="icon text-xl">🎪</span>
<span class="text flex-grow text-gray-900 dark:text-white">{{ suggestion.text }}</span>
{% if suggestion.location %}
<span class="location text-sm text-gray-500 dark:text-gray-400">{{ suggestion.location }}</span>
{% endif %}
{% elif suggestion.type == 'category' %}
<span class="icon text-xl">📂</span>
<span class="text flex-grow text-gray-900 dark:text-white">{{ suggestion.text }}</span>
<span class="count text-sm text-gray-500 dark:text-gray-400">({{ suggestion.count }} rides)</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}

View File

@@ -0,0 +1,214 @@
{% extends "base/base.html" %}
{% load static %}
{% load ride_tags %}
{% block title %}
{% if park %}
Rides at {{ park.name }} - ThrillWiki
{% else %}
All Rides - ThrillWiki
{% endif %}
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-4">
{% if park %}
Rides at {{ park.name }}
{% else %}
All Rides
{% endif %}
</h1>
{# Search Section #}
<div class="relative">
<div class="flex items-center">
<input type="text"
id="ride-search"
name="q"
class="w-full p-4 border rounded-lg shadow-sm"
placeholder="Search rides by name, park, or category..."
hx-get="{% url 'rides:global_ride_list' %}"
hx-trigger="keyup changed delay:500ms, search"
hx-target="#ride-list-results"
hx-push-url="false"
hx-indicator="#search-loading"
autocomplete="off">
<div id="search-loading" class="loading-indicator htmx-indicator">
<i class="ml-3 fa fa-spinner fa-spin text-gray-400"></i>
</div>
</div>
{# Search Suggestions #}
<div id="search-suggestions-wrapper"
hx-get="{% url 'rides:search_suggestions' %}"
hx-trigger="keyup from:#ride-search delay:200ms"
class="absolute w-full bg-white border rounded-lg shadow-lg mt-1 z-50">
</div>
</div>
{# Quick Filter Buttons #}
<div class="flex flex-wrap gap-2 mt-4">
<button class="filter-btn active"
hx-get="{% url 'rides:global_ride_list' %}"
hx-target="#ride-list-results"
hx-push-url="false"
hx-include="[name='q']">
All Rides
</button>
<button class="filter-btn"
hx-get="{% url 'rides:global_ride_list' %}"
hx-target="#ride-list-results"
hx-push-url="false"
hx-include="[name='q']"
hx-vals='{"operating": "true"}'>
Operating
</button>
{% for code, name in category_choices %}
<button class="filter-btn"
hx-get="{% url 'rides:global_ride_list' %}"
hx-target="#ride-list-results"
hx-push-url="false"
hx-include="[name='q']"
hx-vals='{"category": "{{ code }}"}'>
{{ name }}
</button>
{% endfor %}
</div>
{# Active Filter Tags #}
<div id="active-filters" class="flex flex-wrap gap-2 mt-4">
{% if request.GET.q %}
<span class="filter-tag">
Search: {{ request.GET.q }}
<button class="ml-2"
hx-get="{% url 'rides:global_ride_list' %}"
hx-target="#ride-list-results"
hx-push-url="true"
title="Clear search">&times;</button>
</span>
{% endif %}
{% if request.GET.category %}
<span class="filter-tag">
Category: {{ request.GET.category|get_category_display }}
<button class="ml-2"
hx-get="{% url 'rides:global_ride_list' %}"
hx-target="#ride-list-results"
hx-push-url="true"
hx-include="[name='q']"
title="Clear category">&times;</button>
</span>
{% endif %}
{% if request.GET.operating %}
<span class="filter-tag">
Operating Only
<button class="ml-2"
hx-get="{% url 'rides:global_ride_list' %}"
hx-target="#ride-list-results"
hx-push-url="true"
hx-include="[name='q']"
title="Clear operating filter">&times;</button>
</span>
{% endif %}
</div>
</div>
{# Results Section #}
<div id="ride-list-results">
{% include "rides/partials/ride_list_results.html" %}
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Handle suggestion selection
document.addEventListener('click', function(e) {
const suggestion = e.target.closest('.suggestion');
if (suggestion) {
const searchInput = document.getElementById('ride-search');
searchInput.value = suggestion.dataset.suggestion;
searchInput.dispatchEvent(new Event('keyup')); // Trigger HTMX search
document.getElementById('search-suggestions-wrapper').innerHTML = '';
}
});
// Handle filter button UI
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
});
});
// Initialize active state based on URL params
const urlParams = new URLSearchParams(window.location.search);
const category = urlParams.get('category');
const operating = urlParams.get('operating');
document.querySelectorAll('.filter-btn').forEach(btn => {
const btnVals = btn.getAttribute('hx-vals');
if (
(category && btnVals && btnVals.includes(category)) ||
(operating && btnVals && btnVals.includes('operating')) ||
(!category && !operating && btn.textContent.trim() === 'All Rides')
) {
btn.classList.add('active');
}
});
});
</script>
<style>
.filter-tag {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
background-color: #e5e7eb;
color: #374151;
border-radius: 9999px;
font-size: 0.875rem;
line-height: 1.25rem;
}
.filter-tag button {
color: #6b7280;
font-weight: bold;
cursor: pointer;
}
.filter-tag button:hover {
color: #374151;
}
.loading-indicator {
display: none;
}
.htmx-request .loading-indicator {
display: block;
}
.filter-btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
background-color: #f3f4f6;
color: #374151;
font-size: 0.875rem;
line-height: 1.25rem;
transition: all 0.2s;
}
.filter-btn:hover {
background-color: #e5e7eb;
}
.filter-btn.active {
background-color: #3b82f6;
color: white;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,31 @@
from django import template
from django.templatetags.static import static
from ..models.rides import Categories
register = template.Library()
@register.simple_tag
def get_ride_placeholder_image(category):
"""Return placeholder image based on ride category"""
category_images = {
"RC": "images/placeholders/roller-coaster.jpg",
"DR": "images/placeholders/dark-ride.jpg",
"FR": "images/placeholders/flat-ride.jpg",
"WR": "images/placeholders/water-ride.jpg",
"TR": "images/placeholders/transport.jpg",
"OT": "images/placeholders/other-ride.jpg",
}
return static(category_images.get(category, "images/placeholders/default-ride.jpg"))
@register.simple_tag
def get_park_placeholder_image():
"""Return placeholder image for parks"""
return static("images/placeholders/default-park.jpg")
@register.filter
def get_category_display(code):
"""Convert category code to display name"""
return dict(Categories).get(code, code)

View File

@@ -0,0 +1 @@
# Create your tests here.

View File

@@ -0,0 +1,64 @@
from django.urls import path
from . import views
app_name = "rides"
urlpatterns = [
# Global list views
path("", views.RideListView.as_view(), name="global_ride_list"),
# Global category views
path(
"roller_coasters/",
views.SingleCategoryListView.as_view(),
{"category": "RC"},
name="global_roller_coasters",
),
path(
"dark_rides/",
views.SingleCategoryListView.as_view(),
{"category": "DR"},
name="global_dark_rides",
),
path(
"flat_rides/",
views.SingleCategoryListView.as_view(),
{"category": "FR"},
name="global_flat_rides",
),
path(
"water_rides/",
views.SingleCategoryListView.as_view(),
{"category": "WR"},
name="global_water_rides",
),
path(
"transports/",
views.SingleCategoryListView.as_view(),
{"category": "TR"},
name="global_transports",
),
path(
"others/",
views.SingleCategoryListView.as_view(),
{"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"),
# HTMX endpoints (must come before slug patterns)
path("coaster-fields/", views.show_coaster_fields, name="coaster_fields"),
path(
"search-suggestions/",
views.get_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(
"<slug:ride_slug>/update/",
views.RideUpdateView.as_view(),
name="ride_update",
),
]

454
backend/apps/rides/views.py Normal file
View File

@@ -0,0 +1,454 @@
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
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.http import HttpRequest, HttpResponse, Http404
from django.db.models import Count
from .models.rides import Ride, RideModel, Categories
from .models.company import Company
from .forms import RideForm, RideSearchForm
from apps.parks.models import Park
from apps.moderation.mixins import EditSubmissionMixin, HistoryMixin
from apps.moderation.models import EditSubmission
class ParkContextRequired:
"""Mixin to require park context for views"""
def dispatch(self, request, *args, **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("")
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"
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")
)
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
return context
class RideCreateView(LoginRequiredMixin, ParkContextRequired, CreateView):
"""View for creating a new ride"""
model = Ride
form_class = RideForm
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,
},
)
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
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
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"):
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Company),
submission_type="CREATE",
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"):
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Company),
submission_type="CREATE",
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:
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,
},
)
return super().form_valid(form)
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"
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,
},
)
def get_queryset(self):
"""Get ride for the specific park"""
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
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
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"):
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Company),
submission_type="CREATE",
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"):
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Company),
submission_type="CREATE",
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:
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,
},
)
return super().form_valid(form)
class RideListView(ListView):
"""View for displaying a list of rides"""
model = Ride
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")
)
# Park filter
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()
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)
)
search_query &= term_query
queryset = queryset.filter(search_query)
# Category filter
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")
return queryset
def get_template_names(self):
"""Return appropriate template based on request type"""
if self.request.htmx:
return ["rides/partials/ride_list_results.html"]
return [self.template_name]
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"] = Categories
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"
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"
)
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
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(Categories).get(self.kwargs["category"])
return context
# Alias for parks app to maintain backward compatibility
ParkSingleCategoryListView = SingleCategoryListView
def search_companies(request: HttpRequest) -> HttpResponse:
"""Search companies and return results for HTMX"""
query = request.GET.get("q", "").strip()
role = request.GET.get("role", "").upper()
companies = Company.objects.all().order_by("name")
if role:
companies = companies.filter(roles__contains=[role])
if query:
companies = companies.filter(name__icontains=query)
companies = companies[:10]
return render(
request,
"rides/partials/company_search_results.html",
{"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")
if query:
ride_models = ride_models.filter(name__icontains=query)
if manufacturer_id:
ride_models = ride_models.filter(manufacturer_id=manufacturer_id)
ride_models = ride_models[:10]
return render(
request,
"rides/partials/ride_model_search_results.html",
{
"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()
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]
)
for match in matching_names:
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)
)[:3]
for park in matching_parks:
suggestions.append(
{
"type": "park",
"text": park.name,
"location": park.location.city if park.location else None,
}
)
# Add category matches
for code, name in Categories:
if query in name.lower():
ride_count = Ride.objects.filter(category=code).count()
suggestions.append(
{
"type": "category",
"code": code,
"text": name,
"count": ride_count,
}
)
return render(
request,
"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"
paginate_by = 20
def get_queryset(self):
"""Get filtered rides based on search form."""
queryset = Ride.objects.select_related("park").order_by("name")
# Process search form
form = RideSearchForm(self.request.GET)
if form.is_valid():
ride = form.cleaned_data.get("ride")
if ride:
# If specific ride selected, return just that ride
queryset = queryset.filter(id=ride.id)
else:
# If no specific ride, filter by search term
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"]
def get_context_data(self, **kwargs):
"""Add search form to context."""
context = super().get_context_data(**kwargs)
context["search_form"] = RideSearchForm(self.request.GET)
return context