feat: Implement initial schema and add various API, service, and management command enhancements across the application.

This commit is contained in:
pacnpal
2026-01-01 15:13:01 -05:00
parent c95f99ca10
commit b243b17af7
413 changed files with 11164 additions and 17433 deletions

View File

@@ -9,4 +9,4 @@ companies, rankings, and search functionality.
from . import choices
# Ensure choices are registered on app startup
__all__ = ['choices']
__all__ = ["choices"]

View File

@@ -875,12 +875,8 @@ class RideReviewAdmin(QueryOptimizationMixin, ExportActionMixin, BaseModelAdmin)
"""Display moderation status with color coding."""
if obj.moderated_by:
if obj.is_published:
return format_html(
'<span style="color: green; font-weight: bold;">Approved</span>'
)
return format_html(
'<span style="color: red; font-weight: bold;">Rejected</span>'
)
return format_html('<span style="color: green; font-weight: bold;">Approved</span>')
return format_html('<span style="color: red; font-weight: bold;">Rejected</span>')
return format_html('<span style="color: orange;">Pending</span>')
def save_model(self, request, obj, form, change):
@@ -987,9 +983,7 @@ class CompanyAdmin(
(
"Company Details",
{
"fields": (
"founded_date",
),
"fields": ("founded_date",),
"classes": ("collapse",),
"description": "Historical information about the company.",
},
@@ -1024,7 +1018,7 @@ class CompanyAdmin(
color = colors.get(role, "#6c757d")
badges.append(
f'<span style="background-color: {color}; color: white; '
f'padding: 2px 6px; border-radius: 3px; font-size: 10px; '
f"padding: 2px 6px; border-radius: 3px; font-size: 10px; "
f'margin-right: 4px;">{role}</span>'
)
return format_html("".join(badges))

View File

@@ -23,9 +23,7 @@ class RidesConfig(AppConfig):
from apps.rides.models import Ride
# Register FSM transitions for Ride
apply_state_machine(
Ride, field_name="status", choice_group="statuses", domain="rides"
)
apply_state_machine(Ride, field_name="status", choice_group="statuses", domain="rides")
def _register_callbacks(self):
"""Register FSM transition callbacks for ride models."""
@@ -41,43 +39,19 @@ class RidesConfig(AppConfig):
from apps.rides.models import Ride
# Cache invalidation for all ride status changes
register_callback(
Ride, 'status', '*', '*',
RideCacheInvalidation()
)
register_callback(Ride, "status", "*", "*", RideCacheInvalidation())
# API cache invalidation
register_callback(
Ride, 'status', '*', '*',
APICacheInvalidation(include_geo_cache=True)
)
register_callback(Ride, "status", "*", "*", APICacheInvalidation(include_geo_cache=True))
# Park count updates for status changes that affect active rides
register_callback(
Ride, 'status', '*', 'OPERATING',
ParkCountUpdateCallback()
)
register_callback(
Ride, 'status', 'OPERATING', '*',
ParkCountUpdateCallback()
)
register_callback(
Ride, 'status', '*', 'CLOSED_PERM',
ParkCountUpdateCallback()
)
register_callback(
Ride, 'status', '*', 'DEMOLISHED',
ParkCountUpdateCallback()
)
register_callback(
Ride, 'status', '*', 'RELOCATED',
ParkCountUpdateCallback()
)
register_callback(Ride, "status", "*", "OPERATING", ParkCountUpdateCallback())
register_callback(Ride, "status", "OPERATING", "*", ParkCountUpdateCallback())
register_callback(Ride, "status", "*", "CLOSED_PERM", ParkCountUpdateCallback())
register_callback(Ride, "status", "*", "DEMOLISHED", ParkCountUpdateCallback())
register_callback(Ride, "status", "*", "RELOCATED", ParkCountUpdateCallback())
# Search text update
register_callback(
Ride, 'status', '*', '*',
SearchTextUpdateCallback()
)
register_callback(Ride, "status", "*", "*", SearchTextUpdateCallback())
logger.debug("Registered ride transition callbacks")

View File

@@ -14,73 +14,48 @@ RIDE_CATEGORIES = [
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
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
"color": "purple",
"icon": "dark-ride",
"css_class": "bg-purple-100 text-purple-800",
"sort_order": 2,
},
category=ChoiceCategory.CLASSIFICATION
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
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
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
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
metadata={"color": "gray", "icon": "other", "css_class": "bg-gray-100 text-gray-800", "sort_order": 6},
category=ChoiceCategory.CLASSIFICATION,
),
]
@@ -91,140 +66,140 @@ RIDE_STATUSES = [
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,
'can_transition_to': [
'CLOSED_TEMP',
'SBNO',
'CLOSING',
"color": "green",
"icon": "check-circle",
"css_class": "bg-green-100 text-green-800",
"sort_order": 1,
"can_transition_to": [
"CLOSED_TEMP",
"SBNO",
"CLOSING",
],
'requires_moderator': False,
'is_final': False,
'is_initial': True,
"requires_moderator": False,
"is_final": False,
"is_initial": True,
},
category=ChoiceCategory.STATUS
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,
'can_transition_to': [
'SBNO',
'CLOSING',
"color": "yellow",
"icon": "pause-circle",
"css_class": "bg-yellow-100 text-yellow-800",
"sort_order": 2,
"can_transition_to": [
"SBNO",
"CLOSING",
],
'requires_moderator': False,
'is_final': False,
"requires_moderator": False,
"is_final": False,
},
category=ChoiceCategory.STATUS
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,
'can_transition_to': [
'CLOSED_PERM',
'DEMOLISHED',
'RELOCATED',
"color": "orange",
"icon": "stop-circle",
"css_class": "bg-orange-100 text-orange-800",
"sort_order": 3,
"can_transition_to": [
"CLOSED_PERM",
"DEMOLISHED",
"RELOCATED",
],
'requires_moderator': True,
'is_final': False,
"requires_moderator": True,
"is_final": False,
},
category=ChoiceCategory.STATUS
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,
'can_transition_to': [
'CLOSED_PERM',
'SBNO',
"color": "red",
"icon": "x-circle",
"css_class": "bg-red-100 text-red-800",
"sort_order": 4,
"can_transition_to": [
"CLOSED_PERM",
"SBNO",
],
'requires_moderator': True,
'is_final': False,
"requires_moderator": True,
"is_final": False,
},
category=ChoiceCategory.STATUS
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,
'can_transition_to': [
'DEMOLISHED',
'RELOCATED',
"color": "red",
"icon": "x-circle",
"css_class": "bg-red-100 text-red-800",
"sort_order": 5,
"can_transition_to": [
"DEMOLISHED",
"RELOCATED",
],
'requires_moderator': True,
'is_final': False,
"requires_moderator": True,
"is_final": False,
},
category=ChoiceCategory.STATUS
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,
'can_transition_to': [
'OPERATING',
"color": "blue",
"icon": "tool",
"css_class": "bg-blue-100 text-blue-800",
"sort_order": 6,
"can_transition_to": [
"OPERATING",
],
'requires_moderator': False,
'is_final': False,
"requires_moderator": False,
"is_final": False,
},
category=ChoiceCategory.STATUS
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,
'can_transition_to': [],
'requires_moderator': True,
'is_final': True,
"color": "gray",
"icon": "trash",
"css_class": "bg-gray-100 text-gray-800",
"sort_order": 7,
"can_transition_to": [],
"requires_moderator": True,
"is_final": True,
},
category=ChoiceCategory.STATUS
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,
'can_transition_to': [],
'requires_moderator': True,
'is_final': True,
"color": "purple",
"icon": "arrow-right",
"css_class": "bg-purple-100 text-purple-800",
"sort_order": 8,
"can_transition_to": [],
"requires_moderator": True,
"is_final": True,
},
category=ChoiceCategory.STATUS
category=ChoiceCategory.STATUS,
),
]
@@ -235,24 +210,19 @@ POST_CLOSING_STATUSES = [
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
"color": "orange",
"icon": "stop-circle",
"css_class": "bg-orange-100 text-orange-800",
"sort_order": 1,
},
category=ChoiceCategory.STATUS
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
metadata={"color": "red", "icon": "x-circle", "css_class": "bg-red-100 text-red-800", "sort_order": 2},
category=ChoiceCategory.STATUS,
),
]
@@ -262,37 +232,22 @@ TRACK_MATERIALS = [
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
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
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
metadata={"color": "orange", "icon": "hybrid", "css_class": "bg-orange-100 text-orange-800", "sort_order": 3},
category=ChoiceCategory.TECHNICAL,
),
]
@@ -302,133 +257,83 @@ COASTER_TYPES = [
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
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
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
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
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
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
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
"color": "emerald",
"icon": "family",
"css_class": "bg-emerald-100 text-emerald-800",
"sort_order": 7,
},
category=ChoiceCategory.CLASSIFICATION
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
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
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
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
metadata={"color": "gray", "icon": "other", "css_class": "bg-gray-100 text-gray-800", "sort_order": 11},
category=ChoiceCategory.CLASSIFICATION,
),
]
@@ -438,61 +343,36 @@ PROPULSION_SYSTEMS = [
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
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
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
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
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
metadata={"color": "gray", "icon": "other", "css_class": "bg-gray-100 text-gray-800", "sort_order": 5},
category=ChoiceCategory.TECHNICAL,
),
]
@@ -502,61 +382,36 @@ TARGET_MARKETS = [
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
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
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
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
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
metadata={"color": "blue", "icon": "all-ages", "css_class": "bg-blue-100 text-blue-800", "sort_order": 5},
category=ChoiceCategory.CLASSIFICATION,
),
]
@@ -566,61 +421,41 @@ PHOTO_TYPES = [
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
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
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
"color": "green",
"icon": "installation",
"css_class": "bg-green-100 text-green-800",
"sort_order": 3,
},
category=ChoiceCategory.CLASSIFICATION
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
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
metadata={"color": "orange", "icon": "catalog", "css_class": "bg-orange-100 text-orange-800", "sort_order": 5},
category=ChoiceCategory.CLASSIFICATION,
),
]
@@ -630,97 +465,62 @@ SPEC_CATEGORIES = [
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
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
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
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
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
"color": "purple",
"icon": "lightning",
"css_class": "bg-purple-100 text-purple-800",
"sort_order": 5,
},
category=ChoiceCategory.TECHNICAL
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
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
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
metadata={"color": "gray", "icon": "other", "css_class": "bg-gray-100 text-gray-800", "sort_order": 8},
category=ChoiceCategory.TECHNICAL,
),
]
@@ -731,30 +531,30 @@ RIDES_COMPANY_ROLES = [
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}/'
"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
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}/'
"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
category=ChoiceCategory.CLASSIFICATION,
),
]
@@ -767,7 +567,7 @@ def register_rides_choices():
choices=RIDE_CATEGORIES,
domain="rides",
description="Ride category classifications",
metadata={'domain': 'rides', 'type': 'category'}
metadata={"domain": "rides", "type": "category"},
)
register_choices(
@@ -775,7 +575,7 @@ def register_rides_choices():
choices=RIDE_STATUSES,
domain="rides",
description="Ride operational status options",
metadata={'domain': 'rides', 'type': 'status'}
metadata={"domain": "rides", "type": "status"},
)
register_choices(
@@ -783,7 +583,7 @@ def register_rides_choices():
choices=POST_CLOSING_STATUSES,
domain="rides",
description="Status options after ride closure",
metadata={'domain': 'rides', 'type': 'post_closing_status'}
metadata={"domain": "rides", "type": "post_closing_status"},
)
register_choices(
@@ -791,7 +591,7 @@ def register_rides_choices():
choices=TRACK_MATERIALS,
domain="rides",
description="Roller coaster track material types",
metadata={'domain': 'rides', 'type': 'track_material', 'applies_to': 'roller_coasters'}
metadata={"domain": "rides", "type": "track_material", "applies_to": "roller_coasters"},
)
register_choices(
@@ -799,7 +599,7 @@ def register_rides_choices():
choices=COASTER_TYPES,
domain="rides",
description="Roller coaster type classifications",
metadata={'domain': 'rides', 'type': 'coaster_type', 'applies_to': 'roller_coasters'}
metadata={"domain": "rides", "type": "coaster_type", "applies_to": "roller_coasters"},
)
register_choices(
@@ -807,7 +607,7 @@ def register_rides_choices():
choices=PROPULSION_SYSTEMS,
domain="rides",
description="Roller coaster propulsion and lift systems",
metadata={'domain': 'rides', 'type': 'propulsion_system', 'applies_to': 'roller_coasters'}
metadata={"domain": "rides", "type": "propulsion_system", "applies_to": "roller_coasters"},
)
register_choices(
@@ -815,7 +615,7 @@ def register_rides_choices():
choices=TARGET_MARKETS,
domain="rides",
description="Target market classifications for ride models",
metadata={'domain': 'rides', 'type': 'target_market', 'applies_to': 'ride_models'}
metadata={"domain": "rides", "type": "target_market", "applies_to": "ride_models"},
)
register_choices(
@@ -823,7 +623,7 @@ def register_rides_choices():
choices=PHOTO_TYPES,
domain="rides",
description="Photo type classifications for ride model images",
metadata={'domain': 'rides', 'type': 'photo_type', 'applies_to': 'ride_model_photos'}
metadata={"domain": "rides", "type": "photo_type", "applies_to": "ride_model_photos"},
)
register_choices(
@@ -831,7 +631,7 @@ def register_rides_choices():
choices=SPEC_CATEGORIES,
domain="rides",
description="Technical specification category classifications",
metadata={'domain': 'rides', 'type': 'spec_category', 'applies_to': 'ride_model_specs'}
metadata={"domain": "rides", "type": "spec_category", "applies_to": "ride_model_specs"},
)
register_choices(
@@ -839,7 +639,7 @@ def register_rides_choices():
choices=RIDES_COMPANY_ROLES,
domain="rides",
description="Company role classifications for rides domain (MANUFACTURER and DESIGNER only)",
metadata={'domain': 'rides', 'type': 'company_role'}
metadata={"domain": "rides", "type": "company_role"},
)

