Revert "update"

This reverts commit 75cc618c2b.
This commit is contained in:
pacnpal
2025-09-21 20:11:00 -04:00
parent 75cc618c2b
commit 540f40e689
610 changed files with 4812 additions and 1715 deletions

View File

@@ -1,12 +0,0 @@
"""
Rides Django App
This app handles all ride-related functionality including ride models,
companies, rankings, and search functionality.
"""
# Import choices to ensure they are registered with the global registry
from . import choices
# Ensure choices are registered on app startup
__all__ = ['choices']

View File

@@ -1,710 +0,0 @@
from django.contrib import admin
from django.contrib.gis.admin import GISModelAdmin
from django.utils.html import format_html
from .models.company import Company
from .models.rides import Ride, RideModel, RollerCoasterStats
from .models.location import RideLocation
from .models.reviews import RideReview
from .models.rankings import RideRanking, RidePairComparison, RankingSnapshot
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",)},
),
)
@admin.display(description="Latitude")
def latitude(self, obj):
return obj.latitude
@admin.display(description="Longitude")
def longitude(self, obj):
return obj.longitude
class RollerCoasterStatsInline(admin.StackedInline):
"""Inline admin for RollerCoasterStats"""
model = RollerCoasterStats
extra = 0
fields = (
("height_ft", "length_ft", "speed_mph"),
("track_material", "roller_coaster_type"),
("propulsion_system", "inversions"),
("max_drop_height_ft", "ride_time_seconds"),
("train_style", "trains_count"),
("cars_per_train", "seats_per_car"),
)
classes = ("collapse",)
@admin.register(Ride)
class RideAdmin(admin.ModelAdmin):
"""Enhanced Ride admin with location and coaster stats inlines"""
list_display = (
"name",
"park",
"category_display",
"manufacturer",
"status",
"opening_date",
"average_rating",
)
list_filter = (
"category",
"status",
"park",
"manufacturer",
"designer",
"opening_date",
)
search_fields = (
"name",
"description",
"park__name",
"manufacturer__name",
"designer__name",
)
readonly_fields = ("created_at", "updated_at")
prepopulated_fields = {"slug": ("name",)}
inlines = [RideLocationInline, RollerCoasterStatsInline]
date_hierarchy = "opening_date"
ordering = ("park", "name")
fieldsets = (
(
"Basic Information",
{
"fields": (
"name",
"slug",
"description",
"park",
"park_area",
"category",
)
},
),
(
"Companies",
{
"fields": (
"manufacturer",
"designer",
"ride_model",
)
},
),
(
"Status & Dates",
{
"fields": (
"status",
"post_closing_status",
"opening_date",
"closing_date",
"status_since",
)
},
),
(
"Ride Specifications",
{
"fields": (
"min_height_in",
"max_height_in",
"capacity_per_hour",
"ride_duration_seconds",
"average_rating",
),
"classes": ("collapse",),
},
),
(
"Metadata",
{
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
},
),
)
@admin.display(description="Category")
def category_display(self, obj):
"""Display category with full name"""
choices_dict = dict(obj._meta.get_field("category").choices)
if obj.category in choices_dict:
return choices_dict[obj.category]
else:
raise ValueError(f"Unknown category: {obj.category}")
@admin.register(RideModel)
class RideModelAdmin(admin.ModelAdmin):
"""Admin interface for ride models"""
list_display = (
"name",
"manufacturer",
"category_display",
"ride_count",
)
list_filter = (
"manufacturer",
"category",
)
search_fields = (
"name",
"description",
"manufacturer__name",
)
ordering = ("manufacturer", "name")
fieldsets = (
(
"Model Information",
{
"fields": (
"name",
"manufacturer",
"category",
"description",
)
},
),
)
@admin.display(description="Category")
def category_display(self, obj):
"""Display category with full name"""
choices_dict = dict(obj._meta.get_field("category").choices)
if obj.category in choices_dict:
return choices_dict[obj.category]
else:
raise ValueError(f"Unknown category: {obj.category}")
@admin.display(description="Installations")
def ride_count(self, obj):
"""Display number of ride installations"""
return obj.rides.count()
@admin.register(RollerCoasterStats)
class RollerCoasterStatsAdmin(admin.ModelAdmin):
"""Admin interface for roller coaster statistics"""
list_display = (
"ride",
"height_ft",
"speed_mph",
"length_ft",
"inversions",
"track_material",
"roller_coaster_type",
)
list_filter = (
"track_material",
"roller_coaster_type",
"propulsion_system",
"inversions",
)
search_fields = (
"ride__name",
"ride__park__name",
"track_type",
"train_style",
)
readonly_fields = ("calculated_capacity",)
fieldsets = (
(
"Basic Stats",
{
"fields": (
"ride",
"height_ft",
"length_ft",
"speed_mph",
"max_drop_height_ft",
)
},
),
(
"Track & Design",
{
"fields": (
"track_material",
"track_type",
"roller_coaster_type",
"propulsion_system",
"inversions",
)
},
),
(
"Operation Details",
{
"fields": (
"ride_time_seconds",
"train_style",
"trains_count",
"cars_per_train",
"seats_per_car",
"calculated_capacity",
),
"classes": ("collapse",),
},
),
)
@admin.display(description="Calculated Capacity")
def calculated_capacity(self, obj):
"""Calculate theoretical hourly capacity"""
if all(
[
obj.trains_count,
obj.cars_per_train,
obj.seats_per_car,
obj.ride_time_seconds,
]
):
total_seats = obj.trains_count * obj.cars_per_train * obj.seats_per_car
# Add 2 min loading time
cycles_per_hour = 3600 / (obj.ride_time_seconds + 120)
return f"{int(total_seats * cycles_per_hour)} riders/hour"
return "N/A"
@admin.register(RideReview)
class RideReviewAdmin(admin.ModelAdmin):
"""Admin interface for ride reviews"""
list_display = (
"ride",
"user",
"rating",
"title",
"visit_date",
"is_published",
"created_at",
"moderation_status",
)
list_filter = (
"rating",
"is_published",
"visit_date",
"created_at",
"ride__park",
"moderated_by",
)
search_fields = (
"title",
"content",
"user__username",
"ride__name",
"ride__park__name",
)
readonly_fields = ("created_at", "updated_at")
date_hierarchy = "created_at"
ordering = ("-created_at",)
fieldsets = (
(
"Review Details",
{
"fields": (
"user",
"ride",
"rating",
"title",
"content",
"visit_date",
)
},
),
(
"Publication Status",
{
"fields": ("is_published",),
},
),
(
"Moderation",
{
"fields": (
"moderated_by",
"moderated_at",
"moderation_notes",
),
"classes": ("collapse",),
},
),
(
"Metadata",
{
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
},
),
)
@admin.display(description="Moderation Status", boolean=True)
def moderation_status(self, obj):
"""Display moderation status with color coding"""
if obj.moderated_by:
return format_html(
'<span style="color: {};">{}</span>',
"green" if obj.is_published else "red",
"Approved" if obj.is_published else "Rejected",
)
return format_html('<span style="color: orange;">Pending</span>')
def save_model(self, request, obj, form, change):
"""Auto-set moderation info when status changes"""
if change and "is_published" in form.changed_data:
from django.utils import timezone
obj.moderated_by = request.user
obj.moderated_at = timezone.now()
super().save_model(request, obj, form, change)
@admin.register(Company)
class CompanyAdmin(admin.ModelAdmin):
"""Enhanced Company admin for rides app"""
list_display = (
"name",
"roles_display",
"website",
"founded_date",
"rides_count",
"coasters_count",
)
list_filter = ("roles", "founded_date")
search_fields = ("name", "description")
readonly_fields = ("created_at", "updated_at")
prepopulated_fields = {"slug": ("name",)}
fieldsets = (
(
"Basic Information",
{
"fields": (
"name",
"slug",
"roles",
"description",
"website",
)
},
),
(
"Company Details",
{
"fields": (
"founded_date",
"rides_count",
"coasters_count",
)
},
),
(
"Metadata",
{
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
},
),
)
@admin.display(description="Roles")
def roles_display(self, obj):
"""Display roles as a formatted string"""
return ", ".join(obj.roles) if obj.roles else "No roles"
@admin.register(RideRanking)
class RideRankingAdmin(admin.ModelAdmin):
"""Admin interface for ride rankings"""
list_display = (
"rank",
"ride_name",
"park_name",
"winning_percentage_display",
"wins",
"losses",
"ties",
"average_rating",
"mutual_riders_count",
"last_calculated",
)
list_filter = (
"ride__category",
"last_calculated",
"calculation_version",
)
search_fields = (
"ride__name",
"ride__park__name",
)
readonly_fields = (
"ride",
"rank",
"wins",
"losses",
"ties",
"winning_percentage",
"mutual_riders_count",
"comparison_count",
"average_rating",
"last_calculated",
"calculation_version",
"total_comparisons",
)
ordering = ["rank"]
fieldsets = (
(
"Ride Information",
{"fields": ("ride",)},
),
(
"Ranking Metrics",
{
"fields": (
"rank",
"winning_percentage",
"wins",
"losses",
"ties",
"total_comparisons",
)
},
),
(
"Additional Metrics",
{
"fields": (
"average_rating",
"mutual_riders_count",
"comparison_count",
)
},
),
(
"Calculation Info",
{
"fields": (
"last_calculated",
"calculation_version",
),
"classes": ("collapse",),
},
),
)
@admin.display(description="Ride")
def ride_name(self, obj):
return obj.ride.name
@admin.display(description="Park")
def park_name(self, obj):
return obj.ride.park.name
@admin.display(description="Win %")
def winning_percentage_display(self, obj):
return f"{obj.winning_percentage:.1%}"
def has_add_permission(self, request):
# Rankings are calculated automatically
return False
def has_change_permission(self, request, obj=None):
# Rankings are read-only
return False
@admin.register(RidePairComparison)
class RidePairComparisonAdmin(admin.ModelAdmin):
"""Admin interface for ride pair comparisons"""
list_display = (
"comparison_summary",
"ride_a_name",
"ride_b_name",
"winner_display",
"ride_a_wins",
"ride_b_wins",
"ties",
"mutual_riders_count",
"last_calculated",
)
list_filter = ("last_calculated",)
search_fields = (
"ride_a__name",
"ride_b__name",
"ride_a__park__name",
"ride_b__park__name",
)
readonly_fields = (
"ride_a",
"ride_b",
"ride_a_wins",
"ride_b_wins",
"ties",
"mutual_riders_count",
"ride_a_avg_rating",
"ride_b_avg_rating",
"last_calculated",
"winner",
"is_tie",
)
ordering = ["-mutual_riders_count"]
@admin.display(description="Comparison")
def comparison_summary(self, obj):
return f"{obj.ride_a.name} vs {obj.ride_b.name}"
@admin.display(description="Ride A")
def ride_a_name(self, obj):
return obj.ride_a.name
@admin.display(description="Ride B")
def ride_b_name(self, obj):
return obj.ride_b.name
@admin.display(description="Winner")
def winner_display(self, obj):
if obj.is_tie:
return "TIE"
winner = obj.winner
if winner:
return winner.name
return "N/A"
def has_add_permission(self, request):
# Comparisons are calculated automatically
return False
def has_change_permission(self, request, obj=None):
# Comparisons are read-only
return False
@admin.register(RankingSnapshot)
class RankingSnapshotAdmin(admin.ModelAdmin):
"""Admin interface for ranking history snapshots"""
list_display = (
"ride_name",
"park_name",
"rank",
"winning_percentage_display",
"snapshot_date",
)
list_filter = (
"snapshot_date",
"ride__category",
)
search_fields = (
"ride__name",
"ride__park__name",
)
readonly_fields = (
"ride",
"rank",
"winning_percentage",
"snapshot_date",
)
date_hierarchy = "snapshot_date"
ordering = ["-snapshot_date", "rank"]
@admin.display(description="Ride")
def ride_name(self, obj):
return obj.ride.name
@admin.display(description="Park")
def park_name(self, obj):
return obj.ride.park.name
@admin.display(description="Win %")
def winning_percentage_display(self, obj):
return f"{obj.winning_percentage:.1%}"
def has_add_permission(self, request):
# Snapshots are created automatically
return False
def has_change_permission(self, request, obj=None):
# Snapshots are read-only
return False
admin.site.register(RideLocation, RideLocationAdmin)

View File

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

View File

@@ -1,804 +0,0 @@
"""
Rich Choice Objects for Rides Domain
This module defines all choice objects for the rides domain, replacing
the legacy tuple-based choices with rich choice objects.
"""
from apps.core.choices import RichChoice, ChoiceCategory
from apps.core.choices.registry import register_choices
# Ride Category Choices
RIDE_CATEGORIES = [
RichChoice(
value="RC",
label="Roller Coaster",
description="Thrill rides with tracks featuring hills, loops, and high speeds",
metadata={
'color': 'red',
'icon': 'roller-coaster',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 1
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="DR",
label="Dark Ride",
description="Indoor rides with themed environments and storytelling",
metadata={
'color': 'purple',
'icon': 'dark-ride',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 2
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="FR",
label="Flat Ride",
description="Rides that move along a generally flat plane with spinning, swinging, or bouncing motions",
metadata={
'color': 'blue',
'icon': 'flat-ride',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 3
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="WR",
label="Water Ride",
description="Rides that incorporate water elements like splashing, floating, or getting wet",
metadata={
'color': 'cyan',
'icon': 'water-ride',
'css_class': 'bg-cyan-100 text-cyan-800',
'sort_order': 4
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="TR",
label="Transport Ride",
description="Rides primarily designed for transportation around the park",
metadata={
'color': 'green',
'icon': 'transport',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 5
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="OT",
label="Other",
description="Rides that don't fit into standard categories",
metadata={
'color': 'gray',
'icon': 'other',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 6
},
category=ChoiceCategory.CLASSIFICATION
),
]
# Ride Status Choices
RIDE_STATUSES = [
RichChoice(
value="OPERATING",
label="Operating",
description="Ride is currently open and operating normally",
metadata={
'color': 'green',
'icon': 'check-circle',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 1
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="CLOSED_TEMP",
label="Temporarily Closed",
description="Ride is temporarily closed for maintenance, weather, or other short-term reasons",
metadata={
'color': 'yellow',
'icon': 'pause-circle',
'css_class': 'bg-yellow-100 text-yellow-800',
'sort_order': 2
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="SBNO",
label="Standing But Not Operating",
description="Ride structure remains but is not currently operating",
metadata={
'color': 'orange',
'icon': 'stop-circle',
'css_class': 'bg-orange-100 text-orange-800',
'sort_order': 3
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="CLOSING",
label="Closing",
description="Ride is scheduled to close permanently",
metadata={
'color': 'red',
'icon': 'x-circle',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 4
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="CLOSED_PERM",
label="Permanently Closed",
description="Ride has been permanently closed and will not reopen",
metadata={
'color': 'red',
'icon': 'x-circle',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 5
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="UNDER_CONSTRUCTION",
label="Under Construction",
description="Ride is currently being built or undergoing major renovation",
metadata={
'color': 'blue',
'icon': 'tool',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 6
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="DEMOLISHED",
label="Demolished",
description="Ride has been completely removed and demolished",
metadata={
'color': 'gray',
'icon': 'trash',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 7
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="RELOCATED",
label="Relocated",
description="Ride has been moved to a different location",
metadata={
'color': 'purple',
'icon': 'arrow-right',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 8
},
category=ChoiceCategory.STATUS
),
]
# Post-Closing Status Choices
POST_CLOSING_STATUSES = [
RichChoice(
value="SBNO",
label="Standing But Not Operating",
description="Ride structure remains but is not operating after closure",
metadata={
'color': 'orange',
'icon': 'stop-circle',
'css_class': 'bg-orange-100 text-orange-800',
'sort_order': 1
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="CLOSED_PERM",
label="Permanently Closed",
description="Ride has been permanently closed after the closing date",
metadata={
'color': 'red',
'icon': 'x-circle',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 2
},
category=ChoiceCategory.STATUS
),
]
# Roller Coaster Track Material Choices
TRACK_MATERIALS = [
RichChoice(
value="STEEL",
label="Steel",
description="Modern steel track construction providing smooth rides and complex layouts",
metadata={
'color': 'gray',
'icon': 'steel',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 1
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="WOOD",
label="Wood",
description="Traditional wooden track construction providing classic coaster experience",
metadata={
'color': 'amber',
'icon': 'wood',
'css_class': 'bg-amber-100 text-amber-800',
'sort_order': 2
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="HYBRID",
label="Hybrid",
description="Combination of steel and wooden construction elements",
metadata={
'color': 'orange',
'icon': 'hybrid',
'css_class': 'bg-orange-100 text-orange-800',
'sort_order': 3
},
category=ChoiceCategory.TECHNICAL
),
]
# Roller Coaster Type Choices
COASTER_TYPES = [
RichChoice(
value="SITDOWN",
label="Sit Down",
description="Traditional seated roller coaster with riders sitting upright",
metadata={
'color': 'blue',
'icon': 'sitdown',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 1
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="INVERTED",
label="Inverted",
description="Coaster where riders' feet dangle freely below the track",
metadata={
'color': 'purple',
'icon': 'inverted',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 2
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="FLYING",
label="Flying",
description="Riders lie face-down in a flying position",
metadata={
'color': 'sky',
'icon': 'flying',
'css_class': 'bg-sky-100 text-sky-800',
'sort_order': 3
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="STANDUP",
label="Stand Up",
description="Riders stand upright during the ride",
metadata={
'color': 'green',
'icon': 'standup',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 4
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="WING",
label="Wing",
description="Riders sit on either side of the track with nothing above or below",
metadata={
'color': 'indigo',
'icon': 'wing',
'css_class': 'bg-indigo-100 text-indigo-800',
'sort_order': 5
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="DIVE",
label="Dive",
description="Features a vertical or near-vertical drop as the main element",
metadata={
'color': 'red',
'icon': 'dive',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 6
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="FAMILY",
label="Family",
description="Designed for riders of all ages with moderate thrills",
metadata={
'color': 'emerald',
'icon': 'family',
'css_class': 'bg-emerald-100 text-emerald-800',
'sort_order': 7
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="WILD_MOUSE",
label="Wild Mouse",
description="Compact coaster with sharp turns and sudden drops",
metadata={
'color': 'yellow',
'icon': 'mouse',
'css_class': 'bg-yellow-100 text-yellow-800',
'sort_order': 8
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="SPINNING",
label="Spinning",
description="Cars rotate freely during the ride",
metadata={
'color': 'pink',
'icon': 'spinning',
'css_class': 'bg-pink-100 text-pink-800',
'sort_order': 9
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="FOURTH_DIMENSION",
label="4th Dimension",
description="Seats rotate independently of the track direction",
metadata={
'color': 'violet',
'icon': '4d',
'css_class': 'bg-violet-100 text-violet-800',
'sort_order': 10
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="OTHER",
label="Other",
description="Coaster type that doesn't fit standard classifications",
metadata={
'color': 'gray',
'icon': 'other',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 11
},
category=ChoiceCategory.CLASSIFICATION
),
]
# Propulsion System Choices
PROPULSION_SYSTEMS = [
RichChoice(
value="CHAIN",
label="Chain Lift",
description="Traditional chain lift system to pull trains up the lift hill",
metadata={
'color': 'gray',
'icon': 'chain',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 1
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="LSM",
label="LSM Launch",
description="Linear Synchronous Motor launch system using magnetic propulsion",
metadata={
'color': 'blue',
'icon': 'lightning',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 2
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="HYDRAULIC",
label="Hydraulic Launch",
description="High-pressure hydraulic launch system for rapid acceleration",
metadata={
'color': 'red',
'icon': 'hydraulic',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 3
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="GRAVITY",
label="Gravity",
description="Uses gravity and momentum without mechanical lift systems",
metadata={
'color': 'green',
'icon': 'gravity',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 4
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="OTHER",
label="Other",
description="Propulsion system that doesn't fit standard categories",
metadata={
'color': 'gray',
'icon': 'other',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 5
},
category=ChoiceCategory.TECHNICAL
),
]
# Ride Model Target Market Choices
TARGET_MARKETS = [
RichChoice(
value="FAMILY",
label="Family",
description="Designed for families with children, moderate thrills",
metadata={
'color': 'green',
'icon': 'family',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 1
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="THRILL",
label="Thrill",
description="High-intensity rides for thrill seekers",
metadata={
'color': 'red',
'icon': 'thrill',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 2
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="EXTREME",
label="Extreme",
description="Maximum intensity rides for extreme thrill seekers",
metadata={
'color': 'purple',
'icon': 'extreme',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 3
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="KIDDIE",
label="Kiddie",
description="Gentle rides designed specifically for young children",
metadata={
'color': 'yellow',
'icon': 'kiddie',
'css_class': 'bg-yellow-100 text-yellow-800',
'sort_order': 4
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="ALL_AGES",
label="All Ages",
description="Suitable for riders of all ages and thrill preferences",
metadata={
'color': 'blue',
'icon': 'all-ages',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 5
},
category=ChoiceCategory.CLASSIFICATION
),
]
# Ride Model Photo Type Choices
PHOTO_TYPES = [
RichChoice(
value="PROMOTIONAL",
label="Promotional",
description="Marketing and promotional photos of the ride model",
metadata={
'color': 'blue',
'icon': 'camera',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 1
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="TECHNICAL",
label="Technical Drawing",
description="Technical drawings and engineering diagrams",
metadata={
'color': 'gray',
'icon': 'blueprint',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 2
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="INSTALLATION",
label="Installation Example",
description="Photos of actual installations of this ride model",
metadata={
'color': 'green',
'icon': 'installation',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 3
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="RENDERING",
label="3D Rendering",
description="Computer-generated 3D renderings of the ride model",
metadata={
'color': 'purple',
'icon': 'cube',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 4
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="CATALOG",
label="Catalog Image",
description="Official catalog and brochure images",
metadata={
'color': 'orange',
'icon': 'catalog',
'css_class': 'bg-orange-100 text-orange-800',
'sort_order': 5
},
category=ChoiceCategory.CLASSIFICATION
),
]
# Technical Specification Category Choices
SPEC_CATEGORIES = [
RichChoice(
value="DIMENSIONS",
label="Dimensions",
description="Physical dimensions and measurements",
metadata={
'color': 'blue',
'icon': 'ruler',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 1
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="PERFORMANCE",
label="Performance",
description="Performance specifications and capabilities",
metadata={
'color': 'red',
'icon': 'speedometer',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 2
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="CAPACITY",
label="Capacity",
description="Rider capacity and throughput specifications",
metadata={
'color': 'green',
'icon': 'users',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 3
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="SAFETY",
label="Safety Features",
description="Safety systems and features",
metadata={
'color': 'yellow',
'icon': 'shield',
'css_class': 'bg-yellow-100 text-yellow-800',
'sort_order': 4
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="ELECTRICAL",
label="Electrical Requirements",
description="Power and electrical system requirements",
metadata={
'color': 'purple',
'icon': 'lightning',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 5
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="FOUNDATION",
label="Foundation Requirements",
description="Foundation and structural requirements",
metadata={
'color': 'gray',
'icon': 'foundation',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 6
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="MAINTENANCE",
label="Maintenance",
description="Maintenance requirements and procedures",
metadata={
'color': 'orange',
'icon': 'wrench',
'css_class': 'bg-orange-100 text-orange-800',
'sort_order': 7
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="OTHER",
label="Other",
description="Other technical specifications",
metadata={
'color': 'gray',
'icon': 'other',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 8
},
category=ChoiceCategory.TECHNICAL
),
]
# Company Role Choices for Rides Domain (MANUFACTURER and DESIGNER only)
RIDES_COMPANY_ROLES = [
RichChoice(
value="MANUFACTURER",
label="Ride Manufacturer",
description="Company that designs and builds ride hardware and systems",
metadata={
'color': 'blue',
'icon': 'factory',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 1,
'domain': 'rides',
'permissions': ['manage_ride_models', 'view_manufacturing'],
'url_pattern': '/rides/manufacturers/{slug}/'
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="DESIGNER",
label="Ride Designer",
description="Company that specializes in ride design, layout, and engineering",
metadata={
'color': 'purple',
'icon': 'design',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 2,
'domain': 'rides',
'permissions': ['manage_ride_designs', 'view_design_specs'],
'url_pattern': '/rides/designers/{slug}/'
},
category=ChoiceCategory.CLASSIFICATION
),
]
def register_rides_choices():
"""Register all rides domain choices with the global registry"""
register_choices(
name="categories",
choices=RIDE_CATEGORIES,
domain="rides",
description="Ride category classifications",
metadata={'domain': 'rides', 'type': 'category'}
)
register_choices(
name="statuses",
choices=RIDE_STATUSES,
domain="rides",
description="Ride operational status options",
metadata={'domain': 'rides', 'type': 'status'}
)
register_choices(
name="post_closing_statuses",
choices=POST_CLOSING_STATUSES,
domain="rides",
description="Status options after ride closure",
metadata={'domain': 'rides', 'type': 'post_closing_status'}
)
register_choices(
name="track_materials",
choices=TRACK_MATERIALS,
domain="rides",
description="Roller coaster track material types",
metadata={'domain': 'rides', 'type': 'track_material', 'applies_to': 'roller_coasters'}
)
register_choices(
name="coaster_types",
choices=COASTER_TYPES,
domain="rides",
description="Roller coaster type classifications",
metadata={'domain': 'rides', 'type': 'coaster_type', 'applies_to': 'roller_coasters'}
)
register_choices(
name="propulsion_systems",
choices=PROPULSION_SYSTEMS,
domain="rides",
description="Roller coaster propulsion and lift systems",
metadata={'domain': 'rides', 'type': 'propulsion_system', 'applies_to': 'roller_coasters'}
)
register_choices(
name="target_markets",
choices=TARGET_MARKETS,
domain="rides",
description="Target market classifications for ride models",
metadata={'domain': 'rides', 'type': 'target_market', 'applies_to': 'ride_models'}
)
register_choices(
name="photo_types",
choices=PHOTO_TYPES,
domain="rides",
description="Photo type classifications for ride model images",
metadata={'domain': 'rides', 'type': 'photo_type', 'applies_to': 'ride_model_photos'}
)
register_choices(
name="spec_categories",
choices=SPEC_CATEGORIES,
domain="rides",
description="Technical specification category classifications",
metadata={'domain': 'rides', 'type': 'spec_category', 'applies_to': 'ride_model_specs'}
)
register_choices(
name="company_roles",
choices=RIDES_COMPANY_ROLES,
domain="rides",
description="Company role classifications for rides domain (MANUFACTURER and DESIGNER only)",
metadata={'domain': 'rides', 'type': 'company_role'}
)
# Auto-register choices when module is imported
register_rides_choices()

View File

@@ -1,81 +0,0 @@
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 .choices import RIDE_STATUSES
choices = {choice.value: choice.label for choice in RIDE_STATUSES}
if old_value in choices:
old_value = choices[old_value]
if new_value in choices:
new_value = choices[new_value]
elif field == "post_closing_status":
from .choices import POST_CLOSING_STATUSES
choices = {choice.value: choice.label for choice in POST_CLOSING_STATUSES}
if old_value in choices:
old_value = choices[old_value]
if new_value in choices:
new_value = choices[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 .choices import RIDE_CATEGORIES
choices = {choice.value: choice.label for choice in RIDE_CATEGORIES}
if old_value in choices:
old_value = choices[old_value]
if new_value in choices:
new_value = choices[new_value]
display_changes[field_names[field]] = {
"old": old_value,
"new": new_value,
}
return display_changes

View File

@@ -1,379 +0,0 @@
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

@@ -1,19 +0,0 @@
"""
Forms package for the rides app.
This package contains form classes for ride-related functionality including:
- Advanced search and filtering forms
- Form validation and data processing
"""
# Import forms from the search module in this package
from .search import MasterFilterForm
# Import forms from the base module in this package
from .base import RideForm, RideSearchForm
__all__ = [
"MasterFilterForm",
"RideForm",
"RideSearchForm",
]

View File

@@ -1,379 +0,0 @@
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

@@ -1,604 +0,0 @@
"""
Ride Search and Filter Forms
Forms for the comprehensive ride filtering system with 8 categories:
1. Search text
2. Basic info
3. Dates
4. Height/safety
5. Performance
6. Relationships
7. Roller coaster
8. Company
Each form handles validation and provides clean data for the RideSearchService.
"""
from django import forms
from django.core.exceptions import ValidationError
from typing import Dict, Any
from apps.parks.models import Park, ParkArea
from apps.rides.models.company import Company
from apps.rides.models.rides import RideModel
class BaseFilterForm(forms.Form):
"""Base form with common functionality for all filter forms."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add common CSS classes
for field in self.fields.values():
field.widget.attrs.update({"class": "form-input"})
class SearchTextForm(BaseFilterForm):
"""Form for text-based search filters."""
global_search = forms.CharField(
required=False,
max_length=255,
widget=forms.TextInput(
attrs={
"placeholder": "Search rides, parks, manufacturers...",
"class": (
"w-full px-4 py-2 border border-gray-300 "
"dark:border-gray-600 rounded-lg focus:ring-2 "
"focus:ring-blue-500 focus:border-transparent "
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
),
"autocomplete": "off",
"hx-get": "",
"hx-trigger": "input changed delay:300ms",
"hx-target": "#search-results",
"hx-indicator": "#search-loading",
}
),
)
name_search = forms.CharField(
required=False,
max_length=255,
widget=forms.TextInput(
attrs={
"placeholder": "Search by ride name...",
"class": (
"w-full px-3 py-2 border border-gray-300 "
"dark:border-gray-600 rounded-md focus:ring-2 "
"focus:ring-blue-500 focus:border-transparent "
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
),
}
),
)
description_search = forms.CharField(
required=False,
max_length=255,
widget=forms.TextInput(
attrs={
"placeholder": "Search in descriptions...",
"class": (
"w-full px-3 py-2 border border-gray-300 "
"dark:border-gray-600 rounded-md focus:ring-2 "
"focus:ring-blue-500 focus:border-transparent "
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
),
}
),
)
class BasicInfoForm(BaseFilterForm):
"""Form for basic ride information filters."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Get choices from Rich Choice registry
from apps.core.choices.registry import get_choices
# Get choices - let exceptions propagate if registry fails
category_choices = [(choice.value, choice.label) for choice in get_choices("categories", "rides")]
status_choices = [(choice.value, choice.label) for choice in get_choices("statuses", "rides")]
# Update field choices dynamically
self.fields['category'].choices = category_choices
self.fields['status'].choices = status_choices
category = forms.MultipleChoiceField(
choices=[], # Will be populated in __init__
required=False,
widget=forms.CheckboxSelectMultiple(attrs={"class": "grid grid-cols-2 gap-2"}),
)
status = forms.MultipleChoiceField(
choices=[], # Will be populated in __init__
required=False,
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
)
park = forms.ModelMultipleChoiceField(
queryset=Park.objects.all(),
required=False,
widget=forms.CheckboxSelectMultiple(
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
),
)
park_area = forms.ModelMultipleChoiceField(
queryset=ParkArea.objects.all(),
required=False,
widget=forms.CheckboxSelectMultiple(
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
),
)
class DateRangeWidget(forms.MultiWidget):
"""Custom widget for date range inputs."""
def __init__(self, attrs=None):
widgets = [
forms.DateInput(
attrs={
"type": "date",
"class": (
"px-3 py-2 border border-gray-300 "
"dark:border-gray-600 rounded-md focus:ring-2 "
"focus:ring-blue-500 focus:border-transparent "
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
),
}
),
forms.DateInput(
attrs={
"type": "date",
"class": (
"px-3 py-2 border border-gray-300 "
"dark:border-gray-600 rounded-md focus:ring-2 "
"focus:ring-blue-500 focus:border-transparent "
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
),
}
),
]
super().__init__(widgets, attrs)
def decompress(self, value):
if value:
return [value.get("start"), value.get("end")]
return [None, None]
class DateRangeField(forms.MultiValueField):
"""Custom field for date ranges."""
def __init__(self, *args, **kwargs):
fields = [forms.DateField(required=False), forms.DateField(required=False)]
super().__init__(fields, *args, **kwargs)
self.widget = DateRangeWidget()
def compress(self, data_list):
if data_list:
start_date, end_date = data_list
if start_date or end_date:
return {"start": start_date, "end": end_date}
return None
def validate(self, value):
super().validate(value)
if value and value.get("start") and value.get("end"):
if value["start"] > value["end"]:
raise ValidationError("Start date must be before end date.")
class DateFiltersForm(BaseFilterForm):
"""Form for date range filters."""
opening_date_range = DateRangeField(required=False, label="Opening Date Range")
closing_date_range = DateRangeField(required=False, label="Closing Date Range")
status_since_range = DateRangeField(required=False, label="Status Since Range")
class NumberRangeWidget(forms.MultiWidget):
"""Custom widget for number range inputs."""
def __init__(self, attrs=None, min_val=None, max_val=None, step=None):
widgets = [
forms.NumberInput(
attrs={
"class": (
"px-3 py-2 border border-gray-300 dark:border-gray-600 "
"rounded-md focus:ring-2 focus:ring-blue-500 "
"focus:border-transparent bg-white dark:bg-gray-800 "
"text-gray-900 dark:text-white"
),
"placeholder": "Min",
"min": min_val,
"max": max_val,
"step": step,
}
),
forms.NumberInput(
attrs={
"class": (
"px-3 py-2 border border-gray-300 dark:border-gray-600 "
"rounded-md focus:ring-2 focus:ring-blue-500 "
"focus:border-transparent bg-white dark:bg-gray-800 "
"text-gray-900 dark:text-white"
),
"placeholder": "Max",
"min": min_val,
"max": max_val,
"step": step,
}
),
]
super().__init__(widgets, attrs)
def decompress(self, value):
if value:
return [value.get("min"), value.get("max")]
return [None, None]
class NumberRangeField(forms.MultiValueField):
"""Custom field for number ranges."""
def __init__(self, min_val=None, max_val=None, step=None, *args, **kwargs):
fields = [
forms.FloatField(required=False, min_value=min_val, max_value=max_val),
forms.FloatField(required=False, min_value=min_val, max_value=max_val),
]
super().__init__(fields, *args, **kwargs)
self.widget = NumberRangeWidget(min_val=min_val, max_val=max_val, step=step)
def compress(self, data_list):
if data_list:
min_val, max_val = data_list
if min_val is not None or max_val is not None:
return {"min": min_val, "max": max_val}
return None
def validate(self, value):
super().validate(value)
if value and value.get("min") is not None and value.get("max") is not None:
if value["min"] > value["max"]:
raise ValidationError("Minimum value must be less than maximum value.")
class HeightSafetyForm(BaseFilterForm):
"""Form for height and safety requirement filters."""
min_height_range = NumberRangeField(
min_val=0,
max_val=84, # 7 feet in inches
step=1,
required=False,
label="Minimum Height (inches)",
)
max_height_range = NumberRangeField(
min_val=0, max_val=84, step=1, required=False, label="Maximum Height (inches)"
)
class PerformanceForm(BaseFilterForm):
"""Form for performance metric filters."""
capacity_range = NumberRangeField(
min_val=0, max_val=5000, step=50, required=False, label="Capacity per Hour"
)
duration_range = NumberRangeField(
min_val=0,
max_val=1800, # 30 minutes in seconds
step=10,
required=False,
label="Duration (seconds)",
)
rating_range = NumberRangeField(
min_val=0.0, max_val=10.0, step=0.1, required=False, label="Average Rating"
)
class RelationshipsForm(BaseFilterForm):
"""Form for relationship filters (manufacturer, designer, ride model)."""
manufacturer = forms.ModelMultipleChoiceField(
queryset=Company.objects.filter(roles__contains=["MANUFACTURER"]),
required=False,
widget=forms.CheckboxSelectMultiple(
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
),
)
designer = forms.ModelMultipleChoiceField(
queryset=Company.objects.filter(roles__contains=["DESIGNER"]),
required=False,
widget=forms.CheckboxSelectMultiple(
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
),
)
ride_model = forms.ModelMultipleChoiceField(
queryset=RideModel.objects.all(),
required=False,
widget=forms.CheckboxSelectMultiple(
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
),
)
class RollerCoasterForm(BaseFilterForm):
"""Form for roller coaster specific filters."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Get choices from Rich Choice registry
from apps.core.choices.registry import get_choices
# Get choices - let exceptions propagate if registry fails
track_material_choices = [(choice.value, choice.label) for choice in get_choices("track_materials", "rides")]
coaster_type_choices = [(choice.value, choice.label) for choice in get_choices("coaster_types", "rides")]
propulsion_system_choices = [(choice.value, choice.label) for choice in get_choices("propulsion_systems", "rides")]
# Update field choices dynamically
self.fields['track_material'].choices = track_material_choices
self.fields['coaster_type'].choices = coaster_type_choices
self.fields['propulsion_system'].choices = propulsion_system_choices
height_ft_range = NumberRangeField(
min_val=0, max_val=500, step=1, required=False, label="Height (feet)"
)
length_ft_range = NumberRangeField(
min_val=0, max_val=10000, step=10, required=False, label="Length (feet)"
)
speed_mph_range = NumberRangeField(
min_val=0, max_val=150, step=1, required=False, label="Speed (mph)"
)
inversions_range = NumberRangeField(
min_val=0, max_val=20, step=1, required=False, label="Number of Inversions"
)
track_material = forms.MultipleChoiceField(
choices=[], # Will be populated in __init__
required=False,
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
)
coaster_type = forms.MultipleChoiceField(
choices=[], # Will be populated in __init__
required=False,
widget=forms.CheckboxSelectMultiple(
attrs={"class": "grid grid-cols-2 gap-2 max-h-48 overflow-y-auto"}
),
)
propulsion_system = forms.MultipleChoiceField(
choices=[], # Will be populated in __init__
required=False,
widget=forms.CheckboxSelectMultiple(
attrs={"class": "space-y-2 max-h-48 overflow-y-auto"}
),
)
class CompanyForm(BaseFilterForm):
"""Form for company-related filters."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Get choices from Rich Choice registry
from apps.core.choices.registry import get_choices
# Get both rides and parks company roles - let exceptions propagate if registry fails
rides_roles = [(choice.value, choice.label) for choice in get_choices("company_roles", "rides")]
parks_roles = [(choice.value, choice.label) for choice in get_choices("company_roles", "parks")]
role_choices = rides_roles + parks_roles
# Update field choices dynamically
self.fields['manufacturer_roles'].choices = role_choices
self.fields['designer_roles'].choices = role_choices
manufacturer_roles = forms.MultipleChoiceField(
choices=[], # Will be populated in __init__
required=False,
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
)
designer_roles = forms.MultipleChoiceField(
choices=[], # Will be populated in __init__
required=False,
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
)
founded_date_range = DateRangeField(
required=False, label="Company Founded Date Range"
)
class SortingForm(BaseFilterForm):
"""Form for sorting options."""
# Static sorting choices - these are UI-specific and don't need Rich Choice Objects
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Static sort choices for UI functionality
sort_choices = [
("relevance", "Relevance"),
("name_asc", "Name (A-Z)"),
("name_desc", "Name (Z-A)"),
("opening_date_asc", "Opening Date (Oldest)"),
("opening_date_desc", "Opening Date (Newest)"),
("rating_asc", "Rating (Lowest)"),
("rating_desc", "Rating (Highest)"),
("height_asc", "Height (Shortest)"),
("height_desc", "Height (Tallest)"),
("speed_asc", "Speed (Slowest)"),
("speed_desc", "Speed (Fastest)"),
("capacity_asc", "Capacity (Lowest)"),
("capacity_desc", "Capacity (Highest)"),
]
self.fields['sort_by'].choices = sort_choices
sort_by = forms.ChoiceField(
choices=[], # Will be populated in __init__
required=False,
initial="relevance",
widget=forms.Select(
attrs={
"class": (
"px-3 py-2 border border-gray-300 dark:border-gray-600 "
"rounded-md focus:ring-2 focus:ring-blue-500 "
"focus:border-transparent bg-white dark:bg-gray-800 "
"text-gray-900 dark:text-white"
),
"hx-get": "",
"hx-trigger": "change",
"hx-target": "#search-results",
"hx-indicator": "#search-loading",
}
),
)
class MasterFilterForm(BaseFilterForm):
"""Master form that combines all filter categories."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize all sub-forms
self.search_text_form = SearchTextForm(*args, **kwargs)
self.basic_info_form = BasicInfoForm(*args, **kwargs)
self.date_filters_form = DateFiltersForm(*args, **kwargs)
self.height_safety_form = HeightSafetyForm(*args, **kwargs)
self.performance_form = PerformanceForm(*args, **kwargs)
self.relationships_form = RelationshipsForm(*args, **kwargs)
self.roller_coaster_form = RollerCoasterForm(*args, **kwargs)
self.company_form = CompanyForm(*args, **kwargs)
self.sorting_form = SortingForm(*args, **kwargs)
# Add all fields to this form
self.fields.update(self.search_text_form.fields)
self.fields.update(self.basic_info_form.fields)
self.fields.update(self.date_filters_form.fields)
self.fields.update(self.height_safety_form.fields)
self.fields.update(self.performance_form.fields)
self.fields.update(self.relationships_form.fields)
self.fields.update(self.roller_coaster_form.fields)
self.fields.update(self.company_form.fields)
self.fields.update(self.sorting_form.fields)
def clean(self):
cleaned_data = super().clean()
# Validate all sub-forms
for form in [
self.search_text_form,
self.basic_info_form,
self.date_filters_form,
self.height_safety_form,
self.performance_form,
self.relationships_form,
self.roller_coaster_form,
self.company_form,
self.sorting_form,
]:
if not form.is_valid():
for field, errors in form.errors.items():
self.add_error(field, errors)
return cleaned_data
def get_filter_dict(self) -> Dict[str, Any]:
"""Convert form data to search service filter format."""
if not self.is_valid():
return {}
filters = {}
# Add all cleaned data to filters
for field_name, value in self.cleaned_data.items():
if value: # Only include non-empty values
filters[field_name] = value
return filters
def get_search_filters(self) -> Dict[str, Any]:
"""Alias for get_filter_dict for backward compatibility."""
return self.get_filter_dict()
def get_filter_summary(self) -> Dict[str, Any]:
"""Get summary of active filters for display."""
active_filters = {}
if not self.is_valid():
return active_filters
# Group filters by category
categories = {
"Search": ["global_search", "name_search", "description_search"],
"Basic Info": ["category", "status", "park", "park_area"],
"Dates": ["opening_date_range", "closing_date_range", "status_since_range"],
"Height & Safety": ["min_height_range", "max_height_range"],
"Performance": ["capacity_range", "duration_range", "rating_range"],
"Relationships": ["manufacturer", "designer", "ride_model"],
"Roller Coaster": [
"height_ft_range",
"length_ft_range",
"speed_mph_range",
"inversions_range",
"track_material",
"coaster_type",
"propulsion_system",
],
"Company": ["manufacturer_roles", "designer_roles", "founded_date_range"],
}
for category, field_names in categories.items():
category_filters = []
for field_name in field_names:
value = self.cleaned_data.get(field_name)
if value:
category_filters.append(
{
"field": field_name,
"value": value,
"label": self.fields[field_name].label
or field_name.replace("_", " ").title(),
}
)
if category_filters:
active_filters[category] = category_filters
return active_filters
def get_active_filters_summary(self) -> Dict[str, Any]:
"""Alias for get_filter_summary for backward compatibility."""
return self.get_filter_summary()
def has_active_filters(self) -> bool:
"""Check if any filters are currently active."""
if not self.is_valid():
return False
for field_name, value in self.cleaned_data.items():
if value: # If any field has a value, we have active filters
return True
return False

View File

@@ -1,36 +0,0 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from apps.rides.services import RideRankingService
class Command(BaseCommand):
help = "Calculates and updates ride rankings using the Internet Roller Coaster Poll algorithm"
def add_arguments(self, parser):
parser.add_argument(
"--category",
type=str,
default=None,
help="Optional ride category to filter (e.g., RC for roller coasters)",
)
def handle(self, *args, **options):
category = options.get("category")
service = RideRankingService()
self.stdout.write(
self.style.SUCCESS(
f"Starting ride ranking calculation at {timezone.now().isoformat()}"
)
)
result = service.update_all_rankings(category=category)
self.stdout.write(
self.style.SUCCESS(
f"Completed ranking calculation: {result.get('rides_ranked', 0)} rides ranked, "
f"{result.get('comparisons_made', 0)} comparisons, "
f"duration={result.get('duration', 0):.2f}s"
)
)

View File

@@ -1,301 +0,0 @@
"""
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(propulsion_system="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

@@ -1,740 +0,0 @@
# 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

@@ -1,141 +0,0 @@
# 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

@@ -1,88 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-24 18:23
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("rides", "0002_add_business_constraints"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="company",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="company",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridereview",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridereview",
name="update_update",
),
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="8229e39396673e3801d570ad8d1c602b528fadc1",
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="9d4d58e00963bf10b3cc428ef609044104366655",
operation="UPDATE",
pgid="pgtrigger_update_update_456a8",
table="rides_company",
when="AFTER",
),
),
),
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="0d6021859fef528429d7d6028439c08de6040e52",
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="b0acd6ed16f909a42f9bedd975c7f385d0868d32",
operation="UPDATE",
pgid="pgtrigger_update_update_90298",
table="rides_ridereview",
when="AFTER",
),
),
),
]

View File

@@ -1,469 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-24 19:11
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0006_remove_company_insert_insert_and_more"),
("pghistory", "0007_auto_20250421_0444"),
("rides", "0003_remove_company_insert_insert_and_more"),
]
operations = [
migrations.CreateModel(
name="RideEvent",
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)),
("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={
"abstract": False,
},
),
migrations.CreateModel(
name="RideModelEvent",
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)),
("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={
"abstract": False,
},
),
migrations.CreateModel(
name="RollerCoasterStatsEvent",
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()),
(
"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={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_rideevent" ("average_rating", "capacity_per_hour", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at") VALUES (NEW."average_rating", NEW."capacity_per_hour", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at"); RETURN NULL;',
hash="870aa867ae6892f187dc0382e4a6833b5d1267c5",
operation="INSERT",
pgid="pgtrigger_insert_insert_52074",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_rideevent" ("average_rating", "capacity_per_hour", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at") VALUES (NEW."average_rating", NEW."capacity_per_hour", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at"); RETURN NULL;',
hash="8bafb42256ee98b4517ae4d39d0e774111794fea",
operation="UPDATE",
pgid="pgtrigger_update_update_4917a",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridemodel",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridemodelevent" ("category", "created_at", "description", "id", "manufacturer_id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", NEW."manufacturer_id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."updated_at"); RETURN NULL;',
hash="e9e3c3ec4cb2400b363035534c580c94a3bb1d53",
operation="INSERT",
pgid="pgtrigger_insert_insert_0aaee",
table="rides_ridemodel",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridemodel",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridemodelevent" ("category", "created_at", "description", "id", "manufacturer_id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", NEW."manufacturer_id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."updated_at"); RETURN NULL;',
hash="4c8073b866beac402ace852e23974fcb01d24267",
operation="UPDATE",
pgid="pgtrigger_update_update_0ca1a",
table="rides_ridemodel",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="rollercoasterstats",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_rollercoasterstatsevent" ("cars_per_train", "height_ft", "id", "inversions", "launch_type", "length_ft", "max_drop_height_ft", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "ride_time_seconds", "roller_coaster_type", "seats_per_car", "speed_mph", "track_material", "track_type", "train_style", "trains_count") VALUES (NEW."cars_per_train", NEW."height_ft", NEW."id", NEW."inversions", NEW."launch_type", NEW."length_ft", NEW."max_drop_height_ft", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_id", NEW."ride_time_seconds", NEW."roller_coaster_type", NEW."seats_per_car", NEW."speed_mph", NEW."track_material", NEW."track_type", NEW."train_style", NEW."trains_count"); RETURN NULL;',
hash="529f2cf3bb62b57c85123143523475c1999099ec",
operation="INSERT",
pgid="pgtrigger_insert_insert_96f8b",
table="rides_rollercoasterstats",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="rollercoasterstats",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_rollercoasterstatsevent" ("cars_per_train", "height_ft", "id", "inversions", "launch_type", "length_ft", "max_drop_height_ft", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "ride_time_seconds", "roller_coaster_type", "seats_per_car", "speed_mph", "track_material", "track_type", "train_style", "trains_count") VALUES (NEW."cars_per_train", NEW."height_ft", NEW."id", NEW."inversions", NEW."launch_type", NEW."length_ft", NEW."max_drop_height_ft", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_id", NEW."ride_time_seconds", NEW."roller_coaster_type", NEW."seats_per_car", NEW."speed_mph", NEW."track_material", NEW."track_type", NEW."train_style", NEW."trains_count"); RETURN NULL;',
hash="3c9d3cb53ac46a2f4b2a27a63c5ed17a18de9827",
operation="UPDATE",
pgid="pgtrigger_update_update_24e8a",
table="rides_rollercoasterstats",
when="AFTER",
),
),
),
migrations.AddField(
model_name="rideevent",
name="designer",
field=models.ForeignKey(
blank=True,
db_constraint=False,
limit_choices_to={"roles__contains": ["DESIGNER"]},
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.company",
),
),
migrations.AddField(
model_name="rideevent",
name="manufacturer",
field=models.ForeignKey(
blank=True,
db_constraint=False,
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.company",
),
),
migrations.AddField(
model_name="rideevent",
name="park",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="parks.park",
),
),
migrations.AddField(
model_name="rideevent",
name="park_area",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="parks.parkarea",
),
),
migrations.AddField(
model_name="rideevent",
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="rideevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ride",
),
),
migrations.AddField(
model_name="rideevent",
name="ride_model",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="The specific model/type of this ride",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ridemodel",
),
),
migrations.AddField(
model_name="ridemodelevent",
name="manufacturer",
field=models.ForeignKey(
blank=True,
db_constraint=False,
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.company",
),
),
migrations.AddField(
model_name="ridemodelevent",
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="ridemodelevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridemodel",
),
),
migrations.AddField(
model_name="rollercoasterstatsevent",
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="rollercoasterstatsevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.rollercoasterstats",
),
),
migrations.AddField(
model_name="rollercoasterstatsevent",
name="ride",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
]

View File

@@ -1,127 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-24 19:25
import django.contrib.gis.db.models.fields
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pghistory", "0007_auto_20250421_0444"),
("rides", "0004_rideevent_ridemodelevent_rollercoasterstatsevent_and_more"),
]
operations = [
migrations.CreateModel(
name="RideLocationEvent",
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()),
(
"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,
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={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="ridelocation",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridelocationevent" ("accessibility_notes", "created_at", "entrance_notes", "id", "notes", "park_area", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "ride_id", "updated_at") VALUES (NEW."accessibility_notes", NEW."created_at", NEW."entrance_notes", NEW."id", NEW."notes", NEW."park_area", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."point", NEW."ride_id", NEW."updated_at"); RETURN NULL;',
hash="04c4c3aa17d4ef852d52b40d1dba4cd7372d5e29",
operation="INSERT",
pgid="pgtrigger_insert_insert_b66c2",
table="rides_ridelocation",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridelocation",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridelocationevent" ("accessibility_notes", "created_at", "entrance_notes", "id", "notes", "park_area", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "ride_id", "updated_at") VALUES (NEW."accessibility_notes", NEW."created_at", NEW."entrance_notes", NEW."id", NEW."notes", NEW."park_area", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."point", NEW."ride_id", NEW."updated_at"); RETURN NULL;',
hash="7073b4517d00b884b2f3fddf89caeefaa64058ad",
operation="UPDATE",
pgid="pgtrigger_update_update_402ba",
table="rides_ridelocation",
when="AFTER",
),
),
),
migrations.AddField(
model_name="ridelocationevent",
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="ridelocationevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridelocation",
),
),
migrations.AddField(
model_name="ridelocationevent",
name="ride",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
]

View File

@@ -1,602 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-25 00:50
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pghistory", "0007_auto_20250421_0444"),
("rides", "0005_ridelocationevent_ridelocation_insert_insert_and_more"),
]
operations = [
migrations.CreateModel(
name="RidePairComparison",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"ride_a_wins",
models.PositiveIntegerField(
default=0,
help_text="Number of mutual riders who rated ride_a higher",
),
),
(
"ride_b_wins",
models.PositiveIntegerField(
default=0,
help_text="Number of mutual riders who rated ride_b higher",
),
),
(
"ties",
models.PositiveIntegerField(
default=0,
help_text="Number of mutual riders who rated both rides equally",
),
),
(
"mutual_riders_count",
models.PositiveIntegerField(
default=0,
help_text="Total number of users who have rated both rides",
),
),
(
"ride_a_avg_rating",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average rating of ride_a from mutual riders",
max_digits=3,
null=True,
),
),
(
"ride_b_avg_rating",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average rating of ride_b from mutual riders",
max_digits=3,
null=True,
),
),
(
"last_calculated",
models.DateTimeField(
auto_now=True,
help_text="When this comparison was last calculated",
),
),
(
"ride_a",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comparisons_as_a",
to="rides.ride",
),
),
(
"ride_b",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comparisons_as_b",
to="rides.ride",
),
),
],
),
migrations.CreateModel(
name="RidePairComparisonEvent",
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()),
(
"ride_a_wins",
models.PositiveIntegerField(
default=0,
help_text="Number of mutual riders who rated ride_a higher",
),
),
(
"ride_b_wins",
models.PositiveIntegerField(
default=0,
help_text="Number of mutual riders who rated ride_b higher",
),
),
(
"ties",
models.PositiveIntegerField(
default=0,
help_text="Number of mutual riders who rated both rides equally",
),
),
(
"mutual_riders_count",
models.PositiveIntegerField(
default=0,
help_text="Total number of users who have rated both rides",
),
),
(
"ride_a_avg_rating",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average rating of ride_a from mutual riders",
max_digits=3,
null=True,
),
),
(
"ride_b_avg_rating",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average rating of ride_b from mutual riders",
max_digits=3,
null=True,
),
),
(
"last_calculated",
models.DateTimeField(
auto_now=True,
help_text="When this comparison was last calculated",
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridepaircomparison",
),
),
(
"ride_a",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
(
"ride_b",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="RideRanking",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"rank",
models.PositiveIntegerField(
db_index=True, help_text="Overall rank position (1 = best)"
),
),
(
"wins",
models.PositiveIntegerField(
default=0,
help_text="Number of rides this ride beats in pairwise comparisons",
),
),
(
"losses",
models.PositiveIntegerField(
default=0,
help_text="Number of rides that beat this ride in pairwise comparisons",
),
),
(
"ties",
models.PositiveIntegerField(
default=0,
help_text="Number of rides with equal preference in pairwise comparisons",
),
),
(
"winning_percentage",
models.DecimalField(
db_index=True,
decimal_places=4,
help_text="Win percentage where ties count as 0.5",
max_digits=5,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(1),
],
),
),
(
"mutual_riders_count",
models.PositiveIntegerField(
default=0,
help_text="Total number of users who have rated this ride",
),
),
(
"comparison_count",
models.PositiveIntegerField(
default=0,
help_text="Number of other rides this was compared against",
),
),
(
"average_rating",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average rating from all users who have rated this ride",
max_digits=3,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(10),
],
),
),
(
"last_calculated",
models.DateTimeField(
default=django.utils.timezone.now,
help_text="When this ranking was last calculated",
),
),
(
"calculation_version",
models.CharField(
default="1.0",
help_text="Algorithm version used for calculation",
max_length=10,
),
),
(
"ride",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="ranking",
to="rides.ride",
),
),
],
options={
"ordering": ["rank"],
},
),
migrations.CreateModel(
name="RideRankingEvent",
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()),
(
"rank",
models.PositiveIntegerField(
help_text="Overall rank position (1 = best)"
),
),
(
"wins",
models.PositiveIntegerField(
default=0,
help_text="Number of rides this ride beats in pairwise comparisons",
),
),
(
"losses",
models.PositiveIntegerField(
default=0,
help_text="Number of rides that beat this ride in pairwise comparisons",
),
),
(
"ties",
models.PositiveIntegerField(
default=0,
help_text="Number of rides with equal preference in pairwise comparisons",
),
),
(
"winning_percentage",
models.DecimalField(
decimal_places=4,
help_text="Win percentage where ties count as 0.5",
max_digits=5,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(1),
],
),
),
(
"mutual_riders_count",
models.PositiveIntegerField(
default=0,
help_text="Total number of users who have rated this ride",
),
),
(
"comparison_count",
models.PositiveIntegerField(
default=0,
help_text="Number of other rides this was compared against",
),
),
(
"average_rating",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average rating from all users who have rated this ride",
max_digits=3,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(10),
],
),
),
(
"last_calculated",
models.DateTimeField(
default=django.utils.timezone.now,
help_text="When this ranking was last calculated",
),
),
(
"calculation_version",
models.CharField(
default="1.0",
help_text="Algorithm version used for calculation",
max_length=10,
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.rideranking",
),
),
(
"ride",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="RankingSnapshot",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("rank", models.PositiveIntegerField()),
(
"winning_percentage",
models.DecimalField(decimal_places=4, max_digits=5),
),
(
"snapshot_date",
models.DateField(
db_index=True,
help_text="Date when this ranking snapshot was taken",
),
),
(
"ride",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ranking_history",
to="rides.ride",
),
),
],
options={
"ordering": ["-snapshot_date", "rank"],
"indexes": [
models.Index(
fields=["snapshot_date", "rank"],
name="rides_ranki_snapsho_8e2657_idx",
),
models.Index(
fields=["ride", "-snapshot_date"],
name="rides_ranki_ride_id_827bb9_idx",
),
],
"unique_together": {("ride", "snapshot_date")},
},
),
migrations.AddIndex(
model_name="ridepaircomparison",
index=models.Index(
fields=["ride_a", "ride_b"], name="rides_ridep_ride_a__eb0674_idx"
),
),
migrations.AddIndex(
model_name="ridepaircomparison",
index=models.Index(
fields=["last_calculated"], name="rides_ridep_last_ca_bd9f6c_idx"
),
),
migrations.AlterUniqueTogether(
name="ridepaircomparison",
unique_together={("ride_a", "ride_b")},
),
pgtrigger.migrations.AddTrigger(
model_name="ridepaircomparison",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridepaircomparisonevent" ("id", "last_calculated", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_a_avg_rating", "ride_a_id", "ride_a_wins", "ride_b_avg_rating", "ride_b_id", "ride_b_wins", "ties") VALUES (NEW."id", NEW."last_calculated", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_a_avg_rating", NEW."ride_a_id", NEW."ride_a_wins", NEW."ride_b_avg_rating", NEW."ride_b_id", NEW."ride_b_wins", NEW."ties"); RETURN NULL;',
hash="6a640e10fcfd58c48029ee5b84ea7f0826f50022",
operation="INSERT",
pgid="pgtrigger_insert_insert_9ad59",
table="rides_ridepaircomparison",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridepaircomparison",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridepaircomparisonevent" ("id", "last_calculated", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_a_avg_rating", "ride_a_id", "ride_a_wins", "ride_b_avg_rating", "ride_b_id", "ride_b_wins", "ties") VALUES (NEW."id", NEW."last_calculated", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_a_avg_rating", NEW."ride_a_id", NEW."ride_a_wins", NEW."ride_b_avg_rating", NEW."ride_b_id", NEW."ride_b_wins", NEW."ties"); RETURN NULL;',
hash="a77eee0b791bada3f84f008dabd7486c66b03fa6",
operation="UPDATE",
pgid="pgtrigger_update_update_73b31",
table="rides_ridepaircomparison",
when="AFTER",
),
),
),
migrations.AddIndex(
model_name="rideranking",
index=models.Index(fields=["rank"], name="rides_rider_rank_ea4706_idx"),
),
migrations.AddIndex(
model_name="rideranking",
index=models.Index(
fields=["winning_percentage", "-mutual_riders_count"],
name="rides_rider_winning_d9b3e8_idx",
),
),
migrations.AddIndex(
model_name="rideranking",
index=models.Index(
fields=["ride", "last_calculated"],
name="rides_rider_ride_id_ece73d_idx",
),
),
migrations.AddConstraint(
model_name="rideranking",
constraint=models.CheckConstraint(
condition=models.Q(
("winning_percentage__gte", 0), ("winning_percentage__lte", 1)
),
name="rideranking_winning_percentage_range",
violation_error_message="Winning percentage must be between 0 and 1",
),
),
migrations.AddConstraint(
model_name="rideranking",
constraint=models.CheckConstraint(
condition=models.Q(
("average_rating__isnull", True),
models.Q(("average_rating__gte", 1), ("average_rating__lte", 10)),
_connector="OR",
),
name="rideranking_average_rating_range",
violation_error_message="Average rating must be between 1 and 10",
),
),
pgtrigger.migrations.AddTrigger(
model_name="rideranking",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_riderankingevent" ("average_rating", "calculation_version", "comparison_count", "id", "last_calculated", "losses", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "ride_id", "ties", "winning_percentage", "wins") VALUES (NEW."average_rating", NEW."calculation_version", NEW."comparison_count", NEW."id", NEW."last_calculated", NEW."losses", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."ride_id", NEW."ties", NEW."winning_percentage", NEW."wins"); RETURN NULL;',
hash="c5f9dced5824a55e6f36e476eb382ed770aa5716",
operation="INSERT",
pgid="pgtrigger_insert_insert_01af3",
table="rides_rideranking",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="rideranking",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_riderankingevent" ("average_rating", "calculation_version", "comparison_count", "id", "last_calculated", "losses", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "ride_id", "ties", "winning_percentage", "wins") VALUES (NEW."average_rating", NEW."calculation_version", NEW."comparison_count", NEW."id", NEW."last_calculated", NEW."losses", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."ride_id", NEW."ties", NEW."winning_percentage", NEW."wins"); RETURN NULL;',
hash="363e44ce3c87e8b66406d63d6f1b26ad604c79d2",
operation="UPDATE",
pgid="pgtrigger_update_update_c3f27",
table="rides_rideranking",
when="AFTER",
),
),
),
]

View File

@@ -1,224 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-26 17:39
import apps.rides.models.media
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):
dependencies = [
("pghistory", "0007_auto_20250421_0444"),
("rides", "0006_add_ride_rankings"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="RidePhoto",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"image",
models.ImageField(
max_length=255,
upload_to=apps.rides.models.media.ride_photo_upload_path,
),
),
("caption", models.CharField(blank=True, max_length=255)),
("alt_text", models.CharField(blank=True, max_length=255)),
("is_primary", models.BooleanField(default=False)),
("is_approved", models.BooleanField(default=False)),
(
"photo_type",
models.CharField(
choices=[
("exterior", "Exterior View"),
("queue", "Queue Area"),
("station", "Station"),
("onride", "On-Ride"),
("construction", "Construction"),
("other", "Other"),
],
default="exterior",
max_length=50,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("date_taken", models.DateTimeField(blank=True, null=True)),
(
"ride",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="photos",
to="rides.ride",
),
),
(
"uploaded_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="uploaded_ride_photos",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-is_primary", "-created_at"],
},
),
migrations.CreateModel(
name="RidePhotoEvent",
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()),
(
"image",
models.ImageField(
max_length=255,
upload_to=apps.rides.models.media.ride_photo_upload_path,
),
),
("caption", models.CharField(blank=True, max_length=255)),
("alt_text", models.CharField(blank=True, max_length=255)),
("is_primary", models.BooleanField(default=False)),
("is_approved", models.BooleanField(default=False)),
(
"photo_type",
models.CharField(
choices=[
("exterior", "Exterior View"),
("queue", "Queue Area"),
("station", "Station"),
("onride", "On-Ride"),
("construction", "Construction"),
("other", "Other"),
],
default="exterior",
max_length=50,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("date_taken", models.DateTimeField(blank=True, null=True)),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridephoto",
),
),
(
"ride",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
(
"uploaded_by",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.AddIndex(
model_name="ridephoto",
index=models.Index(
fields=["ride", "is_primary"], name="rides_ridep_ride_id_aa49f1_idx"
),
),
migrations.AddIndex(
model_name="ridephoto",
index=models.Index(
fields=["ride", "is_approved"], name="rides_ridep_ride_id_f1eddc_idx"
),
),
migrations.AddIndex(
model_name="ridephoto",
index=models.Index(
fields=["ride", "photo_type"], name="rides_ridep_ride_id_49e7ec_idx"
),
),
migrations.AddIndex(
model_name="ridephoto",
index=models.Index(
fields=["created_at"], name="rides_ridep_created_106e02_idx"
),
),
migrations.AddConstraint(
model_name="ridephoto",
constraint=models.UniqueConstraint(
condition=models.Q(("is_primary", True)),
fields=("ride",),
name="unique_primary_ride_photo",
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridephoto",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridephotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "ride_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_type", NEW."ride_id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="8027f17cac76b8301927e468ab4873ae9f38f27a",
operation="INSERT",
pgid="pgtrigger_insert_insert_0043a",
table="rides_ridephoto",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridephoto",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridephotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "ride_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_type", NEW."ride_id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="54562f9a78754cac359f1efd5c0e8d6d144d1806",
operation="UPDATE",
pgid="pgtrigger_update_update_93a7e",
table="rides_ridephoto",
when="AFTER",
),
),
),
]

View File

@@ -1,105 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-28 18:35
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0007_ridephoto_ridephotoevent_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="update_update",
),
migrations.AddField(
model_name="ride",
name="banner_image",
field=models.ForeignKey(
blank=True,
help_text="Photo to use as banner image for this ride",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rides_using_as_banner",
to="rides.ridephoto",
),
),
migrations.AddField(
model_name="ride",
name="card_image",
field=models.ForeignKey(
blank=True,
help_text="Photo to use as card image for this ride",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rides_using_as_card",
to="rides.ridephoto",
),
),
migrations.AddField(
model_name="rideevent",
name="banner_image",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Photo to use as banner image for this ride",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ridephoto",
),
),
migrations.AddField(
model_name="rideevent",
name="card_image",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Photo to use as card image for this ride",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ridephoto",
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at"); RETURN NULL;',
hash="462120d462bacf795e3e8d2d48e56a8adb85c63b",
operation="INSERT",
pgid="pgtrigger_insert_insert_52074",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at"); RETURN NULL;',
hash="dc36bcf1b24242b781d63799024095b0f8da79b6",
operation="UPDATE",
pgid="pgtrigger_update_update_4917a",
table="rides_ride",
when="AFTER",
),
),
),
]