View File

@@ -1,5 +1,3 @@
def get_ride_display_changes(changes: dict) -> dict:
"""Returns a human-readable version of the ride changes"""
field_names = {

View File

@@ -348,9 +348,7 @@ class RideForm(forms.ModelForm):
# editing
if self.instance and self.instance.pk:
if self.instance.manufacturer:
self.fields["manufacturer_search"].initial = (
self.instance.manufacturer.name
)
self.fields["manufacturer_search"].initial = self.instance.manufacturer.name
self.fields["manufacturer"].initial = self.instance.manufacturer
if self.instance.designer:
self.fields["designer_search"].initial = self.instance.designer.name

View File

@@ -348,9 +348,7 @@ class RideForm(forms.ModelForm):
# editing
if self.instance and self.instance.pk:
if self.instance.manufacturer:
self.fields["manufacturer_search"].initial = (
self.instance.manufacturer.name
)
self.fields["manufacturer_search"].initial = self.instance.manufacturer.name
self.fields["manufacturer"].initial = self.instance.manufacturer
if self.instance.designer:
self.fields["designer_search"].initial = self.instance.designer.name

View File

@@ -105,8 +105,8 @@ class BasicInfoForm(BaseFilterForm):
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
self.fields["category"].choices = category_choices
self.fields["status"].choices = status_choices
category = forms.MultipleChoiceField(
choices=[], # Will be populated in __init__
@@ -123,17 +123,13 @@ class BasicInfoForm(BaseFilterForm):
park = forms.ModelMultipleChoiceField(
queryset=Park.objects.all(),
required=False,
widget=forms.CheckboxSelectMultiple(
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
),
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"}
),
widget=forms.CheckboxSelectMultiple(attrs={"class": "max-h-48 overflow-y-auto space-y-1"}),
)
@@ -266,7 +262,7 @@ class NumberRangeField(forms.MultiValueField):
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 and value.get("min") is not None and value.get("max") is not None: # noqa: SIM102
if value["min"] > value["max"]:
raise ValidationError("Minimum value must be less than maximum value.")
@@ -282,17 +278,13 @@ class HeightSafetyForm(BaseFilterForm):
label="Minimum Height (inches)",
)
max_height_range = NumberRangeField(
min_val=0, max_val=84, step=1, required=False, label="Maximum 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"
)
capacity_range = NumberRangeField(min_val=0, max_val=5000, step=50, required=False, label="Capacity per Hour")
duration_range = NumberRangeField(
min_val=0,
@@ -302,9 +294,7 @@ class PerformanceForm(BaseFilterForm):
label="Duration (seconds)",
)
rating_range = NumberRangeField(
min_val=0.0, max_val=10.0, step=0.1, required=False, label="Average Rating"
)
rating_range = NumberRangeField(min_val=0.0, max_val=10.0, step=0.1, required=False, label="Average Rating")
class RelationshipsForm(BaseFilterForm):
@@ -313,25 +303,19 @@ class RelationshipsForm(BaseFilterForm):
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"}
),
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"}
),
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"}
),
widget=forms.CheckboxSelectMultiple(attrs={"class": "max-h-48 overflow-y-auto space-y-1"}),
)
@@ -347,28 +331,22 @@ class RollerCoasterForm(BaseFilterForm):
# 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")]
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
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)"
)
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)"
)
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)"
)
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"
)
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__
@@ -379,17 +357,13 @@ class RollerCoasterForm(BaseFilterForm):
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"}
),
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"}
),
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2 max-h-48 overflow-y-auto"}),
)
@@ -408,8 +382,8 @@ class CompanyForm(BaseFilterForm):
role_choices = rides_roles + parks_roles
# Update field choices dynamically
self.fields['manufacturer_roles'].choices = role_choices
self.fields['designer_roles'].choices = role_choices
self.fields["manufacturer_roles"].choices = role_choices
self.fields["designer_roles"].choices = role_choices
manufacturer_roles = forms.MultipleChoiceField(
choices=[], # Will be populated in __init__
@@ -423,9 +397,7 @@ class CompanyForm(BaseFilterForm):
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
)
founded_date_range = DateRangeField(
required=False, label="Company Founded Date Range"
)
founded_date_range = DateRangeField(required=False, label="Company Founded Date Range")
class SortingForm(BaseFilterForm):
@@ -452,7 +424,7 @@ class SortingForm(BaseFilterForm):
("capacity_desc", "Capacity (Highest)"),
]
self.fields['sort_by'].choices = sort_choices
self.fields["sort_by"].choices = sort_choices
sort_by = forms.ChoiceField(
choices=[], # Will be populated in __init__
@@ -578,8 +550,7 @@ class MasterFilterForm(BaseFilterForm):
{
"field": field_name,
"value": value,
"label": self.fields[field_name].label
or field_name.replace("_", " ").title(),
"label": self.fields[field_name].label or field_name.replace("_", " ").title(),
}
)

View File

@@ -19,11 +19,7 @@ class Command(BaseCommand):
category = options.get("category")
service = RideRankingService()
self.stdout.write(
self.style.SUCCESS(
f"Starting ride ranking calculation at {timezone.now().isoformat()}"
)
)
self.stdout.write(self.style.SUCCESS(f"Starting ride ranking calculation at {timezone.now().isoformat()}"))
result = service.update_all_rankings(category=category)

View File

@@ -3,7 +3,6 @@ Custom managers and QuerySets for Rides models.
Optimized queries following Django styleguide patterns.
"""
from django.db.models import Count, F, Prefetch, Q
from apps.core.managers import (
@@ -35,9 +34,7 @@ class RideQuerySet(StatusQuerySet, ReviewableQuerySet):
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)
)
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."""
@@ -54,8 +51,7 @@ class RideQuerySet(StatusQuerySet, ReviewableQuerySet):
def with_capacity_info(self):
"""Add capacity-related annotations."""
return self.annotate(
estimated_daily_capacity=F("capacity_per_hour")
* 10, # Assuming 10 operating hours
estimated_daily_capacity=F("capacity_per_hour") * 10, # Assuming 10 operating hours
duration_minutes=F("ride_duration_seconds") / 60.0,
)
@@ -65,9 +61,7 @@ class RideQuerySet(StatusQuerySet, ReviewableQuerySet):
def optimized_for_list(self):
"""Optimize for ride list display."""
return self.select_related(
"park", "park_area", "manufacturer", "designer", "ride_model"
).with_review_stats()
return self.select_related("park", "park_area", "manufacturer", "designer", "ride_model").with_review_stats()
def optimized_for_detail(self):
"""Optimize for ride detail display."""
@@ -94,9 +88,7 @@ class RideQuerySet(StatusQuerySet, ReviewableQuerySet):
def with_coaster_stats(self):
"""Always prefetch coaster_stats for roller coaster queries."""
return self.select_related(
"park", "manufacturer", "ride_model"
).prefetch_related("coaster_stats")
return self.select_related("park", "manufacturer", "ride_model").prefetch_related("coaster_stats")
def for_map_display(self):
"""Optimize for map display."""
@@ -129,14 +121,12 @@ class RideQuerySet(StatusQuerySet, ReviewableQuerySet):
if min_height:
queryset = queryset.filter(
Q(rollercoaster_stats__height_ft__gte=min_height)
| Q(min_height_in__gte=min_height)
Q(rollercoaster_stats__height_ft__gte=min_height) | Q(min_height_in__gte=min_height)
)
if max_height:
queryset = queryset.filter(
Q(rollercoaster_stats__height_ft__lte=max_height)
| Q(max_height_in__lte=max_height)
Q(rollercoaster_stats__height_ft__lte=max_height) | Q(max_height_in__lte=max_height)
)
if min_speed:
@@ -146,10 +136,7 @@ class RideQuerySet(StatusQuerySet, ReviewableQuerySet):
if inversions:
queryset = queryset.filter(rollercoaster_stats__inversions__gt=0)
else:
queryset = queryset.filter(
Q(rollercoaster_stats__inversions=0)
| Q(rollercoaster_stats__isnull=True)
)
queryset = queryset.filter(Q(rollercoaster_stats__inversions=0) | Q(rollercoaster_stats__isnull=True))
return queryset
@@ -167,9 +154,7 @@ class RideManager(StatusManager, ReviewableManager):
return self.get_queryset().thrill_rides()
def family_friendly(self, *, max_height_requirement: int = 42):
return self.get_queryset().family_friendly(
max_height_requirement=max_height_requirement
)
return self.get_queryset().family_friendly(max_height_requirement=max_height_requirement)
def by_park(self, *, park_id: int):
return self.get_queryset().by_park(park_id=park_id)
@@ -203,9 +188,7 @@ class RideModelQuerySet(BaseQuerySet):
"""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
),
operating_rides_count=Count("rides", filter=Q(rides__status="OPERATING"), distinct=True),
)
def popular_models(self, *, min_installations: int = 5):
@@ -260,9 +243,7 @@ class RideReviewManager(BaseManager):
return self.get_queryset().for_ride(ride_id=ride_id)
def by_rating_range(self, *, min_rating: int = 1, max_rating: int = 10):
return self.get_queryset().by_rating_range(
min_rating=min_rating, max_rating=max_rating
)
return self.get_queryset().by_rating_range(min_rating=min_rating, max_rating=max_rating)
class RollerCoasterStatsQuerySet(BaseQuerySet):

View File

@@ -190,9 +190,7 @@ class Migration(migrations.Migration):
),
(
"average_rating",
models.DecimalField(
blank=True, decimal_places=2, max_digits=3, null=True
),
models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True),
),
],
options={
@@ -374,21 +372,15 @@ class Migration(migrations.Migration):
),
(
"height_ft",
models.DecimalField(
blank=True, decimal_places=2, max_digits=6, null=True
),
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
),
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
),
models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True),
),
("inversions", models.PositiveIntegerField(default=0)),
(
@@ -432,9 +424,7 @@ class Migration(migrations.Migration):
),
(
"max_drop_height_ft",
models.DecimalField(
blank=True, decimal_places=2, max_digits=6, null=True
),
models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True),
),
(
"launch_type",
@@ -692,9 +682,7 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="ridelocation",
index=models.Index(
fields=["park_area"], name="rides_ridel_park_ar_26c90c_idx"
),
index=models.Index(fields=["park_area"], name="rides_ridel_park_ar_26c90c_idx"),
),
migrations.AlterUniqueTogether(
name="ridemodel",

View File

@@ -89,9 +89,7 @@ class Migration(migrations.Migration):
),
(
"average_rating",
models.DecimalField(
blank=True, decimal_places=2, max_digits=3, null=True
),
models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True),
),
],
options={
@@ -140,21 +138,15 @@ class Migration(migrations.Migration):
("id", models.BigIntegerField()),
(
"height_ft",
models.DecimalField(
blank=True, decimal_places=2, max_digits=6, null=True
),
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
),
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
),
models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True),
),
("inversions", models.PositiveIntegerField(default=0)),
(
@@ -198,9 +190,7 @@ class Migration(migrations.Migration):
),
(
"max_drop_height_ft",
models.DecimalField(
blank=True, decimal_places=2, max_digits=6, null=True
),
models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True),
),
(
"launch_type",

View File

@@ -220,9 +220,7 @@ class Migration(migrations.Migration):
),
(
"rank",
models.PositiveIntegerField(
db_index=True, help_text="Overall rank position (1 = best)"
),
models.PositiveIntegerField(db_index=True, help_text="Overall rank position (1 = best)"),
),
(
"wins",
@@ -323,9 +321,7 @@ class Migration(migrations.Migration):
("id", models.BigIntegerField()),
(
"rank",
models.PositiveIntegerField(
help_text="Overall rank position (1 = best)"
),
models.PositiveIntegerField(help_text="Overall rank position (1 = best)"),
),
(
"wins",
@@ -487,15 +483,11 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="ridepaircomparison",
index=models.Index(
fields=["ride_a", "ride_b"], name="rides_ridep_ride_a__eb0674_idx"
),
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"
),
index=models.Index(fields=["last_calculated"], name="rides_ridep_last_ca_bd9f6c_idx"),
),
migrations.AlterUniqueTogether(
name="ridepaircomparison",
@@ -551,9 +543,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint(
model_name="rideranking",
constraint=models.CheckConstraint(
condition=models.Q(
("winning_percentage__gte", 0), ("winning_percentage__lte", 1)
),
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",
),

View File

@@ -163,27 +163,19 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="ridephoto",
index=models.Index(
fields=["ride", "is_primary"], name="rides_ridep_ride_id_aa49f1_idx"
),
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"
),
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"
),
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"
),
index=models.Index(fields=["created_at"], name="rides_ridep_created_106e02_idx"),
),
migrations.AddConstraint(
model_name="ridephoto",

View File

@@ -147,21 +147,15 @@ class Migration(migrations.Migration):
),
(
"spec_name",
models.CharField(
help_text="Name of the specification", max_length=100
),
models.CharField(help_text="Name of the specification", max_length=100),
),
(
"spec_value",
models.CharField(
help_text="Value of the specification", max_length=255
),
models.CharField(help_text="Value of the specification", max_length=255),
),
(
"spec_unit",
models.CharField(
blank=True, help_text="Unit of measurement", max_length=20
),
models.CharField(blank=True, help_text="Unit of measurement", max_length=20),
),
(
"notes",
@@ -203,21 +197,15 @@ class Migration(migrations.Migration):
),
(
"spec_name",
models.CharField(
help_text="Name of the specification", max_length=100
),
models.CharField(help_text="Name of the specification", max_length=100),
),
(
"spec_value",
models.CharField(
help_text="Value of the specification", max_length=255
),
models.CharField(help_text="Value of the specification", max_length=255),
),
(
"spec_unit",
models.CharField(
blank=True, help_text="Unit of measurement", max_length=20
),
models.CharField(blank=True, help_text="Unit of measurement", max_length=20),
),
(
"notes",
@@ -251,33 +239,23 @@ class Migration(migrations.Migration):
),
(
"description",
models.TextField(
blank=True, help_text="Description of variant differences"
),
models.TextField(blank=True, help_text="Description of variant differences"),
),
(
"min_height_ft",
models.DecimalField(
blank=True, decimal_places=2, max_digits=6, null=True
),
models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True),
),
(
"max_height_ft",
models.DecimalField(
blank=True, decimal_places=2, max_digits=6, null=True
),
models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True),
),
(
"min_speed_mph",
models.DecimalField(
blank=True, decimal_places=2, max_digits=5, null=True
),
models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True),
),
(
"max_speed_mph",
models.DecimalField(
blank=True, decimal_places=2, max_digits=5, null=True
),
models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True),
),
(
"distinguishing_features",
@@ -307,33 +285,23 @@ class Migration(migrations.Migration):
),
(
"description",
models.TextField(
blank=True, help_text="Description of variant differences"
),
models.TextField(blank=True, help_text="Description of variant differences"),
),
(
"min_height_ft",
models.DecimalField(
blank=True, decimal_places=2, max_digits=6, null=True
),
models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True),
),
(
"max_height_ft",
models.DecimalField(
blank=True, decimal_places=2, max_digits=6, null=True
),
models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True),
),
(
"min_speed_mph",
models.DecimalField(
blank=True, decimal_places=2, max_digits=5, null=True
),
models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True),
),
(
"max_speed_mph",
models.DecimalField(
blank=True, decimal_places=2, max_digits=5, null=True
),
models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True),
),
(
"distinguishing_features",
@@ -750,9 +718,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="ridemodel",
name="description",
field=models.TextField(
blank=True, help_text="Detailed description of the ride model"
),
field=models.TextField(blank=True, help_text="Detailed description of the ride model"),
),
migrations.AlterField(
model_name="ridemodel",
@@ -794,9 +760,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="ridemodelevent",
name="description",
field=models.TextField(
blank=True, help_text="Detailed description of the ride model"
),
field=models.TextField(blank=True, help_text="Detailed description of the ride model"),
),
migrations.AlterField(
model_name="ridemodelevent",

View File

@@ -13,8 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="ridemodel",
name="slug",
field=models.SlugField(
help_text="URL-friendly identifier", max_length=255, unique=True
),
field=models.SlugField(help_text="URL-friendly identifier", max_length=255, unique=True),
),
]

View File

@@ -16,9 +16,7 @@ def update_ride_model_slugs(apps, schema_editor):
counter = 1
base_slug = new_slug
while (
RideModel.objects.filter(
manufacturer=ride_model.manufacturer, slug=new_slug
)
RideModel.objects.filter(manufacturer=ride_model.manufacturer, slug=new_slug)
.exclude(pk=ride_model.pk)
.exists()
):
@@ -37,16 +35,12 @@ def reverse_ride_model_slugs(apps, schema_editor):
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}"
)
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()
):
while RideModel.objects.filter(slug=old_slug).exclude(pk=ride_model.pk).exists():
old_slug = f"{base_slug}-{counter}"
counter += 1

View File

@@ -39,16 +39,12 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="company",
name="url",
field=models.URLField(
blank=True, help_text="Frontend URL for this company"
),
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"
),
field=models.URLField(blank=True, help_text="Frontend URL for this company"),
),
migrations.AddField(
model_name="ride",
@@ -63,16 +59,12 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="ridemodel",
name="url",
field=models.URLField(
blank=True, help_text="Frontend URL for this ride model"
),
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"
),
field=models.URLField(blank=True, help_text="Frontend URL for this ride model"),
),
pgtrigger.migrations.AddTrigger(
model_name="company",

View File

@@ -23,16 +23,12 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="ride",
name="park_url",
field=models.URLField(
blank=True, help_text="Frontend URL for this ride's park"
),
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"
),
field=models.URLField(blank=True, help_text="Frontend URL for this ride's park"),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",

View File

@@ -12,13 +12,15 @@ from django.db import migrations
def populate_computed_fields(apps, schema_editor):
"""Populate computed fields for all existing rides."""
Ride = apps.get_model('rides', 'Ride')
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'
))
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
@@ -39,7 +41,7 @@ def populate_computed_fields(apps, schema_editor):
# Park info
if ride.park:
search_parts.append(ride.park.name)
if hasattr(ride.park, 'location') and ride.park.location:
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:
@@ -62,7 +64,7 @@ def populate_computed_fields(apps, schema_editor):
("TR", "Transport"),
("OT", "Other"),
]
category_display = dict(category_choices).get(ride.category, '')
category_display = dict(category_choices).get(ride.category, "")
if category_display:
search_parts.append(category_display)
@@ -79,7 +81,7 @@ def populate_computed_fields(apps, schema_editor):
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
]
status_display = dict(status_choices).get(ride.status, '')
status_display = dict(status_choices).get(ride.status, "")
if status_display:
search_parts.append(status_display)
@@ -95,24 +97,24 @@ def populate_computed_fields(apps, schema_editor):
if ride.ride_model.manufacturer:
search_parts.append(ride.ride_model.manufacturer.name)
ride.search_text = ' '.join(filter(None, search_parts)).lower()
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)
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')
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='')
Ride.objects.all().update(opening_year=None, search_text="")
class Migration(migrations.Migration):
dependencies = [
('rides', '0018_add_hybrid_filtering_fields'),
("rides", "0018_add_hybrid_filtering_fields"),
]
operations = [

View File

@@ -19,163 +19,136 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('rides', '0019_populate_hybrid_filtering_fields'),
("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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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;"
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"
"CREATE EXTENSION IF NOT EXISTS pg_trgm;", reverse_sql="-- Cannot safely drop pg_trgm extension"
),
]

View File

@@ -11,30 +11,30 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rides', '0025_convert_ride_status_to_fsm'),
("rides", "0025_convert_ride_status_to_fsm"),
]
operations = [
# Remove the old unique_together constraint
migrations.AlterUniqueTogether(
name='ridemodel',
name="ridemodel",
unique_together=set(),
),
# Add new UniqueConstraints with better error messages
migrations.AddConstraint(
model_name='ridemodel',
model_name="ridemodel",
constraint=models.UniqueConstraint(
fields=['manufacturer', 'name'],
name='ridemodel_manufacturer_name_unique',
violation_error_message='A ride model with this name already exists for this manufacturer'
fields=["manufacturer", "name"],
name="ridemodel_manufacturer_name_unique",
violation_error_message="A ride model with this name already exists for this manufacturer",
),
),
migrations.AddConstraint(
model_name='ridemodel',
model_name="ridemodel",
constraint=models.UniqueConstraint(
fields=['manufacturer', 'slug'],
name='ridemodel_manufacturer_slug_unique',
violation_error_message='A ride model with this slug already exists for this manufacturer'
fields=["manufacturer", "slug"],
name="ridemodel_manufacturer_slug_unique",
violation_error_message="A ride model with this slug already exists for this manufacturer",
),
),
]

View File

@@ -98,23 +98,17 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="company",
name="coasters_count",
field=models.IntegerField(
default=0, help_text="Number of coasters manufactured (auto-calculated)"
),
field=models.IntegerField(default=0, help_text="Number of coasters manufactured (auto-calculated)"),
),
migrations.AlterField(
model_name="company",
name="description",
field=models.TextField(
blank=True, help_text="Detailed company description"
),
field=models.TextField(blank=True, help_text="Detailed company description"),
),
migrations.AlterField(
model_name="company",
name="founded_date",
field=models.DateField(
blank=True, help_text="Date the company was founded", null=True
),
field=models.DateField(blank=True, help_text="Date the company was founded", null=True),
),
migrations.AlterField(
model_name="company",
@@ -124,9 +118,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="company",
name="rides_count",
field=models.IntegerField(
default=0, help_text="Number of rides manufactured (auto-calculated)"
),
field=models.IntegerField(default=0, help_text="Number of rides manufactured (auto-calculated)"),
),
migrations.AlterField(
model_name="company",
@@ -151,9 +143,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="company",
name="slug",
field=models.SlugField(
help_text="URL-friendly identifier", max_length=255, unique=True
),
field=models.SlugField(help_text="URL-friendly identifier", max_length=255, unique=True),
),
migrations.AlterField(
model_name="company",
@@ -163,23 +153,17 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="companyevent",
name="coasters_count",
field=models.IntegerField(
default=0, help_text="Number of coasters manufactured (auto-calculated)"
),
field=models.IntegerField(default=0, help_text="Number of coasters manufactured (auto-calculated)"),
),
migrations.AlterField(
model_name="companyevent",
name="description",
field=models.TextField(
blank=True, help_text="Detailed company description"
),
field=models.TextField(blank=True, help_text="Detailed company description"),
),
migrations.AlterField(
model_name="companyevent",
name="founded_date",
field=models.DateField(
blank=True, help_text="Date the company was founded", null=True
),
field=models.DateField(blank=True, help_text="Date the company was founded", null=True),
),
migrations.AlterField(
model_name="companyevent",
@@ -210,9 +194,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="companyevent",
name="rides_count",
field=models.IntegerField(
default=0, help_text="Number of rides manufactured (auto-calculated)"
),
field=models.IntegerField(default=0, help_text="Number of rides manufactured (auto-calculated)"),
),
migrations.AlterField(
model_name="companyevent",
@@ -237,9 +219,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="companyevent",
name="slug",
field=models.SlugField(
db_index=False, help_text="URL-friendly identifier", max_length=255
),
field=models.SlugField(db_index=False, help_text="URL-friendly identifier", max_length=255),
),
migrations.AlterField(
model_name="companyevent",
@@ -321,23 +301,17 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="ridemodelphoto",
name="caption",
field=models.CharField(
blank=True, help_text="Photo caption or description", max_length=500
),
field=models.CharField(blank=True, help_text="Photo caption or description", max_length=500),
),
migrations.AlterField(
model_name="ridemodelphoto",
name="copyright_info",
field=models.CharField(
blank=True, help_text="Copyright information", max_length=255
),
field=models.CharField(blank=True, help_text="Copyright information", max_length=255),
),
migrations.AlterField(
model_name="ridemodelphoto",
name="photographer",
field=models.CharField(
blank=True, help_text="Name of the photographer", max_length=255
),
field=models.CharField(blank=True, help_text="Name of the photographer", max_length=255),
),
migrations.AlterField(
model_name="ridemodelphoto",
@@ -352,9 +326,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="ridemodelphoto",
name="source",
field=models.CharField(
blank=True, help_text="Source of the photo", max_length=255
),
field=models.CharField(blank=True, help_text="Source of the photo", max_length=255),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
@@ -368,16 +340,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="ridemodelphotoevent",
name="caption",
field=models.CharField(
blank=True, help_text="Photo caption or description", max_length=500
),
field=models.CharField(blank=True, help_text="Photo caption or description", max_length=500),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
name="copyright_info",
field=models.CharField(
blank=True, help_text="Copyright information", max_length=255
),
field=models.CharField(blank=True, help_text="Copyright information", max_length=255),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
@@ -403,9 +371,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="ridemodelphotoevent",
name="photographer",
field=models.CharField(
blank=True, help_text="Name of the photographer", max_length=255
),
field=models.CharField(blank=True, help_text="Name of the photographer", max_length=255),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
@@ -422,9 +388,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="ridemodelphotoevent",
name="source",
field=models.CharField(
blank=True, help_text="Source of the photo", max_length=255
),
field=models.CharField(blank=True, help_text="Source of the photo", max_length=255),
),
migrations.AlterField(
model_name="ridemodeltechnicalspec",
@@ -709,9 +673,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="rollercoasterstats",
name="cars_per_train",
field=models.PositiveIntegerField(
blank=True, help_text="Number of cars per train", null=True
),
field=models.PositiveIntegerField(blank=True, help_text="Number of cars per train", null=True),
),
migrations.AlterField(
model_name="rollercoasterstats",
@@ -727,9 +689,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="rollercoasterstats",
name="inversions",
field=models.PositiveIntegerField(
default=0, help_text="Number of inversions"
),
field=models.PositiveIntegerField(default=0, help_text="Number of inversions"),
),
migrations.AlterField(
model_name="rollercoasterstats",
@@ -766,16 +726,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="rollercoasterstats",
name="ride_time_seconds",
field=models.PositiveIntegerField(
blank=True, help_text="Duration of the ride in seconds", null=True
),
field=models.PositiveIntegerField(blank=True, help_text="Duration of the ride in seconds", null=True),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="seats_per_car",
field=models.PositiveIntegerField(
blank=True, help_text="Number of seats per car", null=True
),
field=models.PositiveIntegerField(blank=True, help_text="Number of seats per car", null=True),
),
migrations.AlterField(
model_name="rollercoasterstats",
@@ -809,16 +765,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="rollercoasterstats",
name="trains_count",
field=models.PositiveIntegerField(
blank=True, help_text="Number of trains", null=True
),
field=models.PositiveIntegerField(blank=True, help_text="Number of trains", null=True),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="cars_per_train",
field=models.PositiveIntegerField(
blank=True, help_text="Number of cars per train", null=True
),
field=models.PositiveIntegerField(blank=True, help_text="Number of cars per train", null=True),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
@@ -834,9 +786,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="inversions",
field=models.PositiveIntegerField(
default=0, help_text="Number of inversions"
),
field=models.PositiveIntegerField(default=0, help_text="Number of inversions"),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
@@ -896,16 +846,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="ride_time_seconds",
field=models.PositiveIntegerField(
blank=True, help_text="Duration of the ride in seconds", null=True
),
field=models.PositiveIntegerField(blank=True, help_text="Duration of the ride in seconds", null=True),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="seats_per_car",
field=models.PositiveIntegerField(
blank=True, help_text="Number of seats per car", null=True
),
field=models.PositiveIntegerField(blank=True, help_text="Number of seats per car", null=True),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
@@ -939,8 +885,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="trains_count",
field=models.PositiveIntegerField(
blank=True, help_text="Number of trains", null=True
),
field=models.PositiveIntegerField(blank=True, help_text="Number of trains", null=True),
),
]

View File

@@ -34,16 +34,11 @@ class RideFormMixin:
Returns:
Dictionary with submission results from RideService
"""
result = RideService.handle_new_entity_suggestions(
form_data=form.cleaned_data,
submitter=self.request.user
)
result = RideService.handle_new_entity_suggestions(form_data=form.cleaned_data, submitter=self.request.user)
if result['total_submissions'] > 0:
if result["total_submissions"] > 0:
messages.info(
self.request,
f"Created {result['total_submissions']} moderation submission(s) "
"for new entities"
self.request, f"Created {result['total_submissions']} moderation submission(s) " "for new entities"
)
return result