View File

@@ -1,48 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-28 19:10
from django.db import migrations
from django.utils.text import slugify
def populate_ride_model_slugs(apps, schema_editor):
"""Populate unique slugs for existing RideModel records."""
RideModel = apps.get_model("rides", "RideModel")
apps.get_model("rides", "Company")
for ride_model in RideModel.objects.all():
# Generate base slug from manufacturer name + model name
if ride_model.manufacturer:
base_slug = slugify(f"{ride_model.manufacturer.name} {ride_model.name}")
else:
base_slug = slugify(ride_model.name)
# Ensure uniqueness
slug = base_slug
counter = 1
while RideModel.objects.filter(slug=slug).exclude(pk=ride_model.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
# Update the slug
ride_model.slug = slug
ride_model.save(update_fields=["slug"])
def reverse_populate_ride_model_slugs(apps, schema_editor):
"""Reverse operation - clear slugs (not really needed but for completeness)."""
RideModel = apps.get_model("rides", "RideModel")
RideModel.objects.all().update(slug="")
class Migration(migrations.Migration):
dependencies = [
("rides", "0010_add_comprehensive_ride_model_system"),
]
operations = [
migrations.RunPython(
populate_ride_model_slugs,
reverse_populate_ride_model_slugs,
),
]

View File

@@ -1,20 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-28 19:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0011_populate_ride_model_slugs"),
]
operations = [
migrations.AlterField(
model_name="ridemodel",
name="slug",
field=models.SlugField(
help_text="URL-friendly identifier", max_length=255, unique=True
),
),
]

View File

@@ -1,38 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-28 19:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0012_make_ride_model_slug_unique"),
]
operations = [
migrations.AlterUniqueTogether(
name="ridemodel",
unique_together={("manufacturer", "name")},
),
migrations.AlterField(
model_name="ridemodel",
name="slug",
field=models.SlugField(
help_text="URL-friendly identifier (unique within manufacturer)",
max_length=255,
),
),
migrations.AlterField(
model_name="ridemodelevent",
name="slug",
field=models.SlugField(
db_index=False,
help_text="URL-friendly identifier (unique within manufacturer)",
max_length=255,
),
),
migrations.AlterUniqueTogether(
name="ridemodel",
unique_together={("manufacturer", "name"), ("manufacturer", "slug")},
),
]

View File

@@ -1,69 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-28 19:19
from django.db import migrations
from django.utils.text import slugify
def update_ride_model_slugs(apps, schema_editor):
"""Update RideModel slugs to be just the model name, not manufacturer + name."""
RideModel = apps.get_model("rides", "RideModel")
for ride_model in RideModel.objects.all():
# Generate new slug from just the name
new_slug = slugify(ride_model.name)
# Ensure uniqueness within the same manufacturer
counter = 1
base_slug = new_slug
while (
RideModel.objects.filter(
manufacturer=ride_model.manufacturer, slug=new_slug
)
.exclude(pk=ride_model.pk)
.exists()
):
new_slug = f"{base_slug}-{counter}"
counter += 1
# Update the slug
ride_model.slug = new_slug
ride_model.save(update_fields=["slug"])
print(f"Updated {ride_model.name}: {ride_model.slug}")
def reverse_ride_model_slugs(apps, schema_editor):
"""Reverse the slug update by regenerating the old format."""
RideModel = apps.get_model("rides", "RideModel")
for ride_model in RideModel.objects.all():
# Generate old-style slug with manufacturer + name
old_slug = slugify(
f"{ride_model.manufacturer.name if ride_model.manufacturer else ''} {ride_model.name}"
)
# Ensure uniqueness globally (old way)
counter = 1
base_slug = old_slug
while (
RideModel.objects.filter(slug=old_slug).exclude(pk=ride_model.pk).exists()
):
old_slug = f"{base_slug}-{counter}"
counter += 1
# Update the slug
ride_model.slug = old_slug
ride_model.save(update_fields=["slug"])
class Migration(migrations.Migration):
dependencies = [
("rides", "0013_fix_ride_model_slugs"),
]
operations = [
migrations.RunPython(
update_ride_model_slugs,
reverse_ride_model_slugs,
),
]

View File

@@ -1,164 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-28 22:59
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0014_update_ride_model_slugs_data"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="company",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="company",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridemodel",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridemodel",
name="update_update",
),
migrations.AddField(
model_name="company",
name="url",
field=models.URLField(
blank=True, help_text="Frontend URL for this company"
),
),
migrations.AddField(
model_name="companyevent",
name="url",
field=models.URLField(
blank=True, help_text="Frontend URL for this company"
),
),
migrations.AddField(
model_name="ride",
name="url",
field=models.URLField(blank=True, help_text="Frontend URL for this ride"),
),
migrations.AddField(
model_name="rideevent",
name="url",
field=models.URLField(blank=True, help_text="Frontend URL for this ride"),
),
migrations.AddField(
model_name="ridemodel",
name="url",
field=models.URLField(
blank=True, help_text="Frontend URL for this ride model"
),
),
migrations.AddField(
model_name="ridemodelevent",
name="url",
field=models.URLField(
blank=True, help_text="Frontend URL for this ride model"
),
),
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", "url", "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."url", NEW."website"); RETURN NULL;',
hash="fe6c1e3f09822f5e7f716cd83483cf152ec138f0",
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", "url", "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."url", NEW."website"); RETURN NULL;',
hash="0b76cb36b7551ed3e64e674b8cfe343d4d2ec306",
operation="UPDATE",
pgid="pgtrigger_update_update_456a8",
table="rides_company",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="6764dc3b0c0e73dda649939bb1ee7b7de143125f",
operation="INSERT",
pgid="pgtrigger_insert_insert_52074",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="63c4066af11852396506fd964989632336205573",
operation="UPDATE",
pgid="pgtrigger_update_update_4917a",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridemodel",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridemodelevent" ("category", "created_at", "description", "first_installation_year", "id", "is_discontinued", "last_installation_year", "manufacturer_id", "meta_description", "meta_title", "name", "notable_features", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "primary_image_id", "restraint_system", "slug", "support_structure", "target_market", "total_installations", "track_type", "train_configuration", "typical_capacity_range_max", "typical_capacity_range_min", "typical_height_range_max_ft", "typical_height_range_min_ft", "typical_speed_range_max_mph", "typical_speed_range_min_mph", "updated_at", "url") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."first_installation_year", NEW."id", NEW."is_discontinued", NEW."last_installation_year", NEW."manufacturer_id", NEW."meta_description", NEW."meta_title", NEW."name", NEW."notable_features", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."primary_image_id", NEW."restraint_system", NEW."slug", NEW."support_structure", NEW."target_market", NEW."total_installations", NEW."track_type", NEW."train_configuration", NEW."typical_capacity_range_max", NEW."typical_capacity_range_min", NEW."typical_height_range_max_ft", NEW."typical_height_range_min_ft", NEW."typical_speed_range_max_mph", NEW."typical_speed_range_min_mph", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="9cee65f580a26ae9edc8f9fc1f3d9b25da1856c3",
operation="INSERT",
pgid="pgtrigger_insert_insert_0aaee",
table="rides_ridemodel",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridemodel",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridemodelevent" ("category", "created_at", "description", "first_installation_year", "id", "is_discontinued", "last_installation_year", "manufacturer_id", "meta_description", "meta_title", "name", "notable_features", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "primary_image_id", "restraint_system", "slug", "support_structure", "target_market", "total_installations", "track_type", "train_configuration", "typical_capacity_range_max", "typical_capacity_range_min", "typical_height_range_max_ft", "typical_height_range_min_ft", "typical_speed_range_max_mph", "typical_speed_range_min_mph", "updated_at", "url") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."first_installation_year", NEW."id", NEW."is_discontinued", NEW."last_installation_year", NEW."manufacturer_id", NEW."meta_description", NEW."meta_title", NEW."name", NEW."notable_features", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."primary_image_id", NEW."restraint_system", NEW."slug", NEW."support_structure", NEW."target_market", NEW."total_installations", NEW."track_type", NEW."train_configuration", NEW."typical_capacity_range_max", NEW."typical_capacity_range_min", NEW."typical_height_range_max_ft", NEW."typical_height_range_min_ft", NEW."typical_speed_range_max_mph", NEW."typical_speed_range_min_mph", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="365f87607f9f7bfee1caaabdd32b16032e04ae82",
operation="UPDATE",
pgid="pgtrigger_update_update_0ca1a",
table="rides_ridemodel",
when="AFTER",
),
),
),
]

View File

@@ -1,66 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-28 23:12
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0015_remove_company_insert_insert_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="update_update",
),
migrations.AddField(
model_name="ride",
name="park_url",
field=models.URLField(
blank=True, help_text="Frontend URL for this ride's park"
),
),
migrations.AddField(
model_name="rideevent",
name="park_url",
field=models.URLField(
blank=True, help_text="Frontend URL for this ride's park"
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="3b83e1d1dbc2d5ca5792929845db1dd6d306700a",
operation="INSERT",
pgid="pgtrigger_insert_insert_52074",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="efd782a22f5bec46d06b234ffc55b6c06360ade1",
operation="UPDATE",
pgid="pgtrigger_update_update_4917a",
table="rides_ride",
when="AFTER",
),
),
),
]

View File

@@ -1,133 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-30 21:41
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("django_cloudflareimages_toolkit", "0001_initial"),
("rides", "0016_remove_ride_insert_insert_remove_ride_update_update_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="ridemodelphoto",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridemodelphoto",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridephoto",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridephoto",
name="update_update",
),
migrations.AlterField(
model_name="ridemodelphoto",
name="image",
field=models.ForeignKey(
help_text="Photo of the ride model stored on Cloudflare Images",
on_delete=django.db.models.deletion.CASCADE,
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
name="image",
field=models.ForeignKey(
db_constraint=False,
help_text="Photo of the ride model stored on Cloudflare Images",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AlterField(
model_name="ridephoto",
name="image",
field=models.ForeignKey(
help_text="Ride photo stored on Cloudflare Images",
on_delete=django.db.models.deletion.CASCADE,
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AlterField(
model_name="ridephotoevent",
name="image",
field=models.ForeignKey(
db_constraint=False,
help_text="Ride photo stored on Cloudflare Images",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridemodelphoto",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridemodelphotoevent" ("alt_text", "caption", "copyright_info", "created_at", "id", "image_id", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "photographer", "ride_model_id", "source", "updated_at") VALUES (NEW."alt_text", NEW."caption", NEW."copyright_info", NEW."created_at", NEW."id", NEW."image_id", NEW."is_primary", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_type", NEW."photographer", NEW."ride_model_id", NEW."source", NEW."updated_at"); RETURN NULL;',
hash="fa289c31e25da0c08740d9e9c4072f3e4df81c42",
operation="INSERT",
pgid="pgtrigger_insert_insert_c5e58",
table="rides_ridemodelphoto",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridemodelphoto",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridemodelphotoevent" ("alt_text", "caption", "copyright_info", "created_at", "id", "image_id", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "photographer", "ride_model_id", "source", "updated_at") VALUES (NEW."alt_text", NEW."caption", NEW."copyright_info", NEW."created_at", NEW."id", NEW."image_id", NEW."is_primary", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_type", NEW."photographer", NEW."ride_model_id", NEW."source", NEW."updated_at"); RETURN NULL;',
hash="1ead1d3fd3dd553f585ae76aa6f3215314322ff4",
operation="UPDATE",
pgid="pgtrigger_update_update_3afcd",
table="rides_ridemodelphoto",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridephoto",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridephotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image_id", "is_approved", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "ride_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."created_at", NEW."date_taken", NEW."id", NEW."image_id", NEW."is_approved", NEW."is_primary", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_type", NEW."ride_id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="51487ac871d9d90c75f695f106e5f1f43fdb00c6",
operation="INSERT",
pgid="pgtrigger_insert_insert_0043a",
table="rides_ridephoto",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridephoto",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridephotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image_id", "is_approved", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "ride_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."created_at", NEW."date_taken", NEW."id", NEW."image_id", NEW."is_approved", NEW."is_primary", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_type", NEW."ride_id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="6147489f087c144f887386548cba269ffc193094",
operation="UPDATE",
pgid="pgtrigger_update_update_93a7e",
table="rides_ridephoto",
when="AFTER",
),
),
),
]

View File

@@ -1,72 +0,0 @@
# Generated by Django 5.2.5 on 2025-09-14 19:18
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0017_remove_ridemodelphoto_insert_insert_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="update_update",
),
migrations.AddField(
model_name="ride",
name="opening_year",
field=models.IntegerField(blank=True, db_index=True, null=True),
),
migrations.AddField(
model_name="ride",
name="search_text",
field=models.TextField(blank=True, db_index=True),
),
migrations.AddField(
model_name="rideevent",
name="opening_year",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="rideevent",
name="search_text",
field=models.TextField(blank=True),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "opening_year", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "search_text", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."opening_year", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."search_text", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="64e055c574495c0f09b3cbfb12442d4e4113e4f2",
operation="INSERT",
pgid="pgtrigger_insert_insert_52074",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "opening_year", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "search_text", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."opening_year", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."search_text", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="6476c8dd4bbb0e2ae42ca2daa5c691b87f9119e9",
operation="UPDATE",
pgid="pgtrigger_update_update_4917a",
table="rides_ride",
when="AFTER",
),
),
),
]

View File

@@ -1,124 +0,0 @@
"""
Populate computed fields for hybrid filtering in rides.
This migration populates the opening_year and search_text fields that were added
in the previous migration. These fields enable efficient hybrid filtering by
pre-computing commonly filtered and searched data.
"""
from django.db import migrations
import pghistory
def populate_computed_fields(apps, schema_editor):
"""Populate computed fields for all existing rides."""
Ride = apps.get_model('rides', 'Ride')
# Disable pghistory triggers during bulk operations to avoid performance issues
with pghistory.context(disable=True):
rides = list(Ride.objects.all().select_related(
'park', 'park__location', 'park_area', 'manufacturer', 'designer', 'ride_model'
))
for ride in rides:
# Extract opening year from opening_date
if ride.opening_date:
ride.opening_year = ride.opening_date.year
else:
ride.opening_year = None
# Build comprehensive search text
search_parts = []
# Basic ride info
if ride.name:
search_parts.append(ride.name)
if ride.description:
search_parts.append(ride.description)
# Park info
if ride.park:
search_parts.append(ride.park.name)
if hasattr(ride.park, 'location') and ride.park.location:
if ride.park.location.city:
search_parts.append(ride.park.location.city)
if ride.park.location.state:
search_parts.append(ride.park.location.state)
if ride.park.location.country:
search_parts.append(ride.park.location.country)
# Park area
if ride.park_area:
search_parts.append(ride.park_area.name)
# Category
if ride.category:
category_choices = [
("", "Select ride type"),
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
]
category_display = dict(category_choices).get(ride.category, '')
if category_display:
search_parts.append(category_display)
# Status
if ride.status:
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"),
]
status_display = dict(status_choices).get(ride.status, '')
if status_display:
search_parts.append(status_display)
# Companies
if ride.manufacturer:
search_parts.append(ride.manufacturer.name)
if ride.designer:
search_parts.append(ride.designer.name)
# Ride model
if ride.ride_model:
search_parts.append(ride.ride_model.name)
if ride.ride_model.manufacturer:
search_parts.append(ride.ride_model.manufacturer.name)
ride.search_text = ' '.join(filter(None, search_parts)).lower()
# Bulk update all rides
Ride.objects.bulk_update(rides, ['opening_year', 'search_text'], batch_size=1000)
def reverse_populate_computed_fields(apps, schema_editor):
"""Clear computed fields (reverse operation)."""
Ride = apps.get_model('rides', 'Ride')
# Disable pghistory triggers during bulk operations
with pghistory.context(disable=True):
Ride.objects.all().update(opening_year=None, search_text='')
class Migration(migrations.Migration):
dependencies = [
('rides', '0018_add_hybrid_filtering_fields'),
]
operations = [
migrations.RunPython(
populate_computed_fields,
reverse_populate_computed_fields,
elidable=True,
),
]

View File

@@ -1,181 +0,0 @@
"""
Add strategic database indexes for hybrid filtering in rides.
This migration creates optimized indexes for the hybrid filtering system,
enabling sub-second query performance across all filter combinations.
Index Strategy:
- Composite indexes for common filter combinations
- Partial indexes for status-based filtering
- Covering indexes to avoid table lookups
- GIN indexes for full-text search
- Individual indexes for range queries
Performance Target: <100ms for most filter combinations
"""
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('rides', '0019_populate_hybrid_filtering_fields'),
]
operations = [
# Composite index for park + category filtering (very common)
migrations.RunSQL(
"CREATE INDEX rides_ride_park_category_idx ON rides_ride (park_id, category) WHERE category != '';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_park_category_idx;"
),
# Composite index for park + status filtering (common)
migrations.RunSQL(
"CREATE INDEX rides_ride_park_status_idx ON rides_ride (park_id, status);",
reverse_sql="DROP INDEX IF EXISTS rides_ride_park_status_idx;"
),
# Composite index for category + status filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_category_status_idx ON rides_ride (category, status) WHERE category != '';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_category_status_idx;"
),
# Composite index for manufacturer + category
migrations.RunSQL(
"CREATE INDEX rides_ride_manufacturer_category_idx ON rides_ride (manufacturer_id, category) WHERE manufacturer_id IS NOT NULL AND category != '';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_manufacturer_category_idx;"
),
# Composite index for opening year + category (for timeline filtering)
migrations.RunSQL(
"CREATE INDEX rides_ride_opening_year_category_idx ON rides_ride (opening_year, category) WHERE opening_year IS NOT NULL AND category != '';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_opening_year_category_idx;"
),
# Partial index for operating rides only (most common filter)
migrations.RunSQL(
"CREATE INDEX rides_ride_operating_only_idx ON rides_ride (park_id, category, opening_year) WHERE status = 'OPERATING';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_operating_only_idx;"
),
# Partial index for roller coasters only (popular category)
migrations.RunSQL(
"CREATE INDEX rides_ride_roller_coasters_idx ON rides_ride (park_id, status, opening_year) WHERE category = 'RC';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_roller_coasters_idx;"
),
# Covering index for list views (includes commonly displayed fields)
migrations.RunSQL(
"CREATE INDEX rides_ride_list_covering_idx ON rides_ride (park_id, category, status) INCLUDE (name, opening_date, average_rating);",
reverse_sql="DROP INDEX IF EXISTS rides_ride_list_covering_idx;"
),
# GIN index for full-text search on computed search_text field
migrations.RunSQL(
"CREATE INDEX rides_ride_search_text_gin_idx ON rides_ride USING gin(to_tsvector('english', search_text));",
reverse_sql="DROP INDEX IF EXISTS rides_ride_search_text_gin_idx;"
),
# Trigram index for fuzzy text search
migrations.RunSQL(
"CREATE INDEX rides_ride_search_text_trgm_idx ON rides_ride USING gin(search_text gin_trgm_ops);",
reverse_sql="DROP INDEX IF EXISTS rides_ride_search_text_trgm_idx;"
),
# Index for rating-based filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_rating_idx ON rides_ride (average_rating) WHERE average_rating IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_rating_idx;"
),
# Index for capacity-based filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_capacity_idx ON rides_ride (capacity_per_hour) WHERE capacity_per_hour IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_capacity_idx;"
),
# Index for height requirement filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_height_req_idx ON rides_ride (min_height_in, max_height_in) WHERE min_height_in IS NOT NULL OR max_height_in IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_height_req_idx;"
),
# Composite index for ride model filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_model_manufacturer_idx ON rides_ride (ride_model_id, manufacturer_id) WHERE ride_model_id IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_model_manufacturer_idx;"
),
# Index for designer filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_designer_idx ON rides_ride (designer_id, category) WHERE designer_id IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_designer_idx;"
),
# Index for park area filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_park_area_idx ON rides_ride (park_area_id, status) WHERE park_area_id IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_park_area_idx;"
),
# Roller coaster stats indexes for performance
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_height_idx ON rides_rollercoasterstats (height_ft) WHERE height_ft IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_height_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_speed_idx ON rides_rollercoasterstats (speed_mph) WHERE speed_mph IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_speed_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_inversions_idx ON rides_rollercoasterstats (inversions);",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_inversions_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_type_material_idx ON rides_rollercoasterstats (roller_coaster_type, track_material);",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_type_material_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_launch_type_idx ON rides_rollercoasterstats (launch_type);",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_launch_type_idx;"
),
# Composite index for complex roller coaster filtering
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_complex_idx ON rides_rollercoasterstats (roller_coaster_type, track_material, launch_type) INCLUDE (height_ft, speed_mph, inversions);",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_complex_idx;"
),
# Index for ride model filtering and search
migrations.RunSQL(
"CREATE INDEX rides_ridemodel_manufacturer_category_idx ON rides_ridemodel (manufacturer_id, category) WHERE manufacturer_id IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ridemodel_manufacturer_category_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_ridemodel_name_trgm_idx ON rides_ridemodel USING gin(name gin_trgm_ops);",
reverse_sql="DROP INDEX IF EXISTS rides_ridemodel_name_trgm_idx;"
),
# Index for company role-based filtering
migrations.RunSQL(
"CREATE INDEX rides_company_manufacturer_role_idx ON rides_company USING gin(roles) WHERE 'MANUFACTURER' = ANY(roles);",
reverse_sql="DROP INDEX IF EXISTS rides_company_manufacturer_role_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_company_designer_role_idx ON rides_company USING gin(roles) WHERE 'DESIGNER' = ANY(roles);",
reverse_sql="DROP INDEX IF EXISTS rides_company_designer_role_idx;"
),
# Ensure trigram extension is available for fuzzy search
migrations.RunSQL(
"CREATE EXTENSION IF NOT EXISTS pg_trgm;",
reverse_sql="-- Cannot safely drop pg_trgm extension"
),
]

View File

@@ -1,405 +0,0 @@
# Generated by Django 5.2.5 on 2025-09-15 17:35
import apps.core.choices.fields
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0020_add_hybrid_filtering_indexes"),
]
operations = [
migrations.AlterField(
model_name="company",
name="roles",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("MANUFACTURER", "Ride Manufacturer"),
("DESIGNER", "Ride Designer"),
],
max_length=20,
),
blank=True,
default=list,
size=None,
),
),
migrations.AlterField(
model_name="companyevent",
name="roles",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("MANUFACTURER", "Ride Manufacturer"),
("DESIGNER", "Ride Designer"),
],
max_length=20,
),
blank=True,
default=list,
size=None,
),
),
migrations.AlterField(
model_name="ride",
name="category",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
blank=True,
choice_group="categories",
choices=[
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport Ride"),
("OT", "Other"),
],
default="",
domain="rides",
help_text="Ride category classification",
max_length=2,
),
),
migrations.AlterField(
model_name="ride",
name="post_closing_status",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
blank=True,
choice_group="post_closing_statuses",
choices=[
("SBNO", "Standing But Not Operating"),
("CLOSED_PERM", "Permanently Closed"),
],
domain="rides",
help_text="Status to change to after closing date",
max_length=20,
null=True,
),
),
migrations.AlterField(
model_name="ride",
name="status",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="statuses",
choices=[
("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",
domain="rides",
help_text="Current operational status of the ride",
max_length=20,
),
),
migrations.AlterField(
model_name="rideevent",
name="category",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
blank=True,
choice_group="categories",
choices=[
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport Ride"),
("OT", "Other"),
],
default="",
domain="rides",
help_text="Ride category classification",
max_length=2,
),
),
migrations.AlterField(
model_name="rideevent",
name="post_closing_status",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
blank=True,
choice_group="post_closing_statuses",
choices=[
("SBNO", "Standing But Not Operating"),
("CLOSED_PERM", "Permanently Closed"),
],
domain="rides",
help_text="Status to change to after closing date",
max_length=20,
null=True,
),
),
migrations.AlterField(
model_name="rideevent",
name="status",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="statuses",
choices=[
("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",
domain="rides",
help_text="Current operational status of the ride",
max_length=20,
),
),
migrations.AlterField(
model_name="ridemodel",
name="category",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
blank=True,
choice_group="categories",
choices=[
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport Ride"),
("OT", "Other"),
],
default="",
domain="rides",
help_text="Primary category classification",
max_length=2,
),
),
migrations.AlterField(
model_name="ridemodel",
name="target_market",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
blank=True,
choice_group="target_markets",
choices=[
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
domain="rides",
help_text="Primary target market for this ride model",
max_length=50,
),
),
migrations.AlterField(
model_name="ridemodelevent",
name="category",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
blank=True,
choice_group="categories",
choices=[
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport Ride"),
("OT", "Other"),
],
default="",
domain="rides",
help_text="Primary category classification",
max_length=2,
),
),
migrations.AlterField(
model_name="ridemodelevent",
name="target_market",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
blank=True,
choice_group="target_markets",
choices=[
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
domain="rides",
help_text="Primary target market for this ride model",
max_length=50,
),
),
migrations.AlterField(
model_name="ridephoto",
name="photo_type",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="photo_types",
choices=[
("exterior", "Exterior View"),
("queue", "Queue Area"),
("station", "Station"),
("onride", "On-Ride"),
("construction", "Construction"),
("other", "Other"),
],
default="exterior",
domain="rides",
help_text="Type of photo for categorization and display purposes",
max_length=50,
),
),
migrations.AlterField(
model_name="ridephotoevent",
name="photo_type",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="photo_types",
choices=[
("exterior", "Exterior View"),
("queue", "Queue Area"),
("station", "Station"),
("onride", "On-Ride"),
("construction", "Construction"),
("other", "Other"),
],
default="exterior",
domain="rides",
help_text="Type of photo for categorization and display purposes",
max_length=50,
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="launch_type",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="launch_systems",
choices=[
("CHAIN", "Chain Lift"),
("LSM", "LSM Launch"),
("HYDRAULIC", "Hydraulic Launch"),
("GRAVITY", "Gravity"),
("OTHER", "Other"),
],
default="CHAIN",
domain="rides",
help_text="Launch or lift system type",
max_length=20,
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="roller_coaster_type",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
blank=True,
choice_group="coaster_types",
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",
domain="rides",
help_text="Roller coaster type classification",
max_length=20,
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="track_material",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
blank=True,
choice_group="track_materials",
choices=[("STEEL", "Steel"), ("WOOD", "Wood"), ("HYBRID", "Hybrid")],
default="STEEL",
domain="rides",
help_text="Track construction material type",
max_length=20,
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="launch_type",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="launch_systems",
choices=[
("CHAIN", "Chain Lift"),
("LSM", "LSM Launch"),
("HYDRAULIC", "Hydraulic Launch"),
("GRAVITY", "Gravity"),
("OTHER", "Other"),
],
default="CHAIN",
domain="rides",
help_text="Launch or lift system type",
max_length=20,
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="roller_coaster_type",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
blank=True,
choice_group="coaster_types",
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",
domain="rides",
help_text="Roller coaster type classification",
max_length=20,
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="track_material",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
blank=True,
choice_group="track_materials",
choices=[("STEEL", "Steel"), ("WOOD", "Wood"), ("HYBRID", "Hybrid")],
default="STEEL",
domain="rides",
help_text="Track construction material type",
max_length=20,
),
),
]

View File

@@ -1,53 +0,0 @@
# Generated by Django 5.2.5 on 2025-09-15 18:07
import apps.core.choices.fields
import django.contrib.postgres.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("rides", "0021_alter_company_roles_alter_companyevent_roles_and_more"),
]
operations = [
migrations.AlterField(
model_name="company",
name="roles",
field=django.contrib.postgres.fields.ArrayField(
base_field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="company_roles",
choices=[
("MANUFACTURER", "Ride Manufacturer"),
("DESIGNER", "Ride Designer"),
],
domain="rides",
max_length=20,
),
blank=True,
default=list,
size=None,
),
),
migrations.AlterField(
model_name="companyevent",
name="roles",
field=django.contrib.postgres.fields.ArrayField(
base_field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="company_roles",
choices=[
("MANUFACTURER", "Ride Manufacturer"),
("DESIGNER", "Ride Designer"),
],
domain="rides",
max_length=20,
),
blank=True,
default=list,
size=None,
),
),
]

View File

@@ -1,94 +0,0 @@
# Generated by Django 5.2.5 on 2025-09-15 19:06
import apps.core.choices.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("rides", "0022_alter_company_roles_alter_companyevent_roles"),
]
operations = [
migrations.AlterField(
model_name="ridemodelphoto",
name="photo_type",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="photo_types",
choices=[
("PROMOTIONAL", "Promotional"),
("TECHNICAL", "Technical Drawing"),
("INSTALLATION", "Installation Example"),
("RENDERING", "3D Rendering"),
("CATALOG", "Catalog Image"),
],
default="PROMOTIONAL",
domain="rides",
help_text="Type of photo for categorization and display purposes",
max_length=20,
),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
name="photo_type",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="photo_types",
choices=[
("PROMOTIONAL", "Promotional"),
("TECHNICAL", "Technical Drawing"),
("INSTALLATION", "Installation Example"),
("RENDERING", "3D Rendering"),
("CATALOG", "Catalog Image"),
],
default="PROMOTIONAL",
domain="rides",
help_text="Type of photo for categorization and display purposes",
max_length=20,
),
),
migrations.AlterField(
model_name="ridemodeltechnicalspec",
name="spec_category",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="spec_categories",
choices=[
("DIMENSIONS", "Dimensions"),
("PERFORMANCE", "Performance"),
("CAPACITY", "Capacity"),
("SAFETY", "Safety Features"),
("ELECTRICAL", "Electrical Requirements"),
("FOUNDATION", "Foundation Requirements"),
("MAINTENANCE", "Maintenance"),
("OTHER", "Other"),
],
domain="rides",
help_text="Category of technical specification",
max_length=50,
),
),
migrations.AlterField(
model_name="ridemodeltechnicalspecevent",
name="spec_category",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="spec_categories",
choices=[
("DIMENSIONS", "Dimensions"),
("PERFORMANCE", "Performance"),
("CAPACITY", "Capacity"),
("SAFETY", "Safety Features"),
("ELECTRICAL", "Electrical Requirements"),
("FOUNDATION", "Foundation Requirements"),
("MAINTENANCE", "Maintenance"),
("OTHER", "Other"),
],
domain="rides",
help_text="Category of technical specification",
max_length=50,
),
),
]

View File

@@ -1,99 +0,0 @@
# Generated by Django 5.2.5 on 2025-09-17 01:25
import apps.core.choices.fields
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("rides", "0023_alter_ridemodelphoto_photo_type_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="rollercoasterstats",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="rollercoasterstats",
name="update_update",
),
migrations.RemoveField(
model_name="rollercoasterstats",
name="launch_type",
),
migrations.RemoveField(
model_name="rollercoasterstatsevent",
name="launch_type",
),
migrations.AddField(
model_name="rollercoasterstats",
name="propulsion_system",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="propulsion_systems",
choices=[
("CHAIN", "Chain Lift"),
("LSM", "LSM Launch"),
("HYDRAULIC", "Hydraulic Launch"),
("GRAVITY", "Gravity"),
("OTHER", "Other"),
],
default="CHAIN",
domain="rides",
help_text="Propulsion or lift system type",
max_length=20,
),
),
migrations.AddField(
model_name="rollercoasterstatsevent",
name="propulsion_system",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="propulsion_systems",
choices=[
("CHAIN", "Chain Lift"),
("LSM", "LSM Launch"),
("HYDRAULIC", "Hydraulic Launch"),
("GRAVITY", "Gravity"),
("OTHER", "Other"),
],
default="CHAIN",
domain="rides",
help_text="Propulsion or lift system type",
max_length=20,
),
),
pgtrigger.migrations.AddTrigger(
model_name="rollercoasterstats",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_rollercoasterstatsevent" ("cars_per_train", "height_ft", "id", "inversions", "length_ft", "max_drop_height_ft", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "propulsion_system", "ride_id", "ride_time_seconds", "roller_coaster_type", "seats_per_car", "speed_mph", "track_material", "track_type", "train_style", "trains_count") VALUES (NEW."cars_per_train", NEW."height_ft", NEW."id", NEW."inversions", NEW."length_ft", NEW."max_drop_height_ft", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."propulsion_system", NEW."ride_id", NEW."ride_time_seconds", NEW."roller_coaster_type", NEW."seats_per_car", NEW."speed_mph", NEW."track_material", NEW."track_type", NEW."train_style", NEW."trains_count"); RETURN NULL;',
hash="89e2bb56c0befa025a9961f8df34a8a02c09f188",
operation="INSERT",
pgid="pgtrigger_insert_insert_96f8b",
table="rides_rollercoasterstats",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="rollercoasterstats",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_rollercoasterstatsevent" ("cars_per_train", "height_ft", "id", "inversions", "length_ft", "max_drop_height_ft", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "propulsion_system", "ride_id", "ride_time_seconds", "roller_coaster_type", "seats_per_car", "speed_mph", "track_material", "track_type", "train_style", "trains_count") VALUES (NEW."cars_per_train", NEW."height_ft", NEW."id", NEW."inversions", NEW."length_ft", NEW."max_drop_height_ft", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."propulsion_system", NEW."ride_id", NEW."ride_time_seconds", NEW."roller_coaster_type", NEW."seats_per_car", NEW."speed_mph", NEW."track_material", NEW."track_type", NEW."train_style", NEW."trains_count"); RETURN NULL;',
hash="047cc99ae3282202b6dc43c8dbe07690076d5068",
operation="UPDATE",
pgid="pgtrigger_update_update_24e8a",
table="rides_rollercoasterstats",
when="AFTER",
),
),
),
]

View File