View File

@@ -24,17 +24,11 @@ class Company(TrackedModel):
website = models.URLField(blank=True, help_text="Company website URL")
# General company info
founded_date = models.DateField(
null=True, blank=True, help_text="Date the company was founded"
)
founded_date = models.DateField(null=True, blank=True, help_text="Date the company was founded")
# Manufacturer-specific fields
rides_count = models.IntegerField(
default=0, help_text="Number of rides manufactured (auto-calculated)"
)
coasters_count = models.IntegerField(
default=0, help_text="Number of coasters manufactured (auto-calculated)"
)
rides_count = models.IntegerField(default=0, help_text="Number of rides manufactured (auto-calculated)")
coasters_count = models.IntegerField(default=0, help_text="Number of coasters manufactured (auto-calculated)")
# Frontend URL
url = models.URLField(blank=True, help_text="Frontend URL for this company")
@@ -50,9 +44,7 @@ class Company(TrackedModel):
# 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"
)
frontend_domain = getattr(settings, "FRONTEND_DOMAIN", "https://thrillwiki.com")
primary_role = self.roles[0] # Use first role as primary
if primary_role == "MANUFACTURER":
@@ -76,12 +68,9 @@ class Company(TrackedModel):
# 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()
)
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:
@@ -90,12 +79,10 @@ class Company(TrackedModel):
# Check manual slug history as fallback
try:
historical = HistoricalSlug.objects.get(
content_type__model="company", slug=slug
)
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")
raise cls.DoesNotExist("No company found with this slug") from None
class Meta(TrackedModel.Meta):
app_label = "rides"

View File

@@ -27,27 +27,17 @@ class RideCredit(TrackedModel):
)
# Credit Details
count = models.PositiveIntegerField(
default=1, help_text="Number of times ridden"
)
count = models.PositiveIntegerField(default=1, help_text="Number of times ridden")
rating = models.IntegerField(
null=True,
blank=True,
validators=[MinValueValidator(1), MaxValueValidator(5)],
help_text="Personal rating (1-5)",
)
first_ridden_at = models.DateField(
null=True, blank=True, help_text="Date of first ride"
)
last_ridden_at = models.DateField(
null=True, blank=True, help_text="Date of most recent ride"
)
notes = models.TextField(
blank=True, help_text="Personal notes about the experience"
)
display_order = models.PositiveIntegerField(
default=0, help_text="User-defined display order for drag-drop sorting"
)
first_ridden_at = models.DateField(null=True, blank=True, help_text="Date of first ride")
last_ridden_at = models.DateField(null=True, blank=True, help_text="Date of most recent ride")
notes = models.TextField(blank=True, help_text="Personal notes about the experience")
display_order = models.PositiveIntegerField(default=0, help_text="User-defined display order for drag-drop sorting")
class Meta(TrackedModel.Meta):
verbose_name = "Ride Credit"

View File

@@ -12,9 +12,7 @@ class RideLocation(models.Model):
"""
# Relationships
ride = models.OneToOneField(
"rides.Ride", on_delete=models.CASCADE, related_name="ride_location"
)
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(
@@ -29,9 +27,7 @@ class RideLocation(models.Model):
max_length=100,
blank=True,
db_index=True,
help_text=(
"Themed area or land within the park (e.g., 'Frontierland', 'Tomorrowland')"
),
help_text=("Themed area or land within the park (e.g., 'Frontierland', 'Tomorrowland')"),
)
# General notes field to match database schema

View File

@@ -35,14 +35,12 @@ def ride_photo_upload_path(instance: models.Model, filename: str) -> str:
class RidePhoto(TrackedModel):
"""Photo model specific to rides."""
ride = models.ForeignKey(
"rides.Ride", on_delete=models.CASCADE, related_name="photos"
)
ride = models.ForeignKey("rides.Ride", on_delete=models.CASCADE, related_name="photos")
image = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
"django_cloudflareimages_toolkit.CloudflareImage",
on_delete=models.CASCADE,
help_text="Ride photo stored on Cloudflare Images"
help_text="Ride photo stored on Cloudflare Images",
)
caption = models.CharField(max_length=255, blank=True)
@@ -56,7 +54,7 @@ class RidePhoto(TrackedModel):
domain="rides",
max_length=50,
default="exterior",
help_text="Type of photo for categorization and display purposes"
help_text="Type of photo for categorization and display purposes",
)
# Metadata
@@ -100,9 +98,7 @@ class RidePhoto(TrackedModel):
# Set default caption if not provided
if not self.caption and self.uploaded_by:
self.caption = MediaService.generate_default_caption(
self.uploaded_by.username
)
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:

View File

@@ -22,17 +22,12 @@ class RideRanking(models.Model):
"""
ride = models.OneToOneField(
"rides.Ride", on_delete=models.CASCADE, related_name="ranking",
help_text="Ride this ranking entry describes"
"rides.Ride", on_delete=models.CASCADE, related_name="ranking", help_text="Ride this ranking entry describes"
)
# 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"
)
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",
@@ -66,9 +61,7 @@ class RideRanking(models.Model):
)
# Metadata
last_calculated = models.DateTimeField(
default=timezone.now, help_text="When this ranking was last calculated"
)
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"
)
@@ -85,8 +78,7 @@ class RideRanking(models.Model):
constraints = [
models.CheckConstraint(
name="rideranking_winning_percentage_range",
check=models.Q(winning_percentage__gte=0)
& models.Q(winning_percentage__lte=1),
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(
@@ -115,23 +107,13 @@ class RidePairComparison(models.Model):
(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"
)
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"
)
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(
@@ -153,9 +135,7 @@ class RidePairComparison(models.Model):
)
# Metadata
last_calculated = models.DateTimeField(
auto_now=True, help_text="When this comparison was last calculated"
)
last_calculated = models.DateTimeField(auto_now=True, help_text="When this comparison was last calculated")
class Meta:
verbose_name = "Ride Pair Comparison"
@@ -197,14 +177,10 @@ class RankingSnapshot(models.Model):
This allows us to show ranking trends and movements.
"""
ride = models.ForeignKey(
"rides.Ride", on_delete=models.CASCADE, related_name="ranking_history"
)
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"
)
snapshot_date = models.DateField(db_index=True, help_text="Date when this ranking snapshot was taken")
class Meta:
verbose_name = "Ranking Snapshot"

View File

@@ -12,15 +12,9 @@ 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)]
)
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()
@@ -63,10 +57,7 @@ class RideReview(TrackedModel):
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"
),
violation_error_message=("Moderated reviews must have both moderator and moderation " "timestamp"),
),
]

View File

@@ -1,5 +1,4 @@
import contextlib
from typing import TYPE_CHECKING
import pghistory
from django.contrib.auth.models import AbstractBaseUser
@@ -14,10 +13,6 @@ from config.django import base as settings
from .company import Company
if TYPE_CHECKING:
from .rides import RollerCoasterStats
@pghistory.track()
class RideModel(TrackedModel):
@@ -30,9 +25,7 @@ class RideModel(TrackedModel):
"""
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)"
)
slug = models.SlugField(max_length=255, help_text="URL-friendly identifier (unique within manufacturer)")
manufacturer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
@@ -42,9 +35,7 @@ class RideModel(TrackedModel):
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"
)
description = models.TextField(blank=True, help_text="Detailed description of the ride model")
category = RichChoiceField(
choice_group="categories",
domain="rides",
@@ -125,9 +116,7 @@ class RideModel(TrackedModel):
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"
)
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)"
)
@@ -156,9 +145,7 @@ class RideModel(TrackedModel):
)
# SEO and metadata
meta_title = models.CharField(
max_length=60, blank=True, help_text="SEO meta title (auto-generated if blank)"
)
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,
@@ -175,25 +162,21 @@ class RideModel(TrackedModel):
constraints = [
# Unique constraints (replacing unique_together for better error messages)
models.UniqueConstraint(
fields=['manufacturer', 'name'],
name='ridemodel_manufacturer_name_unique',
violation_error_message='A ride model with this name already exists for this manufacturer'
fields=["manufacturer", "name"],
name="ridemodel_manufacturer_name_unique",
violation_error_message="A ride model with this name already exists for this manufacturer",
),
models.UniqueConstraint(
fields=['manufacturer', 'slug'],
name='ridemodel_manufacturer_slug_unique',
violation_error_message='A ride model with this slug already exists for this manufacturer'
fields=["manufacturer", "slug"],
name="ridemodel_manufacturer_slug_unique",
violation_error_message="A ride model with this slug already exists for this manufacturer",
),
# 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"
)
),
| 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
@@ -201,11 +184,7 @@ class RideModel(TrackedModel):
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"
)
),
| 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
@@ -213,11 +192,7 @@ class RideModel(TrackedModel):
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"
)
),
| 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
@@ -225,27 +200,19 @@ class RideModel(TrackedModel):
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")
),
| 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}"
)
return self.name if not self.manufacturer else f"{self.manufacturer.name} {self.name}"
def clean(self) -> None:
"""Validate RideModel business rules."""
super().clean()
if self.is_discontinued and not self.last_installation_year:
raise ValidationError({
'last_installation_year': 'Discontinued models must have a last installation year'
})
raise ValidationError({"last_installation_year": "Discontinued models must have a last installation year"})
def save(self, *args, **kwargs) -> None:
if not self.slug:
@@ -257,11 +224,7 @@ class RideModel(TrackedModel):
# Ensure uniqueness within the same manufacturer
counter = 1
while (
RideModel.objects.filter(manufacturer=self.manufacturer, slug=self.slug)
.exclude(pk=self.pk)
.exists()
):
while RideModel.objects.filter(manufacturer=self.manufacturer, slug=self.slug).exclude(pk=self.pk).exists():
self.slug = f"{base_slug}-{counter}"
counter += 1
@@ -269,16 +232,12 @@ class RideModel(TrackedModel):
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)
)
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"
)
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)
@@ -342,9 +301,7 @@ class RideModelVariant(TrackedModel):
help_text="Base ride model this variant belongs to",
)
name = models.CharField(max_length=255, help_text="Name of this variant")
description = models.TextField(
blank=True, help_text="Description of variant differences"
)
description = models.TextField(blank=True, help_text="Description of variant differences")
# Variant-specific specifications
min_height_ft = models.DecimalField(
@@ -402,16 +359,12 @@ class RideModelPhoto(TrackedModel):
help_text="Ride model this photo belongs to",
)
image = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
"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, help_text="Photo caption or description"
)
alt_text = models.CharField(
max_length=255, blank=True, help_text="Alternative text for accessibility"
help_text="Photo of the ride model stored on Cloudflare Images",
)
caption = models.CharField(max_length=500, blank=True, help_text="Photo caption or description")
alt_text = models.CharField(max_length=255, blank=True, help_text="Alternative text for accessibility")
# Photo metadata
photo_type = RichChoiceField(
@@ -422,18 +375,12 @@ class RideModelPhoto(TrackedModel):
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"
)
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, help_text="Name of the photographer"
)
photographer = models.CharField(max_length=255, blank=True, help_text="Name of the photographer")
source = models.CharField(max_length=255, blank=True, help_text="Source of the photo")
copyright_info = models.CharField(
max_length=255, blank=True, help_text="Copyright information"
)
copyright_info = models.CharField(max_length=255, blank=True, help_text="Copyright information")
class Meta(TrackedModel.Meta):
verbose_name = "Ride Model Photo"
@@ -446,9 +393,9 @@ class RideModelPhoto(TrackedModel):
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)
RideModelPhoto.objects.filter(ride_model=self.ride_model, is_primary=True).exclude(pk=self.pk).update(
is_primary=False
)
super().save(*args, **kwargs)
@@ -474,15 +421,9 @@ class RideModelTechnicalSpec(TrackedModel):
)
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"
)
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):
verbose_name = "Ride Model Technical Specification"
@@ -503,17 +444,15 @@ class Ride(StateMachineMixin, TrackedModel):
jobs. Use selectors or annotations for real-time calculations if needed.
"""
if TYPE_CHECKING:
coaster_stats: 'RollerCoasterStats'
# Type hint for the reverse relation from RollerCoasterStats
coaster_stats: "RollerCoasterStats"
state_field_name = "status"
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 = models.ForeignKey("parks.Park", on_delete=models.CASCADE, related_name="rides")
park_area = models.ForeignKey(
"parks.ParkArea",
on_delete=models.SET_NULL,
@@ -527,7 +466,7 @@ class Ride(StateMachineMixin, TrackedModel):
max_length=2,
default="",
blank=True,
help_text="Ride category classification"
help_text="Ride category classification",
)
manufacturer = models.ForeignKey(
Company,
@@ -558,7 +497,7 @@ class Ride(StateMachineMixin, TrackedModel):
domain="rides",
max_length=20,
default="OPERATING",
help_text="Current operational status of the ride"
help_text="Current operational status of the ride",
)
post_closing_status = RichChoiceField(
choice_group="post_closing_statuses",
@@ -575,9 +514,7 @@ class Ride(StateMachineMixin, TrackedModel):
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
)
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)
@@ -603,9 +540,7 @@ class Ride(StateMachineMixin, TrackedModel):
# 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"
)
park_url = models.URLField(blank=True, help_text="Frontend URL for this ride's park")
class Meta(TrackedModel.Meta):
verbose_name = "Ride"
@@ -635,17 +570,13 @@ class Ride(StateMachineMixin, TrackedModel):
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"
),
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"
),
violation_error_message=("Maximum height must be between 30 and 90 inches"),
),
# Business rule: Rating must be between 1 and 10
models.CheckConstraint(
@@ -657,14 +588,12 @@ class Ride(StateMachineMixin, TrackedModel):
# 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),
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),
condition=models.Q(ride_duration_seconds__isnull=True) | models.Q(ride_duration_seconds__gt=0),
violation_error_message="Ride duration must be positive",
),
]
@@ -699,9 +628,7 @@ class Ride(StateMachineMixin, TrackedModel):
from django.core.exceptions import ValidationError
if not post_closing_status:
raise ValidationError(
"post_closing_status must be set when entering CLOSING status"
)
raise ValidationError("post_closing_status must be set when entering CLOSING status")
self.transition_to_closing(user=user)
self.closing_date = closing_date
self.post_closing_status = post_closing_status
@@ -770,7 +697,7 @@ class Ride(StateMachineMixin, TrackedModel):
self._ensure_unique_slug_in_park()
# Handle park area validation when park changes
if park_changed and self.park_area:
if park_changed and self.park_area: # noqa: SIM102
# 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
@@ -786,9 +713,7 @@ class Ride(StateMachineMixin, TrackedModel):
# Generate frontend URLs
if self.park:
frontend_domain = getattr(
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
)
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}/"
@@ -817,7 +742,7 @@ class Ride(StateMachineMixin, TrackedModel):
# Park info
if self.park:
search_parts.append(self.park.name)
if hasattr(self.park, 'location') and self.park.location:
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:
@@ -855,7 +780,7 @@ class Ride(StateMachineMixin, TrackedModel):
# Roller coaster stats if available
try:
if hasattr(self, 'coaster_stats') and self.coaster_stats:
if hasattr(self, "coaster_stats") and self.coaster_stats:
stats = self.coaster_stats
if stats.track_type:
search_parts.append(stats.track_type)
@@ -877,7 +802,7 @@ class Ride(StateMachineMixin, TrackedModel):
# Ignore if coaster_stats doesn't exist or has issues
pass
self.search_text = ' '.join(filter(None, search_parts)).lower()
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."""
@@ -885,11 +810,7 @@ class Ride(StateMachineMixin, TrackedModel):
self.slug = base_slug
counter = 1
while (
Ride.objects.filter(park=self.park, slug=self.slug)
.exclude(pk=self.pk)
.exists()
):
while Ride.objects.filter(park=self.park, slug=self.slug).exclude(pk=self.pk).exists():
self.slug = f"{base_slug}-{counter}"
counter += 1
@@ -921,26 +842,15 @@ class Ride(StateMachineMixin, TrackedModel):
# 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
"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
@@ -963,9 +873,9 @@ class Ride(StateMachineMixin, TrackedModel):
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")
historical_query = HistoricalSlug.objects.filter(content_type=content_type, slug=slug).order_by(
"-created_at"
)
for historical in historical_query:
try:
@@ -986,14 +896,13 @@ class Ride(StateMachineMixin, TrackedModel):
except cls.DoesNotExist:
continue
raise cls.DoesNotExist("No ride found with this slug")
raise cls.DoesNotExist("No ride found with this slug") from None
@pghistory.track()
class RollerCoasterStats(models.Model):
"""Model for tracking roller coaster specific statistics"""
ride = models.OneToOneField(
Ride,
on_delete=models.CASCADE,
@@ -1021,22 +930,16 @@ class RollerCoasterStats(models.Model):
blank=True,
help_text="Maximum speed in mph",
)
inversions = models.PositiveIntegerField(
default=0, help_text="Number of inversions"
)
ride_time_seconds = models.PositiveIntegerField(
null=True, blank=True, help_text="Duration of the ride in seconds"
)
track_type = models.CharField(
max_length=255, blank=True, help_text="Type of track (e.g., tubular steel, wooden)"
)
inversions = models.PositiveIntegerField(default=0, help_text="Number of inversions")
ride_time_seconds = models.PositiveIntegerField(null=True, blank=True, help_text="Duration of the ride in seconds")
track_type = models.CharField(max_length=255, blank=True, help_text="Type of track (e.g., tubular steel, wooden)")
track_material = RichChoiceField(
choice_group="track_materials",
domain="rides",
max_length=20,
default="STEEL",
blank=True,
help_text="Track construction material type"
help_text="Track construction material type",
)
roller_coaster_type = RichChoiceField(
choice_group="coaster_types",
@@ -1044,7 +947,7 @@ class RollerCoasterStats(models.Model):
max_length=20,
default="SITDOWN",
blank=True,
help_text="Roller coaster type classification"
help_text="Roller coaster type classification",
)
max_drop_height_ft = models.DecimalField(
max_digits=6,
@@ -1058,20 +961,12 @@ class RollerCoasterStats(models.Model):
domain="rides",
max_length=20,
default="CHAIN",
help_text="Propulsion or lift system type"
)
train_style = models.CharField(
max_length=255, blank=True, help_text="Style of train (e.g., floorless, inverted)"
)
trains_count = models.PositiveIntegerField(
null=True, blank=True, help_text="Number of trains"
)
cars_per_train = models.PositiveIntegerField(
null=True, blank=True, help_text="Number of cars per train"
)
seats_per_car = models.PositiveIntegerField(
null=True, blank=True, help_text="Number of seats per car"
help_text="Propulsion or lift system type",
)
train_style = models.CharField(max_length=255, blank=True, help_text="Style of train (e.g., floorless, inverted)")
trains_count = models.PositiveIntegerField(null=True, blank=True, help_text="Number of trains")
cars_per_train = models.PositiveIntegerField(null=True, blank=True, help_text="Number of cars per train")
seats_per_car = models.PositiveIntegerField(null=True, blank=True, help_text="Number of seats per car")
class Meta:
verbose_name = "Roller Coaster Statistics"