@@ -1,31 +0,0 @@
"""
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
from .company import Company
from .location import RideLocation
from .reviews import RideReview
from .rankings import RideRanking, RidePairComparison, RankingSnapshot
from .media import RidePhoto
__all__ = [
# Primary models
"Ride",
"RideModel",
"RollerCoasterStats",
"Company",
"RideLocation",
"RideReview",
"RidePhoto",
# Rankings
"RideRanking",
"RidePairComparison",
"RankingSnapshot",
]

View File

@@ -1,96 +0,0 @@
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 django.conf import settings
from apps.core.history import HistoricalSlug
from apps.core.models import TrackedModel
from apps.core.choices.fields import RichChoiceField
@pghistory.track()
class Company(TrackedModel):
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
roles = ArrayField(
RichChoiceField(choice_group="company_roles", domain="rides", max_length=20),
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)
# Frontend URL
url = models.URLField(blank=True, help_text="Frontend URL for this company")
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
# Generate frontend URL based on primary role
# CRITICAL: Only MANUFACTURER and DESIGNER are for rides domain
# OPERATOR and PROPERTY_OWNER are for parks domain and handled separately
if self.roles:
frontend_domain = getattr(
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
)
primary_role = self.roles[0] # Use first role as primary
if primary_role == "MANUFACTURER":
self.url = f"{frontend_domain}/rides/manufacturers/{self.slug}/"
elif primary_role == "DESIGNER":
self.url = f"{frontend_domain}/rides/designers/{self.slug}/"
# OPERATOR and PROPERTY_OWNER URLs are handled by parks domain, not here
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})
@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
try:
from django.apps import apps
history_model = apps.get_model('rides', f'{cls.__name__}Event')
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
except LookupError:
# History model doesn't exist, skip pghistory check
pass
# 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(TrackedModel.Meta):
app_label = "rides"
ordering = ["name"]
verbose_name_plural = "Companies"

View File

@@ -1,126 +0,0 @@
from django.contrib.gis.db import models as gis_models
from django.db import models
from django.contrib.gis.geos import Point
import pghistory
@pghistory.track()
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

@@ -1,139 +0,0 @@
"""
Ride-specific media models for ThrillWiki.
This module contains media models specific to rides domain.
"""
from typing import Any, Optional, List, cast
from django.db import models
from django.conf import settings
from apps.core.history import TrackedModel
from apps.core.choices import RichChoiceField
from apps.core.services.media_service import MediaService
import pghistory
def ride_photo_upload_path(instance: models.Model, filename: str) -> str:
"""Generate upload path for ride photos."""
photo = cast("RidePhoto", instance)
ride = photo.ride
if ride is None:
raise ValueError("Ride cannot be None")
return MediaService.generate_upload_path(
domain="park",
identifier=ride.slug,
filename=filename,
subdirectory=ride.park.slug,
)
@pghistory.track()
class RidePhoto(TrackedModel):
"""Photo model specific to rides."""
ride = models.ForeignKey(
"rides.Ride", on_delete=models.CASCADE, related_name="photos"
)
image = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.CASCADE,
help_text="Ride photo stored on Cloudflare Images"
)
caption = models.CharField(max_length=255, blank=True)
alt_text = models.CharField(max_length=255, blank=True)
is_primary = models.BooleanField(default=False)
is_approved = models.BooleanField(default=False)
# Ride-specific metadata
photo_type = RichChoiceField(
choice_group="photo_types",
domain="rides",
max_length=50,
default="exterior",
help_text="Type of photo for categorization and display purposes"
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
date_taken = models.DateTimeField(null=True, blank=True)
# User who uploaded the photo
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name="uploaded_ride_photos",
)
class Meta(TrackedModel.Meta):
app_label = "rides"
ordering = ["-is_primary", "-created_at"]
indexes = [
models.Index(fields=["ride", "is_primary"]),
models.Index(fields=["ride", "is_approved"]),
models.Index(fields=["ride", "photo_type"]),
models.Index(fields=["created_at"]),
]
constraints = [
# Only one primary photo per ride
models.UniqueConstraint(
fields=["ride"],
condition=models.Q(is_primary=True),
name="unique_primary_ride_photo",
)
]
def __str__(self) -> str:
return f"Photo of {self.ride.name} - {self.caption or 'No caption'}"
def save(self, *args: Any, **kwargs: Any) -> None:
# Extract EXIF date if this is a new photo
if not self.pk and not self.date_taken and self.image:
self.date_taken = MediaService.extract_exif_date(self.image)
# Set default caption if not provided
if not self.caption and self.uploaded_by:
self.caption = MediaService.generate_default_caption(
self.uploaded_by.username
)
# If this is marked as primary, unmark other primary photos for this ride
if self.is_primary:
RidePhoto.objects.filter(
ride=self.ride,
is_primary=True,
).exclude(
pk=self.pk
).update(is_primary=False)
super().save(*args, **kwargs)
@property
def file_size(self) -> Optional[int]:
"""Get file size in bytes."""
try:
return self.image.size
except (ValueError, OSError):
return None
@property
def dimensions(self) -> Optional[List[int]]:
"""Get image dimensions as [width, height]."""
try:
return [self.image.width, self.image.height]
except (ValueError, OSError):
return None
def get_absolute_url(self) -> str:
"""Get absolute URL for this photo."""
return f"/parks/{self.ride.park.slug}/rides/{self.ride.slug}/photos/{self.pk}/"
@property
def park(self):
"""Get the park this ride belongs to."""
return self.ride.park

View File

@@ -1,212 +0,0 @@
"""
Models for ride ranking system using Internet Roller Coaster Poll algorithm.
This system calculates rankings based on pairwise comparisons between rides,
where each ride is compared to every other ride to determine which one
more riders preferred.
"""
from django.db import models
from django.utils import timezone
from django.core.validators import MinValueValidator, MaxValueValidator
import pghistory
@pghistory.track()
class RideRanking(models.Model):
"""
Stores calculated rankings for rides using the Internet Roller Coaster Poll algorithm.
Rankings are recalculated daily based on user reviews/ratings.
Each ride's rank is determined by its winning percentage in pairwise comparisons.
"""
ride = models.OneToOneField(
"rides.Ride", on_delete=models.CASCADE, related_name="ranking"
)
# Core ranking metrics
rank = models.PositiveIntegerField(
db_index=True, help_text="Overall rank position (1 = best)"
)
wins = models.PositiveIntegerField(
default=0, help_text="Number of rides this ride beats in pairwise comparisons"
)
losses = models.PositiveIntegerField(
default=0,
help_text="Number of rides that beat this ride in pairwise comparisons",
)
ties = models.PositiveIntegerField(
default=0,
help_text="Number of rides with equal preference in pairwise comparisons",
)
winning_percentage = models.DecimalField(
max_digits=5,
decimal_places=4,
validators=[MinValueValidator(0), MaxValueValidator(1)],
db_index=True,
help_text="Win percentage where ties count as 0.5",
)
# Additional metrics
mutual_riders_count = models.PositiveIntegerField(
default=0, help_text="Total number of users who have rated this ride"
)
comparison_count = models.PositiveIntegerField(
default=0, help_text="Number of other rides this was compared against"
)
average_rating = models.DecimalField(
max_digits=3,
decimal_places=2,
null=True,
blank=True,
validators=[MinValueValidator(1), MaxValueValidator(10)],
help_text="Average rating from all users who have rated this ride",
)
# Metadata
last_calculated = models.DateTimeField(
default=timezone.now, help_text="When this ranking was last calculated"
)
calculation_version = models.CharField(
max_length=10, default="1.0", help_text="Algorithm version used for calculation"
)
class Meta:
ordering = ["rank"]
indexes = [
models.Index(fields=["rank"]),
models.Index(fields=["winning_percentage", "-mutual_riders_count"]),
models.Index(fields=["ride", "last_calculated"]),
]
constraints = [
models.CheckConstraint(
name="rideranking_winning_percentage_range",
check=models.Q(winning_percentage__gte=0)
& models.Q(winning_percentage__lte=1),
violation_error_message="Winning percentage must be between 0 and 1",
),
models.CheckConstraint(
name="rideranking_average_rating_range",
check=models.Q(average_rating__isnull=True)
| (models.Q(average_rating__gte=1) & models.Q(average_rating__lte=10)),
violation_error_message="Average rating must be between 1 and 10",
),
]
def __str__(self):
return f"#{self.rank} - {self.ride.name} ({self.winning_percentage:.1%})"
@property
def total_comparisons(self):
"""Total number of pairwise comparisons (wins + losses + ties)."""
return self.wins + self.losses + self.ties
@pghistory.track()
class RidePairComparison(models.Model):
"""
Caches pairwise comparison results between two rides.
This model stores the results of comparing two rides based on mutual riders
(users who have rated both rides). It's used to speed up ranking calculations.
"""
ride_a = models.ForeignKey(
"rides.Ride", on_delete=models.CASCADE, related_name="comparisons_as_a"
)
ride_b = models.ForeignKey(
"rides.Ride", on_delete=models.CASCADE, related_name="comparisons_as_b"
)
# Comparison results
ride_a_wins = models.PositiveIntegerField(
default=0, help_text="Number of mutual riders who rated ride_a higher"
)
ride_b_wins = models.PositiveIntegerField(
default=0, help_text="Number of mutual riders who rated ride_b higher"
)
ties = models.PositiveIntegerField(
default=0, help_text="Number of mutual riders who rated both rides equally"
)
# Metrics
mutual_riders_count = models.PositiveIntegerField(
default=0, help_text="Total number of users who have rated both rides"
)
ride_a_avg_rating = models.DecimalField(
max_digits=3,
decimal_places=2,
null=True,
blank=True,
help_text="Average rating of ride_a from mutual riders",
)
ride_b_avg_rating = models.DecimalField(
max_digits=3,
decimal_places=2,
null=True,
blank=True,
help_text="Average rating of ride_b from mutual riders",
)
# Metadata
last_calculated = models.DateTimeField(
auto_now=True, help_text="When this comparison was last calculated"
)
class Meta:
unique_together = [["ride_a", "ride_b"]]
indexes = [
models.Index(fields=["ride_a", "ride_b"]),
models.Index(fields=["last_calculated"]),
]
def __str__(self):
winner = "TIE"
if self.ride_a_wins > self.ride_b_wins:
winner = self.ride_a.name
elif self.ride_b_wins > self.ride_a_wins:
winner = self.ride_b.name
return f"{self.ride_a.name} vs {self.ride_b.name} - Winner: {winner}"
@property
def winner(self):
"""Returns the winning ride or None for a tie."""
if self.ride_a_wins > self.ride_b_wins:
return self.ride_a
elif self.ride_b_wins > self.ride_a_wins:
return self.ride_b
return None
@property
def is_tie(self):
"""Returns True if the comparison resulted in a tie."""
return self.ride_a_wins == self.ride_b_wins
class RankingSnapshot(models.Model):
"""
Stores historical snapshots of rankings for tracking changes over time.
This allows us to show ranking trends and movements.
"""
ride = models.ForeignKey(
"rides.Ride", on_delete=models.CASCADE, related_name="ranking_history"
)
rank = models.PositiveIntegerField()
winning_percentage = models.DecimalField(max_digits=5, decimal_places=4)
snapshot_date = models.DateField(
db_index=True, help_text="Date when this ranking snapshot was taken"
)
class Meta:
unique_together = [["ride", "snapshot_date"]]
ordering = ["-snapshot_date", "rank"]
indexes = [
models.Index(fields=["snapshot_date", "rank"]),
models.Index(fields=["ride", "-snapshot_date"]),
]
def __str__(self):
return f"{self.ride.name} - Rank #{self.rank} on {self.snapshot_date}"

View File

@@ -1,73 +0,0 @@
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(TrackedModel.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

@@ -1,898 +0,0 @@
from django.db import models
from django.utils.text import slugify
from config.django import base as settings
from apps.core.models import TrackedModel
from apps.core.choices import RichChoiceField
from .company import Company
import pghistory
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .rides import RollerCoasterStats
@pghistory.track()
class RideModel(TrackedModel):
"""
Represents a specific model/type of ride that can be manufactured by different
companies. This serves as a catalog of ride designs that can be referenced
by individual ride installations.
For example: B&M Dive Coaster, Vekoma Boomerang, RMC I-Box, etc.
"""
name = models.CharField(max_length=255, help_text="Name of the ride model")
slug = models.SlugField(
max_length=255, help_text="URL-friendly identifier (unique within manufacturer)"
)
manufacturer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
related_name="ride_models",
null=True,
blank=True,
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
help_text="Primary manufacturer of this ride model",
)
description = models.TextField(
blank=True, help_text="Detailed description of the ride model"
)
category = RichChoiceField(
choice_group="categories",
domain="rides",
max_length=2,
default="",
blank=True,
help_text="Primary category classification",
)
# Technical specifications
typical_height_range_min_ft = models.DecimalField(
max_digits=6,
decimal_places=2,
null=True,
blank=True,
help_text="Minimum typical height in feet for this model",
)
typical_height_range_max_ft = models.DecimalField(
max_digits=6,
decimal_places=2,
null=True,
blank=True,
help_text="Maximum typical height in feet for this model",
)
typical_speed_range_min_mph = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text="Minimum typical speed in mph for this model",
)
typical_speed_range_max_mph = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text="Maximum typical speed in mph for this model",
)
typical_capacity_range_min = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Minimum typical hourly capacity for this model",
)
typical_capacity_range_max = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Maximum typical hourly capacity for this model",
)
# Design characteristics
track_type = models.CharField(
max_length=100,
blank=True,
help_text="Type of track system (e.g., tubular steel, I-Box, wooden)",
)
support_structure = models.CharField(
max_length=100,
blank=True,
help_text="Type of support structure (e.g., steel, wooden, hybrid)",
)
train_configuration = models.CharField(
max_length=200,
blank=True,
help_text="Typical train configuration (e.g., 2 trains, 7 cars per train, 4 seats per car)",
)
restraint_system = models.CharField(
max_length=100,
blank=True,
help_text="Type of restraint system (e.g., over-shoulder, lap bar, vest)",
)
# Market information
first_installation_year = models.PositiveIntegerField(
null=True, blank=True, help_text="Year of first installation of this model"
)
last_installation_year = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Year of last installation of this model (if discontinued)",
)
is_discontinued = models.BooleanField(
default=False, help_text="Whether this model is no longer being manufactured"
)
total_installations = models.PositiveIntegerField(
default=0, help_text="Total number of installations worldwide (auto-calculated)"
)
# Design features
notable_features = models.TextField(
blank=True,
help_text="Notable design features or innovations (JSON or comma-separated)",
)
target_market = RichChoiceField(
choice_group="target_markets",
domain="rides",
max_length=50,
blank=True,
help_text="Primary target market for this ride model",
)
# Media
primary_image = models.ForeignKey(
"RideModelPhoto",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="ride_models_as_primary",
help_text="Primary promotional image for this ride model",
)
# SEO and metadata
meta_title = models.CharField(
max_length=60, blank=True, help_text="SEO meta title (auto-generated if blank)"
)
meta_description = models.CharField(
max_length=160,
blank=True,
help_text="SEO meta description (auto-generated if blank)",
)
# Frontend URL
url = models.URLField(blank=True, help_text="Frontend URL for this ride model")
class Meta(TrackedModel.Meta):
ordering = ["manufacturer__name", "name"]
unique_together = [["manufacturer", "name"], ["manufacturer", "slug"]]
constraints = [
# Height range validation
models.CheckConstraint(
name="ride_model_height_range_logical",
condition=models.Q(typical_height_range_min_ft__isnull=True)
| models.Q(typical_height_range_max_ft__isnull=True)
| models.Q(
typical_height_range_min_ft__lte=models.F(
"typical_height_range_max_ft"
)
),
violation_error_message="Minimum height cannot exceed maximum height",
),
# Speed range validation
models.CheckConstraint(
name="ride_model_speed_range_logical",
condition=models.Q(typical_speed_range_min_mph__isnull=True)
| models.Q(typical_speed_range_max_mph__isnull=True)
| models.Q(
typical_speed_range_min_mph__lte=models.F(
"typical_speed_range_max_mph"
)
),
violation_error_message="Minimum speed cannot exceed maximum speed",
),
# Capacity range validation
models.CheckConstraint(
name="ride_model_capacity_range_logical",
condition=models.Q(typical_capacity_range_min__isnull=True)
| models.Q(typical_capacity_range_max__isnull=True)
| models.Q(
typical_capacity_range_min__lte=models.F(
"typical_capacity_range_max"
)
),
violation_error_message="Minimum capacity cannot exceed maximum capacity",
),
# Installation years validation
models.CheckConstraint(
name="ride_model_installation_years_logical",
condition=models.Q(first_installation_year__isnull=True)
| models.Q(last_installation_year__isnull=True)
| models.Q(
first_installation_year__lte=models.F("last_installation_year")
),
violation_error_message="First installation year cannot be after last installation year",
),
]
def __str__(self) -> str:
return (
self.name
if not self.manufacturer
else f"{self.manufacturer.name} {self.name}"
)
def save(self, *args, **kwargs) -> None:
if not self.slug:
from django.utils.text import slugify
# Only use the ride model name for the slug, not manufacturer
base_slug = slugify(self.name)
self.slug = base_slug
# Ensure uniqueness within the same manufacturer
counter = 1
while (
RideModel.objects.filter(manufacturer=self.manufacturer, slug=self.slug)
.exclude(pk=self.pk)
.exists()
):
self.slug = f"{base_slug}-{counter}"
counter += 1
# Auto-generate meta fields if blank
if not self.meta_title:
self.meta_title = str(self)[:60]
if not self.meta_description:
desc = (
f"{self} - {self.description[:100]}" if self.description else str(self)
)
self.meta_description = desc[:160]
# Generate frontend URL
if self.manufacturer:
frontend_domain = getattr(
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
)
self.url = f"{frontend_domain}/rides/manufacturers/{self.manufacturer.slug}/{self.slug}/"
super().save(*args, **kwargs)
def update_installation_count(self) -> None:
"""Update the total installations count based on actual ride instances."""
# Import here to avoid circular import
from django.apps import apps
Ride = apps.get_model("rides", "Ride")
self.total_installations = Ride.objects.filter(ride_model=self).count()
self.save(update_fields=["total_installations"])
@property
def installation_years_range(self) -> str:
"""Get a formatted string of installation years range."""
if self.first_installation_year and self.last_installation_year:
return f"{self.first_installation_year}-{self.last_installation_year}"
elif self.first_installation_year:
return (
f"{self.first_installation_year}-present"
if not self.is_discontinued
else f"{self.first_installation_year}+"
)
return "Unknown"
@property
def height_range_display(self) -> str:
"""Get a formatted string of height range."""
if self.typical_height_range_min_ft and self.typical_height_range_max_ft:
return f"{self.typical_height_range_min_ft}-{self.typical_height_range_max_ft} ft"
elif self.typical_height_range_min_ft:
return f"{self.typical_height_range_min_ft}+ ft"
elif self.typical_height_range_max_ft:
return f"Up to {self.typical_height_range_max_ft} ft"
return "Variable"
@property
def speed_range_display(self) -> str:
"""Get a formatted string of speed range."""
if self.typical_speed_range_min_mph and self.typical_speed_range_max_mph:
return f"{self.typical_speed_range_min_mph}-{self.typical_speed_range_max_mph} mph"
elif self.typical_speed_range_min_mph:
return f"{self.typical_speed_range_min_mph}+ mph"
elif self.typical_speed_range_max_mph:
return f"Up to {self.typical_speed_range_max_mph} mph"
return "Variable"
@pghistory.track()
class RideModelVariant(TrackedModel):
"""
Represents specific variants or configurations of a ride model.
For example: B&M Hyper Coaster might have variants like "Mega Coaster", "Giga Coaster"
"""
ride_model = models.ForeignKey(
RideModel, on_delete=models.CASCADE, related_name="variants"
)
name = models.CharField(max_length=255, help_text="Name of this variant")
description = models.TextField(
blank=True, help_text="Description of variant differences"
)
# Variant-specific specifications
min_height_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True
)
max_height_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True
)
min_speed_mph = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
max_speed_mph = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
# Distinguishing features
distinguishing_features = models.TextField(
blank=True, help_text="What makes this variant unique from the base model"
)
class Meta(TrackedModel.Meta):
ordering = ["ride_model", "name"]
unique_together = ["ride_model", "name"]
def __str__(self) -> str:
return f"{self.ride_model} - {self.name}"
@pghistory.track()
class RideModelPhoto(TrackedModel):
"""Photos associated with ride models for catalog/promotional purposes."""
ride_model = models.ForeignKey(
RideModel, on_delete=models.CASCADE, related_name="photos"
)
image = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.CASCADE,
help_text="Photo of the ride model stored on Cloudflare Images"
)
caption = models.CharField(max_length=500, blank=True)
alt_text = models.CharField(max_length=255, blank=True)
# Photo metadata
photo_type = RichChoiceField(
choice_group="photo_types",
domain="rides",
max_length=20,
default="PROMOTIONAL",
help_text="Type of photo for categorization and display purposes",
)
is_primary = models.BooleanField(
default=False, help_text="Whether this is the primary photo for the ride model"
)
# Attribution
photographer = models.CharField(max_length=255, blank=True)
source = models.CharField(max_length=255, blank=True)
copyright_info = models.CharField(max_length=255, blank=True)
class Meta(TrackedModel.Meta):
ordering = ["-is_primary", "-created_at"]
def __str__(self) -> str:
return f"Photo of {self.ride_model.name}"
def save(self, *args, **kwargs) -> None:
# Ensure only one primary photo per ride model
if self.is_primary:
RideModelPhoto.objects.filter(
ride_model=self.ride_model, is_primary=True
).exclude(pk=self.pk).update(is_primary=False)
super().save(*args, **kwargs)
@pghistory.track()
class RideModelTechnicalSpec(TrackedModel):
"""
Technical specifications for ride models that don't fit in the main model.
This allows for flexible specification storage.
"""
ride_model = models.ForeignKey(
RideModel, on_delete=models.CASCADE, related_name="technical_specs"
)
spec_category = RichChoiceField(
choice_group="spec_categories",
domain="rides",
max_length=50,
help_text="Category of technical specification",
)
spec_name = models.CharField(max_length=100, help_text="Name of the specification")
spec_value = models.CharField(
max_length=255, help_text="Value of the specification"
)
spec_unit = models.CharField(
max_length=20, blank=True, help_text="Unit of measurement"
)
notes = models.TextField(
blank=True, help_text="Additional notes about this specification"
)
class Meta(TrackedModel.Meta):
ordering = ["spec_category", "spec_name"]
unique_together = ["ride_model", "spec_category", "spec_name"]
def __str__(self) -> str:
unit_str = f" {self.spec_unit}" if self.spec_unit else ""
return f"{self.ride_model.name} - {self.spec_name}: {self.spec_value}{unit_str}"
@pghistory.track()
class Ride(TrackedModel):
"""Model for individual ride installations at parks
Note: The average_rating field is denormalized and refreshed by background
jobs. Use selectors or annotations for real-time calculations if needed.
"""
if TYPE_CHECKING:
coaster_stats: 'RollerCoasterStats'
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 = RichChoiceField(
choice_group="categories",
domain="rides",
max_length=2,
default="",
blank=True,
help_text="Ride category classification"
)
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 = RichChoiceField(
choice_group="statuses",
domain="rides",
max_length=20,
default="OPERATING",
help_text="Current operational status of the ride"
)
post_closing_status = RichChoiceField(
choice_group="post_closing_statuses",
domain="rides",
max_length=20,
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
)
# Computed fields for hybrid filtering
opening_year = models.IntegerField(null=True, blank=True, db_index=True)
search_text = models.TextField(blank=True, db_index=True)
# Image settings - references to existing photos
banner_image = models.ForeignKey(
"RidePhoto",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="rides_using_as_banner",
help_text="Photo to use as banner image for this ride",
)
card_image = models.ForeignKey(
"RidePhoto",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="rides_using_as_card",
help_text="Photo to use as card image for this ride",
)
# Frontend URL
url = models.URLField(blank=True, help_text="Frontend URL for this ride")
park_url = models.URLField(
blank=True, help_text="Frontend URL for this ride's park"
)
class Meta(TrackedModel.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:
# Handle slug generation and conflicts
if not self.slug:
self.slug = slugify(self.name)
# Check for slug conflicts when park changes or slug is new
original_ride = None
if self.pk:
try:
original_ride = Ride.objects.get(pk=self.pk)
except Ride.DoesNotExist:
pass
# If park changed or this is a new ride, ensure slug uniqueness within the park
park_changed = original_ride and original_ride.park.id != self.park.id
if not self.pk or park_changed:
self._ensure_unique_slug_in_park()
# Handle park area validation when park changes
if park_changed and self.park_area:
# Check if park_area belongs to the new park
if self.park_area.park.id != self.park.id:
# Clear park_area if it doesn't belong to the new park
self.park_area = None
# Sync manufacturer with ride model's manufacturer
if self.ride_model and self.ride_model.manufacturer:
self.manufacturer = self.ride_model.manufacturer
elif self.ride_model and not self.ride_model.manufacturer:
# If ride model has no manufacturer, clear the ride's manufacturer
# to maintain consistency
self.manufacturer = None
# Generate frontend URLs
if self.park:
frontend_domain = getattr(
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
)
self.url = f"{frontend_domain}/parks/{self.park.slug}/rides/{self.slug}/"
self.park_url = f"{frontend_domain}/parks/{self.park.slug}/"
# Populate computed fields
self._populate_computed_fields()
super().save(*args, **kwargs)
def _populate_computed_fields(self) -> None:
"""Populate computed fields for hybrid filtering."""
# Extract opening year from opening_date
if self.opening_date:
self.opening_year = self.opening_date.year
else:
self.opening_year = None
# Build comprehensive search text
search_parts = []
# Basic ride info
if self.name:
search_parts.append(self.name)
if self.description:
search_parts.append(self.description)
# Park info
if self.park:
search_parts.append(self.park.name)
if hasattr(self.park, 'location') and self.park.location:
if self.park.location.city:
search_parts.append(self.park.location.city)
if self.park.location.state:
search_parts.append(self.park.location.state)
if self.park.location.country:
search_parts.append(self.park.location.country)
# Park area
if self.park_area:
search_parts.append(self.park_area.name)
# Category
if self.category:
category_choice = self.get_category_rich_choice()
if category_choice:
search_parts.append(category_choice.label)
# Status
if self.status:
status_choice = self.get_status_rich_choice()
if status_choice:
search_parts.append(status_choice.label)
# Companies
if self.manufacturer:
search_parts.append(self.manufacturer.name)
if self.designer:
search_parts.append(self.designer.name)
# Ride model
if self.ride_model:
search_parts.append(self.ride_model.name)
if self.ride_model.manufacturer:
search_parts.append(self.ride_model.manufacturer.name)
# Roller coaster stats if available
try:
if hasattr(self, 'coaster_stats') and self.coaster_stats:
stats = self.coaster_stats
if stats.track_type:
search_parts.append(stats.track_type)
if stats.track_material:
material_choice = stats.get_track_material_rich_choice()
if material_choice:
search_parts.append(material_choice.label)
if stats.roller_coaster_type:
type_choice = stats.get_roller_coaster_type_rich_choice()
if type_choice:
search_parts.append(type_choice.label)
if stats.propulsion_system:
propulsion_choice = stats.get_propulsion_system_rich_choice()
if propulsion_choice:
search_parts.append(propulsion_choice.label)
if stats.train_style:
search_parts.append(stats.train_style)
except Exception:
# Ignore if coaster_stats doesn't exist or has issues
pass
self.search_text = ' '.join(filter(None, search_parts)).lower()
def _ensure_unique_slug_in_park(self) -> None:
"""Ensure the ride's slug is unique within its park."""
base_slug = slugify(self.name)
self.slug = base_slug
counter = 1
while (
Ride.objects.filter(park=self.park, slug=self.slug)
.exclude(pk=self.pk)
.exists()
):
self.slug = f"{base_slug}-{counter}"
counter += 1
def move_to_park(self, new_park, clear_park_area=True):
"""
Move this ride to a different park with proper handling of related data.
Args:
new_park: The new Park instance to move the ride to
clear_park_area: Whether to clear park_area (default True, since areas are park-specific)
Returns:
dict: Summary of changes made
"""
old_park = self.park
old_url = self.url
old_park_area = self.park_area
# Update park
self.park = new_park
# Handle park area
if clear_park_area:
self.park_area = None
# Save will handle slug conflicts and URL updates
self.save()
# Return summary of changes
changes = {
'old_park': {
'id': old_park.id,
'name': old_park.name,
'slug': old_park.slug
},
'new_park': {
'id': new_park.id,
'name': new_park.name,
'slug': new_park.slug
},
'url_changed': old_url != self.url,
'old_url': old_url,
'new_url': self.url,
'park_area_cleared': clear_park_area and old_park_area is not None,
'old_park_area': {
'id': old_park_area.id,
'name': old_park_area.name
} if old_park_area else None,
'slug_changed': self.slug != slugify(self.name),
'final_slug': self.slug
}
return changes
@classmethod
def get_by_slug(cls, slug: str, park=None) -> tuple["Ride", bool]:
"""Get ride by current or historical slug, optionally within a specific park"""
from django.contrib.contenttypes.models import ContentType
from apps.core.history import HistoricalSlug
# Build base query
base_query = cls.objects
if park:
base_query = base_query.filter(park=park)
try:
ride = base_query.get(slug=slug)
return ride, False
except cls.DoesNotExist:
# Try historical slugs in HistoricalSlug model
content_type = ContentType.objects.get_for_model(cls)
historical_query = HistoricalSlug.objects.filter(
content_type=content_type, slug=slug
).order_by("-created_at")
for historical in historical_query:
try:
ride = base_query.get(pk=historical.object_id)
return ride, True
except cls.DoesNotExist:
continue
# Try pghistory events
event_model = getattr(cls, "event_model", None)
if event_model:
historical_events = event_model.objects.filter(slug=slug).order_by("-pgh_created_at")
for historical_event in historical_events:
try:
ride = base_query.get(pk=historical_event.pgh_obj_id)
return ride, True
except cls.DoesNotExist:
continue
raise cls.DoesNotExist("No ride found with this slug")
@pghistory.track()
class RollerCoasterStats(models.Model):
"""Model for tracking roller coaster specific statistics"""
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 = RichChoiceField(
choice_group="track_materials",
domain="rides",
max_length=20,
default="STEEL",
blank=True,
help_text="Track construction material type"
)
roller_coaster_type = RichChoiceField(
choice_group="coaster_types",
domain="rides",
max_length=20,
default="SITDOWN",
blank=True,
help_text="Roller coaster type classification"
)
max_drop_height_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True
)
propulsion_system = RichChoiceField(
choice_group="propulsion_systems",
domain="rides",
max_length=20,
default="CHAIN",
help_text="Propulsion or lift system type"
)
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

@@ -1,22 +0,0 @@
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