View File

@@ -13,9 +13,7 @@ from .choices import RIDE_CATEGORIES
from .models import Ride, RideModel, RideReview
def ride_list_for_display(
*, filters: dict[str, Any] | None = None
) -> QuerySet[Ride]:
def ride_list_for_display(*, filters: dict[str, Any] | None = None) -> QuerySet[Ride]:
"""
Get rides optimized for list display with related data.
@@ -85,9 +83,7 @@ def ride_detail_optimized(*, slug: str, park_slug: str) -> Ride:
"park__location",
Prefetch(
"reviews",
queryset=RideReview.objects.select_related("user").filter(
is_published=True
),
queryset=RideReview.objects.select_related("user").filter(is_published=True),
),
"photos",
)
@@ -171,9 +167,7 @@ def rides_in_park(*, park_slug: str) -> QuerySet[Ride]:
)
def rides_near_location(
*, point: Point, distance_km: float = 50, limit: int = 10
) -> QuerySet[Ride]:
def rides_near_location(*, point: Point, distance_km: float = 50, limit: int = 10) -> QuerySet[Ride]:
"""
Get rides near a specific geographic location.
@@ -227,9 +221,7 @@ def ride_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet[Ride]:
"""
return (
Ride.objects.filter(
Q(name__icontains=query)
| Q(park__name__icontains=query)
| Q(manufacturer__name__icontains=query)
Q(name__icontains=query) | Q(park__name__icontains=query) | Q(manufacturer__name__icontains=query)
)
.select_related("park", "manufacturer")
.prefetch_related("park__location")
@@ -254,16 +246,10 @@ def rides_with_recent_reviews(*, days: int = 30) -> QuerySet[Ride]:
cutoff_date = timezone.now() - timedelta(days=days)
return (
Ride.objects.filter(
reviews__created_at__gte=cutoff_date, reviews__is_published=True
)
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)
)
)
.annotate(recent_review_count=Count("reviews", filter=Q(reviews__created_at__gte=cutoff_date)))
.order_by("-recent_review_count")
.distinct()
)

View File

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

View File

@@ -95,13 +95,13 @@ class SmartRideLoader:
total_count = queryset.count()
# Get progressive batch
rides = list(queryset[offset:offset + self.PROGRESSIVE_LOAD_SIZE])
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
"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: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -148,8 +148,7 @@ class SmartRideLoader:
return count
def _get_client_side_data(self, filters: dict[str, Any] | None,
total_count: int) -> dict[str, Any]:
def _get_client_side_data(self, filters: dict[str, Any] | None, 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)
@@ -158,45 +157,46 @@ class SmartRideLoader:
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')
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)
"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: dict[str, Any] | None,
total_count: int) -> dict[str, Any]:
def _get_server_side_data(self, filters: dict[str, Any] | None, 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])
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
"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: dict[str, Any] | None):
@@ -205,118 +205,110 @@ class SmartRideLoader:
# 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'
)
"park", "park__location", "park_area", "manufacturer", "designer", "ride_model", "ride_model__manufacturer"
).prefetch_related("coaster_stats")
if not filters:
return queryset.order_by('name')
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()
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_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'])
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'])
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'])
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 "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'])
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'])
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 "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 "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'])
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 "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'])
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 "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'])
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 "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'])
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 "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 "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'])
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 "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'])
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 "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'])
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 "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 "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']:
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)
@@ -325,10 +317,12 @@ class SmartRideLoader:
queryset = queryset.filter(q_objects)
# Apply ordering
ordering = filters.get('ordering', 'name')
if ordering in ['height_ft', '-height_ft', 'speed_mph', '-speed_mph']:
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')
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)
@@ -342,99 +336,99 @@ class SmartRideLoader:
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(),
"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,
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,
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,
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,
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_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,
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,
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:
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,
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)
@@ -448,267 +442,250 @@ class SmartRideLoader:
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'))
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'))
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'))
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'))
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'))
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'))
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'))
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'))
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'))
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'))
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']
}
{"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']
}
{"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']
"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']
"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']
"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
{"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']
}
{"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
{"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
{"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']
}
{"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'),
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'),
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,
"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'
"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'
"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'
"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'
"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'
"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'
"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'
"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'
"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'
"inversions": {
"min": coaster_stats["min_inversions"] or 0,
"max": coaster_stats["max_inversions"] or 20,
"step": 1,
"unit": "inversions",
},
},
'total_count': Ride.objects.count(),
"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',
"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]
@@ -718,14 +695,14 @@ class SmartRideLoader:
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',
"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]
@@ -735,19 +712,19 @@ class SmartRideLoader:
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',
"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]
@@ -757,9 +734,9 @@ class SmartRideLoader:
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)',
"STEEL": "Steel",
"WOOD": "Wood",
"HYBRID": "Hybrid (Steel/Wood)",
}
if material in material_labels:
return material_labels[material]
@@ -769,15 +746,15 @@ class SmartRideLoader:
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',
"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]

View File

@@ -69,9 +69,7 @@ class RideLocationService:
return ride_location
@classmethod
def update_ride_location(
cls, ride_location: RideLocation, **updates
) -> RideLocation:
def update_ride_location(cls, ride_location: RideLocation, **updates) -> RideLocation:
"""
Update ride location with validation.
@@ -149,9 +147,7 @@ class RideLocationService:
if park:
queryset = queryset.filter(ride__park=park)
return list(
queryset.select_related("ride", "ride__park").order_by("point__distance")
)
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]:
@@ -249,9 +245,7 @@ class RideLocationService:
# 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
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
@@ -277,9 +271,7 @@ class RideLocationService:
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")
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
@@ -312,22 +304,15 @@ class RideLocationService:
# 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
):
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.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)}"
)
logger.warning(f"Error updating ride location for {ride_location.ride.name}: {str(e)}")
continue
return updated_count
@@ -346,9 +331,7 @@ class RideLocationService:
area_map = {}
ride_locations = (
RideLocation.objects.filter(ride__park=park)
.select_related("ride")
.order_by("park_area", "ride__name")
RideLocation.objects.filter(ride__park=park).select_related("ride").order_by("park_area", "ride__name")
)
for ride_location in ride_locations:

View File

@@ -143,11 +143,7 @@ class RideMediaService:
Returns:
List of RidePhoto instances
"""
return list(
ride.photos.filter(photo_type=photo_type, is_approved=True).order_by(
"-created_at"
)
)
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:
@@ -218,9 +214,7 @@ class RideMediaService:
photo.image.delete(save=False)
photo.delete()
logger.info(
f"Photo {photo_id} deleted from ride {ride_slug} by user {deleted_by.username}"
)
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)}")
@@ -272,9 +266,7 @@ class RideMediaService:
if RideMediaService.approve_photo(photo, approved_by):
approved_count += 1
logger.info(
f"Bulk approved {approved_count} photos by user {approved_by.username}"
)
logger.info(f"Bulk approved {approved_count} photos by user {approved_by.username}")
return approved_count
@staticmethod
@@ -289,9 +281,7 @@ class RideMediaService:
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"
)
ride.photos.filter(photo_type="construction", is_approved=True).order_by("date_taken", "created_at")
)
@staticmethod

View File

@@ -53,9 +53,7 @@ class RideRankingService:
Dictionary with statistics about the ranking calculation
"""
start_time = timezone.now()
self.logger.info(
f"Starting ranking calculation for category: {category or 'ALL'}"
)
self.logger.info(f"Starting ranking calculation for category: {category or 'ALL'}")
try:
with transaction.atomic():
@@ -87,9 +85,7 @@ class RideRankingService:
self._cleanup_old_data()
duration = (timezone.now() - start_time).total_seconds()
self.logger.info(
f"Ranking calculation completed in {duration:.2f} seconds"
)
self.logger.info(f"Ranking calculation completed in {duration:.2f} seconds")
return {
"status": "success",
@@ -113,9 +109,7 @@ class RideRankingService:
"""
queryset = (
Ride.objects.filter(status="OPERATING", reviews__is_published=True)
.annotate(
review_count=Count("reviews", filter=Q(reviews__is_published=True))
)
.annotate(review_count=Count("reviews", filter=Q(reviews__is_published=True)))
.filter(review_count__gt=0)
)
@@ -124,9 +118,7 @@ class RideRankingService:
return list(queryset.distinct())
def _calculate_all_comparisons(
self, rides: list[Ride]
) -> dict[tuple[int, int], RidePairComparison]:
def _calculate_all_comparisons(self, rides: list[Ride]) -> dict[tuple[int, int], RidePairComparison]:
"""
Calculate pairwise comparisons for all ride pairs.
@@ -146,15 +138,11 @@ class RideRankingService:
processed += 1
if processed % 100 == 0:
self.logger.debug(
f"Processed {processed}/{total_pairs} comparisons"
)
self.logger.debug(f"Processed {processed}/{total_pairs} comparisons")
return comparisons
def _calculate_pairwise_comparison(
self, ride_a: Ride, ride_b: Ride
) -> RidePairComparison | None:
def _calculate_pairwise_comparison(self, ride_a: Ride, ride_b: Ride) -> RidePairComparison | None:
"""
Calculate the pairwise comparison between two rides.
@@ -163,15 +151,11 @@ class RideRankingService:
"""
# 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
)
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
)
RideReview.objects.filter(ride=ride_b, is_published=True).values_list("user_id", flat=True)
)
mutual_riders = ride_a_reviewers & ride_b_reviewers
@@ -183,16 +167,12 @@ class RideRankingService:
# 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
)
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
)
for review in RideReview.objects.filter(ride=ride_b, user_id__in=mutual_riders, is_published=True)
}
# Count wins and ties
@@ -212,12 +192,8 @@ class RideRankingService:
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
)
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(
@@ -228,16 +204,8 @@ class RideRankingService:
"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))
),
"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))),
},
)
@@ -294,16 +262,12 @@ class RideRankingService:
# 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)
)
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(
ride_stats = RideReview.objects.filter(ride=ride, is_published=True).aggregate(
avg_rating=Avg("rating"), reviewer_count=Count("user", distinct=True)
)
@@ -356,11 +320,7 @@ class RideRankingService:
tied_group = [rankings[i]]
j = i + 1
while (
j < len(rankings)
and rankings[j]["winning_percentage"]
== rankings[i]["winning_percentage"]
):
while j < len(rankings) and rankings[j]["winning_percentage"] == rankings[i]["winning_percentage"]:
tied_group.append(rankings[j])
j += 1
@@ -462,9 +422,7 @@ class RideRankingService:
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()
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")
@@ -486,9 +444,7 @@ class RideRankingService:
)
# Get ranking history
history = RankingSnapshot.objects.filter(ride=ride).order_by(
"-snapshot_date"
)[:30]
history = RankingSnapshot.objects.filter(ride=ride).order_by("-snapshot_date")[:30]
return {
"current_rank": ranking.rank,
@@ -501,32 +457,18 @@ class RideRankingService:
"last_calculated": ranking.last_calculated,
"head_to_head": [
{
"opponent": (
comp.ride_b if comp.ride_a_id == ride.id else comp.ride_a
),
"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
)
(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
)
(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"
)

View File

@@ -127,9 +127,7 @@ class RideSearchService:
# Apply text search with ranking
if filters.get("global_search"):
queryset, search_rank = self._apply_full_text_search(
queryset, filters["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:
@@ -176,9 +174,7 @@ class RideSearchService:
"applied_filters": self._get_applied_filters_summary(filters),
}
def _apply_full_text_search(
self, queryset, search_term: str
) -> tuple[models.QuerySet, models.Expression]:
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.
"""
@@ -201,17 +197,14 @@ class RideSearchService:
search_query = SearchQuery(search_term, config="english")
# Calculate search rank
search_rank = SearchRank(
search_vector, search_query, weights=self.SEARCH_RANK_WEIGHTS
)
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)
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
@@ -219,36 +212,22 @@ class RideSearchService:
return queryset, final_rank
def _apply_basic_info_filters(
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
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"]]
)
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"]]
)
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"]]
)
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
@@ -256,11 +235,7 @@ class RideSearchService:
# Park area filter (multi-select)
if filters.get("park_area"):
areas = (
filters["park_area"]
if isinstance(filters["park_area"], list)
else [filters["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
@@ -297,9 +272,7 @@ class RideSearchService:
return queryset
def _apply_height_safety_filters(
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
def _apply_height_safety_filters(self, queryset, filters: dict[str, Any]) -> models.QuerySet:
"""Apply height and safety requirement filters."""
# Minimum height range
@@ -320,9 +293,7 @@ class RideSearchService:
return queryset
def _apply_performance_filters(
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
def _apply_performance_filters(self, queryset, filters: dict[str, Any]) -> models.QuerySet:
"""Apply performance metric filters."""
# Capacity range
@@ -337,13 +308,9 @@ class RideSearchService:
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"]
)
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"]
)
queryset = queryset.filter(ride_duration_seconds__lte=duration_range["max"])
# Rating range
if filters.get("rating_range"):
@@ -355,17 +322,13 @@ class RideSearchService:
return queryset
def _apply_relationship_filters(
self, queryset, filters: dict[str, Any]
) -> models.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"]]
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)
@@ -374,11 +337,7 @@ class RideSearchService:
# Designer filter (multi-select)
if filters.get("designer"):
designers = (
filters["designer"]
if isinstance(filters["designer"], list)
else [filters["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
@@ -386,11 +345,7 @@ class RideSearchService:
# 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"]]
)
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
@@ -398,9 +353,7 @@ class RideSearchService:
return queryset
def _apply_roller_coaster_filters(
self, queryset, filters: dict[str, Any]
) -> models.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"
@@ -426,14 +379,8 @@ class RideSearchService:
# 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
)
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"):
@@ -457,18 +404,12 @@ class RideSearchService:
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"]}
)
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"]}
)
queryset = queryset.filter(**{f"{field_name}__lte": range_filter["max"]})
return queryset
def _apply_company_filters(
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
def _apply_company_filters(self, queryset, filters: dict[str, Any]) -> models.QuerySet:
"""Apply company-related filters."""
# Manufacturer roles filter
@@ -518,13 +459,9 @@ class RideSearchService:
return queryset.order_by("-search_rank", "name")
# Apply the sorting
return queryset.order_by(
sort_field, "name"
) # Always add name as secondary sort
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]:
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:
@@ -601,9 +538,7 @@ class RideSearchService:
else:
raise ValueError(f"Unknown filter key: {filter_key}")
def get_search_suggestions(
self, query: str, limit: int = 10
) -> list[dict[str, Any]]:
def get_search_suggestions(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
"""
Get search suggestions for autocomplete functionality.
"""
@@ -686,17 +621,11 @@ class RideSearchService:
# 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
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")
)
return list(base_queryset.values("park__name", "park__slug").distinct().order_by("park__name"))
elif filter_type == "manufacturer":
return list(

View File

@@ -3,7 +3,6 @@ Services for ride status transitions and management.
Following Django styleguide pattern for business logic encapsulation.
"""
from django.contrib.auth.models import AbstractBaseUser
from django.db import transaction
@@ -34,9 +33,7 @@ class RideStatusService:
return ride
@staticmethod
def close_ride_temporarily(
*, ride_id: int, user: AbstractBaseUser | None = None
) -> Ride:
def close_ride_temporarily(*, ride_id: int, user: AbstractBaseUser | None = None) -> Ride:
"""
Temporarily close a ride.
@@ -56,9 +53,7 @@ class RideStatusService:
return ride
@staticmethod
def mark_ride_sbno(
*, ride_id: int, user: AbstractBaseUser | None = None
) -> Ride:
def mark_ride_sbno(*, ride_id: int, user: AbstractBaseUser | None = None) -> Ride:
"""
Mark a ride as SBNO (Standing But Not Operating).
@@ -111,9 +106,7 @@ class RideStatusService:
return ride
@staticmethod
def close_ride_permanently(
*, ride_id: int, user: AbstractBaseUser | None = None
) -> Ride:
def close_ride_permanently(*, ride_id: int, user: AbstractBaseUser | None = None) -> Ride:
"""
Permanently close a ride.

View File

@@ -139,9 +139,7 @@ class RideService:
return ride
@staticmethod
def close_ride_temporarily(
*, ride_id: int, user: UserType | None = None
) -> Ride:
def close_ride_temporarily(*, ride_id: int, user: UserType | None = None) -> Ride:
"""
Temporarily close a ride.
@@ -161,9 +159,7 @@ class RideService:
return ride
@staticmethod
def mark_ride_sbno(
*, ride_id: int, user: UserType | None = None
) -> Ride:
def mark_ride_sbno(*, ride_id: int, user: UserType | None = None) -> Ride:
"""
Mark a ride as SBNO (Standing But Not Operating).
@@ -216,9 +212,7 @@ class RideService:
return ride
@staticmethod
def close_ride_permanently(
*, ride_id: int, user: UserType | None = None
) -> Ride:
def close_ride_permanently(*, ride_id: int, user: UserType | None = None) -> Ride:
"""
Permanently close a ride.
@@ -258,9 +252,7 @@ class RideService:
return ride
@staticmethod
def relocate_ride(
*, ride_id: int, new_park_id: int, user: UserType | None = None
) -> Ride:
def relocate_ride(*, ride_id: int, new_park_id: int, user: UserType | None = None) -> Ride:
"""
Relocate a ride to a new park.
@@ -336,12 +328,7 @@ class RideService:
"""
from apps.moderation.services import ModerationService
result = {
'manufacturers': [],
'designers': [],
'ride_models': [],
'total_submissions': 0
}
result = {"manufacturers": [], "designers": [], "ride_models": [], "total_submissions": 0}
# Check for new manufacturer
manufacturer_name = form_data.get("manufacturer_search")
@@ -354,8 +341,8 @@ class RideService:
reason=f"New manufacturer suggested: {manufacturer_name}",
)
if submission:
result['manufacturers'].append(submission.id)
result['total_submissions'] += 1
result["manufacturers"].append(submission.id)
result["total_submissions"] += 1
# Check for new designer
designer_name = form_data.get("designer_search")
@@ -368,8 +355,8 @@ class RideService:
reason=f"New designer suggested: {designer_name}",
)
if submission:
result['designers'].append(submission.id)
result['total_submissions'] += 1
result["designers"].append(submission.id)
result["total_submissions"] += 1
# Check for new ride model
ride_model_name = form_data.get("ride_model_search")
@@ -386,7 +373,7 @@ class RideService:
reason=f"New ride model suggested: {ride_model_name}",
)
if submission:
result['ride_models'].append(submission.id)
result['total_submissions'] += 1
result["ride_models"].append(submission.id)
result["total_submissions"] += 1
return result