@@ -1,302 +0,0 @@
"""
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
from .choices import RIDE_CATEGORIES
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")
.annotate(average_rating_calculated=Avg("reviews__rating"))
)
if filters:
if "status" in filters and filters["status"]:
queryset = queryset.filter(status__in=filters["status"])
if "category" in filters and filters["category"]:
queryset = queryset.filter(category__in=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",
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()
.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
"""
stats = {}
for category in RIDE_CATEGORIES:
if category.value: # Skip empty choice
count = Ride.objects.filter(category=category.value).count()
stats[category.value] = {"name": category.label, "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

@@ -1,4 +0,0 @@
from .location_service import RideLocationService
from .media_service import RideMediaService
__all__ = ["RideLocationService", "RideMediaService"]

View File

@@ -1,784 +0,0 @@
"""
Smart Ride Loader for Hybrid Filtering Strategy
This service implements intelligent data loading for rides, automatically choosing
between client-side and server-side filtering based on data size and complexity.
Key Features:
- Automatic strategy selection (≤200 records = client-side, >200 = server-side)
- Progressive loading for large datasets
- Intelligent caching with automatic invalidation
- Comprehensive filter metadata generation
- Optimized database queries with strategic prefetching
Architecture:
- Client-side: Load all data once, filter in frontend
- Server-side: Apply filters in database, paginate results
- Hybrid: Combine both approaches based on data characteristics
"""
from typing import Dict, List, Any, Optional
from django.core.cache import cache
from django.db import models
from django.db.models import Q, Min, Max
import logging
logger = logging.getLogger(__name__)
class SmartRideLoader:
"""
Intelligent ride data loader that chooses optimal filtering strategy.
Strategy Selection:
- ≤200 total records: Client-side filtering (load all data)
- >200 total records: Server-side filtering (database filtering + pagination)
Features:
- Progressive loading for large datasets
- 5-minute intelligent caching
- Comprehensive filter metadata
- Optimized queries with prefetch_related
"""
# Configuration constants
INITIAL_LOAD_SIZE = 50
PROGRESSIVE_LOAD_SIZE = 25
MAX_CLIENT_SIDE_RECORDS = 200
CACHE_TIMEOUT = 300 # 5 minutes
def __init__(self):
self.cache_prefix = "rides_hybrid_"
def get_initial_load(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Get initial data load with automatic strategy selection.
Args:
filters: Optional filter parameters
Returns:
Dict containing:
- strategy: 'client_side' or 'server_side'
- data: List of ride records
- total_count: Total number of records
- has_more: Whether more data is available
- filter_metadata: Available filter options
"""
# Get total count for strategy decision
total_count = self._get_total_count(filters)
# Choose strategy based on total count
if total_count <= self.MAX_CLIENT_SIDE_RECORDS:
return self._get_client_side_data(filters, total_count)
else:
return self._get_server_side_data(filters, total_count)
def get_progressive_load(self, offset: int, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Get additional data for progressive loading (server-side strategy only).
Args:
offset: Number of records to skip
filters: Filter parameters
Returns:
Dict containing additional ride records
"""
# Build queryset with filters
queryset = self._build_filtered_queryset(filters)
# Get total count for this filtered set
total_count = queryset.count()
# Get progressive batch
rides = list(queryset[offset:offset + self.PROGRESSIVE_LOAD_SIZE])
return {
'rides': self._serialize_rides(rides),
'total_count': total_count,
'has_more': len(rides) == self.PROGRESSIVE_LOAD_SIZE,
'next_offset': offset + len(rides) if len(rides) == self.PROGRESSIVE_LOAD_SIZE else None
}
def get_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Get comprehensive filter metadata for dynamic filter generation.
Args:
filters: Optional filters to scope the metadata
Returns:
Dict containing all available filter options and ranges
"""
cache_key = f"{self.cache_prefix}filter_metadata_{hash(str(filters))}"
metadata = cache.get(cache_key)
if metadata is None:
metadata = self._generate_filter_metadata(filters)
cache.set(cache_key, metadata, self.CACHE_TIMEOUT)
return metadata
def invalidate_cache(self) -> None:
"""Invalidate all cached data for rides."""
# Note: In production, you might want to use cache versioning
# or more sophisticated cache invalidation
cache_keys = [
f"{self.cache_prefix}client_side_all",
f"{self.cache_prefix}filter_metadata",
f"{self.cache_prefix}total_count",
]
for key in cache_keys:
cache.delete(key)
def _get_total_count(self, filters: Optional[Dict[str, Any]] = None) -> int:
"""Get total count of rides matching filters."""
cache_key = f"{self.cache_prefix}total_count_{hash(str(filters))}"
count = cache.get(cache_key)
if count is None:
queryset = self._build_filtered_queryset(filters)
count = queryset.count()
cache.set(cache_key, count, self.CACHE_TIMEOUT)
return count
def _get_client_side_data(self, filters: Optional[Dict[str, Any]],
total_count: int) -> Dict[str, Any]:
"""Get all data for client-side filtering."""
cache_key = f"{self.cache_prefix}client_side_all"
cached_data = cache.get(cache_key)
if cached_data is None:
from apps.rides.models import Ride
# Load all rides with optimized query
queryset = Ride.objects.select_related(
'park',
'park__location',
'park_area',
'manufacturer',
'designer',
'ride_model',
'ride_model__manufacturer'
).prefetch_related(
'coaster_stats'
).order_by('name')
rides = list(queryset)
cached_data = self._serialize_rides(rides)
cache.set(cache_key, cached_data, self.CACHE_TIMEOUT)
return {
'strategy': 'client_side',
'rides': cached_data,
'total_count': total_count,
'has_more': False,
'filter_metadata': self.get_filter_metadata(filters)
}
def _get_server_side_data(self, filters: Optional[Dict[str, Any]],
total_count: int) -> Dict[str, Any]:
"""Get initial batch for server-side filtering."""
# Build filtered queryset
queryset = self._build_filtered_queryset(filters)
# Get initial batch
rides = list(queryset[:self.INITIAL_LOAD_SIZE])
return {
'strategy': 'server_side',
'rides': self._serialize_rides(rides),
'total_count': total_count,
'has_more': len(rides) == self.INITIAL_LOAD_SIZE,
'next_offset': len(rides) if len(rides) == self.INITIAL_LOAD_SIZE else None
}
def _build_filtered_queryset(self, filters: Optional[Dict[str, Any]]):
"""Build Django queryset with applied filters."""
from apps.rides.models import Ride
# Start with optimized base queryset
queryset = Ride.objects.select_related(
'park',
'park__location',
'park_area',
'manufacturer',
'designer',
'ride_model',
'ride_model__manufacturer'
).prefetch_related(
'coaster_stats'
)
if not filters:
return queryset.order_by('name')
# Apply filters
q_objects = Q()
# Text search using computed search_text field
if 'search' in filters and filters['search']:
search_term = filters['search'].lower()
q_objects &= Q(search_text__icontains=search_term)
# Park filters
if 'park_slug' in filters and filters['park_slug']:
q_objects &= Q(park__slug=filters['park_slug'])
if 'park_id' in filters and filters['park_id']:
q_objects &= Q(park_id=filters['park_id'])
# Category filters
if 'category' in filters and filters['category']:
q_objects &= Q(category__in=filters['category'])
# Status filters
if 'status' in filters and filters['status']:
q_objects &= Q(status__in=filters['status'])
# Company filters
if 'manufacturer_ids' in filters and filters['manufacturer_ids']:
q_objects &= Q(manufacturer_id__in=filters['manufacturer_ids'])
if 'designer_ids' in filters and filters['designer_ids']:
q_objects &= Q(designer_id__in=filters['designer_ids'])
# Ride model filters
if 'ride_model_ids' in filters and filters['ride_model_ids']:
q_objects &= Q(ride_model_id__in=filters['ride_model_ids'])
# Opening year filters using computed opening_year field
if 'opening_year' in filters and filters['opening_year']:
q_objects &= Q(opening_year=filters['opening_year'])
if 'min_opening_year' in filters and filters['min_opening_year']:
q_objects &= Q(opening_year__gte=filters['min_opening_year'])
if 'max_opening_year' in filters and filters['max_opening_year']:
q_objects &= Q(opening_year__lte=filters['max_opening_year'])
# Rating filters
if 'min_rating' in filters and filters['min_rating']:
q_objects &= Q(average_rating__gte=filters['min_rating'])
if 'max_rating' in filters and filters['max_rating']:
q_objects &= Q(average_rating__lte=filters['max_rating'])
# Height requirement filters
if 'min_height_requirement' in filters and filters['min_height_requirement']:
q_objects &= Q(min_height_in__gte=filters['min_height_requirement'])
if 'max_height_requirement' in filters and filters['max_height_requirement']:
q_objects &= Q(max_height_in__lte=filters['max_height_requirement'])
# Capacity filters
if 'min_capacity' in filters and filters['min_capacity']:
q_objects &= Q(capacity_per_hour__gte=filters['min_capacity'])
if 'max_capacity' in filters and filters['max_capacity']:
q_objects &= Q(capacity_per_hour__lte=filters['max_capacity'])
# Roller coaster specific filters
if 'roller_coaster_type' in filters and filters['roller_coaster_type']:
q_objects &= Q(coaster_stats__roller_coaster_type__in=filters['roller_coaster_type'])
if 'track_material' in filters and filters['track_material']:
q_objects &= Q(coaster_stats__track_material__in=filters['track_material'])
if 'propulsion_system' in filters and filters['propulsion_system']:
q_objects &= Q(coaster_stats__propulsion_system__in=filters['propulsion_system'])
# Roller coaster height filters
if 'min_height_ft' in filters and filters['min_height_ft']:
q_objects &= Q(coaster_stats__height_ft__gte=filters['min_height_ft'])
if 'max_height_ft' in filters and filters['max_height_ft']:
q_objects &= Q(coaster_stats__height_ft__lte=filters['max_height_ft'])
# Roller coaster speed filters
if 'min_speed_mph' in filters and filters['min_speed_mph']:
q_objects &= Q(coaster_stats__speed_mph__gte=filters['min_speed_mph'])
if 'max_speed_mph' in filters and filters['max_speed_mph']:
q_objects &= Q(coaster_stats__speed_mph__lte=filters['max_speed_mph'])
# Inversion filters
if 'min_inversions' in filters and filters['min_inversions']:
q_objects &= Q(coaster_stats__inversions__gte=filters['min_inversions'])
if 'max_inversions' in filters and filters['max_inversions']:
q_objects &= Q(coaster_stats__inversions__lte=filters['max_inversions'])
if 'has_inversions' in filters and filters['has_inversions'] is not None:
if filters['has_inversions']:
q_objects &= Q(coaster_stats__inversions__gt=0)
else:
q_objects &= Q(coaster_stats__inversions=0)
# Apply filters and ordering
queryset = queryset.filter(q_objects)
# Apply ordering
ordering = filters.get('ordering', 'name')
if ordering in ['height_ft', '-height_ft', 'speed_mph', '-speed_mph']:
# For coaster stats ordering, we need to join and order by the stats
ordering_field = ordering.replace('height_ft', 'coaster_stats__height_ft').replace('speed_mph', 'coaster_stats__speed_mph')
queryset = queryset.order_by(ordering_field)
else:
queryset = queryset.order_by(ordering)
return queryset
def _serialize_rides(self, rides: List) -> List[Dict[str, Any]]:
"""Serialize ride objects to dictionaries."""
serialized = []
for ride in rides:
# Basic ride data
ride_data = {
'id': ride.id,
'name': ride.name,
'slug': ride.slug,
'description': ride.description,
'category': ride.category,
'status': ride.status,
'opening_date': ride.opening_date.isoformat() if ride.opening_date else None,
'closing_date': ride.closing_date.isoformat() if ride.closing_date else None,
'opening_year': ride.opening_year,
'min_height_in': ride.min_height_in,
'max_height_in': ride.max_height_in,
'capacity_per_hour': ride.capacity_per_hour,
'ride_duration_seconds': ride.ride_duration_seconds,
'average_rating': float(ride.average_rating) if ride.average_rating else None,
'url': ride.url,
'park_url': ride.park_url,
'created_at': ride.created_at.isoformat(),
'updated_at': ride.updated_at.isoformat(),
}
# Park data
if ride.park:
ride_data['park'] = {
'id': ride.park.id,
'name': ride.park.name,
'slug': ride.park.slug,
}
# Park location data
if hasattr(ride.park, 'location') and ride.park.location:
ride_data['park']['location'] = {
'city': ride.park.location.city,
'state': ride.park.location.state,
'country': ride.park.location.country,
}
# Park area data
if ride.park_area:
ride_data['park_area'] = {
'id': ride.park_area.id,
'name': ride.park_area.name,
'slug': ride.park_area.slug,
}
# Company data
if ride.manufacturer:
ride_data['manufacturer'] = {
'id': ride.manufacturer.id,
'name': ride.manufacturer.name,
'slug': ride.manufacturer.slug,
}
if ride.designer:
ride_data['designer'] = {
'id': ride.designer.id,
'name': ride.designer.name,
'slug': ride.designer.slug,
}
# Ride model data
if ride.ride_model:
ride_data['ride_model'] = {
'id': ride.ride_model.id,
'name': ride.ride_model.name,
'slug': ride.ride_model.slug,
'category': ride.ride_model.category,
}
if ride.ride_model.manufacturer:
ride_data['ride_model']['manufacturer'] = {
'id': ride.ride_model.manufacturer.id,
'name': ride.ride_model.manufacturer.name,
'slug': ride.ride_model.manufacturer.slug,
}
# Roller coaster stats
if hasattr(ride, 'coaster_stats') and ride.coaster_stats:
stats = ride.coaster_stats
ride_data['coaster_stats'] = {
'height_ft': float(stats.height_ft) if stats.height_ft else None,
'length_ft': float(stats.length_ft) if stats.length_ft else None,
'speed_mph': float(stats.speed_mph) if stats.speed_mph else None,
'inversions': stats.inversions,
'ride_time_seconds': stats.ride_time_seconds,
'track_type': stats.track_type,
'track_material': stats.track_material,
'roller_coaster_type': stats.roller_coaster_type,
'max_drop_height_ft': float(stats.max_drop_height_ft) if stats.max_drop_height_ft else None,
'propulsion_system': stats.propulsion_system,
'train_style': stats.train_style,
'trains_count': stats.trains_count,
'cars_per_train': stats.cars_per_train,
'seats_per_car': stats.seats_per_car,
}
serialized.append(ride_data)
return serialized
def _generate_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Generate comprehensive filter metadata."""
from apps.rides.models import Ride, RideModel
from apps.rides.models.company import Company
from apps.rides.models.rides import RollerCoasterStats
# Get unique values from database with counts
parks_data = list(Ride.objects.exclude(
park__isnull=True
).select_related('park').values(
'park__id', 'park__name', 'park__slug'
).annotate(count=models.Count('id')).distinct().order_by('park__name'))
park_areas_data = list(Ride.objects.exclude(
park_area__isnull=True
).select_related('park_area').values(
'park_area__id', 'park_area__name', 'park_area__slug'
).annotate(count=models.Count('id')).distinct().order_by('park_area__name'))
manufacturers_data = list(Company.objects.filter(
roles__contains=['MANUFACTURER']
).values('id', 'name', 'slug').annotate(
count=models.Count('manufactured_rides')
).order_by('name'))
designers_data = list(Company.objects.filter(
roles__contains=['DESIGNER']
).values('id', 'name', 'slug').annotate(
count=models.Count('designed_rides')
).order_by('name'))
ride_models_data = list(RideModel.objects.select_related(
'manufacturer'
).values(
'id', 'name', 'slug', 'manufacturer__name', 'manufacturer__slug', 'category'
).annotate(count=models.Count('rides')).order_by('manufacturer__name', 'name'))
# Get categories and statuses with counts
categories_data = list(Ride.objects.values('category').annotate(
count=models.Count('id')
).order_by('category'))
statuses_data = list(Ride.objects.values('status').annotate(
count=models.Count('id')
).order_by('status'))
# Get roller coaster specific data with counts
rc_types_data = list(RollerCoasterStats.objects.values('roller_coaster_type').annotate(
count=models.Count('ride')
).exclude(roller_coaster_type__isnull=True).order_by('roller_coaster_type'))
track_materials_data = list(RollerCoasterStats.objects.values('track_material').annotate(
count=models.Count('ride')
).exclude(track_material__isnull=True).order_by('track_material'))
propulsion_systems_data = list(RollerCoasterStats.objects.values('propulsion_system').annotate(
count=models.Count('ride')
).exclude(propulsion_system__isnull=True).order_by('propulsion_system'))
# Convert to frontend-expected format with value/label/count
categories = [
{
'value': item['category'],
'label': self._get_category_label(item['category']),
'count': item['count']
}
for item in categories_data
]
statuses = [
{
'value': item['status'],
'label': self._get_status_label(item['status']),
'count': item['count']
}
for item in statuses_data
]
roller_coaster_types = [
{
'value': item['roller_coaster_type'],
'label': self._get_rc_type_label(item['roller_coaster_type']),
'count': item['count']
}
for item in rc_types_data
]
track_materials = [
{
'value': item['track_material'],
'label': self._get_track_material_label(item['track_material']),
'count': item['count']
}
for item in track_materials_data
]
propulsion_systems = [
{
'value': item['propulsion_system'],
'label': self._get_propulsion_system_label(item['propulsion_system']),
'count': item['count']
}
for item in propulsion_systems_data
]
# Convert other data to expected format
parks = [
{
'value': str(item['park__id']),
'label': item['park__name'],
'count': item['count']
}
for item in parks_data
]
park_areas = [
{
'value': str(item['park_area__id']),
'label': item['park_area__name'],
'count': item['count']
}
for item in park_areas_data
]
manufacturers = [
{
'value': str(item['id']),
'label': item['name'],
'count': item['count']
}
for item in manufacturers_data
]
designers = [
{
'value': str(item['id']),
'label': item['name'],
'count': item['count']
}
for item in designers_data
]
ride_models = [
{
'value': str(item['id']),
'label': f"{item['manufacturer__name']} {item['name']}",
'count': item['count']
}
for item in ride_models_data
]
# Calculate ranges from actual data
ride_stats = Ride.objects.aggregate(
min_rating=Min('average_rating'),
max_rating=Max('average_rating'),
min_height_req=Min('min_height_in'),
max_height_req=Max('max_height_in'),
min_capacity=Min('capacity_per_hour'),
max_capacity=Max('capacity_per_hour'),
min_duration=Min('ride_duration_seconds'),
max_duration=Max('ride_duration_seconds'),
min_year=Min('opening_year'),
max_year=Max('opening_year'),
)
# Calculate roller coaster specific ranges
coaster_stats = RollerCoasterStats.objects.aggregate(
min_height_ft=Min('height_ft'),
max_height_ft=Max('height_ft'),
min_length_ft=Min('length_ft'),
max_length_ft=Max('length_ft'),
min_speed_mph=Min('speed_mph'),
max_speed_mph=Max('speed_mph'),
min_inversions=Min('inversions'),
max_inversions=Max('inversions'),
min_ride_time=Min('ride_time_seconds'),
max_ride_time=Max('ride_time_seconds'),
min_drop_height=Min('max_drop_height_ft'),
max_drop_height=Max('max_drop_height_ft'),
min_trains=Min('trains_count'),
max_trains=Max('trains_count'),
min_cars=Min('cars_per_train'),
max_cars=Max('cars_per_train'),
min_seats=Min('seats_per_car'),
max_seats=Max('seats_per_car'),
)
return {
'categorical': {
'categories': categories,
'statuses': statuses,
'roller_coaster_types': roller_coaster_types,
'track_materials': track_materials,
'propulsion_systems': propulsion_systems,
'parks': parks,
'park_areas': park_areas,
'manufacturers': manufacturers,
'designers': designers,
'ride_models': ride_models,
},
'ranges': {
'rating': {
'min': float(ride_stats['min_rating'] or 1),
'max': float(ride_stats['max_rating'] or 10),
'step': 0.1,
'unit': 'stars'
},
'height_requirement': {
'min': ride_stats['min_height_req'] or 30,
'max': ride_stats['max_height_req'] or 90,
'step': 1,
'unit': 'inches'
},
'capacity': {
'min': ride_stats['min_capacity'] or 0,
'max': ride_stats['max_capacity'] or 5000,
'step': 50,
'unit': 'riders/hour'
},
'ride_duration': {
'min': ride_stats['min_duration'] or 0,
'max': ride_stats['max_duration'] or 600,
'step': 10,
'unit': 'seconds'
},
'opening_year': {
'min': ride_stats['min_year'] or 1800,
'max': ride_stats['max_year'] or 2030,
'step': 1,
'unit': 'year'
},
'height_ft': {
'min': float(coaster_stats['min_height_ft'] or 0),
'max': float(coaster_stats['max_height_ft'] or 500),
'step': 5,
'unit': 'feet'
},
'length_ft': {
'min': float(coaster_stats['min_length_ft'] or 0),
'max': float(coaster_stats['max_length_ft'] or 10000),
'step': 100,
'unit': 'feet'
},
'speed_mph': {
'min': float(coaster_stats['min_speed_mph'] or 0),
'max': float(coaster_stats['max_speed_mph'] or 150),
'step': 5,
'unit': 'mph'
},
'inversions': {
'min': coaster_stats['min_inversions'] or 0,
'max': coaster_stats['max_inversions'] or 20,
'step': 1,
'unit': 'inversions'
},
},
'total_count': Ride.objects.count(),
}
def _get_category_label(self, category: str) -> str:
"""Convert category code to human-readable label."""
category_labels = {
'RC': 'Roller Coaster',
'DR': 'Dark Ride',
'FR': 'Flat Ride',
'WR': 'Water Ride',
'TR': 'Transport Ride',
'OT': 'Other',
}
if category in category_labels:
return category_labels[category]
else:
raise ValueError(f"Unknown ride category: {category}")
def _get_status_label(self, status: str) -> str:
"""Convert status code to human-readable label."""
status_labels = {
'OPERATING': 'Operating',
'CLOSED_TEMP': 'Temporarily Closed',
'SBNO': 'Standing But Not Operating',
'CLOSING': 'Closing Soon',
'CLOSED_PERM': 'Permanently Closed',
'UNDER_CONSTRUCTION': 'Under Construction',
'DEMOLISHED': 'Demolished',
'RELOCATED': 'Relocated',
}
if status in status_labels:
return status_labels[status]
else:
raise ValueError(f"Unknown ride status: {status}")
def _get_rc_type_label(self, rc_type: str) -> str:
"""Convert roller coaster type to human-readable label."""
rc_type_labels = {
'SITDOWN': 'Sit Down',
'INVERTED': 'Inverted',
'SUSPENDED': 'Suspended',
'FLOORLESS': 'Floorless',
'FLYING': 'Flying',
'WING': 'Wing',
'DIVE': 'Dive',
'SPINNING': 'Spinning',
'WILD_MOUSE': 'Wild Mouse',
'BOBSLED': 'Bobsled',
'PIPELINE': 'Pipeline',
'FOURTH_DIMENSION': '4th Dimension',
'FAMILY': 'Family',
}
if rc_type in rc_type_labels:
return rc_type_labels[rc_type]
else:
raise ValueError(f"Unknown roller coaster type: {rc_type}")
def _get_track_material_label(self, material: str) -> str:
"""Convert track material to human-readable label."""
material_labels = {
'STEEL': 'Steel',
'WOOD': 'Wood',
'HYBRID': 'Hybrid (Steel/Wood)',
}
if material in material_labels:
return material_labels[material]
else:
raise ValueError(f"Unknown track material: {material}")
def _get_propulsion_system_label(self, propulsion_system: str) -> str:
"""Convert propulsion system to human-readable label."""
propulsion_labels = {
'CHAIN': 'Chain Lift',
'LSM': 'Linear Synchronous Motor',
'LIM': 'Linear Induction Motor',
'HYDRAULIC': 'Hydraulic Launch',
'PNEUMATIC': 'Pneumatic Launch',
'CABLE': 'Cable Lift',
'FLYWHEEL': 'Flywheel Launch',
'GRAVITY': 'Gravity',
'NONE': 'No Propulsion System',
}
if propulsion_system in propulsion_labels:
return propulsion_labels[propulsion_system]
else:
raise ValueError(f"Unknown propulsion system: {propulsion_system}")

View File

@@ -1,359 +0,0 @@
"""
Rides-specific location services with OpenStreetMap integration.
Handles location management for individual rides within parks.
"""
import requests
from typing import List, Dict, Any, Optional
from django.db import transaction
import logging
from ..models import RideLocation
logger = logging.getLogger(__name__)
class RideLocationService:
"""
Location service specifically for rides using OpenStreetMap integration.
Focuses on precise positioning within parks and navigation assistance.
"""
NOMINATIM_BASE_URL = "https://nominatim.openstreetmap.org"
USER_AGENT = "ThrillWiki/1.0 (https://thrillwiki.com)"
@classmethod
def create_ride_location(
cls,
*,
ride,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
park_area: str = "",
notes: str = "",
entrance_notes: str = "",
accessibility_notes: str = "",
) -> RideLocation:
"""
Create a location for a ride within a park.
Args:
ride: Ride instance
latitude: Latitude coordinate (optional for rides)
longitude: Longitude coordinate (optional for rides)
park_area: Themed area within the park
notes: General location notes
entrance_notes: Entrance and navigation notes
accessibility_notes: Accessibility information
Returns:
Created RideLocation instance
"""
with transaction.atomic():
ride_location = RideLocation(
ride=ride,
park_area=park_area,
notes=notes,
entrance_notes=entrance_notes,
accessibility_notes=accessibility_notes,
)
# Set coordinates if provided
if latitude is not None and longitude is not None:
ride_location.set_coordinates(latitude, longitude)
ride_location.full_clean()
ride_location.save()
return ride_location
@classmethod
def update_ride_location(
cls, ride_location: RideLocation, **updates
) -> RideLocation:
"""
Update ride location with validation.
Args:
ride_location: RideLocation instance to update
**updates: Fields to update
Returns:
Updated RideLocation instance
"""
with transaction.atomic():
# Handle coordinates separately
latitude = updates.pop("latitude", None)
longitude = updates.pop("longitude", None)
# Update regular fields
for field, value in updates.items():
if hasattr(ride_location, field):
setattr(ride_location, field, value)
# Update coordinates if provided
if latitude is not None and longitude is not None:
ride_location.set_coordinates(latitude, longitude)
ride_location.full_clean()
ride_location.save()
return ride_location
@classmethod
def find_rides_in_area(cls, park, park_area: str) -> List[RideLocation]:
"""
Find all rides in a specific park area.
Args:
park: Park instance
park_area: Name of the park area/land
Returns:
List of RideLocation instances in the area
"""
return list(
RideLocation.objects.filter(ride__park=park, park_area__icontains=park_area)
.select_related("ride")
.order_by("ride__name")
)
@classmethod
def find_nearby_rides(
cls, latitude: float, longitude: float, park=None, radius_meters: float = 500
) -> List[RideLocation]:
"""
Find rides near given coordinates using PostGIS.
Useful for finding rides near a specific location within a park.
Args:
latitude: Center latitude
longitude: Center longitude
park: Optional park to limit search to
radius_meters: Search radius in meters (default: 500m)
Returns:
List of nearby RideLocation instances
"""
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import Distance
center_point = Point(longitude, latitude, srid=4326)
queryset = RideLocation.objects.filter(
point__distance_lte=(center_point, Distance(m=radius_meters)),
point__isnull=False,
)
if park:
queryset = queryset.filter(ride__park=park)
return list(
queryset.select_related("ride", "ride__park").order_by("point__distance")
)
@classmethod
def get_ride_navigation_info(cls, ride_location: RideLocation) -> Dict[str, Any]:
"""
Get comprehensive navigation information for a ride.
Args:
ride_location: RideLocation instance
Returns:
Dictionary with navigation information
"""
info = {
"ride_name": ride_location.ride.name,
"park_name": ride_location.ride.park.name,
"park_area": ride_location.park_area,
"has_coordinates": ride_location.has_coordinates,
"entrance_notes": ride_location.entrance_notes,
"accessibility_notes": ride_location.accessibility_notes,
"general_notes": ride_location.notes,
}
# Add coordinate information if available
if ride_location.has_coordinates:
info.update(
{
"latitude": ride_location.latitude,
"longitude": ride_location.longitude,
"coordinates": ride_location.coordinates,
}
)
# Calculate distance to park entrance if park has location
park_location = getattr(ride_location.ride.park, "location", None)
if park_location and park_location.point:
distance_km = ride_location.distance_to_park_location()
if distance_km is not None:
info["distance_from_park_entrance_km"] = round(distance_km, 2)
return info
@classmethod
def estimate_ride_coordinates_from_park(
cls,
ride_location: RideLocation,
area_offset_meters: Optional[Dict[str, List[float]]] = None,
) -> Optional[List[float]]:
"""
Estimate ride coordinates based on park location and area.
Useful when exact ride coordinates are not available.
Args:
ride_location: RideLocation instance
area_offset_meters: Dictionary mapping area names to [north_offset, east_offset] in meters
Returns:
Estimated [latitude, longitude] list or None
"""
park_location = getattr(ride_location.ride.park, "location", None)
if not park_location or not park_location.point:
return None
# Default area offsets (rough estimates for common themed areas)
default_offsets = {
"main street": (0, 0), # Usually at entrance
"fantasyland": (200, 100), # Often north-east
"tomorrowland": (100, 200), # Often east
"frontierland": (-100, -200), # Often south-west
"adventureland": (-200, 100), # Often south-east
"new orleans square": (-150, -100),
"critter country": (-200, -200),
"galaxy's edge": (300, 300), # Often on periphery
"cars land": (200, -200),
"pixar pier": (0, 300), # Often waterfront
}
offsets = area_offset_meters or default_offsets
# Find matching area offset
area_lower = ride_location.park_area.lower()
offset = None
for area_name, area_offset in offsets.items():
if area_name in area_lower:
offset = area_offset
break
if not offset:
# Default small random offset if no specific area match
import random
offset = (random.randint(-100, 100), random.randint(-100, 100))
# Convert meter offsets to coordinate offsets
# Rough conversion: 1 degree latitude ≈ 111,000 meters
# 1 degree longitude varies by latitude, but we'll use a rough approximation
lat_offset = offset[0] / 111000 # North offset in degrees
lon_offset = offset[1] / (
111000 * abs(park_location.latitude) * 0.01
) # East offset
estimated_lat = park_location.latitude + lat_offset
estimated_lon = park_location.longitude + lon_offset
return [estimated_lat, estimated_lon]
@classmethod
def bulk_update_ride_areas_from_osm(cls, park) -> int:
"""
Bulk update ride locations for a park using OSM data.
Attempts to find more precise locations for rides within the park.
Args:
park: Park instance
Returns:
Number of ride locations updated
"""
updated_count = 0
park_location = getattr(park, "location", None)
if not park_location or not park_location.point:
return updated_count
# Get all rides in the park that don't have precise coordinates
ride_locations = RideLocation.objects.filter(
ride__park=park, point__isnull=True
).select_related("ride")
for ride_location in ride_locations:
# Try to search for the specific ride within the park area
search_query = f"{ride_location.ride.name} {park.name}"
try:
# Search for the ride specifically
params = {
"q": search_query,
"format": "json",
"limit": 5,
"addressdetails": 1,
"bounded": 1, # Restrict to viewbox
# Create a bounding box around the park (roughly 2km radius)
"viewbox": f"{park_location.longitude - 0.02},{park_location.latitude + 0.02},{park_location.longitude + 0.02},{park_location.latitude - 0.02}",
}
headers = {"User-Agent": cls.USER_AGENT}
response = requests.get(
f"{cls.NOMINATIM_BASE_URL}/search",
params=params,
headers=headers,
timeout=5,
)
if response.status_code == 200:
results = response.json()
# Look for results that might be the ride
for result in results:
display_name = result.get("display_name", "").lower()
if (
ride_location.ride.name.lower() in display_name
and park.name.lower() in display_name
):
# Update the ride location
ride_location.set_coordinates(
float(result["lat"]), float(result["lon"])
)
ride_location.save()
updated_count += 1
break
except Exception as e:
logger.warning(
f"Error updating ride location for {ride_location.ride.name}: {str(e)}"
)
continue
return updated_count
@classmethod
def generate_park_area_map(cls, park) -> Dict[str, List[str]]:
"""
Generate a map of park areas and the rides in each area.
Args:
park: Park instance
Returns:
Dictionary mapping area names to lists of ride names
"""
area_map = {}
ride_locations = (
RideLocation.objects.filter(ride__park=park)
.select_related("ride")
.order_by("park_area", "ride__name")
)
for ride_location in ride_locations:
area = ride_location.park_area or "Unknown Area"
if area not in area_map:
area_map[area] = []
area_map[area].append(ride_location.ride.name)
return area_map

View File

@@ -1,305 +0,0 @@
"""
Ride-specific media service for ThrillWiki.
This module provides media management functionality specific to rides.
"""
import logging
from typing import List, Optional, Dict, Any
from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
from django.contrib.auth import get_user_model
from apps.core.services.media_service import MediaService
from ..models import Ride, RidePhoto
User = get_user_model()
logger = logging.getLogger(__name__)
class RideMediaService:
"""Service for managing ride-specific media operations."""
@staticmethod
def upload_photo(
ride: Ride,
image_file: UploadedFile,
user: User,
caption: str = "",
alt_text: str = "",
photo_type: str = "exterior",
is_primary: bool = False,
auto_approve: bool = False,
) -> RidePhoto:
"""
Upload a photo for a ride.
Args:
ride: Ride instance
image_file: Uploaded image file
user: User uploading the photo
caption: Photo caption
alt_text: Alt text for accessibility
photo_type: Type of photo (exterior, queue, station, etc.)
is_primary: Whether this should be the primary photo
auto_approve: Whether to auto-approve the photo
Returns:
Created RidePhoto instance
Raises:
ValueError: If image validation fails
"""
# Validate image file
is_valid, error_message = MediaService.validate_image_file(image_file)
if not is_valid:
raise ValueError(error_message)
# Process image
processed_image = MediaService.process_image(image_file)
with transaction.atomic():
# Create photo instance
photo = RidePhoto(
ride=ride,
image=processed_image,
caption=caption or MediaService.generate_default_caption(user.username),
alt_text=alt_text,
photo_type=photo_type,
is_primary=is_primary,
is_approved=auto_approve,
uploaded_by=user,
)
# Extract EXIF date
photo.date_taken = MediaService.extract_exif_date(processed_image)
photo.save()
logger.info(f"Photo uploaded for ride {ride.slug} by user {user.username}")
return photo
@staticmethod
def get_ride_photos(
ride: Ride,
approved_only: bool = True,
primary_first: bool = True,
photo_type: Optional[str] = None,
) -> List[RidePhoto]:
"""
Get photos for a ride.
Args:
ride: Ride instance
approved_only: Whether to only return approved photos
primary_first: Whether to order primary photos first
photo_type: Filter by photo type (optional)
Returns:
List of RidePhoto instances
"""
queryset = ride.photos.all()
if approved_only:
queryset = queryset.filter(is_approved=True)
if photo_type:
queryset = queryset.filter(photo_type=photo_type)
if primary_first:
queryset = queryset.order_by("-is_primary", "-created_at")
else:
queryset = queryset.order_by("-created_at")
return list(queryset)
@staticmethod
def get_primary_photo(ride: Ride) -> Optional[RidePhoto]:
"""
Get the primary photo for a ride.
Args:
ride: Ride instance
Returns:
Primary RidePhoto instance or None
"""
try:
return ride.photos.filter(is_primary=True, is_approved=True).first()
except RidePhoto.DoesNotExist:
return None
@staticmethod
def get_photos_by_type(ride: Ride, photo_type: str) -> List[RidePhoto]:
"""
Get photos of a specific type for a ride.
Args:
ride: Ride instance
photo_type: Type of photos to retrieve
Returns:
List of RidePhoto instances
"""
return list(
ride.photos.filter(photo_type=photo_type, is_approved=True).order_by(
"-created_at"
)
)
@staticmethod
def set_primary_photo(ride: Ride, photo: RidePhoto) -> bool:
"""
Set a photo as the primary photo for a ride.
Args:
ride: Ride instance
photo: RidePhoto to set as primary
Returns:
True if successful, False otherwise
"""
if photo.ride != ride:
return False
with transaction.atomic():
# Unset current primary
ride.photos.filter(is_primary=True).update(is_primary=False)
# Set new primary
photo.is_primary = True
photo.save()
logger.info(f"Set photo {photo.pk} as primary for ride {ride.slug}")
return True
@staticmethod
def approve_photo(photo: RidePhoto, approved_by: User) -> bool:
"""
Approve a ride photo.
Args:
photo: RidePhoto to approve
approved_by: User approving the photo
Returns:
True if successful, False otherwise
"""
try:
photo.is_approved = True
photo.save()
logger.info(f"Photo {photo.pk} approved by user {approved_by.username}")
return True
except Exception as e:
logger.error(f"Failed to approve photo {photo.pk}: {str(e)}")
return False
@staticmethod
def delete_photo(photo: RidePhoto, deleted_by: User) -> bool:
"""
Delete a ride photo.
Args:
photo: RidePhoto to delete
deleted_by: User deleting the photo
Returns:
True if successful, False otherwise
"""
try:
ride_slug = photo.ride.slug
photo_id = photo.pk
# Delete the file and database record
if photo.image:
photo.image.delete(save=False)
photo.delete()
logger.info(
f"Photo {photo_id} deleted from ride {ride_slug} by user {deleted_by.username}"
)
return True
except Exception as e:
logger.error(f"Failed to delete photo {photo.pk}: {str(e)}")
return False
@staticmethod
def get_photo_stats(ride: Ride) -> Dict[str, Any]:
"""
Get photo statistics for a ride.
Args:
ride: Ride instance
Returns:
Dictionary with photo statistics
"""
photos = ride.photos.all()
# Get counts by photo type
type_counts = {}
for photo_type, _ in RidePhoto._meta.get_field("photo_type").choices:
type_counts[photo_type] = photos.filter(photo_type=photo_type).count()
return {
"total_photos": photos.count(),
"approved_photos": photos.filter(is_approved=True).count(),
"pending_photos": photos.filter(is_approved=False).count(),
"has_primary": photos.filter(is_primary=True).exists(),
"recent_uploads": photos.order_by("-created_at")[:5].count(),
"by_type": type_counts,
}
@staticmethod
def bulk_approve_photos(photos: List[RidePhoto], approved_by: User) -> int:
"""
Bulk approve multiple photos.
Args:
photos: List of RidePhoto instances to approve
approved_by: User approving the photos
Returns:
Number of photos successfully approved
"""
approved_count = 0
with transaction.atomic():
for photo in photos:
if RideMediaService.approve_photo(photo, approved_by):
approved_count += 1
logger.info(
f"Bulk approved {approved_count} photos by user {approved_by.username}"
)
return approved_count
@staticmethod
def get_construction_timeline(ride: Ride) -> List[RidePhoto]:
"""
Get construction photos ordered chronologically.
Args:
ride: Ride instance
Returns:
List of construction RidePhoto instances ordered by date taken
"""
return list(
ride.photos.filter(photo_type="construction", is_approved=True).order_by(
"date_taken", "created_at"
)
)
@staticmethod
def get_onride_photos(ride: Ride) -> List[RidePhoto]:
"""
Get on-ride photos for a ride.
Args:
ride: Ride instance
Returns:
List of on-ride RidePhoto instances
"""
return RideMediaService.get_photos_by_type(ride, "onride")

View File

@@ -1,550 +0,0 @@
"""
Service for calculating ride rankings using the Internet Roller Coaster Poll algorithm.
This service implements a pairwise comparison system where each ride is compared
to every other ride based on mutual riders (users who have rated both rides).
Rankings are determined by winning percentage in these comparisons.
"""
import logging
from typing import Dict, List, Optional
from decimal import Decimal
from datetime import date
from django.db import transaction
from django.db.models import Avg, Count, Q
from django.utils import timezone
from apps.rides.models import (
Ride,
RideReview,
RideRanking,
RidePairComparison,
RankingSnapshot,
)
logger = logging.getLogger(__name__)
class RideRankingService:
"""
Calculates ride rankings using the Internet Roller Coaster Poll algorithm.
Algorithm Overview:
1. For each pair of rides, find users who have rated both
2. Count how many users preferred each ride (higher rating)
3. Calculate wins, losses, and ties for each ride
4. Rank rides by winning percentage (ties count as 0.5 wins)
5. Break ties by head-to-head comparison
"""
def __init__(self):
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
self.calculation_version = "1.0"
def update_all_rankings(self, category: Optional[str] = None) -> Dict[str, any]:
"""
Main entry point to update all ride rankings.
Args:
category: Optional ride category to filter ('RC' for roller coasters, etc.)
If None, ranks all rides.
Returns:
Dictionary with statistics about the ranking calculation
"""
start_time = timezone.now()
self.logger.info(
f"Starting ranking calculation for category: {category or 'ALL'}"
)
try:
with transaction.atomic():
# Get rides to rank
rides = self._get_eligible_rides(category)
if not rides:
self.logger.warning("No eligible rides found for ranking")
return {
"status": "skipped",
"message": "No eligible rides found",
"duration": (timezone.now() - start_time).total_seconds(),
}
self.logger.info(f"Found {len(rides)} rides to rank")
# Calculate pairwise comparisons
comparisons = self._calculate_all_comparisons(rides)
# Calculate rankings from comparisons
rankings = self._calculate_rankings_from_comparisons(rides, comparisons)
# Save rankings
self._save_rankings(rankings)
# Save snapshots for historical tracking
self._save_ranking_snapshots(rankings)
# Clean up old data
self._cleanup_old_data()
duration = (timezone.now() - start_time).total_seconds()
self.logger.info(
f"Ranking calculation completed in {duration:.2f} seconds"
)
return {
"status": "success",
"rides_ranked": len(rides),
"comparisons_made": len(comparisons),
"duration": duration,
"timestamp": timezone.now(),
}
except Exception as e:
self.logger.error(f"Error updating rankings: {e}", exc_info=True)
raise
def _get_eligible_rides(self, category: Optional[str] = None) -> List[Ride]:
"""
Get rides that are eligible for ranking.
Only includes rides that:
- Are currently operating
- Have at least one review/rating
"""
queryset = (
Ride.objects.filter(status="OPERATING", reviews__is_published=True)
.annotate(
review_count=Count("reviews", filter=Q(reviews__is_published=True))
)
.filter(review_count__gt=0)
)
if category:
queryset = queryset.filter(category=category)
return list(queryset.distinct())
def _calculate_all_comparisons(
self, rides: List[Ride]
) -> Dict[tuple[int, int], RidePairComparison]:
"""
Calculate pairwise comparisons for all ride pairs.
Returns a dictionary keyed by (ride_a_id, ride_b_id) tuples.
"""
comparisons = {}
total_pairs = len(rides) * (len(rides) - 1) // 2
processed = 0
for i, ride_a in enumerate(rides):
for ride_b in rides[i + 1 :]:
comparison = self._calculate_pairwise_comparison(ride_a, ride_b)
if comparison:
# Store both directions for easy lookup
comparisons[(ride_a.id, ride_b.id)] = comparison
comparisons[(ride_b.id, ride_a.id)] = comparison
processed += 1
if processed % 100 == 0:
self.logger.debug(
f"Processed {processed}/{total_pairs} comparisons"
)
return comparisons
def _calculate_pairwise_comparison(
self, ride_a: Ride, ride_b: Ride
) -> Optional[RidePairComparison]:
"""
Calculate the pairwise comparison between two rides.
Finds users who have rated both rides and determines which ride
they preferred based on their ratings.
"""
# Get mutual riders (users who have rated both rides)
ride_a_reviewers = set(
RideReview.objects.filter(ride=ride_a, is_published=True).values_list(
"user_id", flat=True
)
)
ride_b_reviewers = set(
RideReview.objects.filter(ride=ride_b, is_published=True).values_list(
"user_id", flat=True
)
)
mutual_riders = ride_a_reviewers & ride_b_reviewers
if not mutual_riders:
# No mutual riders, no comparison possible
return None
# Get ratings from mutual riders
ride_a_ratings = {
review.user_id: review.rating
for review in RideReview.objects.filter(
ride=ride_a, user_id__in=mutual_riders, is_published=True
)
}
ride_b_ratings = {
review.user_id: review.rating
for review in RideReview.objects.filter(
ride=ride_b, user_id__in=mutual_riders, is_published=True
)
}
# Count wins and ties
ride_a_wins = 0
ride_b_wins = 0
ties = 0
for user_id in mutual_riders:
rating_a = ride_a_ratings.get(user_id, 0)
rating_b = ride_b_ratings.get(user_id, 0)
if rating_a > rating_b:
ride_a_wins += 1
elif rating_b > rating_a:
ride_b_wins += 1
else:
ties += 1
# Calculate average ratings from mutual riders
ride_a_avg = (
sum(ride_a_ratings.values()) / len(ride_a_ratings) if ride_a_ratings else 0
)
ride_b_avg = (
sum(ride_b_ratings.values()) / len(ride_b_ratings) if ride_b_ratings else 0
)
# Create or update comparison record
comparison, created = RidePairComparison.objects.update_or_create(
ride_a=ride_a if ride_a.id < ride_b.id else ride_b,
ride_b=ride_b if ride_a.id < ride_b.id else ride_a,
defaults={
"ride_a_wins": ride_a_wins if ride_a.id < ride_b.id else ride_b_wins,
"ride_b_wins": ride_b_wins if ride_a.id < ride_b.id else ride_a_wins,
"ties": ties,
"mutual_riders_count": len(mutual_riders),
"ride_a_avg_rating": (
Decimal(str(ride_a_avg))
if ride_a.id < ride_b.id
else Decimal(str(ride_b_avg))
),
"ride_b_avg_rating": (
Decimal(str(ride_b_avg))
if ride_a.id < ride_b.id
else Decimal(str(ride_a_avg))
),
},
)
return comparison
def _calculate_rankings_from_comparisons(
self, rides: List[Ride], comparisons: Dict[tuple[int, int], RidePairComparison]
) -> List[Dict]:
"""
Calculate final rankings from pairwise comparisons.
Returns a list of dictionaries containing ranking data for each ride.
"""
rankings = []
for ride in rides:
wins = 0
losses = 0
ties = 0
comparison_count = 0
# Count wins, losses, and ties
for other_ride in rides:
if ride.id == other_ride.id:
continue
comparison_key = (
min(ride.id, other_ride.id),
max(ride.id, other_ride.id),
)
comparison = comparisons.get(comparison_key)
if not comparison:
continue
comparison_count += 1
# Determine win/loss/tie for this ride
if comparison.ride_a_id == ride.id:
if comparison.ride_a_wins > comparison.ride_b_wins:
wins += 1
elif comparison.ride_a_wins < comparison.ride_b_wins:
losses += 1
else:
ties += 1
else: # ride_b_id == ride.id
if comparison.ride_b_wins > comparison.ride_a_wins:
wins += 1
elif comparison.ride_b_wins < comparison.ride_a_wins:
losses += 1
else:
ties += 1
# Calculate winning percentage (ties count as 0.5)
total_comparisons = wins + losses + ties
if total_comparisons > 0:
winning_percentage = Decimal(
str((wins + 0.5 * ties) / total_comparisons)
)
else:
winning_percentage = Decimal("0.5")
# Get average rating and reviewer count
ride_stats = RideReview.objects.filter(
ride=ride, is_published=True
).aggregate(
avg_rating=Avg("rating"), reviewer_count=Count("user", distinct=True)
)
rankings.append(
{
"ride": ride,
"wins": wins,
"losses": losses,
"ties": ties,
"winning_percentage": winning_percentage,
"comparison_count": comparison_count,
"average_rating": ride_stats["avg_rating"],
"mutual_riders_count": ride_stats["reviewer_count"] or 0,
}
)
# Sort by winning percentage (descending), then by mutual riders count for ties
rankings.sort(
key=lambda x: (
x["winning_percentage"],
x["mutual_riders_count"],
x["average_rating"] or 0,
),
reverse=True,
)
# Handle tie-breaking with head-to-head comparisons
rankings = self._apply_tiebreakers(rankings, comparisons)
# Assign final ranks
for i, ranking_data in enumerate(rankings, 1):
ranking_data["rank"] = i
return rankings
def _apply_tiebreakers(
self,
rankings: List[Dict],
comparisons: Dict[tuple[int, int], RidePairComparison],
) -> List[Dict]:
"""
Apply head-to-head tiebreaker for rides with identical winning percentages.
If two rides have the same winning percentage, the one that beat the other
in their head-to-head comparison gets the higher rank.
"""
i = 0
while i < len(rankings) - 1:
# Find rides with same winning percentage
tied_group = [rankings[i]]
j = i + 1
while (
j < len(rankings)
and rankings[j]["winning_percentage"]
== rankings[i]["winning_percentage"]
):
tied_group.append(rankings[j])
j += 1
if len(tied_group) > 1:
# Apply head-to-head tiebreaker within the group
tied_group = self._sort_tied_group(tied_group, comparisons)
# Replace the tied section with sorted group
rankings[i:j] = tied_group
i = j
return rankings
def _sort_tied_group(
self,
tied_group: List[Dict],
comparisons: Dict[tuple[int, int], RidePairComparison],
) -> List[Dict]:
"""
Sort a group of tied rides using head-to-head comparisons.
"""
# Create mini-rankings within the tied group
for ride_data in tied_group:
mini_wins = 0
mini_losses = 0
for other_data in tied_group:
if ride_data["ride"].id == other_data["ride"].id:
continue
comparison_key = (
min(ride_data["ride"].id, other_data["ride"].id),
max(ride_data["ride"].id, other_data["ride"].id),
)
comparison = comparisons.get(comparison_key)
if comparison:
if comparison.ride_a_id == ride_data["ride"].id:
if comparison.ride_a_wins > comparison.ride_b_wins:
mini_wins += 1
elif comparison.ride_a_wins < comparison.ride_b_wins:
mini_losses += 1
else:
if comparison.ride_b_wins > comparison.ride_a_wins:
mini_wins += 1
elif comparison.ride_b_wins < comparison.ride_a_wins:
mini_losses += 1
ride_data["tiebreaker_score"] = mini_wins - mini_losses
# Sort by tiebreaker score, then by mutual riders count, then by average rating
tied_group.sort(
key=lambda x: (
x["tiebreaker_score"],
x["mutual_riders_count"],
x["average_rating"] or 0,
),
reverse=True,
)
return tied_group
def _save_rankings(self, rankings: List[Dict]):
"""Save calculated rankings to the database."""
for ranking_data in rankings:
RideRanking.objects.update_or_create(
ride=ranking_data["ride"],
defaults={
"rank": ranking_data["rank"],
"wins": ranking_data["wins"],
"losses": ranking_data["losses"],
"ties": ranking_data["ties"],
"winning_percentage": ranking_data["winning_percentage"],
"mutual_riders_count": ranking_data["mutual_riders_count"],
"comparison_count": ranking_data["comparison_count"],
"average_rating": ranking_data["average_rating"],
"last_calculated": timezone.now(),
"calculation_version": self.calculation_version,
},
)
def _save_ranking_snapshots(self, rankings: List[Dict]):
"""Save ranking snapshots for historical tracking."""
today = date.today()
for ranking_data in rankings:
RankingSnapshot.objects.update_or_create(
ride=ranking_data["ride"],
snapshot_date=today,
defaults={
"rank": ranking_data["rank"],
"winning_percentage": ranking_data["winning_percentage"],
},
)
def _cleanup_old_data(self, days_to_keep: int = 365):
"""Clean up old comparison and snapshot data."""
cutoff_date = timezone.now() - timezone.timedelta(days=days_to_keep)
# Delete old snapshots
deleted_snapshots = RankingSnapshot.objects.filter(
snapshot_date__lt=cutoff_date.date()
).delete()
if deleted_snapshots[0] > 0:
self.logger.info(f"Deleted {deleted_snapshots[0]} old ranking snapshots")
def get_ride_ranking_details(self, ride: Ride) -> Optional[Dict]:
"""
Get detailed ranking information for a specific ride.
Returns dictionary with ranking details or None if not ranked.
"""
try:
ranking = RideRanking.objects.get(ride=ride)
# Get recent head-to-head comparisons
comparisons = (
RidePairComparison.objects.filter(Q(ride_a=ride) | Q(ride_b=ride))
.select_related("ride_a", "ride_b")
.order_by("-mutual_riders_count")[:10]
)
# Get ranking history
history = RankingSnapshot.objects.filter(ride=ride).order_by(
"-snapshot_date"
)[:30]
return {
"current_rank": ranking.rank,
"winning_percentage": ranking.winning_percentage,
"wins": ranking.wins,
"losses": ranking.losses,
"ties": ranking.ties,
"average_rating": ranking.average_rating,
"mutual_riders_count": ranking.mutual_riders_count,
"last_calculated": ranking.last_calculated,
"head_to_head": [
{
"opponent": (
comp.ride_b if comp.ride_a_id == ride.id else comp.ride_a
),
"result": (
"win"
if (
(
comp.ride_a_id == ride.id
and comp.ride_a_wins > comp.ride_b_wins
)
or (
comp.ride_b_id == ride.id
and comp.ride_b_wins > comp.ride_a_wins
)
)
else (
"loss"
if (
(
comp.ride_a_id == ride.id
and comp.ride_a_wins < comp.ride_b_wins
)
or (
comp.ride_b_id == ride.id
and comp.ride_b_wins < comp.ride_a_wins
)
)
else "tie"
)
),
"mutual_riders": comp.mutual_riders_count,
}
for comp in comparisons
],
"ranking_history": [
{
"date": snapshot.snapshot_date,
"rank": snapshot.rank,
"winning_percentage": snapshot.winning_percentage,
}
for snapshot in history
],
}
except RideRanking.DoesNotExist:
return None

View File

@@ -1,730 +0,0 @@
"""
Ride Search Service
Provides comprehensive search and filtering capabilities for rides using PostgreSQL's
advanced full-text search features including SearchVector, SearchQuery, SearchRank,
and TrigramSimilarity for fuzzy matching.
This service implements the filtering design specified in:
backend/docs/ride_filtering_design.md
"""
from django.contrib.postgres.search import (
SearchVector,
SearchQuery,
SearchRank,
TrigramSimilarity,
)
from django.db import models
from django.db.models import Q, F, Value
from django.db.models.functions import Greatest
from typing import Dict, List, Optional, Any
from apps.rides.models import Ride
from apps.parks.models import Park
from apps.rides.models.company import Company
class RideSearchService:
"""
Advanced search service for rides with PostgreSQL full-text search capabilities.
Features:
- Full-text search with ranking and highlighting
- Fuzzy matching with trigram similarity
- Comprehensive filtering across 8 categories
- Range filtering for numeric fields
- Date range filtering
- Multi-select filtering
- Sorting with multiple options
- Search suggestions and autocomplete
"""
# Search configuration
SEARCH_LANGUAGES = ["english"]
TRIGRAM_SIMILARITY_THRESHOLD = 0.3
SEARCH_RANK_WEIGHTS = [0.1, 0.2, 0.4, 1.0] # D, C, B, A weights
# Filter categories from our design
FILTER_CATEGORIES = {
"search_text": ["global_search", "name_search", "description_search"],
"basic_info": ["category", "status", "park", "park_area"],
"dates": ["opening_date_range", "closing_date_range", "status_since_range"],
"height_safety": ["min_height_range", "max_height_range"],
"performance": ["capacity_range", "duration_range", "rating_range"],
"relationships": ["manufacturer", "designer", "ride_model"],
"roller_coaster": [
"height_ft_range",
"length_ft_range",
"speed_mph_range",
"inversions_range",
"track_material",
"coaster_type",
"propulsion_system",
],
"company": ["manufacturer_roles", "designer_roles", "founded_date_range"],
}
# Sorting options
SORT_OPTIONS = {
"relevance": "search_rank",
"name_asc": "name",
"name_desc": "-name",
"opening_date_asc": "opening_date",
"opening_date_desc": "-opening_date",
"rating_asc": "average_rating",
"rating_desc": "-average_rating",
"height_asc": "rollercoasterstats__height_ft",
"height_desc": "-rollercoasterstats__height_ft",
"speed_asc": "rollercoasterstats__speed_mph",
"speed_desc": "-rollercoasterstats__speed_mph",
"capacity_asc": "capacity_per_hour",
"capacity_desc": "-capacity_per_hour",
"created_asc": "created_at",
"created_desc": "-created_at",
}
def __init__(self):
"""Initialize the search service."""
self.base_queryset = self._get_base_queryset()
def _get_base_queryset(self):
"""
Get the base queryset with all necessary relationships pre-loaded
for optimal performance.
"""
return Ride.objects.select_related(
"park",
"park_area",
"manufacturer",
"designer",
"ride_model",
"rollercoasterstats",
).prefetch_related("manufacturer__roles", "designer__roles")
def search_and_filter(
self,
filters: Dict[str, Any],
sort_by: str = "relevance",
page: int = 1,
page_size: int = 20,
) -> Dict[str, Any]:
"""
Main search and filter method that combines all capabilities.
Args:
filters: Dictionary of filter parameters
sort_by: Sorting option key
page: Page number for pagination
page_size: Number of results per page
Returns:
Dictionary containing results, pagination info, and metadata
"""
queryset = self.base_queryset
search_metadata = {}
# Apply text search with ranking
if filters.get("global_search"):
queryset, search_rank = self._apply_full_text_search(
queryset, filters["global_search"]
)
search_metadata["search_applied"] = True
search_metadata["search_term"] = filters["global_search"]
else:
search_rank = Value(0)
# Apply all filter categories
queryset = self._apply_basic_info_filters(queryset, filters)
queryset = self._apply_date_filters(queryset, filters)
queryset = self._apply_height_safety_filters(queryset, filters)
queryset = self._apply_performance_filters(queryset, filters)
queryset = self._apply_relationship_filters(queryset, filters)
queryset = self._apply_roller_coaster_filters(queryset, filters)
queryset = self._apply_company_filters(queryset, filters)
# Add search rank to queryset for sorting
queryset = queryset.annotate(search_rank=search_rank)
# Apply sorting
queryset = self._apply_sorting(queryset, sort_by)
# Get total count before pagination
total_count = queryset.count()
# Apply pagination
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
results = list(queryset[start_idx:end_idx])
# Generate search highlights if search was applied
if filters.get("global_search"):
results = self._add_search_highlights(results, filters["global_search"])
return {
"results": results,
"pagination": {
"page": page,
"page_size": page_size,
"total_count": total_count,
"total_pages": (total_count + page_size - 1) // page_size,
"has_next": end_idx < total_count,
"has_previous": page > 1,
},
"metadata": search_metadata,
"applied_filters": self._get_applied_filters_summary(filters),
}
def _apply_full_text_search(
self, queryset, search_term: str
) -> tuple[models.QuerySet, models.Expression]:
"""
Apply PostgreSQL full-text search with ranking and fuzzy matching.
"""
if not search_term or not search_term.strip():
return queryset, Value(0)
search_term = search_term.strip()
# Create search vector combining multiple fields with different weights
search_vector = (
SearchVector("name", weight="A")
+ SearchVector("description", weight="B")
+ SearchVector("park__name", weight="C")
+ SearchVector("manufacturer__name", weight="C")
+ SearchVector("designer__name", weight="C")
+ SearchVector("ride_model__name", weight="D")
)
# Create search query - try different query types for best results
search_query = SearchQuery(search_term, config="english")
# Calculate search rank
search_rank = SearchRank(
search_vector, search_query, weights=self.SEARCH_RANK_WEIGHTS
)
# Apply trigram similarity for fuzzy matching on name
trigram_similarity = TrigramSimilarity("name", search_term)
# Combine full-text search with trigram similarity
queryset = queryset.annotate(trigram_similarity=trigram_similarity).filter(
Q(search_vector=search_query)
| Q(trigram_similarity__gte=self.TRIGRAM_SIMILARITY_THRESHOLD)
)
# Use the greatest of search rank and trigram similarity for final ranking
final_rank = Greatest(search_rank, F("trigram_similarity"))
return queryset, final_rank
def _apply_basic_info_filters(
self, queryset, filters: Dict[str, Any]
) -> models.QuerySet:
"""Apply basic information filters."""
# Category filter (multi-select)
if filters.get("category"):
categories = (
filters["category"]
if isinstance(filters["category"], list)
else [filters["category"]]
)
queryset = queryset.filter(category__in=categories)
# Status filter (multi-select)
if filters.get("status"):
statuses = (
filters["status"]
if isinstance(filters["status"], list)
else [filters["status"]]
)
queryset = queryset.filter(status__in=statuses)
# Park filter (multi-select)
if filters.get("park"):
parks = (
filters["park"]
if isinstance(filters["park"], list)
else [filters["park"]]
)
if isinstance(parks[0], str): # If slugs provided
queryset = queryset.filter(park__slug__in=parks)
else: # If IDs provided
queryset = queryset.filter(park_id__in=parks)
# Park area filter (multi-select)
if filters.get("park_area"):
areas = (
filters["park_area"]
if isinstance(filters["park_area"], list)
else [filters["park_area"]]
)
if isinstance(areas[0], str): # If slugs provided
queryset = queryset.filter(park_area__slug__in=areas)
else: # If IDs provided
queryset = queryset.filter(park_area_id__in=areas)
return queryset
def _apply_date_filters(self, queryset, filters: Dict[str, Any]) -> models.QuerySet:
"""Apply date range filters."""
# Opening date range
if filters.get("opening_date_range"):
date_range = filters["opening_date_range"]
if date_range.get("start"):
queryset = queryset.filter(opening_date__gte=date_range["start"])
if date_range.get("end"):
queryset = queryset.filter(opening_date__lte=date_range["end"])
# Closing date range
if filters.get("closing_date_range"):
date_range = filters["closing_date_range"]
if date_range.get("start"):
queryset = queryset.filter(closing_date__gte=date_range["start"])
if date_range.get("end"):
queryset = queryset.filter(closing_date__lte=date_range["end"])
# Status since range
if filters.get("status_since_range"):
date_range = filters["status_since_range"]
if date_range.get("start"):
queryset = queryset.filter(status_since__gte=date_range["start"])
if date_range.get("end"):
queryset = queryset.filter(status_since__lte=date_range["end"])
return queryset
def _apply_height_safety_filters(
self, queryset, filters: Dict[str, Any]
) -> models.QuerySet:
"""Apply height and safety requirement filters."""
# Minimum height range
if filters.get("min_height_range"):
height_range = filters["min_height_range"]
if height_range.get("min") is not None:
queryset = queryset.filter(min_height_in__gte=height_range["min"])
if height_range.get("max") is not None:
queryset = queryset.filter(min_height_in__lte=height_range["max"])
# Maximum height range
if filters.get("max_height_range"):
height_range = filters["max_height_range"]
if height_range.get("min") is not None:
queryset = queryset.filter(max_height_in__gte=height_range["min"])
if height_range.get("max") is not None:
queryset = queryset.filter(max_height_in__lte=height_range["max"])
return queryset
def _apply_performance_filters(
self, queryset, filters: Dict[str, Any]
) -> models.QuerySet:
"""Apply performance metric filters."""
# Capacity range
if filters.get("capacity_range"):
capacity_range = filters["capacity_range"]
if capacity_range.get("min") is not None:
queryset = queryset.filter(capacity_per_hour__gte=capacity_range["min"])
if capacity_range.get("max") is not None:
queryset = queryset.filter(capacity_per_hour__lte=capacity_range["max"])
# Duration range
if filters.get("duration_range"):
duration_range = filters["duration_range"]
if duration_range.get("min") is not None:
queryset = queryset.filter(
ride_duration_seconds__gte=duration_range["min"]
)
if duration_range.get("max") is not None:
queryset = queryset.filter(
ride_duration_seconds__lte=duration_range["max"]
)
# Rating range
if filters.get("rating_range"):
rating_range = filters["rating_range"]
if rating_range.get("min") is not None:
queryset = queryset.filter(average_rating__gte=rating_range["min"])
if rating_range.get("max") is not None:
queryset = queryset.filter(average_rating__lte=rating_range["max"])
return queryset
def _apply_relationship_filters(
self, queryset, filters: Dict[str, Any]
) -> models.QuerySet:
"""Apply relationship filters (manufacturer, designer, ride model)."""
# Manufacturer filter (multi-select)
if filters.get("manufacturer"):
manufacturers = (
filters["manufacturer"]
if isinstance(filters["manufacturer"], list)
else [filters["manufacturer"]]
)
if isinstance(manufacturers[0], str): # If slugs provided
queryset = queryset.filter(manufacturer__slug__in=manufacturers)
else: # If IDs provided
queryset = queryset.filter(manufacturer_id__in=manufacturers)
# Designer filter (multi-select)
if filters.get("designer"):
designers = (
filters["designer"]
if isinstance(filters["designer"], list)
else [filters["designer"]]
)
if isinstance(designers[0], str): # If slugs provided
queryset = queryset.filter(designer__slug__in=designers)
else: # If IDs provided
queryset = queryset.filter(designer_id__in=designers)
# Ride model filter (multi-select)
if filters.get("ride_model"):
models_list = (
filters["ride_model"]
if isinstance(filters["ride_model"], list)
else [filters["ride_model"]]
)
if isinstance(models_list[0], str): # If slugs provided
queryset = queryset.filter(ride_model__slug__in=models_list)
else: # If IDs provided
queryset = queryset.filter(ride_model_id__in=models_list)
return queryset
def _apply_roller_coaster_filters(
self, queryset, filters: Dict[str, Any]
) -> models.QuerySet:
"""Apply roller coaster specific filters."""
queryset = self._apply_numeric_range_filter(
queryset, filters, "height_ft_range", "rollercoasterstats__height_ft"
)
queryset = self._apply_numeric_range_filter(
queryset, filters, "length_ft_range", "rollercoasterstats__length_ft"
)
queryset = self._apply_numeric_range_filter(
queryset, filters, "speed_mph_range", "rollercoasterstats__speed_mph"
)
queryset = self._apply_numeric_range_filter(
queryset, filters, "inversions_range", "rollercoasterstats__inversions"
)
# Track material filter (multi-select)
if filters.get("track_material"):
materials = (
filters["track_material"]
if isinstance(filters["track_material"], list)
else [filters["track_material"]]
)
queryset = queryset.filter(rollercoasterstats__track_material__in=materials)
# Coaster type filter (multi-select)
if filters.get("coaster_type"):
types = (
filters["coaster_type"]
if isinstance(filters["coaster_type"], list)
else [filters["coaster_type"]]
)
queryset = queryset.filter(
rollercoasterstats__roller_coaster_type__in=types
)
# Propulsion system filter (multi-select)
if filters.get("propulsion_system"):
propulsion_systems = (
filters["propulsion_system"]
if isinstance(filters["propulsion_system"], list)
else [filters["propulsion_system"]]
)
queryset = queryset.filter(rollercoasterstats__propulsion_system__in=propulsion_systems)
return queryset
def _apply_numeric_range_filter(
self,
queryset,
filters: Dict[str, Any],
filter_key: str,
field_name: str,
) -> models.QuerySet:
"""Apply numeric range filter to reduce complexity."""
if filters.get(filter_key):
range_filter = filters[filter_key]
if range_filter.get("min") is not None:
queryset = queryset.filter(
**{f"{field_name}__gte": range_filter["min"]}
)
if range_filter.get("max") is not None:
queryset = queryset.filter(
**{f"{field_name}__lte": range_filter["max"]}
)
return queryset
def _apply_company_filters(
self, queryset, filters: Dict[str, Any]
) -> models.QuerySet:
"""Apply company-related filters."""
# Manufacturer roles filter
if filters.get("manufacturer_roles"):
roles = (
filters["manufacturer_roles"]
if isinstance(filters["manufacturer_roles"], list)
else [filters["manufacturer_roles"]]
)
queryset = queryset.filter(manufacturer__roles__overlap=roles)
# Designer roles filter
if filters.get("designer_roles"):
roles = (
filters["designer_roles"]
if isinstance(filters["designer_roles"], list)
else [filters["designer_roles"]]
)
queryset = queryset.filter(designer__roles__overlap=roles)
# Founded date range
if filters.get("founded_date_range"):
date_range = filters["founded_date_range"]
if date_range.get("start"):
queryset = queryset.filter(
Q(manufacturer__founded_date__gte=date_range["start"])
| Q(designer__founded_date__gte=date_range["start"])
)
if date_range.get("end"):
queryset = queryset.filter(
Q(manufacturer__founded_date__lte=date_range["end"])
| Q(designer__founded_date__lte=date_range["end"])
)
return queryset
def _apply_sorting(self, queryset, sort_by: str) -> models.QuerySet:
"""Apply sorting to the queryset."""
if sort_by not in self.SORT_OPTIONS:
sort_by = "relevance"
sort_field = self.SORT_OPTIONS[sort_by]
# Handle special case for relevance sorting
if sort_by == "relevance":
return queryset.order_by("-search_rank", "name")
# Apply the sorting
return queryset.order_by(
sort_field, "name"
) # Always add name as secondary sort
def _add_search_highlights(
self, results: List[Ride], search_term: str
) -> List[Ride]:
"""Add search highlights to results using SearchHeadline."""
if not search_term or not results:
return results
# Create search query for highlighting
SearchQuery(search_term, config="english")
# Add highlights to each result
# (note: highlights would need to be processed at query time)
for ride in results:
# Store highlighted versions as dynamic attributes (for template use)
setattr(ride, "highlighted_name", ride.name)
setattr(ride, "highlighted_description", ride.description)
return results
def _get_applied_filters_summary(self, filters: Dict[str, Any]) -> Dict[str, Any]:
"""Generate a summary of applied filters for the frontend."""
applied = {}
# Count filters in each category
for category, filter_keys in self.FILTER_CATEGORIES.items():
category_filters = []
for key in filter_keys:
if filters.get(key):
category_filters.append(
{
"key": key,
"value": filters[key],
"display_name": self._get_filter_display_name(key),
}
)
if category_filters:
applied[category] = category_filters
return applied
def _get_filter_display_name(self, filter_key: str) -> str:
"""Convert filter key to human-readable display name."""
display_names = {
"global_search": "Search",
"category": "Category",
"status": "Status",
"park": "Park",
"park_area": "Park Area",
"opening_date_range": "Opening Date",
"closing_date_range": "Closing Date",
"status_since_range": "Status Since",
"min_height_range": "Minimum Height",
"max_height_range": "Maximum Height",
"capacity_range": "Capacity",
"duration_range": "Duration",
"rating_range": "Rating",
"manufacturer": "Manufacturer",
"designer": "Designer",
"ride_model": "Ride Model",
"height_ft_range": "Height (ft)",
"length_ft_range": "Length (ft)",
"speed_mph_range": "Speed (mph)",
"inversions_range": "Inversions",
"track_material": "Track Material",
"coaster_type": "Coaster Type",
"propulsion_system": "Propulsion System",
"manufacturer_roles": "Manufacturer Roles",
"designer_roles": "Designer Roles",
"founded_date_range": "Founded Date",
}
if filter_key in display_names:
return display_names[filter_key]
else:
raise ValueError(f"Unknown filter key: {filter_key}")
def get_search_suggestions(
self, query: str, limit: int = 10
) -> List[Dict[str, Any]]:
"""
Get search suggestions for autocomplete functionality.
"""
if not query or len(query) < 2:
return []
suggestions = []
# Ride names with trigram similarity
ride_suggestions = (
Ride.objects.annotate(similarity=TrigramSimilarity("name", query))
.filter(similarity__gte=0.1)
.order_by("-similarity")
.values("name", "slug", "similarity")[: limit // 2]
)
for ride in ride_suggestions:
suggestions.append(
{
"type": "ride",
"text": ride["name"],
"slug": ride["slug"],
"score": ride["similarity"],
}
)
# Park names
park_suggestions = (
Park.objects.annotate(similarity=TrigramSimilarity("name", query))
.filter(similarity__gte=0.1)
.order_by("-similarity")
.values("name", "slug", "similarity")[: limit // 4]
)
for park in park_suggestions:
suggestions.append(
{
"type": "park",
"text": park["name"],
"slug": park["slug"],
"score": park["similarity"],
}
)
# Manufacturer names
manufacturer_suggestions = (
Company.objects.filter(roles__contains=["MANUFACTURER"])
.annotate(similarity=TrigramSimilarity("name", query))
.filter(similarity__gte=0.1)
.order_by("-similarity")
.values("name", "slug", "similarity")[: limit // 4]
)
for manufacturer in manufacturer_suggestions:
suggestions.append(
{
"type": "manufacturer",
"text": manufacturer["name"],
"slug": manufacturer["slug"],
"score": manufacturer["similarity"],
}
)
# Sort by score and return top results
suggestions.sort(key=lambda x: x["score"], reverse=True)
return suggestions[:limit]
def get_filter_options(
self, filter_type: str, context_filters: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""
Get available options for a specific filter type.
Optionally filter options based on current context.
"""
context_filters = context_filters or {}
base_queryset = self.base_queryset
# Apply context filters to narrow down options
if context_filters:
temp_filters = context_filters.copy()
temp_filters.pop(
filter_type, None
) # Remove the filter we're getting options for
base_queryset = self._apply_all_filters(base_queryset, temp_filters)
if filter_type == "park":
return list(
base_queryset.values("park__name", "park__slug")
.distinct()
.order_by("park__name")
)
elif filter_type == "manufacturer":
return list(
base_queryset.filter(manufacturer__isnull=False)
.values("manufacturer__name", "manufacturer__slug")
.distinct()
.order_by("manufacturer__name")
)
elif filter_type == "designer":
return list(
base_queryset.filter(designer__isnull=False)
.values("designer__name", "designer__slug")
.distinct()
.order_by("designer__name")
)
# Add more filter options as needed
return []
def _apply_all_filters(self, queryset, filters: Dict[str, Any]) -> models.QuerySet:
"""Apply all filters except search ranking."""
queryset = self._apply_basic_info_filters(queryset, filters)
queryset = self._apply_date_filters(queryset, filters)
queryset = self._apply_height_safety_filters(queryset, filters)
queryset = self._apply_performance_filters(queryset, filters)
queryset = self._apply_relationship_filters(queryset, filters)
queryset = self._apply_roller_coaster_filters(queryset, filters)
queryset = self._apply_company_filters(queryset, filters)
return queryset

View File

@@ -1,17 +0,0 @@
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

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

View File

@@ -1,26 +0,0 @@
{% 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

@@ -1,214 +0,0 @@
{% 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

@@ -1,38 +0,0 @@
from django import template
from django.templatetags.static import static
from ..choices import RIDE_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",
}
if category in category_images:
return static(category_images[category])
else:
raise ValueError(f"Unknown ride category: {category}")
@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"""
choices = {choice.value: choice.label for choice in RIDE_CATEGORIES}
if code in choices:
return choices[code]
else:
raise ValueError(f"Unknown ride category code: {code}")

View File

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

View File

@@ -1,86 +0,0 @@
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",
),
# Ranking endpoints
path("rankings/", views.RideRankingsView.as_view(), name="rankings"),
path(
"rankings/<slug:ride_slug>/",
views.RideRankingDetailView.as_view(),
name="ranking_detail",
),
path(
"rankings/<slug:ride_slug>/history-chart/",
views.ranking_history_chart,
name="ranking_history_chart",
),
path(
"rankings/<slug:ride_slug>/comparisons/",
views.ranking_comparisons,
name="ranking_comparisons",
),
# Company list views
path("manufacturers/", views.ManufacturerListView.as_view(), name="manufacturer_list"),
path("designers/", views.DesignerListView.as_view(), name="designer_list"),
# API endpoints moved to centralized backend/api/v1/rides/ structure
# Frontend requests to /api/ are proxied to /api/v1/ by Vite
# 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",
),
]

View File

@@ -1,700 +0,0 @@
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.http import HttpRequest, HttpResponse, Http404
from django.db.models import Count
from .models.rides import Ride, RideModel
from .choices import RIDE_CATEGORIES
from .models.company import Company
from .forms import RideForm, RideSearchForm
from .forms.search import MasterFilterForm
from .services.search import RideSearchService
from apps.parks.models import Park
from apps.moderation.mixins import EditSubmissionMixin, HistoryMixin
from apps.moderation.services import ModerationService
from .models.rankings import RideRanking, RankingSnapshot
from .services.ranking_service import RideRankingService
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"):
ModerationService.create_edit_submission_with_queue(
content_object=None,
changes={"name": manufacturer_name, "roles": ["MANUFACTURER"]},
submitter=self.request.user,
submission_type="CREATE",
reason=f"New manufacturer suggested during ride creation: {manufacturer_name}",
)
# Check for new designer
designer_name = form.cleaned_data.get("designer_search")
if designer_name and not form.cleaned_data.get("designer"):
ModerationService.create_edit_submission_with_queue(
content_object=None,
changes={"name": designer_name, "roles": ["DESIGNER"]},
submitter=self.request.user,
submission_type="CREATE",
reason=f"New designer suggested during ride creation: {designer_name}",
)
# 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:
ModerationService.create_edit_submission_with_queue(
content_object=None,
changes={
"name": ride_model_name,
"manufacturer": manufacturer.id,
},
submitter=self.request.user,
submission_type="CREATE",
reason=f"New ride model suggested during ride creation: {ride_model_name}",
)
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"):
ModerationService.create_edit_submission_with_queue(
content_object=None,
changes={"name": manufacturer_name, "roles": ["MANUFACTURER"]},
submitter=self.request.user,
submission_type="CREATE",
reason=f"New manufacturer suggested during ride update: {manufacturer_name}",
)
# Check for new designer
designer_name = form.cleaned_data.get("designer_search")
if designer_name and not form.cleaned_data.get("designer"):
ModerationService.create_edit_submission_with_queue(
content_object=None,
changes={"name": designer_name, "roles": ["DESIGNER"]},
submitter=self.request.user,
submission_type="CREATE",
reason=f"New designer suggested during ride update: {designer_name}",
)
# 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:
ModerationService.create_edit_submission_with_queue(
content_object=None,
changes={
"name": ride_model_name,
"manufacturer": manufacturer.id,
},
submitter=self.request.user,
submission_type="CREATE",
reason=f"New ride model suggested during ride update: {ride_model_name}",
)
return super().form_valid(form)
class RideListView(ListView):
"""Enhanced view for displaying a list of rides with advanced filtering"""
model = Ride
template_name = "rides/ride_list.html"
context_object_name = "rides"
paginate_by = 24
def get_queryset(self):
"""Get filtered rides using the advanced search service"""
# Initialize search service
search_service = RideSearchService()
# Parse filters from request
filter_form = MasterFilterForm(self.request.GET)
# Apply park context if available
park = None
if "park_slug" in self.kwargs:
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
park = self.park
# For now, use a simpler approach until we can properly integrate the search service
queryset = (
Ride.objects.all()
.select_related("park", "ride_model", "ride_model__manufacturer")
.prefetch_related("photos")
)
if park:
queryset = queryset.filter(park=park)
# Apply basic search if provided
search_query = self.request.GET.get('search', '').strip()
if search_query:
queryset = queryset.filter(name__icontains=search_query)
return queryset
def get_template_names(self):
"""Return appropriate template based on request type"""
if hasattr(self.request, "htmx") and self.request.htmx:
return ["rides/partials/ride_list_results.html"]
return [self.template_name]
def get_context_data(self, **kwargs):
"""Add filter form and context data"""
context = super().get_context_data(**kwargs)
# Add park context
if hasattr(self, "park"):
context["park"] = self.park
context["park_slug"] = self.kwargs["park_slug"]
# Add filter form
filter_form = MasterFilterForm(self.request.GET)
context["filter_form"] = filter_form
# Use Rich Choice registry directly
from apps.core.choices.registry import get_choices
choices = get_choices("categories", "rides")
context["category_choices"] = [(choice.value, choice.label) for choice in choices]
# Add filter summary for display
if filter_form.is_valid():
context["active_filters"] = filter_form.get_filter_summary()
context["has_filters"] = filter_form.has_active_filters()
else:
context["active_filters"] = {}
context["has_filters"] = False
# Add total count before filtering (for display purposes)
if hasattr(self, "park"):
context["total_rides"] = Ride.objects.filter(park=self.park).count()
else:
context["total_rides"] = Ride.objects.count()
# Add filtered count
context["filtered_count"] = self.get_queryset().count()
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"]
# Find the category choice by value using Rich Choice registry
from apps.core.choices.registry import get_choices
choices = get_choices("categories", "rides")
category_choice = next((choice for choice in choices if choice.value == self.kwargs["category"]), None)
context["category"] = category_choice.label if category_choice else "Unknown"
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
from apps.core.choices.registry import get_choices
choices = get_choices("categories", "rides")
for choice in choices:
if query in choice.label.lower():
ride_count = Ride.objects.filter(category=choice.value).count()
suggestions.append(
{
"type": "category",
"code": choice.value,
"text": choice.label,
"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
class RideRankingsView(ListView):
"""View for displaying ride rankings using the Internet Roller Coaster Poll."""
model = RideRanking
template_name = "rides/rankings.html"
context_object_name = "rankings"
paginate_by = 50
def get_queryset(self):
"""Get rankings with optimized queries."""
queryset = RideRanking.objects.select_related(
"ride", "ride__park", "ride__manufacturer", "ride__ride_model"
).order_by("rank")
# Filter by category if specified
category = self.request.GET.get("category")
if category and category != "all":
queryset = queryset.filter(ride__category=category)
# Filter by minimum mutual riders
min_riders = self.request.GET.get("min_riders")
if min_riders:
try:
min_riders = int(min_riders)
queryset = queryset.filter(mutual_riders_count__gte=min_riders)
except ValueError:
pass
return queryset
def get_template_names(self):
"""Return appropriate template based on request type."""
if self.request.htmx:
return ["rides/partials/rankings_table.html"]
return [self.template_name]
def get_context_data(self, **kwargs):
"""Add context for rankings view."""
context = super().get_context_data(**kwargs)
# Use Rich Choice registry directly
from apps.core.choices.registry import get_choices
choices = get_choices("categories", "rides")
context["category_choices"] = [(choice.value, choice.label) for choice in choices]
context["selected_category"] = self.request.GET.get("category", "all")
context["min_riders"] = self.request.GET.get("min_riders", "")
# Add statistics
if self.object_list:
context["total_ranked"] = RideRanking.objects.count()
context["last_updated"] = (
self.object_list[0].last_calculated if self.object_list else None
)
return context
class RideRankingDetailView(DetailView):
"""View for displaying detailed ranking information for a specific ride."""
model = Ride
template_name = "rides/ranking_detail.html"
slug_url_kwarg = "ride_slug"
def get_queryset(self):
"""Get ride with ranking data."""
return Ride.objects.select_related(
"park", "manufacturer", "ranking"
).prefetch_related("comparisons_as_a", "comparisons_as_b", "ranking_history")
def get_context_data(self, **kwargs):
"""Add ranking details to context."""
context = super().get_context_data(**kwargs)
# Get ranking details from service
service = RideRankingService()
ranking_details = service.get_ride_ranking_details(self.object)
if ranking_details:
context.update(ranking_details)
# Get recent movement
recent_snapshots = RankingSnapshot.objects.filter(
ride=self.object
).order_by("-snapshot_date")[:7]
if len(recent_snapshots) >= 2:
context["rank_change"] = (
recent_snapshots[0].rank - recent_snapshots[1].rank
)
context["previous_rank"] = recent_snapshots[1].rank
else:
context["not_ranked"] = True
return context
def ranking_history_chart(request: HttpRequest, ride_slug: str) -> HttpResponse:
"""HTMX endpoint for ranking history chart data."""
ride = get_object_or_404(Ride, slug=ride_slug)
# Get last 30 days of ranking history
history = RankingSnapshot.objects.filter(ride=ride).order_by("-snapshot_date")[:30]
# Prepare data for chart
chart_data = [
{
"date": snapshot.snapshot_date.isoformat(),
"rank": snapshot.rank,
"win_pct": float(snapshot.winning_percentage) * 100,
}
for snapshot in reversed(history)
]
return render(
request,
"rides/partials/ranking_chart.html",
{"chart_data": chart_data, "ride": ride},
)
def ranking_comparisons(request: HttpRequest, ride_slug: str) -> HttpResponse:
"""HTMX endpoint for ride head-to-head comparisons."""
ride = get_object_or_404(Ride, slug=ride_slug)
# Get head-to-head comparisons
from django.db.models import Q
from .models.rankings import RidePairComparison
comparisons = (
RidePairComparison.objects.filter(Q(ride_a=ride) | Q(ride_b=ride))
.select_related("ride_a", "ride_b", "ride_a__park", "ride_b__park")
.order_by("-mutual_riders_count")[:20]
)
# Format comparisons for display
comparison_data = []
for comp in comparisons:
if comp.ride_a == ride:
opponent = comp.ride_b
wins = comp.ride_a_wins
losses = comp.ride_b_wins
else:
opponent = comp.ride_a
wins = comp.ride_b_wins
losses = comp.ride_a_wins
result = "win" if wins > losses else "loss" if losses > wins else "tie"
comparison_data.append(
{
"opponent": opponent,
"wins": wins,
"losses": losses,
"ties": comp.ties,
"result": result,
"mutual_riders": comp.mutual_riders_count,
}
)
return render(
request,
"rides/partials/ranking_comparisons.html",
{"comparisons": comparison_data, "ride": ride},
)
class ManufacturerListView(ListView):
"""View for displaying a list of ride manufacturers"""
model = Company
template_name = "manufacturers/manufacturer_list.html"
context_object_name = "manufacturers"
paginate_by = 24
def get_queryset(self):
"""Get companies that are manufacturers"""
return (
Company.objects.filter(roles__contains=["MANUFACTURER"])
.annotate(ride_count=Count("manufactured_rides"))
.order_by("name")
)
def get_context_data(self, **kwargs):
"""Add context data"""
context = super().get_context_data(**kwargs)
context["total_manufacturers"] = self.get_queryset().count()
return context
class DesignerListView(ListView):
"""View for displaying a list of ride designers"""
model = Company
template_name = "designers/designer_list.html"
context_object_name = "designers"
paginate_by = 24
def get_queryset(self):
"""Get companies that are designers"""
return (
Company.objects.filter(roles__contains=["DESIGNER"])
.annotate(ride_count=Count("designed_rides"))
.order_by("name")
)
def get_context_data(self, **kwargs):
"""Add context data"""
context = super().get_context_data(**kwargs)
context["total_designers"] = self.get_queryset().count()
return context