View File

@@ -13,6 +13,7 @@ logger = logging.getLogger(__name__)
# Computed Field Maintenance
# =============================================================================
def update_ride_search_text(ride):
"""
Update ride's search_text computed field.
@@ -25,7 +26,7 @@ def update_ride_search_text(ride):
try:
ride._populate_computed_fields()
ride.save(update_fields=['search_text'])
ride.save(update_fields=["search_text"])
logger.debug(f"Updated search_text for ride {ride.pk}")
except Exception as e:
logger.exception(f"Failed to update search_text for ride {ride.pk}: {e}")
@@ -47,22 +48,16 @@ def handle_ride_status(sender, instance, **kwargs):
if today >= instance.closing_date and instance.status == "CLOSING":
target_status = instance.post_closing_status or "SBNO"
logger.info(
f"Ride {instance.pk} closing date reached, "
f"transitioning to {target_status}"
)
logger.info(f"Ride {instance.pk} closing date reached, " f"transitioning to {target_status}")
# Try to use FSM transition method if available
transition_method_name = f'transition_to_{target_status.lower()}'
transition_method_name = f"transition_to_{target_status.lower()}"
if hasattr(instance, transition_method_name):
# Check if transition is allowed before attempting
if hasattr(instance, 'can_proceed'):
can_proceed = getattr(instance, f'can_transition_to_{target_status.lower()}', None)
if hasattr(instance, "can_proceed"):
can_proceed = getattr(instance, f"can_transition_to_{target_status.lower()}", None)
if can_proceed and callable(can_proceed) and not can_proceed():
logger.warning(
f"FSM transition to {target_status} not allowed "
f"for ride {instance.pk}"
)
logger.warning(f"FSM transition to {target_status} not allowed " f"for ride {instance.pk}")
# Fall back to direct status change
instance.status = target_status
instance.status_since = instance.closing_date
@@ -72,13 +67,9 @@ def handle_ride_status(sender, instance, **kwargs):
method = getattr(instance, transition_method_name)
method()
instance.status_since = instance.closing_date
logger.info(
f"Applied FSM transition to {target_status} for ride {instance.pk}"
)
logger.info(f"Applied FSM transition to {target_status} for ride {instance.pk}")
except Exception as e:
logger.exception(
f"Failed to apply FSM transition for ride {instance.pk}: {e}"
)
logger.exception(f"Failed to apply FSM transition for ride {instance.pk}: {e}")
# Fall back to direct status change
instance.status = target_status
instance.status_since = instance.closing_date
@@ -101,23 +92,20 @@ def validate_closing_status(sender, instance, **kwargs):
if instance.status == "CLOSING":
# Ensure post_closing_status is set
if not instance.post_closing_status:
logger.warning(
f"Ride {instance.pk} entering CLOSING without post_closing_status set"
)
logger.warning(f"Ride {instance.pk} entering CLOSING without post_closing_status set")
# Default to SBNO if not set
instance.post_closing_status = "SBNO"
# Ensure closing_date is set
if not instance.closing_date:
logger.warning(
f"Ride {instance.pk} entering CLOSING without closing_date set"
)
logger.warning(f"Ride {instance.pk} entering CLOSING without closing_date set")
# Default to today's date
instance.closing_date = timezone.now().date()
# FSM transition signal handlers
def handle_ride_transition_to_closing(instance, source, target, user, **kwargs):
"""
Validate transition to CLOSING status.
@@ -134,20 +122,15 @@ def handle_ride_transition_to_closing(instance, source, target, user, **kwargs):
Returns:
True if transition should proceed, False to abort.
"""
if target != 'CLOSING':
if target != "CLOSING":
return True
if not instance.post_closing_status:
logger.error(
f"Cannot transition ride {instance.pk} to CLOSING: "
"post_closing_status not set"
)
logger.error(f"Cannot transition ride {instance.pk} to CLOSING: " "post_closing_status not set")
return False
if not instance.closing_date:
logger.warning(
f"Ride {instance.pk} transitioning to CLOSING without closing_date"
)
logger.warning(f"Ride {instance.pk} transitioning to CLOSING without closing_date")
return True
@@ -166,45 +149,35 @@ def apply_post_closing_status(instance, user=None):
Returns:
True if status was applied, False otherwise.
"""
if instance.status != 'CLOSING':
logger.debug(
f"Ride {instance.pk} not in CLOSING state, skipping"
)
if instance.status != "CLOSING":
logger.debug(f"Ride {instance.pk} not in CLOSING state, skipping")
return False
target_status = instance.post_closing_status
if not target_status:
logger.warning(
f"Ride {instance.pk} in CLOSING but no post_closing_status set"
)
logger.warning(f"Ride {instance.pk} in CLOSING but no post_closing_status set")
return False
# Try to use FSM transition
transition_method_name = f'transition_to_{target_status.lower()}'
transition_method_name = f"transition_to_{target_status.lower()}"
if hasattr(instance, transition_method_name):
try:
method = getattr(instance, transition_method_name)
method(user=user)
instance.post_closing_status = None
instance.save(update_fields=['post_closing_status'])
logger.info(
f"Applied post_closing_status {target_status} to ride {instance.pk}"
)
instance.save(update_fields=["post_closing_status"])
logger.info(f"Applied post_closing_status {target_status} to ride {instance.pk}")
return True
except Exception as e:
logger.exception(
f"Failed to apply post_closing_status for ride {instance.pk}: {e}"
)
logger.exception(f"Failed to apply post_closing_status for ride {instance.pk}: {e}")
return False
else:
# Direct status change
instance.status = target_status
instance.post_closing_status = None
instance.status_since = timezone.now().date()
instance.save(update_fields=['status', 'post_closing_status', 'status_since'])
logger.info(
f"Applied post_closing_status {target_status} to ride {instance.pk} (direct)"
)
instance.save(update_fields=["status", "post_closing_status", "status_since"])
logger.info(f"Applied post_closing_status {target_status} to ride {instance.pk} (direct)")
return True
@@ -212,7 +185,8 @@ def apply_post_closing_status(instance, user=None):
# Computed Field Maintenance Signal Handlers
# =============================================================================
@receiver(post_save, sender='parks.Park')
@receiver(post_save, sender="parks.Park")
def update_ride_search_text_on_park_change(sender, instance, **kwargs):
"""
Update ride search_text when park name or location changes.
@@ -227,7 +201,7 @@ def update_ride_search_text_on_park_change(sender, instance, **kwargs):
logger.exception(f"Failed to update ride search_text on park change: {e}")
@receiver(post_save, sender='parks.Company')
@receiver(post_save, sender="parks.Company")
def update_ride_search_text_on_company_change(sender, instance, **kwargs):
"""
Update ride search_text when manufacturer/designer name changes.
@@ -248,7 +222,7 @@ def update_ride_search_text_on_company_change(sender, instance, **kwargs):
logger.exception(f"Failed to update ride search_text on company change: {e}")
@receiver(post_save, sender='rides.RideModel')
@receiver(post_save, sender="rides.RideModel")
def update_ride_search_text_on_ride_model_change(sender, instance, **kwargs):
"""
Update ride search_text when ride model name changes.

View File

@@ -36,9 +36,7 @@ def check_overdue_closings() -> dict:
# Query rides that need transition
today = timezone.now().date()
overdue_rides = Ride.objects.filter(
status="CLOSING", closing_date__lte=today
).select_for_update()
overdue_rides = Ride.objects.filter(status="CLOSING", closing_date__lte=today).select_for_update()
processed = 0
succeeded = 0
@@ -109,9 +107,7 @@ def _get_system_user():
logger.info("Created system user for automated tasks")
except Exception as e:
# If creation fails, try to get moderator or admin user
logger.warning(
"Failed to create system user, falling back to moderator: %s", str(e)
)
logger.warning("Failed to create system user, falling back to moderator: %s", str(e))
try:
system_user = User.objects.filter(is_staff=True).first()
if not system_user:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -98,9 +98,7 @@ def show_coaster_fields(request: HttpRequest) -> HttpResponse:
return render(request, "rides/partials/coaster_fields.html")
def ride_status_actions(
request: HttpRequest, park_slug: str, ride_slug: str
) -> HttpResponse:
def ride_status_actions(request: HttpRequest, park_slug: str, ride_slug: str) -> HttpResponse:
"""
Return FSM status actions for ride moderators.
@@ -131,9 +129,7 @@ def ride_status_actions(
)
def ride_header_badge(
request: HttpRequest, park_slug: str, ride_slug: str
) -> HttpResponse:
def ride_header_badge(request: HttpRequest, park_slug: str, ride_slug: str) -> HttpResponse:
"""
Return the header status badge partial for a ride.
@@ -205,9 +201,7 @@ class RideDetailView(HistoryMixin, DetailView):
return context
class RideCreateView(
LoginRequiredMixin, ParkContextRequired, RideFormMixin, CreateView
):
class RideCreateView(LoginRequiredMixin, ParkContextRequired, RideFormMixin, CreateView):
"""
View for creating a new ride.
@@ -389,9 +383,7 @@ class RideListView(ListView):
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["category_choices"] = [(choice.value, choice.label) for choice in choices]
# Add filter summary for display
if filter_form.is_valid():
@@ -512,10 +504,7 @@ def get_search_suggestions(request: HttpRequest) -> HttpResponse:
if query:
# Get common ride names
matching_names = (
Ride.objects.filter(name__icontains=query)
.values("name")
.annotate(count=Count("id"))
.order_by("-count")[:3]
Ride.objects.filter(name__icontains=query).values("name").annotate(count=Count("id")).order_by("-count")[:3]
)
for match in matching_names:
@@ -663,18 +652,14 @@ class RideRankingsView(ListView):
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["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
)
context["last_updated"] = self.object_list[0].last_calculated if self.object_list else None
return context
@@ -688,9 +673,9 @@ class RideRankingDetailView(DetailView):
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")
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."""
@@ -704,14 +689,10 @@ class RideRankingDetailView(DetailView):
context.update(ranking_details)
# Get recent movement
recent_snapshots = RankingSnapshot.objects.filter(
ride=self.object
).order_by("-snapshot_date")[:7]
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["rank_change"] = recent_snapshots[0].rank - recent_snapshots[1].rank
context["previous_rank"] = recent_snapshots[1].rank
else:
context["not_ranked"] = True