mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:51:10 -05:00
@@ -1,12 +0,0 @@
|
||||
"""
|
||||
Rides Django App
|
||||
|
||||
This app handles all ride-related functionality including ride models,
|
||||
companies, rankings, and search functionality.
|
||||
"""
|
||||
|
||||
# Import choices to ensure they are registered with the global registry
|
||||
from . import choices
|
||||
|
||||
# Ensure choices are registered on app startup
|
||||
__all__ = ['choices']
|
||||
@@ -1,710 +0,0 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.gis.admin import GISModelAdmin
|
||||
from django.utils.html import format_html
|
||||
from .models.company import Company
|
||||
from .models.rides import Ride, RideModel, RollerCoasterStats
|
||||
from .models.location import RideLocation
|
||||
from .models.reviews import RideReview
|
||||
from .models.rankings import RideRanking, RidePairComparison, RankingSnapshot
|
||||
|
||||
|
||||
class ManufacturerAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "headquarters", "website", "rides_count")
|
||||
search_fields = ("name",)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).filter(roles__contains=["MANUFACTURER"])
|
||||
|
||||
|
||||
class DesignerAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "headquarters", "website")
|
||||
search_fields = ("name",)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).filter(roles__contains=["DESIGNER"])
|
||||
|
||||
|
||||
class RideLocationInline(admin.StackedInline):
|
||||
"""Inline admin for RideLocation"""
|
||||
|
||||
model = RideLocation
|
||||
extra = 0
|
||||
fields = (
|
||||
"park_area",
|
||||
"point",
|
||||
"entrance_notes",
|
||||
"accessibility_notes",
|
||||
)
|
||||
|
||||
|
||||
class RideLocationAdmin(GISModelAdmin):
|
||||
"""Admin for standalone RideLocation management"""
|
||||
|
||||
list_display = ("ride", "park_area", "has_coordinates", "created_at")
|
||||
list_filter = ("park_area", "created_at")
|
||||
search_fields = ("ride__name", "park_area", "entrance_notes")
|
||||
readonly_fields = (
|
||||
"latitude",
|
||||
"longitude",
|
||||
"coordinates",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
fieldsets = (
|
||||
("Ride", {"fields": ("ride",)}),
|
||||
(
|
||||
"Location Information",
|
||||
{
|
||||
"fields": (
|
||||
"park_area",
|
||||
"point",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"coordinates",
|
||||
),
|
||||
"description": "Optional coordinates - not all rides need precise location tracking",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Navigation Notes",
|
||||
{
|
||||
"fields": ("entrance_notes", "accessibility_notes"),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="Latitude")
|
||||
def latitude(self, obj):
|
||||
return obj.latitude
|
||||
|
||||
@admin.display(description="Longitude")
|
||||
def longitude(self, obj):
|
||||
return obj.longitude
|
||||
|
||||
|
||||
class RollerCoasterStatsInline(admin.StackedInline):
|
||||
"""Inline admin for RollerCoasterStats"""
|
||||
|
||||
model = RollerCoasterStats
|
||||
extra = 0
|
||||
fields = (
|
||||
("height_ft", "length_ft", "speed_mph"),
|
||||
("track_material", "roller_coaster_type"),
|
||||
("propulsion_system", "inversions"),
|
||||
("max_drop_height_ft", "ride_time_seconds"),
|
||||
("train_style", "trains_count"),
|
||||
("cars_per_train", "seats_per_car"),
|
||||
)
|
||||
classes = ("collapse",)
|
||||
|
||||
|
||||
@admin.register(Ride)
|
||||
class RideAdmin(admin.ModelAdmin):
|
||||
"""Enhanced Ride admin with location and coaster stats inlines"""
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"park",
|
||||
"category_display",
|
||||
"manufacturer",
|
||||
"status",
|
||||
"opening_date",
|
||||
"average_rating",
|
||||
)
|
||||
list_filter = (
|
||||
"category",
|
||||
"status",
|
||||
"park",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"opening_date",
|
||||
)
|
||||
search_fields = (
|
||||
"name",
|
||||
"description",
|
||||
"park__name",
|
||||
"manufacturer__name",
|
||||
"designer__name",
|
||||
)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
inlines = [RideLocationInline, RollerCoasterStatsInline]
|
||||
date_hierarchy = "opening_date"
|
||||
ordering = ("park", "name")
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Basic Information",
|
||||
{
|
||||
"fields": (
|
||||
"name",
|
||||
"slug",
|
||||
"description",
|
||||
"park",
|
||||
"park_area",
|
||||
"category",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Companies",
|
||||
{
|
||||
"fields": (
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"ride_model",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Status & Dates",
|
||||
{
|
||||
"fields": (
|
||||
"status",
|
||||
"post_closing_status",
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"status_since",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Ride Specifications",
|
||||
{
|
||||
"fields": (
|
||||
"min_height_in",
|
||||
"max_height_in",
|
||||
"capacity_per_hour",
|
||||
"ride_duration_seconds",
|
||||
"average_rating",
|
||||
),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="Category")
|
||||
def category_display(self, obj):
|
||||
"""Display category with full name"""
|
||||
choices_dict = dict(obj._meta.get_field("category").choices)
|
||||
if obj.category in choices_dict:
|
||||
return choices_dict[obj.category]
|
||||
else:
|
||||
raise ValueError(f"Unknown category: {obj.category}")
|
||||
|
||||
|
||||
@admin.register(RideModel)
|
||||
class RideModelAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for ride models"""
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"manufacturer",
|
||||
"category_display",
|
||||
"ride_count",
|
||||
)
|
||||
list_filter = (
|
||||
"manufacturer",
|
||||
"category",
|
||||
)
|
||||
search_fields = (
|
||||
"name",
|
||||
"description",
|
||||
"manufacturer__name",
|
||||
)
|
||||
ordering = ("manufacturer", "name")
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Model Information",
|
||||
{
|
||||
"fields": (
|
||||
"name",
|
||||
"manufacturer",
|
||||
"category",
|
||||
"description",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="Category")
|
||||
def category_display(self, obj):
|
||||
"""Display category with full name"""
|
||||
choices_dict = dict(obj._meta.get_field("category").choices)
|
||||
if obj.category in choices_dict:
|
||||
return choices_dict[obj.category]
|
||||
else:
|
||||
raise ValueError(f"Unknown category: {obj.category}")
|
||||
|
||||
@admin.display(description="Installations")
|
||||
def ride_count(self, obj):
|
||||
"""Display number of ride installations"""
|
||||
return obj.rides.count()
|
||||
|
||||
|
||||
@admin.register(RollerCoasterStats)
|
||||
class RollerCoasterStatsAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for roller coaster statistics"""
|
||||
|
||||
list_display = (
|
||||
"ride",
|
||||
"height_ft",
|
||||
"speed_mph",
|
||||
"length_ft",
|
||||
"inversions",
|
||||
"track_material",
|
||||
"roller_coaster_type",
|
||||
)
|
||||
list_filter = (
|
||||
"track_material",
|
||||
"roller_coaster_type",
|
||||
"propulsion_system",
|
||||
"inversions",
|
||||
)
|
||||
search_fields = (
|
||||
"ride__name",
|
||||
"ride__park__name",
|
||||
"track_type",
|
||||
"train_style",
|
||||
)
|
||||
readonly_fields = ("calculated_capacity",)
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Basic Stats",
|
||||
{
|
||||
"fields": (
|
||||
"ride",
|
||||
"height_ft",
|
||||
"length_ft",
|
||||
"speed_mph",
|
||||
"max_drop_height_ft",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Track & Design",
|
||||
{
|
||||
"fields": (
|
||||
"track_material",
|
||||
"track_type",
|
||||
"roller_coaster_type",
|
||||
"propulsion_system",
|
||||
"inversions",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Operation Details",
|
||||
{
|
||||
"fields": (
|
||||
"ride_time_seconds",
|
||||
"train_style",
|
||||
"trains_count",
|
||||
"cars_per_train",
|
||||
"seats_per_car",
|
||||
"calculated_capacity",
|
||||
),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="Calculated Capacity")
|
||||
def calculated_capacity(self, obj):
|
||||
"""Calculate theoretical hourly capacity"""
|
||||
if all(
|
||||
[
|
||||
obj.trains_count,
|
||||
obj.cars_per_train,
|
||||
obj.seats_per_car,
|
||||
obj.ride_time_seconds,
|
||||
]
|
||||
):
|
||||
total_seats = obj.trains_count * obj.cars_per_train * obj.seats_per_car
|
||||
# Add 2 min loading time
|
||||
cycles_per_hour = 3600 / (obj.ride_time_seconds + 120)
|
||||
return f"{int(total_seats * cycles_per_hour)} riders/hour"
|
||||
return "N/A"
|
||||
|
||||
|
||||
@admin.register(RideReview)
|
||||
class RideReviewAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for ride reviews"""
|
||||
|
||||
list_display = (
|
||||
"ride",
|
||||
"user",
|
||||
"rating",
|
||||
"title",
|
||||
"visit_date",
|
||||
"is_published",
|
||||
"created_at",
|
||||
"moderation_status",
|
||||
)
|
||||
list_filter = (
|
||||
"rating",
|
||||
"is_published",
|
||||
"visit_date",
|
||||
"created_at",
|
||||
"ride__park",
|
||||
"moderated_by",
|
||||
)
|
||||
search_fields = (
|
||||
"title",
|
||||
"content",
|
||||
"user__username",
|
||||
"ride__name",
|
||||
"ride__park__name",
|
||||
)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
date_hierarchy = "created_at"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Review Details",
|
||||
{
|
||||
"fields": (
|
||||
"user",
|
||||
"ride",
|
||||
"rating",
|
||||
"title",
|
||||
"content",
|
||||
"visit_date",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Publication Status",
|
||||
{
|
||||
"fields": ("is_published",),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Moderation",
|
||||
{
|
||||
"fields": (
|
||||
"moderated_by",
|
||||
"moderated_at",
|
||||
"moderation_notes",
|
||||
),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="Moderation Status", boolean=True)
|
||||
def moderation_status(self, obj):
|
||||
"""Display moderation status with color coding"""
|
||||
if obj.moderated_by:
|
||||
return format_html(
|
||||
'<span style="color: {};">{}</span>',
|
||||
"green" if obj.is_published else "red",
|
||||
"Approved" if obj.is_published else "Rejected",
|
||||
)
|
||||
return format_html('<span style="color: orange;">Pending</span>')
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Auto-set moderation info when status changes"""
|
||||
if change and "is_published" in form.changed_data:
|
||||
from django.utils import timezone
|
||||
|
||||
obj.moderated_by = request.user
|
||||
obj.moderated_at = timezone.now()
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
@admin.register(Company)
|
||||
class CompanyAdmin(admin.ModelAdmin):
|
||||
"""Enhanced Company admin for rides app"""
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"roles_display",
|
||||
"website",
|
||||
"founded_date",
|
||||
"rides_count",
|
||||
"coasters_count",
|
||||
)
|
||||
list_filter = ("roles", "founded_date")
|
||||
search_fields = ("name", "description")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Basic Information",
|
||||
{
|
||||
"fields": (
|
||||
"name",
|
||||
"slug",
|
||||
"roles",
|
||||
"description",
|
||||
"website",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Company Details",
|
||||
{
|
||||
"fields": (
|
||||
"founded_date",
|
||||
"rides_count",
|
||||
"coasters_count",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="Roles")
|
||||
def roles_display(self, obj):
|
||||
"""Display roles as a formatted string"""
|
||||
return ", ".join(obj.roles) if obj.roles else "No roles"
|
||||
|
||||
|
||||
@admin.register(RideRanking)
|
||||
class RideRankingAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for ride rankings"""
|
||||
|
||||
list_display = (
|
||||
"rank",
|
||||
"ride_name",
|
||||
"park_name",
|
||||
"winning_percentage_display",
|
||||
"wins",
|
||||
"losses",
|
||||
"ties",
|
||||
"average_rating",
|
||||
"mutual_riders_count",
|
||||
"last_calculated",
|
||||
)
|
||||
list_filter = (
|
||||
"ride__category",
|
||||
"last_calculated",
|
||||
"calculation_version",
|
||||
)
|
||||
search_fields = (
|
||||
"ride__name",
|
||||
"ride__park__name",
|
||||
)
|
||||
readonly_fields = (
|
||||
"ride",
|
||||
"rank",
|
||||
"wins",
|
||||
"losses",
|
||||
"ties",
|
||||
"winning_percentage",
|
||||
"mutual_riders_count",
|
||||
"comparison_count",
|
||||
"average_rating",
|
||||
"last_calculated",
|
||||
"calculation_version",
|
||||
"total_comparisons",
|
||||
)
|
||||
ordering = ["rank"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Ride Information",
|
||||
{"fields": ("ride",)},
|
||||
),
|
||||
(
|
||||
"Ranking Metrics",
|
||||
{
|
||||
"fields": (
|
||||
"rank",
|
||||
"winning_percentage",
|
||||
"wins",
|
||||
"losses",
|
||||
"ties",
|
||||
"total_comparisons",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Additional Metrics",
|
||||
{
|
||||
"fields": (
|
||||
"average_rating",
|
||||
"mutual_riders_count",
|
||||
"comparison_count",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Calculation Info",
|
||||
{
|
||||
"fields": (
|
||||
"last_calculated",
|
||||
"calculation_version",
|
||||
),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="Ride")
|
||||
def ride_name(self, obj):
|
||||
return obj.ride.name
|
||||
|
||||
@admin.display(description="Park")
|
||||
def park_name(self, obj):
|
||||
return obj.ride.park.name
|
||||
|
||||
@admin.display(description="Win %")
|
||||
def winning_percentage_display(self, obj):
|
||||
return f"{obj.winning_percentage:.1%}"
|
||||
|
||||
def has_add_permission(self, request):
|
||||
# Rankings are calculated automatically
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
# Rankings are read-only
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(RidePairComparison)
|
||||
class RidePairComparisonAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for ride pair comparisons"""
|
||||
|
||||
list_display = (
|
||||
"comparison_summary",
|
||||
"ride_a_name",
|
||||
"ride_b_name",
|
||||
"winner_display",
|
||||
"ride_a_wins",
|
||||
"ride_b_wins",
|
||||
"ties",
|
||||
"mutual_riders_count",
|
||||
"last_calculated",
|
||||
)
|
||||
list_filter = ("last_calculated",)
|
||||
search_fields = (
|
||||
"ride_a__name",
|
||||
"ride_b__name",
|
||||
"ride_a__park__name",
|
||||
"ride_b__park__name",
|
||||
)
|
||||
readonly_fields = (
|
||||
"ride_a",
|
||||
"ride_b",
|
||||
"ride_a_wins",
|
||||
"ride_b_wins",
|
||||
"ties",
|
||||
"mutual_riders_count",
|
||||
"ride_a_avg_rating",
|
||||
"ride_b_avg_rating",
|
||||
"last_calculated",
|
||||
"winner",
|
||||
"is_tie",
|
||||
)
|
||||
ordering = ["-mutual_riders_count"]
|
||||
|
||||
@admin.display(description="Comparison")
|
||||
def comparison_summary(self, obj):
|
||||
return f"{obj.ride_a.name} vs {obj.ride_b.name}"
|
||||
|
||||
@admin.display(description="Ride A")
|
||||
def ride_a_name(self, obj):
|
||||
return obj.ride_a.name
|
||||
|
||||
@admin.display(description="Ride B")
|
||||
def ride_b_name(self, obj):
|
||||
return obj.ride_b.name
|
||||
|
||||
@admin.display(description="Winner")
|
||||
def winner_display(self, obj):
|
||||
if obj.is_tie:
|
||||
return "TIE"
|
||||
winner = obj.winner
|
||||
if winner:
|
||||
return winner.name
|
||||
return "N/A"
|
||||
|
||||
def has_add_permission(self, request):
|
||||
# Comparisons are calculated automatically
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
# Comparisons are read-only
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(RankingSnapshot)
|
||||
class RankingSnapshotAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for ranking history snapshots"""
|
||||
|
||||
list_display = (
|
||||
"ride_name",
|
||||
"park_name",
|
||||
"rank",
|
||||
"winning_percentage_display",
|
||||
"snapshot_date",
|
||||
)
|
||||
list_filter = (
|
||||
"snapshot_date",
|
||||
"ride__category",
|
||||
)
|
||||
search_fields = (
|
||||
"ride__name",
|
||||
"ride__park__name",
|
||||
)
|
||||
readonly_fields = (
|
||||
"ride",
|
||||
"rank",
|
||||
"winning_percentage",
|
||||
"snapshot_date",
|
||||
)
|
||||
date_hierarchy = "snapshot_date"
|
||||
ordering = ["-snapshot_date", "rank"]
|
||||
|
||||
@admin.display(description="Ride")
|
||||
def ride_name(self, obj):
|
||||
return obj.ride.name
|
||||
|
||||
@admin.display(description="Park")
|
||||
def park_name(self, obj):
|
||||
return obj.ride.park.name
|
||||
|
||||
@admin.display(description="Win %")
|
||||
def winning_percentage_display(self, obj):
|
||||
return f"{obj.winning_percentage:.1%}"
|
||||
|
||||
def has_add_permission(self, request):
|
||||
# Snapshots are created automatically
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
# Snapshots are read-only
|
||||
return False
|
||||
|
||||
|
||||
admin.site.register(RideLocation, RideLocationAdmin)
|
||||
@@ -1,9 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RidesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.rides"
|
||||
|
||||
def ready(self):
|
||||
pass
|
||||
@@ -1,804 +0,0 @@
|
||||
"""
|
||||
Rich Choice Objects for Rides Domain
|
||||
|
||||
This module defines all choice objects for the rides domain, replacing
|
||||
the legacy tuple-based choices with rich choice objects.
|
||||
"""
|
||||
|
||||
from apps.core.choices import RichChoice, ChoiceCategory
|
||||
from apps.core.choices.registry import register_choices
|
||||
|
||||
|
||||
# Ride Category Choices
|
||||
RIDE_CATEGORIES = [
|
||||
RichChoice(
|
||||
value="RC",
|
||||
label="Roller Coaster",
|
||||
description="Thrill rides with tracks featuring hills, loops, and high speeds",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'roller-coaster',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="DR",
|
||||
label="Dark Ride",
|
||||
description="Indoor rides with themed environments and storytelling",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'dark-ride',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="FR",
|
||||
label="Flat Ride",
|
||||
description="Rides that move along a generally flat plane with spinning, swinging, or bouncing motions",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'flat-ride',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="WR",
|
||||
label="Water Ride",
|
||||
description="Rides that incorporate water elements like splashing, floating, or getting wet",
|
||||
metadata={
|
||||
'color': 'cyan',
|
||||
'icon': 'water-ride',
|
||||
'css_class': 'bg-cyan-100 text-cyan-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="TR",
|
||||
label="Transport Ride",
|
||||
description="Rides primarily designed for transportation around the park",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'transport',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="OT",
|
||||
label="Other",
|
||||
description="Rides that don't fit into standard categories",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'other',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 6
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# Ride Status Choices
|
||||
RIDE_STATUSES = [
|
||||
RichChoice(
|
||||
value="OPERATING",
|
||||
label="Operating",
|
||||
description="Ride is currently open and operating normally",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'check-circle',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="CLOSED_TEMP",
|
||||
label="Temporarily Closed",
|
||||
description="Ride is temporarily closed for maintenance, weather, or other short-term reasons",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'pause-circle',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="SBNO",
|
||||
label="Standing But Not Operating",
|
||||
description="Ride structure remains but is not currently operating",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'stop-circle',
|
||||
'css_class': 'bg-orange-100 text-orange-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="CLOSING",
|
||||
label="Closing",
|
||||
description="Ride is scheduled to close permanently",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="CLOSED_PERM",
|
||||
label="Permanently Closed",
|
||||
description="Ride has been permanently closed and will not reopen",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="UNDER_CONSTRUCTION",
|
||||
label="Under Construction",
|
||||
description="Ride is currently being built or undergoing major renovation",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'tool',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 6
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="DEMOLISHED",
|
||||
label="Demolished",
|
||||
description="Ride has been completely removed and demolished",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'trash',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 7
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="RELOCATED",
|
||||
label="Relocated",
|
||||
description="Ride has been moved to a different location",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'arrow-right',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 8
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
]
|
||||
|
||||
# Post-Closing Status Choices
|
||||
POST_CLOSING_STATUSES = [
|
||||
RichChoice(
|
||||
value="SBNO",
|
||||
label="Standing But Not Operating",
|
||||
description="Ride structure remains but is not operating after closure",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'stop-circle',
|
||||
'css_class': 'bg-orange-100 text-orange-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="CLOSED_PERM",
|
||||
label="Permanently Closed",
|
||||
description="Ride has been permanently closed after the closing date",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
]
|
||||
|
||||
# Roller Coaster Track Material Choices
|
||||
TRACK_MATERIALS = [
|
||||
RichChoice(
|
||||
value="STEEL",
|
||||
label="Steel",
|
||||
description="Modern steel track construction providing smooth rides and complex layouts",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'steel',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="WOOD",
|
||||
label="Wood",
|
||||
description="Traditional wooden track construction providing classic coaster experience",
|
||||
metadata={
|
||||
'color': 'amber',
|
||||
'icon': 'wood',
|
||||
'css_class': 'bg-amber-100 text-amber-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="HYBRID",
|
||||
label="Hybrid",
|
||||
description="Combination of steel and wooden construction elements",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'hybrid',
|
||||
'css_class': 'bg-orange-100 text-orange-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
]
|
||||
|
||||
# Roller Coaster Type Choices
|
||||
COASTER_TYPES = [
|
||||
RichChoice(
|
||||
value="SITDOWN",
|
||||
label="Sit Down",
|
||||
description="Traditional seated roller coaster with riders sitting upright",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'sitdown',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="INVERTED",
|
||||
label="Inverted",
|
||||
description="Coaster where riders' feet dangle freely below the track",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'inverted',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="FLYING",
|
||||
label="Flying",
|
||||
description="Riders lie face-down in a flying position",
|
||||
metadata={
|
||||
'color': 'sky',
|
||||
'icon': 'flying',
|
||||
'css_class': 'bg-sky-100 text-sky-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="STANDUP",
|
||||
label="Stand Up",
|
||||
description="Riders stand upright during the ride",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'standup',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="WING",
|
||||
label="Wing",
|
||||
description="Riders sit on either side of the track with nothing above or below",
|
||||
metadata={
|
||||
'color': 'indigo',
|
||||
'icon': 'wing',
|
||||
'css_class': 'bg-indigo-100 text-indigo-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="DIVE",
|
||||
label="Dive",
|
||||
description="Features a vertical or near-vertical drop as the main element",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'dive',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 6
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="FAMILY",
|
||||
label="Family",
|
||||
description="Designed for riders of all ages with moderate thrills",
|
||||
metadata={
|
||||
'color': 'emerald',
|
||||
'icon': 'family',
|
||||
'css_class': 'bg-emerald-100 text-emerald-800',
|
||||
'sort_order': 7
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="WILD_MOUSE",
|
||||
label="Wild Mouse",
|
||||
description="Compact coaster with sharp turns and sudden drops",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'mouse',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800',
|
||||
'sort_order': 8
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="SPINNING",
|
||||
label="Spinning",
|
||||
description="Cars rotate freely during the ride",
|
||||
metadata={
|
||||
'color': 'pink',
|
||||
'icon': 'spinning',
|
||||
'css_class': 'bg-pink-100 text-pink-800',
|
||||
'sort_order': 9
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="FOURTH_DIMENSION",
|
||||
label="4th Dimension",
|
||||
description="Seats rotate independently of the track direction",
|
||||
metadata={
|
||||
'color': 'violet',
|
||||
'icon': '4d',
|
||||
'css_class': 'bg-violet-100 text-violet-800',
|
||||
'sort_order': 10
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="OTHER",
|
||||
label="Other",
|
||||
description="Coaster type that doesn't fit standard classifications",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'other',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 11
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# Propulsion System Choices
|
||||
PROPULSION_SYSTEMS = [
|
||||
RichChoice(
|
||||
value="CHAIN",
|
||||
label="Chain Lift",
|
||||
description="Traditional chain lift system to pull trains up the lift hill",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'chain',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="LSM",
|
||||
label="LSM Launch",
|
||||
description="Linear Synchronous Motor launch system using magnetic propulsion",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'lightning',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="HYDRAULIC",
|
||||
label="Hydraulic Launch",
|
||||
description="High-pressure hydraulic launch system for rapid acceleration",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'hydraulic',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="GRAVITY",
|
||||
label="Gravity",
|
||||
description="Uses gravity and momentum without mechanical lift systems",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'gravity',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="OTHER",
|
||||
label="Other",
|
||||
description="Propulsion system that doesn't fit standard categories",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'other',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
]
|
||||
|
||||
# Ride Model Target Market Choices
|
||||
TARGET_MARKETS = [
|
||||
RichChoice(
|
||||
value="FAMILY",
|
||||
label="Family",
|
||||
description="Designed for families with children, moderate thrills",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'family',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="THRILL",
|
||||
label="Thrill",
|
||||
description="High-intensity rides for thrill seekers",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'thrill',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="EXTREME",
|
||||
label="Extreme",
|
||||
description="Maximum intensity rides for extreme thrill seekers",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'extreme',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="KIDDIE",
|
||||
label="Kiddie",
|
||||
description="Gentle rides designed specifically for young children",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'kiddie',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="ALL_AGES",
|
||||
label="All Ages",
|
||||
description="Suitable for riders of all ages and thrill preferences",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'all-ages',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# Ride Model Photo Type Choices
|
||||
PHOTO_TYPES = [
|
||||
RichChoice(
|
||||
value="PROMOTIONAL",
|
||||
label="Promotional",
|
||||
description="Marketing and promotional photos of the ride model",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'camera',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="TECHNICAL",
|
||||
label="Technical Drawing",
|
||||
description="Technical drawings and engineering diagrams",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'blueprint',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="INSTALLATION",
|
||||
label="Installation Example",
|
||||
description="Photos of actual installations of this ride model",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'installation',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="RENDERING",
|
||||
label="3D Rendering",
|
||||
description="Computer-generated 3D renderings of the ride model",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'cube',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="CATALOG",
|
||||
label="Catalog Image",
|
||||
description="Official catalog and brochure images",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'catalog',
|
||||
'css_class': 'bg-orange-100 text-orange-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# Technical Specification Category Choices
|
||||
SPEC_CATEGORIES = [
|
||||
RichChoice(
|
||||
value="DIMENSIONS",
|
||||
label="Dimensions",
|
||||
description="Physical dimensions and measurements",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'ruler',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="PERFORMANCE",
|
||||
label="Performance",
|
||||
description="Performance specifications and capabilities",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'speedometer',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="CAPACITY",
|
||||
label="Capacity",
|
||||
description="Rider capacity and throughput specifications",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'users',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="SAFETY",
|
||||
label="Safety Features",
|
||||
description="Safety systems and features",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'shield',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="ELECTRICAL",
|
||||
label="Electrical Requirements",
|
||||
description="Power and electrical system requirements",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'lightning',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="FOUNDATION",
|
||||
label="Foundation Requirements",
|
||||
description="Foundation and structural requirements",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'foundation',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 6
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="MAINTENANCE",
|
||||
label="Maintenance",
|
||||
description="Maintenance requirements and procedures",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'wrench',
|
||||
'css_class': 'bg-orange-100 text-orange-800',
|
||||
'sort_order': 7
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="OTHER",
|
||||
label="Other",
|
||||
description="Other technical specifications",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'other',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 8
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
]
|
||||
|
||||
# Company Role Choices for Rides Domain (MANUFACTURER and DESIGNER only)
|
||||
RIDES_COMPANY_ROLES = [
|
||||
RichChoice(
|
||||
value="MANUFACTURER",
|
||||
label="Ride Manufacturer",
|
||||
description="Company that designs and builds ride hardware and systems",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'factory',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 1,
|
||||
'domain': 'rides',
|
||||
'permissions': ['manage_ride_models', 'view_manufacturing'],
|
||||
'url_pattern': '/rides/manufacturers/{slug}/'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="DESIGNER",
|
||||
label="Ride Designer",
|
||||
description="Company that specializes in ride design, layout, and engineering",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'design',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 2,
|
||||
'domain': 'rides',
|
||||
'permissions': ['manage_ride_designs', 'view_design_specs'],
|
||||
'url_pattern': '/rides/designers/{slug}/'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def register_rides_choices():
|
||||
"""Register all rides domain choices with the global registry"""
|
||||
|
||||
register_choices(
|
||||
name="categories",
|
||||
choices=RIDE_CATEGORIES,
|
||||
domain="rides",
|
||||
description="Ride category classifications",
|
||||
metadata={'domain': 'rides', 'type': 'category'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="statuses",
|
||||
choices=RIDE_STATUSES,
|
||||
domain="rides",
|
||||
description="Ride operational status options",
|
||||
metadata={'domain': 'rides', 'type': 'status'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="post_closing_statuses",
|
||||
choices=POST_CLOSING_STATUSES,
|
||||
domain="rides",
|
||||
description="Status options after ride closure",
|
||||
metadata={'domain': 'rides', 'type': 'post_closing_status'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="track_materials",
|
||||
choices=TRACK_MATERIALS,
|
||||
domain="rides",
|
||||
description="Roller coaster track material types",
|
||||
metadata={'domain': 'rides', 'type': 'track_material', 'applies_to': 'roller_coasters'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="coaster_types",
|
||||
choices=COASTER_TYPES,
|
||||
domain="rides",
|
||||
description="Roller coaster type classifications",
|
||||
metadata={'domain': 'rides', 'type': 'coaster_type', 'applies_to': 'roller_coasters'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="propulsion_systems",
|
||||
choices=PROPULSION_SYSTEMS,
|
||||
domain="rides",
|
||||
description="Roller coaster propulsion and lift systems",
|
||||
metadata={'domain': 'rides', 'type': 'propulsion_system', 'applies_to': 'roller_coasters'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="target_markets",
|
||||
choices=TARGET_MARKETS,
|
||||
domain="rides",
|
||||
description="Target market classifications for ride models",
|
||||
metadata={'domain': 'rides', 'type': 'target_market', 'applies_to': 'ride_models'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="photo_types",
|
||||
choices=PHOTO_TYPES,
|
||||
domain="rides",
|
||||
description="Photo type classifications for ride model images",
|
||||
metadata={'domain': 'rides', 'type': 'photo_type', 'applies_to': 'ride_model_photos'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="spec_categories",
|
||||
choices=SPEC_CATEGORIES,
|
||||
domain="rides",
|
||||
description="Technical specification category classifications",
|
||||
metadata={'domain': 'rides', 'type': 'spec_category', 'applies_to': 'ride_model_specs'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="company_roles",
|
||||
choices=RIDES_COMPANY_ROLES,
|
||||
domain="rides",
|
||||
description="Company role classifications for rides domain (MANUFACTURER and DESIGNER only)",
|
||||
metadata={'domain': 'rides', 'type': 'company_role'}
|
||||
)
|
||||
|
||||
|
||||
# Auto-register choices when module is imported
|
||||
register_rides_choices()
|
||||
@@ -1,81 +0,0 @@
|
||||
from typing import Dict
|
||||
|
||||
|
||||
def get_ride_display_changes(changes: Dict) -> Dict:
|
||||
"""Returns a human-readable version of the ride changes"""
|
||||
field_names = {
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"status": "Status",
|
||||
"post_closing_status": "Post-Closing Status",
|
||||
"opening_date": "Opening Date",
|
||||
"closing_date": "Closing Date",
|
||||
"status_since": "Status Since",
|
||||
"capacity_per_hour": "Hourly Capacity",
|
||||
"min_height_in": "Minimum Height",
|
||||
"max_height_in": "Maximum Height",
|
||||
"ride_duration_seconds": "Ride Duration",
|
||||
}
|
||||
|
||||
display_changes = {}
|
||||
for field, change in changes.items():
|
||||
if field in field_names:
|
||||
old_value = change.get("old", "")
|
||||
new_value = change.get("new", "")
|
||||
|
||||
# Format specific fields
|
||||
if field == "status":
|
||||
from .choices import RIDE_STATUSES
|
||||
|
||||
choices = {choice.value: choice.label for choice in RIDE_STATUSES}
|
||||
if old_value in choices:
|
||||
old_value = choices[old_value]
|
||||
if new_value in choices:
|
||||
new_value = choices[new_value]
|
||||
elif field == "post_closing_status":
|
||||
from .choices import POST_CLOSING_STATUSES
|
||||
|
||||
choices = {choice.value: choice.label for choice in POST_CLOSING_STATUSES}
|
||||
if old_value in choices:
|
||||
old_value = choices[old_value]
|
||||
if new_value in choices:
|
||||
new_value = choices[new_value]
|
||||
|
||||
display_changes[field_names[field]] = {
|
||||
"old": old_value,
|
||||
"new": new_value,
|
||||
}
|
||||
|
||||
return display_changes
|
||||
|
||||
|
||||
def get_ride_model_display_changes(changes: Dict) -> Dict:
|
||||
"""Returns a human-readable version of the ride model changes"""
|
||||
field_names = {
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"category": "Category",
|
||||
}
|
||||
|
||||
display_changes = {}
|
||||
for field, change in changes.items():
|
||||
if field in field_names:
|
||||
old_value = change.get("old", "")
|
||||
new_value = change.get("new", "")
|
||||
|
||||
# Format category field
|
||||
if field == "category":
|
||||
from .choices import RIDE_CATEGORIES
|
||||
|
||||
choices = {choice.value: choice.label for choice in RIDE_CATEGORIES}
|
||||
if old_value in choices:
|
||||
old_value = choices[old_value]
|
||||
if new_value in choices:
|
||||
new_value = choices[new_value]
|
||||
|
||||
display_changes[field_names[field]] = {
|
||||
"old": old_value,
|
||||
"new": new_value,
|
||||
}
|
||||
|
||||
return display_changes
|
||||
@@ -1,379 +0,0 @@
|
||||
from apps.parks.models import Park, ParkArea
|
||||
from django import forms
|
||||
from django.forms import ModelChoiceField
|
||||
from django.urls import reverse_lazy
|
||||
from .models.company import Company
|
||||
from .models.rides import Ride, RideModel
|
||||
|
||||
Manufacturer = Company
|
||||
Designer = Company
|
||||
|
||||
|
||||
class RideForm(forms.ModelForm):
|
||||
park_search = forms.CharField(
|
||||
label="Park *",
|
||||
required=True,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a park...",
|
||||
"hx-get": "/parks/search/",
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#park-search-results",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
manufacturer_search = forms.CharField(
|
||||
label="Manufacturer",
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a manufacturer...",
|
||||
"hx-get": reverse_lazy("rides:search_companies"),
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#manufacturer-search-results",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
designer_search = forms.CharField(
|
||||
label="Designer",
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a designer...",
|
||||
"hx-get": reverse_lazy("rides:search_companies"),
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#designer-search-results",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
ride_model_search = forms.CharField(
|
||||
label="Ride Model",
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a ride model...",
|
||||
"hx-get": reverse_lazy("rides:search_ride_models"),
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#ride-model-search-results",
|
||||
"hx-include": "[name='manufacturer']",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
park = forms.ModelChoiceField(
|
||||
queryset=Park.objects.all(),
|
||||
required=True,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
manufacturer = forms.ModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
designer = forms.ModelChoiceField(
|
||||
queryset=Designer.objects.all(),
|
||||
required=False,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
ride_model = forms.ModelChoiceField(
|
||||
queryset=RideModel.objects.all(),
|
||||
required=False,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
park_area = ModelChoiceField(
|
||||
queryset=ParkArea.objects.none(),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Select an area within the park...",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Ride
|
||||
fields = [
|
||||
"name",
|
||||
"category",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"ride_model",
|
||||
"status",
|
||||
"post_closing_status",
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"status_since",
|
||||
"min_height_in",
|
||||
"max_height_in",
|
||||
"capacity_per_hour",
|
||||
"ride_duration_seconds",
|
||||
"description",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Official name of the ride",
|
||||
}
|
||||
),
|
||||
"category": forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"hx-get": reverse_lazy("rides:coaster_fields"),
|
||||
"hx-target": "#coaster-fields",
|
||||
"hx-trigger": "change",
|
||||
"hx-include": "this",
|
||||
"hx-swap": "innerHTML",
|
||||
}
|
||||
),
|
||||
"status": forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Current operational status",
|
||||
"x-model": "status",
|
||||
"@change": "handleStatusChange",
|
||||
}
|
||||
),
|
||||
"post_closing_status": forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Status after closing",
|
||||
"x-show": "status === 'CLOSING'",
|
||||
}
|
||||
),
|
||||
"opening_date": forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Date when ride first opened",
|
||||
}
|
||||
),
|
||||
"closing_date": forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Date when ride will close",
|
||||
"x-show": "['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(status)",
|
||||
":required": "status === 'CLOSING'",
|
||||
}
|
||||
),
|
||||
"status_since": forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Date when current status took effect",
|
||||
}
|
||||
),
|
||||
"min_height_in": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Minimum height requirement in inches",
|
||||
}
|
||||
),
|
||||
"max_height_in": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Maximum height limit in inches (if applicable)",
|
||||
}
|
||||
),
|
||||
"capacity_per_hour": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Theoretical hourly ride capacity",
|
||||
}
|
||||
),
|
||||
"ride_duration_seconds": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Total duration of one ride cycle in seconds",
|
||||
}
|
||||
),
|
||||
"description": forms.Textarea(
|
||||
attrs={
|
||||
"rows": 4,
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-textarea "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "General description and notable features of the ride",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
park = kwargs.pop("park", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Make category required
|
||||
self.fields["category"].required = True
|
||||
|
||||
# Clear any default values for date fields
|
||||
self.fields["opening_date"].initial = None
|
||||
self.fields["closing_date"].initial = None
|
||||
self.fields["status_since"].initial = None
|
||||
|
||||
# Move fields to the beginning in desired order
|
||||
field_order = [
|
||||
"park_search",
|
||||
"park",
|
||||
"park_area",
|
||||
"name",
|
||||
"manufacturer_search",
|
||||
"manufacturer",
|
||||
"designer_search",
|
||||
"designer",
|
||||
"ride_model_search",
|
||||
"ride_model",
|
||||
"category",
|
||||
"status",
|
||||
"post_closing_status",
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"status_since",
|
||||
"min_height_in",
|
||||
"max_height_in",
|
||||
"capacity_per_hour",
|
||||
"ride_duration_seconds",
|
||||
"description",
|
||||
]
|
||||
self.order_fields(field_order)
|
||||
|
||||
if park:
|
||||
# If park is provided, set it as the initial value
|
||||
self.fields["park"].initial = park
|
||||
# Hide the park search field since we know the park
|
||||
del self.fields["park_search"]
|
||||
# Create new park_area field with park's areas
|
||||
self.fields["park_area"] = forms.ModelChoiceField(
|
||||
queryset=park.areas.all(),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Select an area within the park...",
|
||||
}
|
||||
),
|
||||
)
|
||||
else:
|
||||
# If no park provided, show park search and disable park_area until
|
||||
# park is selected
|
||||
self.fields["park_area"].widget.attrs["disabled"] = True
|
||||
# Initialize park search with current park name if editing
|
||||
if self.instance and self.instance.pk and self.instance.park:
|
||||
self.fields["park_search"].initial = self.instance.park.name
|
||||
self.fields["park"].initial = self.instance.park
|
||||
|
||||
# Initialize manufacturer, designer, and ride model search fields if
|
||||
# editing
|
||||
if self.instance and self.instance.pk:
|
||||
if self.instance.manufacturer:
|
||||
self.fields["manufacturer_search"].initial = (
|
||||
self.instance.manufacturer.name
|
||||
)
|
||||
self.fields["manufacturer"].initial = self.instance.manufacturer
|
||||
if self.instance.designer:
|
||||
self.fields["designer_search"].initial = self.instance.designer.name
|
||||
self.fields["designer"].initial = self.instance.designer
|
||||
if self.instance.ride_model:
|
||||
self.fields["ride_model_search"].initial = self.instance.ride_model.name
|
||||
self.fields["ride_model"].initial = self.instance.ride_model
|
||||
|
||||
|
||||
class RideSearchForm(forms.Form):
|
||||
"""Form for searching rides with HTMX autocomplete."""
|
||||
|
||||
ride = forms.ModelChoiceField(
|
||||
queryset=Ride.objects.all(),
|
||||
label="Find a ride",
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"hx-get": reverse_lazy("rides:search"),
|
||||
"hx-trigger": "change",
|
||||
"hx-target": "#ride-search-results",
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
"""
|
||||
Forms package for the rides app.
|
||||
|
||||
This package contains form classes for ride-related functionality including:
|
||||
- Advanced search and filtering forms
|
||||
- Form validation and data processing
|
||||
"""
|
||||
|
||||
# Import forms from the search module in this package
|
||||
from .search import MasterFilterForm
|
||||
|
||||
# Import forms from the base module in this package
|
||||
from .base import RideForm, RideSearchForm
|
||||
|
||||
__all__ = [
|
||||
"MasterFilterForm",
|
||||
"RideForm",
|
||||
"RideSearchForm",
|
||||
]
|
||||
@@ -1,379 +0,0 @@
|
||||
from apps.parks.models import Park, ParkArea
|
||||
from django import forms
|
||||
from django.forms import ModelChoiceField
|
||||
from django.urls import reverse_lazy
|
||||
from ..models.company import Company
|
||||
from ..models.rides import Ride, RideModel
|
||||
|
||||
Manufacturer = Company
|
||||
Designer = Company
|
||||
|
||||
|
||||
class RideForm(forms.ModelForm):
|
||||
park_search = forms.CharField(
|
||||
label="Park *",
|
||||
required=True,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a park...",
|
||||
"hx-get": "/parks/search/",
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#park-search-results",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
manufacturer_search = forms.CharField(
|
||||
label="Manufacturer",
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a manufacturer...",
|
||||
"hx-get": reverse_lazy("rides:search_companies"),
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#manufacturer-search-results",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
designer_search = forms.CharField(
|
||||
label="Designer",
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a designer...",
|
||||
"hx-get": reverse_lazy("rides:search_companies"),
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#designer-search-results",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
ride_model_search = forms.CharField(
|
||||
label="Ride Model",
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a ride model...",
|
||||
"hx-get": reverse_lazy("rides:search_ride_models"),
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#ride-model-search-results",
|
||||
"hx-include": "[name='manufacturer']",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
park = forms.ModelChoiceField(
|
||||
queryset=Park.objects.all(),
|
||||
required=True,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
manufacturer = forms.ModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
designer = forms.ModelChoiceField(
|
||||
queryset=Designer.objects.all(),
|
||||
required=False,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
ride_model = forms.ModelChoiceField(
|
||||
queryset=RideModel.objects.all(),
|
||||
required=False,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
park_area = ModelChoiceField(
|
||||
queryset=ParkArea.objects.none(),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Select an area within the park...",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Ride
|
||||
fields = [
|
||||
"name",
|
||||
"category",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"ride_model",
|
||||
"status",
|
||||
"post_closing_status",
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"status_since",
|
||||
"min_height_in",
|
||||
"max_height_in",
|
||||
"capacity_per_hour",
|
||||
"ride_duration_seconds",
|
||||
"description",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Official name of the ride",
|
||||
}
|
||||
),
|
||||
"category": forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"hx-get": reverse_lazy("rides:coaster_fields"),
|
||||
"hx-target": "#coaster-fields",
|
||||
"hx-trigger": "change",
|
||||
"hx-include": "this",
|
||||
"hx-swap": "innerHTML",
|
||||
}
|
||||
),
|
||||
"status": forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Current operational status",
|
||||
"x-model": "status",
|
||||
"@change": "handleStatusChange",
|
||||
}
|
||||
),
|
||||
"post_closing_status": forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Status after closing",
|
||||
"x-show": "status === 'CLOSING'",
|
||||
}
|
||||
),
|
||||
"opening_date": forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Date when ride first opened",
|
||||
}
|
||||
),
|
||||
"closing_date": forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Date when ride will close",
|
||||
"x-show": "['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(status)",
|
||||
":required": "status === 'CLOSING'",
|
||||
}
|
||||
),
|
||||
"status_since": forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Date when current status took effect",
|
||||
}
|
||||
),
|
||||
"min_height_in": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Minimum height requirement in inches",
|
||||
}
|
||||
),
|
||||
"max_height_in": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Maximum height limit in inches (if applicable)",
|
||||
}
|
||||
),
|
||||
"capacity_per_hour": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Theoretical hourly ride capacity",
|
||||
}
|
||||
),
|
||||
"ride_duration_seconds": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Total duration of one ride cycle in seconds",
|
||||
}
|
||||
),
|
||||
"description": forms.Textarea(
|
||||
attrs={
|
||||
"rows": 4,
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-textarea "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "General description and notable features of the ride",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
park = kwargs.pop("park", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Make category required
|
||||
self.fields["category"].required = True
|
||||
|
||||
# Clear any default values for date fields
|
||||
self.fields["opening_date"].initial = None
|
||||
self.fields["closing_date"].initial = None
|
||||
self.fields["status_since"].initial = None
|
||||
|
||||
# Move fields to the beginning in desired order
|
||||
field_order = [
|
||||
"park_search",
|
||||
"park",
|
||||
"park_area",
|
||||
"name",
|
||||
"manufacturer_search",
|
||||
"manufacturer",
|
||||
"designer_search",
|
||||
"designer",
|
||||
"ride_model_search",
|
||||
"ride_model",
|
||||
"category",
|
||||
"status",
|
||||
"post_closing_status",
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"status_since",
|
||||
"min_height_in",
|
||||
"max_height_in",
|
||||
"capacity_per_hour",
|
||||
"ride_duration_seconds",
|
||||
"description",
|
||||
]
|
||||
self.order_fields(field_order)
|
||||
|
||||
if park:
|
||||
# If park is provided, set it as the initial value
|
||||
self.fields["park"].initial = park
|
||||
# Hide the park search field since we know the park
|
||||
del self.fields["park_search"]
|
||||
# Create new park_area field with park's areas
|
||||
self.fields["park_area"] = forms.ModelChoiceField(
|
||||
queryset=park.areas.all(),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Select an area within the park...",
|
||||
}
|
||||
),
|
||||
)
|
||||
else:
|
||||
# If no park provided, show park search and disable park_area until
|
||||
# park is selected
|
||||
self.fields["park_area"].widget.attrs["disabled"] = True
|
||||
# Initialize park search with current park name if editing
|
||||
if self.instance and self.instance.pk and self.instance.park:
|
||||
self.fields["park_search"].initial = self.instance.park.name
|
||||
self.fields["park"].initial = self.instance.park
|
||||
|
||||
# Initialize manufacturer, designer, and ride model search fields if
|
||||
# editing
|
||||
if self.instance and self.instance.pk:
|
||||
if self.instance.manufacturer:
|
||||
self.fields["manufacturer_search"].initial = (
|
||||
self.instance.manufacturer.name
|
||||
)
|
||||
self.fields["manufacturer"].initial = self.instance.manufacturer
|
||||
if self.instance.designer:
|
||||
self.fields["designer_search"].initial = self.instance.designer.name
|
||||
self.fields["designer"].initial = self.instance.designer
|
||||
if self.instance.ride_model:
|
||||
self.fields["ride_model_search"].initial = self.instance.ride_model.name
|
||||
self.fields["ride_model"].initial = self.instance.ride_model
|
||||
|
||||
|
||||
class RideSearchForm(forms.Form):
|
||||
"""Form for searching rides with HTMX autocomplete."""
|
||||
|
||||
ride = forms.ModelChoiceField(
|
||||
queryset=Ride.objects.all(),
|
||||
label="Find a ride",
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"hx-get": reverse_lazy("rides:search"),
|
||||
"hx-trigger": "change",
|
||||
"hx-target": "#ride-search-results",
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -1,604 +0,0 @@
|
||||
"""
|
||||
Ride Search and Filter Forms
|
||||
|
||||
Forms for the comprehensive ride filtering system with 8 categories:
|
||||
1. Search text
|
||||
2. Basic info
|
||||
3. Dates
|
||||
4. Height/safety
|
||||
5. Performance
|
||||
6. Relationships
|
||||
7. Roller coaster
|
||||
8. Company
|
||||
|
||||
Each form handles validation and provides clean data for the RideSearchService.
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from typing import Dict, Any
|
||||
|
||||
from apps.parks.models import Park, ParkArea
|
||||
from apps.rides.models.company import Company
|
||||
from apps.rides.models.rides import RideModel
|
||||
|
||||
|
||||
class BaseFilterForm(forms.Form):
|
||||
"""Base form with common functionality for all filter forms."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Add common CSS classes
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({"class": "form-input"})
|
||||
|
||||
|
||||
class SearchTextForm(BaseFilterForm):
|
||||
"""Form for text-based search filters."""
|
||||
|
||||
global_search = forms.CharField(
|
||||
required=False,
|
||||
max_length=255,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": "Search rides, parks, manufacturers...",
|
||||
"class": (
|
||||
"w-full px-4 py-2 border border-gray-300 "
|
||||
"dark:border-gray-600 rounded-lg focus:ring-2 "
|
||||
"focus:ring-blue-500 focus:border-transparent "
|
||||
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
),
|
||||
"autocomplete": "off",
|
||||
"hx-get": "",
|
||||
"hx-trigger": "input changed delay:300ms",
|
||||
"hx-target": "#search-results",
|
||||
"hx-indicator": "#search-loading",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
name_search = forms.CharField(
|
||||
required=False,
|
||||
max_length=255,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": "Search by ride name...",
|
||||
"class": (
|
||||
"w-full px-3 py-2 border border-gray-300 "
|
||||
"dark:border-gray-600 rounded-md focus:ring-2 "
|
||||
"focus:ring-blue-500 focus:border-transparent "
|
||||
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
description_search = forms.CharField(
|
||||
required=False,
|
||||
max_length=255,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": "Search in descriptions...",
|
||||
"class": (
|
||||
"w-full px-3 py-2 border border-gray-300 "
|
||||
"dark:border-gray-600 rounded-md focus:ring-2 "
|
||||
"focus:ring-blue-500 focus:border-transparent "
|
||||
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BasicInfoForm(BaseFilterForm):
|
||||
"""Form for basic ride information filters."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Get choices from Rich Choice registry
|
||||
from apps.core.choices.registry import get_choices
|
||||
|
||||
# Get choices - let exceptions propagate if registry fails
|
||||
category_choices = [(choice.value, choice.label) for choice in get_choices("categories", "rides")]
|
||||
status_choices = [(choice.value, choice.label) for choice in get_choices("statuses", "rides")]
|
||||
|
||||
# Update field choices dynamically
|
||||
self.fields['category'].choices = category_choices
|
||||
self.fields['status'].choices = status_choices
|
||||
|
||||
category = forms.MultipleChoiceField(
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "grid grid-cols-2 gap-2"}),
|
||||
)
|
||||
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
|
||||
park = forms.ModelMultipleChoiceField(
|
||||
queryset=Park.objects.all(),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
|
||||
),
|
||||
)
|
||||
|
||||
park_area = forms.ModelMultipleChoiceField(
|
||||
queryset=ParkArea.objects.all(),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DateRangeWidget(forms.MultiWidget):
|
||||
"""Custom widget for date range inputs."""
|
||||
|
||||
def __init__(self, attrs=None):
|
||||
widgets = [
|
||||
forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"px-3 py-2 border border-gray-300 "
|
||||
"dark:border-gray-600 rounded-md focus:ring-2 "
|
||||
"focus:ring-blue-500 focus:border-transparent "
|
||||
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"px-3 py-2 border border-gray-300 "
|
||||
"dark:border-gray-600 rounded-md focus:ring-2 "
|
||||
"focus:ring-blue-500 focus:border-transparent "
|
||||
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
]
|
||||
super().__init__(widgets, attrs)
|
||||
|
||||
def decompress(self, value):
|
||||
if value:
|
||||
return [value.get("start"), value.get("end")]
|
||||
return [None, None]
|
||||
|
||||
|
||||
class DateRangeField(forms.MultiValueField):
|
||||
"""Custom field for date ranges."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
fields = [forms.DateField(required=False), forms.DateField(required=False)]
|
||||
super().__init__(fields, *args, **kwargs)
|
||||
self.widget = DateRangeWidget()
|
||||
|
||||
def compress(self, data_list):
|
||||
if data_list:
|
||||
start_date, end_date = data_list
|
||||
if start_date or end_date:
|
||||
return {"start": start_date, "end": end_date}
|
||||
return None
|
||||
|
||||
def validate(self, value):
|
||||
super().validate(value)
|
||||
if value and value.get("start") and value.get("end"):
|
||||
if value["start"] > value["end"]:
|
||||
raise ValidationError("Start date must be before end date.")
|
||||
|
||||
|
||||
class DateFiltersForm(BaseFilterForm):
|
||||
"""Form for date range filters."""
|
||||
|
||||
opening_date_range = DateRangeField(required=False, label="Opening Date Range")
|
||||
|
||||
closing_date_range = DateRangeField(required=False, label="Closing Date Range")
|
||||
|
||||
status_since_range = DateRangeField(required=False, label="Status Since Range")
|
||||
|
||||
|
||||
class NumberRangeWidget(forms.MultiWidget):
|
||||
"""Custom widget for number range inputs."""
|
||||
|
||||
def __init__(self, attrs=None, min_val=None, max_val=None, step=None):
|
||||
widgets = [
|
||||
forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"px-3 py-2 border border-gray-300 dark:border-gray-600 "
|
||||
"rounded-md focus:ring-2 focus:ring-blue-500 "
|
||||
"focus:border-transparent bg-white dark:bg-gray-800 "
|
||||
"text-gray-900 dark:text-white"
|
||||
),
|
||||
"placeholder": "Min",
|
||||
"min": min_val,
|
||||
"max": max_val,
|
||||
"step": step,
|
||||
}
|
||||
),
|
||||
forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"px-3 py-2 border border-gray-300 dark:border-gray-600 "
|
||||
"rounded-md focus:ring-2 focus:ring-blue-500 "
|
||||
"focus:border-transparent bg-white dark:bg-gray-800 "
|
||||
"text-gray-900 dark:text-white"
|
||||
),
|
||||
"placeholder": "Max",
|
||||
"min": min_val,
|
||||
"max": max_val,
|
||||
"step": step,
|
||||
}
|
||||
),
|
||||
]
|
||||
super().__init__(widgets, attrs)
|
||||
|
||||
def decompress(self, value):
|
||||
if value:
|
||||
return [value.get("min"), value.get("max")]
|
||||
return [None, None]
|
||||
|
||||
|
||||
class NumberRangeField(forms.MultiValueField):
|
||||
"""Custom field for number ranges."""
|
||||
|
||||
def __init__(self, min_val=None, max_val=None, step=None, *args, **kwargs):
|
||||
fields = [
|
||||
forms.FloatField(required=False, min_value=min_val, max_value=max_val),
|
||||
forms.FloatField(required=False, min_value=min_val, max_value=max_val),
|
||||
]
|
||||
super().__init__(fields, *args, **kwargs)
|
||||
self.widget = NumberRangeWidget(min_val=min_val, max_val=max_val, step=step)
|
||||
|
||||
def compress(self, data_list):
|
||||
if data_list:
|
||||
min_val, max_val = data_list
|
||||
if min_val is not None or max_val is not None:
|
||||
return {"min": min_val, "max": max_val}
|
||||
return None
|
||||
|
||||
def validate(self, value):
|
||||
super().validate(value)
|
||||
if value and value.get("min") is not None and value.get("max") is not None:
|
||||
if value["min"] > value["max"]:
|
||||
raise ValidationError("Minimum value must be less than maximum value.")
|
||||
|
||||
|
||||
class HeightSafetyForm(BaseFilterForm):
|
||||
"""Form for height and safety requirement filters."""
|
||||
|
||||
min_height_range = NumberRangeField(
|
||||
min_val=0,
|
||||
max_val=84, # 7 feet in inches
|
||||
step=1,
|
||||
required=False,
|
||||
label="Minimum Height (inches)",
|
||||
)
|
||||
|
||||
max_height_range = NumberRangeField(
|
||||
min_val=0, max_val=84, step=1, required=False, label="Maximum Height (inches)"
|
||||
)
|
||||
|
||||
|
||||
class PerformanceForm(BaseFilterForm):
|
||||
"""Form for performance metric filters."""
|
||||
|
||||
capacity_range = NumberRangeField(
|
||||
min_val=0, max_val=5000, step=50, required=False, label="Capacity per Hour"
|
||||
)
|
||||
|
||||
duration_range = NumberRangeField(
|
||||
min_val=0,
|
||||
max_val=1800, # 30 minutes in seconds
|
||||
step=10,
|
||||
required=False,
|
||||
label="Duration (seconds)",
|
||||
)
|
||||
|
||||
rating_range = NumberRangeField(
|
||||
min_val=0.0, max_val=10.0, step=0.1, required=False, label="Average Rating"
|
||||
)
|
||||
|
||||
|
||||
class RelationshipsForm(BaseFilterForm):
|
||||
"""Form for relationship filters (manufacturer, designer, ride model)."""
|
||||
|
||||
manufacturer = forms.ModelMultipleChoiceField(
|
||||
queryset=Company.objects.filter(roles__contains=["MANUFACTURER"]),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
|
||||
),
|
||||
)
|
||||
|
||||
designer = forms.ModelMultipleChoiceField(
|
||||
queryset=Company.objects.filter(roles__contains=["DESIGNER"]),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
|
||||
),
|
||||
)
|
||||
|
||||
ride_model = forms.ModelMultipleChoiceField(
|
||||
queryset=RideModel.objects.all(),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RollerCoasterForm(BaseFilterForm):
|
||||
"""Form for roller coaster specific filters."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Get choices from Rich Choice registry
|
||||
from apps.core.choices.registry import get_choices
|
||||
|
||||
# Get choices - let exceptions propagate if registry fails
|
||||
track_material_choices = [(choice.value, choice.label) for choice in get_choices("track_materials", "rides")]
|
||||
coaster_type_choices = [(choice.value, choice.label) for choice in get_choices("coaster_types", "rides")]
|
||||
propulsion_system_choices = [(choice.value, choice.label) for choice in get_choices("propulsion_systems", "rides")]
|
||||
|
||||
# Update field choices dynamically
|
||||
self.fields['track_material'].choices = track_material_choices
|
||||
self.fields['coaster_type'].choices = coaster_type_choices
|
||||
self.fields['propulsion_system'].choices = propulsion_system_choices
|
||||
|
||||
height_ft_range = NumberRangeField(
|
||||
min_val=0, max_val=500, step=1, required=False, label="Height (feet)"
|
||||
)
|
||||
|
||||
length_ft_range = NumberRangeField(
|
||||
min_val=0, max_val=10000, step=10, required=False, label="Length (feet)"
|
||||
)
|
||||
|
||||
speed_mph_range = NumberRangeField(
|
||||
min_val=0, max_val=150, step=1, required=False, label="Speed (mph)"
|
||||
)
|
||||
|
||||
inversions_range = NumberRangeField(
|
||||
min_val=0, max_val=20, step=1, required=False, label="Number of Inversions"
|
||||
)
|
||||
|
||||
track_material = forms.MultipleChoiceField(
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
|
||||
coaster_type = forms.MultipleChoiceField(
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "grid grid-cols-2 gap-2 max-h-48 overflow-y-auto"}
|
||||
),
|
||||
)
|
||||
|
||||
propulsion_system = forms.MultipleChoiceField(
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "space-y-2 max-h-48 overflow-y-auto"}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CompanyForm(BaseFilterForm):
|
||||
"""Form for company-related filters."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Get choices from Rich Choice registry
|
||||
from apps.core.choices.registry import get_choices
|
||||
|
||||
# Get both rides and parks company roles - let exceptions propagate if registry fails
|
||||
rides_roles = [(choice.value, choice.label) for choice in get_choices("company_roles", "rides")]
|
||||
parks_roles = [(choice.value, choice.label) for choice in get_choices("company_roles", "parks")]
|
||||
role_choices = rides_roles + parks_roles
|
||||
|
||||
# Update field choices dynamically
|
||||
self.fields['manufacturer_roles'].choices = role_choices
|
||||
self.fields['designer_roles'].choices = role_choices
|
||||
|
||||
manufacturer_roles = forms.MultipleChoiceField(
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
|
||||
designer_roles = forms.MultipleChoiceField(
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
|
||||
founded_date_range = DateRangeField(
|
||||
required=False, label="Company Founded Date Range"
|
||||
)
|
||||
|
||||
|
||||
class SortingForm(BaseFilterForm):
|
||||
"""Form for sorting options."""
|
||||
|
||||
# Static sorting choices - these are UI-specific and don't need Rich Choice Objects
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Static sort choices for UI functionality
|
||||
sort_choices = [
|
||||
("relevance", "Relevance"),
|
||||
("name_asc", "Name (A-Z)"),
|
||||
("name_desc", "Name (Z-A)"),
|
||||
("opening_date_asc", "Opening Date (Oldest)"),
|
||||
("opening_date_desc", "Opening Date (Newest)"),
|
||||
("rating_asc", "Rating (Lowest)"),
|
||||
("rating_desc", "Rating (Highest)"),
|
||||
("height_asc", "Height (Shortest)"),
|
||||
("height_desc", "Height (Tallest)"),
|
||||
("speed_asc", "Speed (Slowest)"),
|
||||
("speed_desc", "Speed (Fastest)"),
|
||||
("capacity_asc", "Capacity (Lowest)"),
|
||||
("capacity_desc", "Capacity (Highest)"),
|
||||
]
|
||||
|
||||
self.fields['sort_by'].choices = sort_choices
|
||||
|
||||
sort_by = forms.ChoiceField(
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
initial="relevance",
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"px-3 py-2 border border-gray-300 dark:border-gray-600 "
|
||||
"rounded-md focus:ring-2 focus:ring-blue-500 "
|
||||
"focus:border-transparent bg-white dark:bg-gray-800 "
|
||||
"text-gray-900 dark:text-white"
|
||||
),
|
||||
"hx-get": "",
|
||||
"hx-trigger": "change",
|
||||
"hx-target": "#search-results",
|
||||
"hx-indicator": "#search-loading",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MasterFilterForm(BaseFilterForm):
|
||||
"""Master form that combines all filter categories."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize all sub-forms
|
||||
self.search_text_form = SearchTextForm(*args, **kwargs)
|
||||
self.basic_info_form = BasicInfoForm(*args, **kwargs)
|
||||
self.date_filters_form = DateFiltersForm(*args, **kwargs)
|
||||
self.height_safety_form = HeightSafetyForm(*args, **kwargs)
|
||||
self.performance_form = PerformanceForm(*args, **kwargs)
|
||||
self.relationships_form = RelationshipsForm(*args, **kwargs)
|
||||
self.roller_coaster_form = RollerCoasterForm(*args, **kwargs)
|
||||
self.company_form = CompanyForm(*args, **kwargs)
|
||||
self.sorting_form = SortingForm(*args, **kwargs)
|
||||
|
||||
# Add all fields to this form
|
||||
self.fields.update(self.search_text_form.fields)
|
||||
self.fields.update(self.basic_info_form.fields)
|
||||
self.fields.update(self.date_filters_form.fields)
|
||||
self.fields.update(self.height_safety_form.fields)
|
||||
self.fields.update(self.performance_form.fields)
|
||||
self.fields.update(self.relationships_form.fields)
|
||||
self.fields.update(self.roller_coaster_form.fields)
|
||||
self.fields.update(self.company_form.fields)
|
||||
self.fields.update(self.sorting_form.fields)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# Validate all sub-forms
|
||||
for form in [
|
||||
self.search_text_form,
|
||||
self.basic_info_form,
|
||||
self.date_filters_form,
|
||||
self.height_safety_form,
|
||||
self.performance_form,
|
||||
self.relationships_form,
|
||||
self.roller_coaster_form,
|
||||
self.company_form,
|
||||
self.sorting_form,
|
||||
]:
|
||||
if not form.is_valid():
|
||||
for field, errors in form.errors.items():
|
||||
self.add_error(field, errors)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def get_filter_dict(self) -> Dict[str, Any]:
|
||||
"""Convert form data to search service filter format."""
|
||||
if not self.is_valid():
|
||||
return {}
|
||||
|
||||
filters = {}
|
||||
|
||||
# Add all cleaned data to filters
|
||||
for field_name, value in self.cleaned_data.items():
|
||||
if value: # Only include non-empty values
|
||||
filters[field_name] = value
|
||||
|
||||
return filters
|
||||
|
||||
def get_search_filters(self) -> Dict[str, Any]:
|
||||
"""Alias for get_filter_dict for backward compatibility."""
|
||||
return self.get_filter_dict()
|
||||
|
||||
def get_filter_summary(self) -> Dict[str, Any]:
|
||||
"""Get summary of active filters for display."""
|
||||
active_filters = {}
|
||||
|
||||
if not self.is_valid():
|
||||
return active_filters
|
||||
|
||||
# Group filters by category
|
||||
categories = {
|
||||
"Search": ["global_search", "name_search", "description_search"],
|
||||
"Basic Info": ["category", "status", "park", "park_area"],
|
||||
"Dates": ["opening_date_range", "closing_date_range", "status_since_range"],
|
||||
"Height & Safety": ["min_height_range", "max_height_range"],
|
||||
"Performance": ["capacity_range", "duration_range", "rating_range"],
|
||||
"Relationships": ["manufacturer", "designer", "ride_model"],
|
||||
"Roller Coaster": [
|
||||
"height_ft_range",
|
||||
"length_ft_range",
|
||||
"speed_mph_range",
|
||||
"inversions_range",
|
||||
"track_material",
|
||||
"coaster_type",
|
||||
"propulsion_system",
|
||||
],
|
||||
"Company": ["manufacturer_roles", "designer_roles", "founded_date_range"],
|
||||
}
|
||||
|
||||
for category, field_names in categories.items():
|
||||
category_filters = []
|
||||
for field_name in field_names:
|
||||
value = self.cleaned_data.get(field_name)
|
||||
if value:
|
||||
category_filters.append(
|
||||
{
|
||||
"field": field_name,
|
||||
"value": value,
|
||||
"label": self.fields[field_name].label
|
||||
or field_name.replace("_", " ").title(),
|
||||
}
|
||||
)
|
||||
|
||||
if category_filters:
|
||||
active_filters[category] = category_filters
|
||||
|
||||
return active_filters
|
||||
|
||||
def get_active_filters_summary(self) -> Dict[str, Any]:
|
||||
"""Alias for get_filter_summary for backward compatibility."""
|
||||
return self.get_filter_summary()
|
||||
|
||||
def has_active_filters(self) -> bool:
|
||||
"""Check if any filters are currently active."""
|
||||
if not self.is_valid():
|
||||
return False
|
||||
|
||||
for field_name, value in self.cleaned_data.items():
|
||||
if value: # If any field has a value, we have active filters
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -1,36 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.rides.services import RideRankingService
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Calculates and updates ride rankings using the Internet Roller Coaster Poll algorithm"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--category",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Optional ride category to filter (e.g., RC for roller coasters)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
category = options.get("category")
|
||||
|
||||
service = RideRankingService()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Starting ride ranking calculation at {timezone.now().isoformat()}"
|
||||
)
|
||||
)
|
||||
|
||||
result = service.update_all_rankings(category=category)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Completed ranking calculation: {result.get('rides_ranked', 0)} rides ranked, "
|
||||
f"{result.get('comparisons_made', 0)} comparisons, "
|
||||
f"duration={result.get('duration', 0):.2f}s"
|
||||
)
|
||||
)
|
||||
@@ -1,301 +0,0 @@
|
||||
"""
|
||||
Custom managers and QuerySets for Rides models.
|
||||
Optimized queries following Django styleguide patterns.
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Union
|
||||
from django.db.models import Q, F, Count, Prefetch
|
||||
|
||||
from apps.core.managers import (
|
||||
BaseQuerySet,
|
||||
BaseManager,
|
||||
ReviewableQuerySet,
|
||||
ReviewableManager,
|
||||
StatusQuerySet,
|
||||
StatusManager,
|
||||
)
|
||||
|
||||
|
||||
class RideQuerySet(StatusQuerySet, ReviewableQuerySet):
|
||||
"""Optimized QuerySet for Ride model."""
|
||||
|
||||
def by_category(self, *, category: Union[str, List[str]]):
|
||||
"""Filter rides by category."""
|
||||
if isinstance(category, list):
|
||||
return self.filter(category__in=category)
|
||||
return self.filter(category=category)
|
||||
|
||||
def coasters(self):
|
||||
"""Filter for roller coasters."""
|
||||
return self.filter(category__in=["RC", "WC"])
|
||||
|
||||
def thrill_rides(self):
|
||||
"""Filter for thrill rides."""
|
||||
return self.filter(category__in=["RC", "WC", "FR"])
|
||||
|
||||
def family_friendly(self, *, max_height_requirement: int = 42):
|
||||
"""Filter for family-friendly rides."""
|
||||
return self.filter(
|
||||
Q(min_height_in__lte=max_height_requirement) | Q(min_height_in__isnull=True)
|
||||
)
|
||||
|
||||
def by_park(self, *, park_id: int):
|
||||
"""Filter rides by park."""
|
||||
return self.filter(park_id=park_id)
|
||||
|
||||
def by_manufacturer(self, *, manufacturer_id: int):
|
||||
"""Filter rides by manufacturer."""
|
||||
return self.filter(manufacturer_id=manufacturer_id)
|
||||
|
||||
def by_designer(self, *, designer_id: int):
|
||||
"""Filter rides by designer."""
|
||||
return self.filter(designer_id=designer_id)
|
||||
|
||||
def with_capacity_info(self):
|
||||
"""Add capacity-related annotations."""
|
||||
return self.annotate(
|
||||
estimated_daily_capacity=F("capacity_per_hour")
|
||||
* 10, # Assuming 10 operating hours
|
||||
duration_minutes=F("ride_duration_seconds") / 60.0,
|
||||
)
|
||||
|
||||
def high_capacity(self, *, min_capacity: int = 1000):
|
||||
"""Filter for high-capacity rides."""
|
||||
return self.filter(capacity_per_hour__gte=min_capacity)
|
||||
|
||||
def optimized_for_list(self):
|
||||
"""Optimize for ride list display."""
|
||||
return self.select_related(
|
||||
"park", "park_area", "manufacturer", "designer", "ride_model"
|
||||
).with_review_stats()
|
||||
|
||||
def optimized_for_detail(self):
|
||||
"""Optimize for ride detail display."""
|
||||
from .models import RideReview
|
||||
|
||||
return self.select_related(
|
||||
"park",
|
||||
"park_area",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"ride_model__manufacturer",
|
||||
).prefetch_related(
|
||||
"location",
|
||||
"rollercoaster_stats",
|
||||
Prefetch(
|
||||
"reviews",
|
||||
queryset=RideReview.objects.select_related("user")
|
||||
.filter(is_published=True)
|
||||
.order_by("-created_at")[:10],
|
||||
),
|
||||
"photos",
|
||||
)
|
||||
|
||||
def for_map_display(self):
|
||||
"""Optimize for map display."""
|
||||
return (
|
||||
self.select_related("park", "park_area")
|
||||
.prefetch_related("location")
|
||||
.values(
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"category",
|
||||
"status",
|
||||
"park__name",
|
||||
"park__slug",
|
||||
"park_area__name",
|
||||
"location__point",
|
||||
)
|
||||
)
|
||||
|
||||
def search_by_specs(
|
||||
self,
|
||||
*,
|
||||
min_height: Optional[int] = None,
|
||||
max_height: Optional[int] = None,
|
||||
min_speed: Optional[float] = None,
|
||||
inversions: Optional[bool] = None,
|
||||
):
|
||||
"""Search rides by physical specifications."""
|
||||
queryset = self
|
||||
|
||||
if min_height:
|
||||
queryset = queryset.filter(
|
||||
Q(rollercoaster_stats__height_ft__gte=min_height)
|
||||
| Q(min_height_in__gte=min_height)
|
||||
)
|
||||
|
||||
if max_height:
|
||||
queryset = queryset.filter(
|
||||
Q(rollercoaster_stats__height_ft__lte=max_height)
|
||||
| Q(max_height_in__lte=max_height)
|
||||
)
|
||||
|
||||
if min_speed:
|
||||
queryset = queryset.filter(rollercoaster_stats__speed_mph__gte=min_speed)
|
||||
|
||||
if inversions is not None:
|
||||
if inversions:
|
||||
queryset = queryset.filter(rollercoaster_stats__inversions__gt=0)
|
||||
else:
|
||||
queryset = queryset.filter(
|
||||
Q(rollercoaster_stats__inversions=0)
|
||||
| Q(rollercoaster_stats__isnull=True)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class RideManager(StatusManager, ReviewableManager):
|
||||
"""Custom manager for Ride model."""
|
||||
|
||||
def get_queryset(self):
|
||||
return RideQuerySet(self.model, using=self._db)
|
||||
|
||||
def coasters(self):
|
||||
return self.get_queryset().coasters()
|
||||
|
||||
def thrill_rides(self):
|
||||
return self.get_queryset().thrill_rides()
|
||||
|
||||
def family_friendly(self, *, max_height_requirement: int = 42):
|
||||
return self.get_queryset().family_friendly(
|
||||
max_height_requirement=max_height_requirement
|
||||
)
|
||||
|
||||
def by_park(self, *, park_id: int):
|
||||
return self.get_queryset().by_park(park_id=park_id)
|
||||
|
||||
def high_capacity(self, *, min_capacity: int = 1000):
|
||||
return self.get_queryset().high_capacity(min_capacity=min_capacity)
|
||||
|
||||
def optimized_for_list(self):
|
||||
return self.get_queryset().optimized_for_list()
|
||||
|
||||
def optimized_for_detail(self):
|
||||
return self.get_queryset().optimized_for_detail()
|
||||
|
||||
|
||||
class RideModelQuerySet(BaseQuerySet):
|
||||
"""QuerySet for RideModel model."""
|
||||
|
||||
def by_manufacturer(self, *, manufacturer_id: int):
|
||||
"""Filter ride models by manufacturer."""
|
||||
return self.filter(manufacturer_id=manufacturer_id)
|
||||
|
||||
def by_category(self, *, category: str):
|
||||
"""Filter ride models by category."""
|
||||
return self.filter(category=category)
|
||||
|
||||
def with_ride_counts(self):
|
||||
"""Add count of rides using this model."""
|
||||
return self.annotate(
|
||||
ride_count=Count("rides", distinct=True),
|
||||
operating_rides_count=Count(
|
||||
"rides", filter=Q(rides__status="OPERATING"), distinct=True
|
||||
),
|
||||
)
|
||||
|
||||
def popular_models(self, *, min_installations: int = 5):
|
||||
"""Filter for popular ride models."""
|
||||
return self.with_ride_counts().filter(ride_count__gte=min_installations)
|
||||
|
||||
def optimized_for_list(self):
|
||||
"""Optimize for model list display."""
|
||||
return self.select_related("manufacturer").with_ride_counts()
|
||||
|
||||
|
||||
class RideModelManager(BaseManager):
|
||||
"""Manager for RideModel model."""
|
||||
|
||||
def get_queryset(self):
|
||||
return RideModelQuerySet(self.model, using=self._db)
|
||||
|
||||
def by_manufacturer(self, *, manufacturer_id: int):
|
||||
return self.get_queryset().by_manufacturer(manufacturer_id=manufacturer_id)
|
||||
|
||||
def popular_models(self, *, min_installations: int = 5):
|
||||
return self.get_queryset().popular_models(min_installations=min_installations)
|
||||
|
||||
|
||||
class RideReviewQuerySet(ReviewableQuerySet):
|
||||
"""QuerySet for RideReview model."""
|
||||
|
||||
def for_ride(self, *, ride_id: int):
|
||||
"""Filter reviews for a specific ride."""
|
||||
return self.filter(ride_id=ride_id)
|
||||
|
||||
def by_user(self, *, user_id: int):
|
||||
"""Filter reviews by user."""
|
||||
return self.filter(user_id=user_id)
|
||||
|
||||
def by_rating_range(self, *, min_rating: int = 1, max_rating: int = 10):
|
||||
"""Filter reviews by rating range."""
|
||||
return self.filter(rating__gte=min_rating, rating__lte=max_rating)
|
||||
|
||||
def optimized_for_display(self):
|
||||
"""Optimize for review display."""
|
||||
return self.select_related("user", "ride", "moderated_by")
|
||||
|
||||
|
||||
class RideReviewManager(BaseManager):
|
||||
"""Manager for RideReview model."""
|
||||
|
||||
def get_queryset(self):
|
||||
return RideReviewQuerySet(self.model, using=self._db)
|
||||
|
||||
def for_ride(self, *, ride_id: int):
|
||||
return self.get_queryset().for_ride(ride_id=ride_id)
|
||||
|
||||
def by_rating_range(self, *, min_rating: int = 1, max_rating: int = 10):
|
||||
return self.get_queryset().by_rating_range(
|
||||
min_rating=min_rating, max_rating=max_rating
|
||||
)
|
||||
|
||||
|
||||
class RollerCoasterStatsQuerySet(BaseQuerySet):
|
||||
"""QuerySet for RollerCoasterStats model."""
|
||||
|
||||
def tall_coasters(self, *, min_height_ft: float = 200):
|
||||
"""Filter for tall roller coasters."""
|
||||
return self.filter(height_ft__gte=min_height_ft)
|
||||
|
||||
def fast_coasters(self, *, min_speed_mph: float = 60):
|
||||
"""Filter for fast roller coasters."""
|
||||
return self.filter(speed_mph__gte=min_speed_mph)
|
||||
|
||||
def with_inversions(self):
|
||||
"""Filter for coasters with inversions."""
|
||||
return self.filter(inversions__gt=0)
|
||||
|
||||
def launched_coasters(self):
|
||||
"""Filter for launched coasters."""
|
||||
return self.exclude(propulsion_system="NONE")
|
||||
|
||||
def by_track_type(self, *, track_type: str):
|
||||
"""Filter by track type."""
|
||||
return self.filter(track_type=track_type)
|
||||
|
||||
def optimized_for_list(self):
|
||||
"""Optimize for stats list display."""
|
||||
return self.select_related("ride", "ride__park")
|
||||
|
||||
|
||||
class RollerCoasterStatsManager(BaseManager):
|
||||
"""Manager for RollerCoasterStats model."""
|
||||
|
||||
def get_queryset(self):
|
||||
return RollerCoasterStatsQuerySet(self.model, using=self._db)
|
||||
|
||||
def tall_coasters(self, *, min_height_ft: float = 200):
|
||||
return self.get_queryset().tall_coasters(min_height_ft=min_height_ft)
|
||||
|
||||
def fast_coasters(self, *, min_speed_mph: float = 60):
|
||||
return self.get_queryset().fast_coasters(min_speed_mph=min_speed_mph)
|
||||
|
||||
def with_inversions(self):
|
||||
return self.get_queryset().with_inversions()
|
||||
|
||||
def launched_coasters(self):
|
||||
return self.get_queryset().launched_coasters()
|
||||
@@ -1,740 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-15 21:30
|
||||
|
||||
import django.contrib.gis.db.models.fields
|
||||
import django.contrib.postgres.fields
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Company",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(max_length=255, unique=True)),
|
||||
(
|
||||
"roles",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("MANUFACTURER", "Ride Manufacturer"),
|
||||
("DESIGNER", "Ride Designer"),
|
||||
("OPERATOR", "Park Operator"),
|
||||
("PROPERTY_OWNER", "Property Owner"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
("description", models.TextField(blank=True)),
|
||||
("website", models.URLField(blank=True)),
|
||||
("founded_date", models.DateField(blank=True, null=True)),
|
||||
("rides_count", models.IntegerField(default=0)),
|
||||
("coasters_count", models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
"verbose_name_plural": "Companies",
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CompanyEvent",
|
||||
fields=[
|
||||
(
|
||||
"pgh_id",
|
||||
models.AutoField(primary_key=True, serialize=False),
|
||||
),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(db_index=False, max_length=255)),
|
||||
(
|
||||
"roles",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("MANUFACTURER", "Ride Manufacturer"),
|
||||
("DESIGNER", "Ride Designer"),
|
||||
("OPERATOR", "Park Operator"),
|
||||
("PROPERTY_OWNER", "Property Owner"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
("description", models.TextField(blank=True)),
|
||||
("website", models.URLField(blank=True)),
|
||||
("founded_date", models.DateField(blank=True, null=True)),
|
||||
("rides_count", models.IntegerField(default=0)),
|
||||
("coasters_count", models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Ride",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(max_length=255)),
|
||||
("description", models.TextField(blank=True)),
|
||||
(
|
||||
"category",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("", "Select ride type"),
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
default="",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("", "Select status"),
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSING", "Closing"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
],
|
||||
default="OPERATING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"post_closing_status",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
],
|
||||
help_text="Status to change to after closing date",
|
||||
max_length=20,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("opening_date", models.DateField(blank=True, null=True)),
|
||||
("closing_date", models.DateField(blank=True, null=True)),
|
||||
("status_since", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"min_height_in",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"max_height_in",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"capacity_per_hour",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"ride_duration_seconds",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"average_rating",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=3, null=True
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RideLocation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"point",
|
||||
django.contrib.gis.db.models.fields.PointField(
|
||||
blank=True,
|
||||
help_text="Geographic coordinates for ride location (longitude, latitude)",
|
||||
null=True,
|
||||
srid=4326,
|
||||
),
|
||||
),
|
||||
(
|
||||
"park_area",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="Themed area or land within the park (e.g., 'Frontierland', 'Tomorrowland')",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"notes",
|
||||
models.TextField(blank=True, help_text="General location notes"),
|
||||
),
|
||||
(
|
||||
"entrance_notes",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Directions to ride entrance, queue location, or navigation tips",
|
||||
),
|
||||
),
|
||||
(
|
||||
"accessibility_notes",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Information about accessible entrances, wheelchair access, etc.",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Ride Location",
|
||||
"verbose_name_plural": "Ride Locations",
|
||||
"ordering": ["ride__name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RideModel",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("description", models.TextField(blank=True)),
|
||||
(
|
||||
"category",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("", "Select ride type"),
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
default="",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["manufacturer", "name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RideReview",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"rating",
|
||||
models.PositiveSmallIntegerField(
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(10),
|
||||
]
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("content", models.TextField()),
|
||||
("visit_date", models.DateField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("is_published", models.BooleanField(default=True)),
|
||||
("moderation_notes", models.TextField(blank=True)),
|
||||
("moderated_at", models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RideReviewEvent",
|
||||
fields=[
|
||||
(
|
||||
"pgh_id",
|
||||
models.AutoField(primary_key=True, serialize=False),
|
||||
),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
(
|
||||
"rating",
|
||||
models.PositiveSmallIntegerField(
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(10),
|
||||
]
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("content", models.TextField()),
|
||||
("visit_date", models.DateField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("is_published", models.BooleanField(default=True)),
|
||||
("moderation_notes", models.TextField(blank=True)),
|
||||
("moderated_at", models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RollerCoasterStats",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"height_ft",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=6, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"length_ft",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=7, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"speed_mph",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=5, null=True
|
||||
),
|
||||
),
|
||||
("inversions", models.PositiveIntegerField(default=0)),
|
||||
(
|
||||
"ride_time_seconds",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
("track_type", models.CharField(blank=True, max_length=255)),
|
||||
(
|
||||
"track_material",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("STEEL", "Steel"),
|
||||
("WOOD", "Wood"),
|
||||
("HYBRID", "Hybrid"),
|
||||
],
|
||||
default="STEEL",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"roller_coaster_type",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("SITDOWN", "Sit Down"),
|
||||
("INVERTED", "Inverted"),
|
||||
("FLYING", "Flying"),
|
||||
("STANDUP", "Stand Up"),
|
||||
("WING", "Wing"),
|
||||
("DIVE", "Dive"),
|
||||
("FAMILY", "Family"),
|
||||
("WILD_MOUSE", "Wild Mouse"),
|
||||
("SPINNING", "Spinning"),
|
||||
("FOURTH_DIMENSION", "4th Dimension"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="SITDOWN",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"max_drop_height_ft",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=6, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"launch_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CHAIN", "Chain Lift"),
|
||||
("LSM", "LSM Launch"),
|
||||
("HYDRAULIC", "Hydraulic Launch"),
|
||||
("GRAVITY", "Gravity"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="CHAIN",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("train_style", models.CharField(blank=True, max_length=255)),
|
||||
(
|
||||
"trains_count",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"cars_per_train",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"seats_per_car",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Roller Coaster Statistics",
|
||||
"verbose_name_plural": "Roller Coaster Statistics",
|
||||
},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="company",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_e7194",
|
||||
table="rides_company",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="company",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_456a8",
|
||||
table="rides_company",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="companyevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="companyevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="rides.company",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ride",
|
||||
name="designer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
limit_choices_to={"roles__contains": ["DESIGNER"]},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="designed_rides",
|
||||
to="rides.company",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ride",
|
||||
name="manufacturer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="manufactured_rides",
|
||||
to="rides.company",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ride",
|
||||
name="park",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="rides",
|
||||
to="parks.park",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ride",
|
||||
name="park_area",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="rides",
|
||||
to="parks.parkarea",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridelocation",
|
||||
name="ride",
|
||||
field=models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="ride_location",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridemodel",
|
||||
name="manufacturer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="ride_models",
|
||||
to="rides.company",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ride",
|
||||
name="ride_model",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="The specific model/type of this ride",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="rides",
|
||||
to="rides.ridemodel",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridereview",
|
||||
name="moderated_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="moderated_ride_reviews",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridereview",
|
||||
name="ride",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="reviews",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridereview",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="ride_reviews",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridereviewevent",
|
||||
name="moderated_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridereviewevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridereviewevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="rides.ridereview",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridereviewevent",
|
||||
name="ride",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridereviewevent",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rollercoasterstats",
|
||||
name="ride",
|
||||
field=models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="coaster_stats",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ridelocation",
|
||||
index=models.Index(
|
||||
fields=["park_area"], name="rides_ridel_park_ar_26c90c_idx"
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="ridemodel",
|
||||
unique_together={("manufacturer", "name")},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="ride",
|
||||
unique_together={("park", "slug")},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="ridereview",
|
||||
unique_together={("ride", "user")},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridereview",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_ridereviewevent" ("content", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."ride_id", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_33237",
|
||||
table="rides_ridereview",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridereview",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_ridereviewevent" ("content", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."ride_id", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_90298",
|
||||
table="rides_ridereview",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,141 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-16 17:42
|
||||
|
||||
import django.db.models.functions.datetime
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("parks", "0003_add_business_constraints"),
|
||||
("rides", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name="ride",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("closing_date__isnull", True),
|
||||
("opening_date__isnull", True),
|
||||
("closing_date__gte", models.F("opening_date")),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_closing_after_opening",
|
||||
violation_error_message="Closing date must be after opening date",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ride",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("min_height_in__isnull", True),
|
||||
("max_height_in__isnull", True),
|
||||
("min_height_in__lte", models.F("max_height_in")),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_height_requirements_logical",
|
||||
violation_error_message="Minimum height cannot exceed maximum height",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ride",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("min_height_in__isnull", True),
|
||||
models.Q(("min_height_in__gte", 30), ("min_height_in__lte", 90)),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_min_height_reasonable",
|
||||
violation_error_message="Minimum height must be between 30 and 90 inches",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ride",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("max_height_in__isnull", True),
|
||||
models.Q(("max_height_in__gte", 30), ("max_height_in__lte", 90)),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_max_height_reasonable",
|
||||
violation_error_message="Maximum height must be between 30 and 90 inches",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ride",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("average_rating__isnull", True),
|
||||
models.Q(("average_rating__gte", 1), ("average_rating__lte", 10)),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_rating_range",
|
||||
violation_error_message="Average rating must be between 1 and 10",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ride",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("capacity_per_hour__isnull", True),
|
||||
("capacity_per_hour__gt", 0),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_capacity_positive",
|
||||
violation_error_message="Hourly capacity must be positive",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ride",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("ride_duration_seconds__isnull", True),
|
||||
("ride_duration_seconds__gt", 0),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_duration_positive",
|
||||
violation_error_message="Ride duration must be positive",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ridereview",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(("rating__gte", 1), ("rating__lte", 10)),
|
||||
name="ride_review_rating_range",
|
||||
violation_error_message="Rating must be between 1 and 10",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ridereview",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
(
|
||||
"visit_date__lte",
|
||||
django.db.models.functions.datetime.Now(),
|
||||
)
|
||||
),
|
||||
name="ride_review_visit_date_not_future",
|
||||
violation_error_message="Visit date cannot be in the future",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ridereview",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
models.Q(
|
||||
("moderated_at__isnull", True),
|
||||
("moderated_by__isnull", True),
|
||||
),
|
||||
models.Q(
|
||||
("moderated_at__isnull", False),
|
||||
("moderated_by__isnull", False),
|
||||
),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_review_moderation_consistency",
|
||||
violation_error_message="Moderated reviews must have both moderator and moderation timestamp",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,88 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-24 18:23
|
||||
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("rides", "0002_add_business_constraints"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="company",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="company",
|
||||
name="update_update",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ridereview",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ridereview",
|
||||
name="update_update",
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="company",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="8229e39396673e3801d570ad8d1c602b528fadc1",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_e7194",
|
||||
table="rides_company",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="company",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="9d4d58e00963bf10b3cc428ef609044104366655",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_456a8",
|
||||
table="rides_company",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridereview",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_ridereviewevent" ("content", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."ride_id", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
|
||||
hash="0d6021859fef528429d7d6028439c08de6040e52",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_33237",
|
||||
table="rides_ridereview",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridereview",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_ridereviewevent" ("content", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."ride_id", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
|
||||
hash="b0acd6ed16f909a42f9bedd975c7f385d0868d32",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_90298",
|
||||
table="rides_ridereview",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,469 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-24 19:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("parks", "0006_remove_company_insert_insert_and_more"),
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
("rides", "0003_remove_company_insert_insert_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="RideEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(db_index=False, max_length=255)),
|
||||
("description", models.TextField(blank=True)),
|
||||
(
|
||||
"category",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("", "Select ride type"),
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
default="",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("", "Select status"),
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSING", "Closing"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
],
|
||||
default="OPERATING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"post_closing_status",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
],
|
||||
help_text="Status to change to after closing date",
|
||||
max_length=20,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("opening_date", models.DateField(blank=True, null=True)),
|
||||
("closing_date", models.DateField(blank=True, null=True)),
|
||||
("status_since", models.DateField(blank=True, null=True)),
|
||||
("min_height_in", models.PositiveIntegerField(blank=True, null=True)),
|
||||
("max_height_in", models.PositiveIntegerField(blank=True, null=True)),
|
||||
(
|
||||
"capacity_per_hour",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"ride_duration_seconds",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"average_rating",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=3, null=True
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RideModelEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("description", models.TextField(blank=True)),
|
||||
(
|
||||
"category",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("", "Select ride type"),
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
default="",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RollerCoasterStatsEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
(
|
||||
"height_ft",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=6, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"length_ft",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=7, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"speed_mph",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=5, null=True
|
||||
),
|
||||
),
|
||||
("inversions", models.PositiveIntegerField(default=0)),
|
||||
(
|
||||
"ride_time_seconds",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
("track_type", models.CharField(blank=True, max_length=255)),
|
||||
(
|
||||
"track_material",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("STEEL", "Steel"),
|
||||
("WOOD", "Wood"),
|
||||
("HYBRID", "Hybrid"),
|
||||
],
|
||||
default="STEEL",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"roller_coaster_type",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("SITDOWN", "Sit Down"),
|
||||
("INVERTED", "Inverted"),
|
||||
("FLYING", "Flying"),
|
||||
("STANDUP", "Stand Up"),
|
||||
("WING", "Wing"),
|
||||
("DIVE", "Dive"),
|
||||
("FAMILY", "Family"),
|
||||
("WILD_MOUSE", "Wild Mouse"),
|
||||
("SPINNING", "Spinning"),
|
||||
("FOURTH_DIMENSION", "4th Dimension"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="SITDOWN",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"max_drop_height_ft",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=6, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"launch_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CHAIN", "Chain Lift"),
|
||||
("LSM", "LSM Launch"),
|
||||
("HYDRAULIC", "Hydraulic Launch"),
|
||||
("GRAVITY", "Gravity"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="CHAIN",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("train_style", models.CharField(blank=True, max_length=255)),
|
||||
("trains_count", models.PositiveIntegerField(blank=True, null=True)),
|
||||
("cars_per_train", models.PositiveIntegerField(blank=True, null=True)),
|
||||
("seats_per_car", models.PositiveIntegerField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ride",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_rideevent" ("average_rating", "capacity_per_hour", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at") VALUES (NEW."average_rating", NEW."capacity_per_hour", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at"); RETURN NULL;',
|
||||
hash="870aa867ae6892f187dc0382e4a6833b5d1267c5",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_52074",
|
||||
table="rides_ride",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ride",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_rideevent" ("average_rating", "capacity_per_hour", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at") VALUES (NEW."average_rating", NEW."capacity_per_hour", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at"); RETURN NULL;',
|
||||
hash="8bafb42256ee98b4517ae4d39d0e774111794fea",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_4917a",
|
||||
table="rides_ride",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridemodel",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_ridemodelevent" ("category", "created_at", "description", "id", "manufacturer_id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", NEW."manufacturer_id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."updated_at"); RETURN NULL;',
|
||||
hash="e9e3c3ec4cb2400b363035534c580c94a3bb1d53",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_0aaee",
|
||||
table="rides_ridemodel",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridemodel",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_ridemodelevent" ("category", "created_at", "description", "id", "manufacturer_id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", NEW."manufacturer_id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."updated_at"); RETURN NULL;',
|
||||
hash="4c8073b866beac402ace852e23974fcb01d24267",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_0ca1a",
|
||||
table="rides_ridemodel",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="rollercoasterstats",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_rollercoasterstatsevent" ("cars_per_train", "height_ft", "id", "inversions", "launch_type", "length_ft", "max_drop_height_ft", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "ride_time_seconds", "roller_coaster_type", "seats_per_car", "speed_mph", "track_material", "track_type", "train_style", "trains_count") VALUES (NEW."cars_per_train", NEW."height_ft", NEW."id", NEW."inversions", NEW."launch_type", NEW."length_ft", NEW."max_drop_height_ft", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_id", NEW."ride_time_seconds", NEW."roller_coaster_type", NEW."seats_per_car", NEW."speed_mph", NEW."track_material", NEW."track_type", NEW."train_style", NEW."trains_count"); RETURN NULL;',
|
||||
hash="529f2cf3bb62b57c85123143523475c1999099ec",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_96f8b",
|
||||
table="rides_rollercoasterstats",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="rollercoasterstats",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_rollercoasterstatsevent" ("cars_per_train", "height_ft", "id", "inversions", "launch_type", "length_ft", "max_drop_height_ft", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "ride_time_seconds", "roller_coaster_type", "seats_per_car", "speed_mph", "track_material", "track_type", "train_style", "trains_count") VALUES (NEW."cars_per_train", NEW."height_ft", NEW."id", NEW."inversions", NEW."launch_type", NEW."length_ft", NEW."max_drop_height_ft", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_id", NEW."ride_time_seconds", NEW."roller_coaster_type", NEW."seats_per_car", NEW."speed_mph", NEW."track_material", NEW."track_type", NEW."train_style", NEW."trains_count"); RETURN NULL;',
|
||||
hash="3c9d3cb53ac46a2f4b2a27a63c5ed17a18de9827",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_24e8a",
|
||||
table="rides_rollercoasterstats",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="designer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
limit_choices_to={"roles__contains": ["DESIGNER"]},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="rides.company",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="manufacturer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="rides.company",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="park",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="parks.park",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="park_area",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="parks.parkarea",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="ride_model",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="The specific model/type of this ride",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="rides.ridemodel",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridemodelevent",
|
||||
name="manufacturer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="rides.company",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridemodelevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridemodelevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="rides.ridemodel",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rollercoasterstatsevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rollercoasterstatsevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="rides.rollercoasterstats",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rollercoasterstatsevent",
|
||||
name="ride",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,127 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-24 19:25
|
||||
|
||||
import django.contrib.gis.db.models.fields
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
("rides", "0004_rideevent_ridemodelevent_rollercoasterstatsevent_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="RideLocationEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
(
|
||||
"point",
|
||||
django.contrib.gis.db.models.fields.PointField(
|
||||
blank=True,
|
||||
help_text="Geographic coordinates for ride location (longitude, latitude)",
|
||||
null=True,
|
||||
srid=4326,
|
||||
),
|
||||
),
|
||||
(
|
||||
"park_area",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Themed area or land within the park (e.g., 'Frontierland', 'Tomorrowland')",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"notes",
|
||||
models.TextField(blank=True, help_text="General location notes"),
|
||||
),
|
||||
(
|
||||
"entrance_notes",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Directions to ride entrance, queue location, or navigation tips",
|
||||
),
|
||||
),
|
||||
(
|
||||
"accessibility_notes",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Information about accessible entrances, wheelchair access, etc.",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridelocation",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_ridelocationevent" ("accessibility_notes", "created_at", "entrance_notes", "id", "notes", "park_area", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "ride_id", "updated_at") VALUES (NEW."accessibility_notes", NEW."created_at", NEW."entrance_notes", NEW."id", NEW."notes", NEW."park_area", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."point", NEW."ride_id", NEW."updated_at"); RETURN NULL;',
|
||||
hash="04c4c3aa17d4ef852d52b40d1dba4cd7372d5e29",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_b66c2",
|
||||
table="rides_ridelocation",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridelocation",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_ridelocationevent" ("accessibility_notes", "created_at", "entrance_notes", "id", "notes", "park_area", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "ride_id", "updated_at") VALUES (NEW."accessibility_notes", NEW."created_at", NEW."entrance_notes", NEW."id", NEW."notes", NEW."park_area", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."point", NEW."ride_id", NEW."updated_at"); RETURN NULL;',
|
||||
hash="7073b4517d00b884b2f3fddf89caeefaa64058ad",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_402ba",
|
||||
table="rides_ridelocation",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridelocationevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridelocationevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="rides.ridelocation",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridelocationevent",
|
||||
name="ride",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,602 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-25 00:50
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
("rides", "0005_ridelocationevent_ridelocation_insert_insert_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="RidePairComparison",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_a_wins",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of mutual riders who rated ride_a higher",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_b_wins",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of mutual riders who rated ride_b higher",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ties",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of mutual riders who rated both rides equally",
|
||||
),
|
||||
),
|
||||
(
|
||||
"mutual_riders_count",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Total number of users who have rated both rides",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_a_avg_rating",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=2,
|
||||
help_text="Average rating of ride_a from mutual riders",
|
||||
max_digits=3,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_b_avg_rating",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=2,
|
||||
help_text="Average rating of ride_b from mutual riders",
|
||||
max_digits=3,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_calculated",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="When this comparison was last calculated",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_a",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="comparisons_as_a",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_b",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="comparisons_as_b",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RidePairComparisonEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
(
|
||||
"ride_a_wins",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of mutual riders who rated ride_a higher",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_b_wins",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of mutual riders who rated ride_b higher",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ties",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of mutual riders who rated both rides equally",
|
||||
),
|
||||
),
|
||||
(
|
||||
"mutual_riders_count",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Total number of users who have rated both rides",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_a_avg_rating",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=2,
|
||||
help_text="Average rating of ride_a from mutual riders",
|
||||
max_digits=3,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_b_avg_rating",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=2,
|
||||
help_text="Average rating of ride_b from mutual riders",
|
||||
max_digits=3,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_calculated",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="When this comparison was last calculated",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="rides.ridepaircomparison",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_a",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_b",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RideRanking",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"rank",
|
||||
models.PositiveIntegerField(
|
||||
db_index=True, help_text="Overall rank position (1 = best)"
|
||||
),
|
||||
),
|
||||
(
|
||||
"wins",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of rides this ride beats in pairwise comparisons",
|
||||
),
|
||||
),
|
||||
(
|
||||
"losses",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of rides that beat this ride in pairwise comparisons",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ties",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of rides with equal preference in pairwise comparisons",
|
||||
),
|
||||
),
|
||||
(
|
||||
"winning_percentage",
|
||||
models.DecimalField(
|
||||
db_index=True,
|
||||
decimal_places=4,
|
||||
help_text="Win percentage where ties count as 0.5",
|
||||
max_digits=5,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(0),
|
||||
django.core.validators.MaxValueValidator(1),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"mutual_riders_count",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Total number of users who have rated this ride",
|
||||
),
|
||||
),
|
||||
(
|
||||
"comparison_count",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of other rides this was compared against",
|
||||
),
|
||||
),
|
||||
(
|
||||
"average_rating",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=2,
|
||||
help_text="Average rating from all users who have rated this ride",
|
||||
max_digits=3,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(10),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_calculated",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now,
|
||||
help_text="When this ranking was last calculated",
|
||||
),
|
||||
),
|
||||
(
|
||||
"calculation_version",
|
||||
models.CharField(
|
||||
default="1.0",
|
||||
help_text="Algorithm version used for calculation",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="ranking",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["rank"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RideRankingEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
(
|
||||
"rank",
|
||||
models.PositiveIntegerField(
|
||||
help_text="Overall rank position (1 = best)"
|
||||
),
|
||||
),
|
||||
(
|
||||
"wins",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of rides this ride beats in pairwise comparisons",
|
||||
),
|
||||
),
|
||||
(
|
||||
"losses",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of rides that beat this ride in pairwise comparisons",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ties",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of rides with equal preference in pairwise comparisons",
|
||||
),
|
||||
),
|
||||
(
|
||||
"winning_percentage",
|
||||
models.DecimalField(
|
||||
decimal_places=4,
|
||||
help_text="Win percentage where ties count as 0.5",
|
||||
max_digits=5,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(0),
|
||||
django.core.validators.MaxValueValidator(1),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"mutual_riders_count",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Total number of users who have rated this ride",
|
||||
),
|
||||
),
|
||||
(
|
||||
"comparison_count",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of other rides this was compared against",
|
||||
),
|
||||
),
|
||||
(
|
||||
"average_rating",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=2,
|
||||
help_text="Average rating from all users who have rated this ride",
|
||||
max_digits=3,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(10),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_calculated",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now,
|
||||
help_text="When this ranking was last calculated",
|
||||
),
|
||||
),
|
||||
(
|
||||
"calculation_version",
|
||||
models.CharField(
|
||||
default="1.0",
|
||||
help_text="Algorithm version used for calculation",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="rides.rideranking",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RankingSnapshot",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("rank", models.PositiveIntegerField()),
|
||||
(
|
||||
"winning_percentage",
|
||||
models.DecimalField(decimal_places=4, max_digits=5),
|
||||
),
|
||||
(
|
||||
"snapshot_date",
|
||||
models.DateField(
|
||||
db_index=True,
|
||||
help_text="Date when this ranking snapshot was taken",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="ranking_history",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-snapshot_date", "rank"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["snapshot_date", "rank"],
|
||||
name="rides_ranki_snapsho_8e2657_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["ride", "-snapshot_date"],
|
||||
name="rides_ranki_ride_id_827bb9_idx",
|
||||
),
|
||||
],
|
||||
"unique_together": {("ride", "snapshot_date")},
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ridepaircomparison",
|
||||
index=models.Index(
|
||||
fields=["ride_a", "ride_b"], name="rides_ridep_ride_a__eb0674_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ridepaircomparison",
|
||||
index=models.Index(
|
||||
fields=["last_calculated"], name="rides_ridep_last_ca_bd9f6c_idx"
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="ridepaircomparison",
|
||||
unique_together={("ride_a", "ride_b")},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridepaircomparison",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_ridepaircomparisonevent" ("id", "last_calculated", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_a_avg_rating", "ride_a_id", "ride_a_wins", "ride_b_avg_rating", "ride_b_id", "ride_b_wins", "ties") VALUES (NEW."id", NEW."last_calculated", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_a_avg_rating", NEW."ride_a_id", NEW."ride_a_wins", NEW."ride_b_avg_rating", NEW."ride_b_id", NEW."ride_b_wins", NEW."ties"); RETURN NULL;',
|
||||
hash="6a640e10fcfd58c48029ee5b84ea7f0826f50022",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_9ad59",
|
||||
table="rides_ridepaircomparison",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridepaircomparison",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_ridepaircomparisonevent" ("id", "last_calculated", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_a_avg_rating", "ride_a_id", "ride_a_wins", "ride_b_avg_rating", "ride_b_id", "ride_b_wins", "ties") VALUES (NEW."id", NEW."last_calculated", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_a_avg_rating", NEW."ride_a_id", NEW."ride_a_wins", NEW."ride_b_avg_rating", NEW."ride_b_id", NEW."ride_b_wins", NEW."ties"); RETURN NULL;',
|
||||
hash="a77eee0b791bada3f84f008dabd7486c66b03fa6",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_73b31",
|
||||
table="rides_ridepaircomparison",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="rideranking",
|
||||
index=models.Index(fields=["rank"], name="rides_rider_rank_ea4706_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="rideranking",
|
||||
index=models.Index(
|
||||
fields=["winning_percentage", "-mutual_riders_count"],
|
||||
name="rides_rider_winning_d9b3e8_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="rideranking",
|
||||
index=models.Index(
|
||||
fields=["ride", "last_calculated"],
|
||||
name="rides_rider_ride_id_ece73d_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="rideranking",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("winning_percentage__gte", 0), ("winning_percentage__lte", 1)
|
||||
),
|
||||
name="rideranking_winning_percentage_range",
|
||||
violation_error_message="Winning percentage must be between 0 and 1",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="rideranking",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("average_rating__isnull", True),
|
||||
models.Q(("average_rating__gte", 1), ("average_rating__lte", 10)),
|
||||
_connector="OR",
|
||||
),
|
||||
name="rideranking_average_rating_range",
|
||||
violation_error_message="Average rating must be between 1 and 10",
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="rideranking",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_riderankingevent" ("average_rating", "calculation_version", "comparison_count", "id", "last_calculated", "losses", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "ride_id", "ties", "winning_percentage", "wins") VALUES (NEW."average_rating", NEW."calculation_version", NEW."comparison_count", NEW."id", NEW."last_calculated", NEW."losses", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."ride_id", NEW."ties", NEW."winning_percentage", NEW."wins"); RETURN NULL;',
|
||||
hash="c5f9dced5824a55e6f36e476eb382ed770aa5716",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_01af3",
|
||||
table="rides_rideranking",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="rideranking",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_riderankingevent" ("average_rating", "calculation_version", "comparison_count", "id", "last_calculated", "losses", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "ride_id", "ties", "winning_percentage", "wins") VALUES (NEW."average_rating", NEW."calculation_version", NEW."comparison_count", NEW."id", NEW."last_calculated", NEW."losses", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."ride_id", NEW."ties", NEW."winning_percentage", NEW."wins"); RETURN NULL;',
|
||||
hash="363e44ce3c87e8b66406d63d6f1b26ad604c79d2",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_c3f27",
|
||||
table="rides_rideranking",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,224 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-26 17:39
|
||||
|
||||
import apps.rides.models.media
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
("rides", "0006_add_ride_rankings"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="RidePhoto",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"image",
|
||||
models.ImageField(
|
||||
max_length=255,
|
||||
upload_to=apps.rides.models.media.ride_photo_upload_path,
|
||||
),
|
||||
),
|
||||
("caption", models.CharField(blank=True, max_length=255)),
|
||||
("alt_text", models.CharField(blank=True, max_length=255)),
|
||||
("is_primary", models.BooleanField(default=False)),
|
||||
("is_approved", models.BooleanField(default=False)),
|
||||
(
|
||||
"photo_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("exterior", "Exterior View"),
|
||||
("queue", "Queue Area"),
|
||||
("station", "Station"),
|
||||
("onride", "On-Ride"),
|
||||
("construction", "Construction"),
|
||||
("other", "Other"),
|
||||
],
|
||||
default="exterior",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("date_taken", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"ride",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="photos",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uploaded_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="uploaded_ride_photos",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-is_primary", "-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RidePhotoEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
(
|
||||
"image",
|
||||
models.ImageField(
|
||||
max_length=255,
|
||||
upload_to=apps.rides.models.media.ride_photo_upload_path,
|
||||
),
|
||||
),
|
||||
("caption", models.CharField(blank=True, max_length=255)),
|
||||
("alt_text", models.CharField(blank=True, max_length=255)),
|
||||
("is_primary", models.BooleanField(default=False)),
|
||||
("is_approved", models.BooleanField(default=False)),
|
||||
(
|
||||
"photo_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("exterior", "Exterior View"),
|
||||
("queue", "Queue Area"),
|
||||
("station", "Station"),
|
||||
("onride", "On-Ride"),
|
||||
("construction", "Construction"),
|
||||
("other", "Other"),
|
||||
],
|
||||
default="exterior",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("date_taken", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="rides.ridephoto",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="rides.ride",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uploaded_by",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ridephoto",
|
||||
index=models.Index(
|
||||
fields=["ride", "is_primary"], name="rides_ridep_ride_id_aa49f1_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ridephoto",
|
||||
index=models.Index(
|
||||
fields=["ride", "is_approved"], name="rides_ridep_ride_id_f1eddc_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ridephoto",
|
||||
index=models.Index(
|
||||
fields=["ride", "photo_type"], name="rides_ridep_ride_id_49e7ec_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ridephoto",
|
||||
index=models.Index(
|
||||
fields=["created_at"], name="rides_ridep_created_106e02_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ridephoto",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("is_primary", True)),
|
||||
fields=("ride",),
|
||||
name="unique_primary_ride_photo",
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridephoto",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_ridephotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "ride_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_type", NEW."ride_id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
|
||||
hash="8027f17cac76b8301927e468ab4873ae9f38f27a",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_0043a",
|
||||
table="rides_ridephoto",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridephoto",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_ridephotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "ride_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_type", NEW."ride_id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
|
||||
hash="54562f9a78754cac359f1efd5c0e8d6d144d1806",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_93a7e",
|
||||
table="rides_ridephoto",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,105 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-28 18:35
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0007_ridephoto_ridephotoevent_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ride",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ride",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ride",
|
||||
name="banner_image",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Photo to use as banner image for this ride",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="rides_using_as_banner",
|
||||
to="rides.ridephoto",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ride",
|
||||
name="card_image",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Photo to use as card image for this ride",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="rides_using_as_card",
|
||||
to="rides.ridephoto",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="banner_image",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="Photo to use as banner image for this ride",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="rides.ridephoto",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="card_image",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="Photo to use as card image for this ride",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="rides.ridephoto",
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ride",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at"); RETURN NULL;',
|
||||
hash="462120d462bacf795e3e8d2d48e56a8adb85c63b",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_52074",
|
||||
table="rides_ride",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ride",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at"); RETURN NULL;',
|
||||
hash="dc36bcf1b24242b781d63799024095b0f8da79b6",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_4917a",
|
||||
table="rides_ride",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,48 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-28 19:10
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils.text import slugify
|
||||
|
||||
|
||||
def populate_ride_model_slugs(apps, schema_editor):
|
||||
"""Populate unique slugs for existing RideModel records."""
|
||||
RideModel = apps.get_model("rides", "RideModel")
|
||||
apps.get_model("rides", "Company")
|
||||
|
||||
for ride_model in RideModel.objects.all():
|
||||
# Generate base slug from manufacturer name + model name
|
||||
if ride_model.manufacturer:
|
||||
base_slug = slugify(f"{ride_model.manufacturer.name} {ride_model.name}")
|
||||
else:
|
||||
base_slug = slugify(ride_model.name)
|
||||
|
||||
# Ensure uniqueness
|
||||
slug = base_slug
|
||||
counter = 1
|
||||
while RideModel.objects.filter(slug=slug).exclude(pk=ride_model.pk).exists():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
# Update the slug
|
||||
ride_model.slug = slug
|
||||
ride_model.save(update_fields=["slug"])
|
||||
|
||||
|
||||
def reverse_populate_ride_model_slugs(apps, schema_editor):
|
||||
"""Reverse operation - clear slugs (not really needed but for completeness)."""
|
||||
RideModel = apps.get_model("rides", "RideModel")
|
||||
RideModel.objects.all().update(slug="")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0010_add_comprehensive_ride_model_system"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
populate_ride_model_slugs,
|
||||
reverse_populate_ride_model_slugs,
|
||||
),
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-28 19:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0011_populate_ride_model_slugs"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="ridemodel",
|
||||
name="slug",
|
||||
field=models.SlugField(
|
||||
help_text="URL-friendly identifier", max_length=255, unique=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,38 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-28 19:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0012_make_ride_model_slug_unique"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="ridemodel",
|
||||
unique_together={("manufacturer", "name")},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodel",
|
||||
name="slug",
|
||||
field=models.SlugField(
|
||||
help_text="URL-friendly identifier (unique within manufacturer)",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodelevent",
|
||||
name="slug",
|
||||
field=models.SlugField(
|
||||
db_index=False,
|
||||
help_text="URL-friendly identifier (unique within manufacturer)",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="ridemodel",
|
||||
unique_together={("manufacturer", "name"), ("manufacturer", "slug")},
|
||||
),
|
||||
]
|
||||
@@ -1,69 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-28 19:19
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils.text import slugify
|
||||
|
||||
|
||||
def update_ride_model_slugs(apps, schema_editor):
|
||||
"""Update RideModel slugs to be just the model name, not manufacturer + name."""
|
||||
RideModel = apps.get_model("rides", "RideModel")
|
||||
|
||||
for ride_model in RideModel.objects.all():
|
||||
# Generate new slug from just the name
|
||||
new_slug = slugify(ride_model.name)
|
||||
|
||||
# Ensure uniqueness within the same manufacturer
|
||||
counter = 1
|
||||
base_slug = new_slug
|
||||
while (
|
||||
RideModel.objects.filter(
|
||||
manufacturer=ride_model.manufacturer, slug=new_slug
|
||||
)
|
||||
.exclude(pk=ride_model.pk)
|
||||
.exists()
|
||||
):
|
||||
new_slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
# Update the slug
|
||||
ride_model.slug = new_slug
|
||||
ride_model.save(update_fields=["slug"])
|
||||
print(f"Updated {ride_model.name}: {ride_model.slug}")
|
||||
|
||||
|
||||
def reverse_ride_model_slugs(apps, schema_editor):
|
||||
"""Reverse the slug update by regenerating the old format."""
|
||||
RideModel = apps.get_model("rides", "RideModel")
|
||||
|
||||
for ride_model in RideModel.objects.all():
|
||||
# Generate old-style slug with manufacturer + name
|
||||
old_slug = slugify(
|
||||
f"{ride_model.manufacturer.name if ride_model.manufacturer else ''} {ride_model.name}"
|
||||
)
|
||||
|
||||
# Ensure uniqueness globally (old way)
|
||||
counter = 1
|
||||
base_slug = old_slug
|
||||
while (
|
||||
RideModel.objects.filter(slug=old_slug).exclude(pk=ride_model.pk).exists()
|
||||
):
|
||||
old_slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
# Update the slug
|
||||
ride_model.slug = old_slug
|
||||
ride_model.save(update_fields=["slug"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0013_fix_ride_model_slugs"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
update_ride_model_slugs,
|
||||
reverse_ride_model_slugs,
|
||||
),
|
||||
]
|
||||
@@ -1,164 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-28 22:59
|
||||
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0014_update_ride_model_slugs_data"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="company",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="company",
|
||||
name="update_update",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ride",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ride",
|
||||
name="update_update",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ridemodel",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ridemodel",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="url",
|
||||
field=models.URLField(
|
||||
blank=True, help_text="Frontend URL for this company"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="companyevent",
|
||||
name="url",
|
||||
field=models.URLField(
|
||||
blank=True, help_text="Frontend URL for this company"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ride",
|
||||
name="url",
|
||||
field=models.URLField(blank=True, help_text="Frontend URL for this ride"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="url",
|
||||
field=models.URLField(blank=True, help_text="Frontend URL for this ride"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridemodel",
|
||||
name="url",
|
||||
field=models.URLField(
|
||||
blank=True, help_text="Frontend URL for this ride model"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridemodelevent",
|
||||
name="url",
|
||||
field=models.URLField(
|
||||
blank=True, help_text="Frontend URL for this ride model"
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="company",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "url", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
|
||||
hash="fe6c1e3f09822f5e7f716cd83483cf152ec138f0",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_e7194",
|
||||
table="rides_company",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="company",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "url", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
|
||||
hash="0b76cb36b7551ed3e64e674b8cfe343d4d2ec306",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_456a8",
|
||||
table="rides_company",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ride",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
|
||||
hash="6764dc3b0c0e73dda649939bb1ee7b7de143125f",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_52074",
|
||||
table="rides_ride",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ride",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
|
||||
hash="63c4066af11852396506fd964989632336205573",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_4917a",
|
||||
table="rides_ride",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridemodel",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_ridemodelevent" ("category", "created_at", "description", "first_installation_year", "id", "is_discontinued", "last_installation_year", "manufacturer_id", "meta_description", "meta_title", "name", "notable_features", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "primary_image_id", "restraint_system", "slug", "support_structure", "target_market", "total_installations", "track_type", "train_configuration", "typical_capacity_range_max", "typical_capacity_range_min", "typical_height_range_max_ft", "typical_height_range_min_ft", "typical_speed_range_max_mph", "typical_speed_range_min_mph", "updated_at", "url") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."first_installation_year", NEW."id", NEW."is_discontinued", NEW."last_installation_year", NEW."manufacturer_id", NEW."meta_description", NEW."meta_title", NEW."name", NEW."notable_features", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."primary_image_id", NEW."restraint_system", NEW."slug", NEW."support_structure", NEW."target_market", NEW."total_installations", NEW."track_type", NEW."train_configuration", NEW."typical_capacity_range_max", NEW."typical_capacity_range_min", NEW."typical_height_range_max_ft", NEW."typical_height_range_min_ft", NEW."typical_speed_range_max_mph", NEW."typical_speed_range_min_mph", NEW."updated_at", NEW."url"); RETURN NULL;',
|
||||
hash="9cee65f580a26ae9edc8f9fc1f3d9b25da1856c3",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_0aaee",
|
||||
table="rides_ridemodel",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridemodel",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_ridemodelevent" ("category", "created_at", "description", "first_installation_year", "id", "is_discontinued", "last_installation_year", "manufacturer_id", "meta_description", "meta_title", "name", "notable_features", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "primary_image_id", "restraint_system", "slug", "support_structure", "target_market", "total_installations", "track_type", "train_configuration", "typical_capacity_range_max", "typical_capacity_range_min", "typical_height_range_max_ft", "typical_height_range_min_ft", "typical_speed_range_max_mph", "typical_speed_range_min_mph", "updated_at", "url") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."first_installation_year", NEW."id", NEW."is_discontinued", NEW."last_installation_year", NEW."manufacturer_id", NEW."meta_description", NEW."meta_title", NEW."name", NEW."notable_features", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."primary_image_id", NEW."restraint_system", NEW."slug", NEW."support_structure", NEW."target_market", NEW."total_installations", NEW."track_type", NEW."train_configuration", NEW."typical_capacity_range_max", NEW."typical_capacity_range_min", NEW."typical_height_range_max_ft", NEW."typical_height_range_min_ft", NEW."typical_speed_range_max_mph", NEW."typical_speed_range_min_mph", NEW."updated_at", NEW."url"); RETURN NULL;',
|
||||
hash="365f87607f9f7bfee1caaabdd32b16032e04ae82",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_0ca1a",
|
||||
table="rides_ridemodel",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,66 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-28 23:12
|
||||
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0015_remove_company_insert_insert_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ride",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ride",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ride",
|
||||
name="park_url",
|
||||
field=models.URLField(
|
||||
blank=True, help_text="Frontend URL for this ride's park"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="park_url",
|
||||
field=models.URLField(
|
||||
blank=True, help_text="Frontend URL for this ride's park"
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ride",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
|
||||
hash="3b83e1d1dbc2d5ca5792929845db1dd6d306700a",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_52074",
|
||||
table="rides_ride",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ride",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
|
||||
hash="efd782a22f5bec46d06b234ffc55b6c06360ade1",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_4917a",
|
||||
table="rides_ride",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,133 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-30 21:41
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("django_cloudflareimages_toolkit", "0001_initial"),
|
||||
("rides", "0016_remove_ride_insert_insert_remove_ride_update_update_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ridemodelphoto",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ridemodelphoto",
|
||||
name="update_update",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ridephoto",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ridephoto",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodelphoto",
|
||||
name="image",
|
||||
field=models.ForeignKey(
|
||||
help_text="Photo of the ride model stored on Cloudflare Images",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="django_cloudflareimages_toolkit.cloudflareimage",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodelphotoevent",
|
||||
name="image",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
help_text="Photo of the ride model stored on Cloudflare Images",
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="django_cloudflareimages_toolkit.cloudflareimage",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridephoto",
|
||||
name="image",
|
||||
field=models.ForeignKey(
|
||||
help_text="Ride photo stored on Cloudflare Images",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="django_cloudflareimages_toolkit.cloudflareimage",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridephotoevent",
|
||||
name="image",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
help_text="Ride photo stored on Cloudflare Images",
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="django_cloudflareimages_toolkit.cloudflareimage",
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridemodelphoto",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_ridemodelphotoevent" ("alt_text", "caption", "copyright_info", "created_at", "id", "image_id", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "photographer", "ride_model_id", "source", "updated_at") VALUES (NEW."alt_text", NEW."caption", NEW."copyright_info", NEW."created_at", NEW."id", NEW."image_id", NEW."is_primary", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_type", NEW."photographer", NEW."ride_model_id", NEW."source", NEW."updated_at"); RETURN NULL;',
|
||||
hash="fa289c31e25da0c08740d9e9c4072f3e4df81c42",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_c5e58",
|
||||
table="rides_ridemodelphoto",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridemodelphoto",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_ridemodelphotoevent" ("alt_text", "caption", "copyright_info", "created_at", "id", "image_id", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "photographer", "ride_model_id", "source", "updated_at") VALUES (NEW."alt_text", NEW."caption", NEW."copyright_info", NEW."created_at", NEW."id", NEW."image_id", NEW."is_primary", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_type", NEW."photographer", NEW."ride_model_id", NEW."source", NEW."updated_at"); RETURN NULL;',
|
||||
hash="1ead1d3fd3dd553f585ae76aa6f3215314322ff4",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_3afcd",
|
||||
table="rides_ridemodelphoto",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridephoto",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_ridephotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image_id", "is_approved", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "ride_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."created_at", NEW."date_taken", NEW."id", NEW."image_id", NEW."is_approved", NEW."is_primary", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_type", NEW."ride_id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
|
||||
hash="51487ac871d9d90c75f695f106e5f1f43fdb00c6",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_0043a",
|
||||
table="rides_ridephoto",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridephoto",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_ridephotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image_id", "is_approved", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "ride_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."created_at", NEW."date_taken", NEW."id", NEW."image_id", NEW."is_approved", NEW."is_primary", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_type", NEW."ride_id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
|
||||
hash="6147489f087c144f887386548cba269ffc193094",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_93a7e",
|
||||
table="rides_ridephoto",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,72 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-14 19:18
|
||||
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0017_remove_ridemodelphoto_insert_insert_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ride",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ride",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ride",
|
||||
name="opening_year",
|
||||
field=models.IntegerField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ride",
|
||||
name="search_text",
|
||||
field=models.TextField(blank=True, db_index=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="opening_year",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="search_text",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ride",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "opening_year", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "search_text", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."opening_year", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."search_text", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
|
||||
hash="64e055c574495c0f09b3cbfb12442d4e4113e4f2",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_52074",
|
||||
table="rides_ride",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ride",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "opening_year", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "search_text", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."opening_year", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."search_text", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
|
||||
hash="6476c8dd4bbb0e2ae42ca2daa5c691b87f9119e9",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_4917a",
|
||||
table="rides_ride",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,124 +0,0 @@
|
||||
"""
|
||||
Populate computed fields for hybrid filtering in rides.
|
||||
|
||||
This migration populates the opening_year and search_text fields that were added
|
||||
in the previous migration. These fields enable efficient hybrid filtering by
|
||||
pre-computing commonly filtered and searched data.
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
import pghistory
|
||||
|
||||
|
||||
def populate_computed_fields(apps, schema_editor):
|
||||
"""Populate computed fields for all existing rides."""
|
||||
Ride = apps.get_model('rides', 'Ride')
|
||||
|
||||
# Disable pghistory triggers during bulk operations to avoid performance issues
|
||||
with pghistory.context(disable=True):
|
||||
rides = list(Ride.objects.all().select_related(
|
||||
'park', 'park__location', 'park_area', 'manufacturer', 'designer', 'ride_model'
|
||||
))
|
||||
|
||||
for ride in rides:
|
||||
# Extract opening year from opening_date
|
||||
if ride.opening_date:
|
||||
ride.opening_year = ride.opening_date.year
|
||||
else:
|
||||
ride.opening_year = None
|
||||
|
||||
# Build comprehensive search text
|
||||
search_parts = []
|
||||
|
||||
# Basic ride info
|
||||
if ride.name:
|
||||
search_parts.append(ride.name)
|
||||
if ride.description:
|
||||
search_parts.append(ride.description)
|
||||
|
||||
# Park info
|
||||
if ride.park:
|
||||
search_parts.append(ride.park.name)
|
||||
if hasattr(ride.park, 'location') and ride.park.location:
|
||||
if ride.park.location.city:
|
||||
search_parts.append(ride.park.location.city)
|
||||
if ride.park.location.state:
|
||||
search_parts.append(ride.park.location.state)
|
||||
if ride.park.location.country:
|
||||
search_parts.append(ride.park.location.country)
|
||||
|
||||
# Park area
|
||||
if ride.park_area:
|
||||
search_parts.append(ride.park_area.name)
|
||||
|
||||
# Category
|
||||
if ride.category:
|
||||
category_choices = [
|
||||
("", "Select ride type"),
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
]
|
||||
category_display = dict(category_choices).get(ride.category, '')
|
||||
if category_display:
|
||||
search_parts.append(category_display)
|
||||
|
||||
# Status
|
||||
if ride.status:
|
||||
status_choices = [
|
||||
("", "Select status"),
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSING", "Closing"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
]
|
||||
status_display = dict(status_choices).get(ride.status, '')
|
||||
if status_display:
|
||||
search_parts.append(status_display)
|
||||
|
||||
# Companies
|
||||
if ride.manufacturer:
|
||||
search_parts.append(ride.manufacturer.name)
|
||||
if ride.designer:
|
||||
search_parts.append(ride.designer.name)
|
||||
|
||||
# Ride model
|
||||
if ride.ride_model:
|
||||
search_parts.append(ride.ride_model.name)
|
||||
if ride.ride_model.manufacturer:
|
||||
search_parts.append(ride.ride_model.manufacturer.name)
|
||||
|
||||
ride.search_text = ' '.join(filter(None, search_parts)).lower()
|
||||
|
||||
# Bulk update all rides
|
||||
Ride.objects.bulk_update(rides, ['opening_year', 'search_text'], batch_size=1000)
|
||||
|
||||
|
||||
def reverse_populate_computed_fields(apps, schema_editor):
|
||||
"""Clear computed fields (reverse operation)."""
|
||||
Ride = apps.get_model('rides', 'Ride')
|
||||
|
||||
# Disable pghistory triggers during bulk operations
|
||||
with pghistory.context(disable=True):
|
||||
Ride.objects.all().update(opening_year=None, search_text='')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('rides', '0018_add_hybrid_filtering_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
populate_computed_fields,
|
||||
reverse_populate_computed_fields,
|
||||
elidable=True,
|
||||
),
|
||||
]
|
||||
@@ -1,181 +0,0 @@
|
||||
"""
|
||||
Add strategic database indexes for hybrid filtering in rides.
|
||||
|
||||
This migration creates optimized indexes for the hybrid filtering system,
|
||||
enabling sub-second query performance across all filter combinations.
|
||||
|
||||
Index Strategy:
|
||||
- Composite indexes for common filter combinations
|
||||
- Partial indexes for status-based filtering
|
||||
- Covering indexes to avoid table lookups
|
||||
- GIN indexes for full-text search
|
||||
- Individual indexes for range queries
|
||||
|
||||
Performance Target: <100ms for most filter combinations
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('rides', '0019_populate_hybrid_filtering_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Composite index for park + category filtering (very common)
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_park_category_idx ON rides_ride (park_id, category) WHERE category != '';",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_park_category_idx;"
|
||||
),
|
||||
|
||||
# Composite index for park + status filtering (common)
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_park_status_idx ON rides_ride (park_id, status);",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_park_status_idx;"
|
||||
),
|
||||
|
||||
# Composite index for category + status filtering
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_category_status_idx ON rides_ride (category, status) WHERE category != '';",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_category_status_idx;"
|
||||
),
|
||||
|
||||
# Composite index for manufacturer + category
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_manufacturer_category_idx ON rides_ride (manufacturer_id, category) WHERE manufacturer_id IS NOT NULL AND category != '';",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_manufacturer_category_idx;"
|
||||
),
|
||||
|
||||
# Composite index for opening year + category (for timeline filtering)
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_opening_year_category_idx ON rides_ride (opening_year, category) WHERE opening_year IS NOT NULL AND category != '';",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_opening_year_category_idx;"
|
||||
),
|
||||
|
||||
# Partial index for operating rides only (most common filter)
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_operating_only_idx ON rides_ride (park_id, category, opening_year) WHERE status = 'OPERATING';",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_operating_only_idx;"
|
||||
),
|
||||
|
||||
# Partial index for roller coasters only (popular category)
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_roller_coasters_idx ON rides_ride (park_id, status, opening_year) WHERE category = 'RC';",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_roller_coasters_idx;"
|
||||
),
|
||||
|
||||
# Covering index for list views (includes commonly displayed fields)
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_list_covering_idx ON rides_ride (park_id, category, status) INCLUDE (name, opening_date, average_rating);",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_list_covering_idx;"
|
||||
),
|
||||
|
||||
# GIN index for full-text search on computed search_text field
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_search_text_gin_idx ON rides_ride USING gin(to_tsvector('english', search_text));",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_search_text_gin_idx;"
|
||||
),
|
||||
|
||||
# Trigram index for fuzzy text search
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_search_text_trgm_idx ON rides_ride USING gin(search_text gin_trgm_ops);",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_search_text_trgm_idx;"
|
||||
),
|
||||
|
||||
# Index for rating-based filtering
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_rating_idx ON rides_ride (average_rating) WHERE average_rating IS NOT NULL;",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_rating_idx;"
|
||||
),
|
||||
|
||||
# Index for capacity-based filtering
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_capacity_idx ON rides_ride (capacity_per_hour) WHERE capacity_per_hour IS NOT NULL;",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_capacity_idx;"
|
||||
),
|
||||
|
||||
# Index for height requirement filtering
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_height_req_idx ON rides_ride (min_height_in, max_height_in) WHERE min_height_in IS NOT NULL OR max_height_in IS NOT NULL;",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_height_req_idx;"
|
||||
),
|
||||
|
||||
# Composite index for ride model filtering
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_model_manufacturer_idx ON rides_ride (ride_model_id, manufacturer_id) WHERE ride_model_id IS NOT NULL;",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_model_manufacturer_idx;"
|
||||
),
|
||||
|
||||
# Index for designer filtering
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_designer_idx ON rides_ride (designer_id, category) WHERE designer_id IS NOT NULL;",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_designer_idx;"
|
||||
),
|
||||
|
||||
# Index for park area filtering
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ride_park_area_idx ON rides_ride (park_area_id, status) WHERE park_area_id IS NOT NULL;",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ride_park_area_idx;"
|
||||
),
|
||||
|
||||
# Roller coaster stats indexes for performance
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_rollercoasterstats_height_idx ON rides_rollercoasterstats (height_ft) WHERE height_ft IS NOT NULL;",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_height_idx;"
|
||||
),
|
||||
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_rollercoasterstats_speed_idx ON rides_rollercoasterstats (speed_mph) WHERE speed_mph IS NOT NULL;",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_speed_idx;"
|
||||
),
|
||||
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_rollercoasterstats_inversions_idx ON rides_rollercoasterstats (inversions);",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_inversions_idx;"
|
||||
),
|
||||
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_rollercoasterstats_type_material_idx ON rides_rollercoasterstats (roller_coaster_type, track_material);",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_type_material_idx;"
|
||||
),
|
||||
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_rollercoasterstats_launch_type_idx ON rides_rollercoasterstats (launch_type);",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_launch_type_idx;"
|
||||
),
|
||||
|
||||
# Composite index for complex roller coaster filtering
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_rollercoasterstats_complex_idx ON rides_rollercoasterstats (roller_coaster_type, track_material, launch_type) INCLUDE (height_ft, speed_mph, inversions);",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_complex_idx;"
|
||||
),
|
||||
|
||||
# Index for ride model filtering and search
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ridemodel_manufacturer_category_idx ON rides_ridemodel (manufacturer_id, category) WHERE manufacturer_id IS NOT NULL;",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ridemodel_manufacturer_category_idx;"
|
||||
),
|
||||
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_ridemodel_name_trgm_idx ON rides_ridemodel USING gin(name gin_trgm_ops);",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_ridemodel_name_trgm_idx;"
|
||||
),
|
||||
|
||||
# Index for company role-based filtering
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_company_manufacturer_role_idx ON rides_company USING gin(roles) WHERE 'MANUFACTURER' = ANY(roles);",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_company_manufacturer_role_idx;"
|
||||
),
|
||||
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX rides_company_designer_role_idx ON rides_company USING gin(roles) WHERE 'DESIGNER' = ANY(roles);",
|
||||
reverse_sql="DROP INDEX IF EXISTS rides_company_designer_role_idx;"
|
||||
),
|
||||
|
||||
# Ensure trigram extension is available for fuzzy search
|
||||
migrations.RunSQL(
|
||||
"CREATE EXTENSION IF NOT EXISTS pg_trgm;",
|
||||
reverse_sql="-- Cannot safely drop pg_trgm extension"
|
||||
),
|
||||
]
|
||||
@@ -1,405 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-15 17:35
|
||||
|
||||
import apps.core.choices.fields
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0020_add_hybrid_filtering_indexes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="company",
|
||||
name="roles",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("MANUFACTURER", "Ride Manufacturer"),
|
||||
("DESIGNER", "Ride Designer"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="companyevent",
|
||||
name="roles",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("MANUFACTURER", "Ride Manufacturer"),
|
||||
("DESIGNER", "Ride Designer"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ride",
|
||||
name="category",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="categories",
|
||||
choices=[
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport Ride"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
default="",
|
||||
domain="rides",
|
||||
help_text="Ride category classification",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ride",
|
||||
name="post_closing_status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="post_closing_statuses",
|
||||
choices=[
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
],
|
||||
domain="rides",
|
||||
help_text="Status to change to after closing date",
|
||||
max_length=20,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ride",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="statuses",
|
||||
choices=[
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSING", "Closing"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
],
|
||||
default="OPERATING",
|
||||
domain="rides",
|
||||
help_text="Current operational status of the ride",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rideevent",
|
||||
name="category",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="categories",
|
||||
choices=[
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport Ride"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
default="",
|
||||
domain="rides",
|
||||
help_text="Ride category classification",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rideevent",
|
||||
name="post_closing_status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="post_closing_statuses",
|
||||
choices=[
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
],
|
||||
domain="rides",
|
||||
help_text="Status to change to after closing date",
|
||||
max_length=20,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rideevent",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="statuses",
|
||||
choices=[
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSING", "Closing"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
],
|
||||
default="OPERATING",
|
||||
domain="rides",
|
||||
help_text="Current operational status of the ride",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodel",
|
||||
name="category",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="categories",
|
||||
choices=[
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport Ride"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
default="",
|
||||
domain="rides",
|
||||
help_text="Primary category classification",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodel",
|
||||
name="target_market",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="target_markets",
|
||||
choices=[
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
domain="rides",
|
||||
help_text="Primary target market for this ride model",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodelevent",
|
||||
name="category",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="categories",
|
||||
choices=[
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport Ride"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
default="",
|
||||
domain="rides",
|
||||
help_text="Primary category classification",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodelevent",
|
||||
name="target_market",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="target_markets",
|
||||
choices=[
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
domain="rides",
|
||||
help_text="Primary target market for this ride model",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridephoto",
|
||||
name="photo_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="photo_types",
|
||||
choices=[
|
||||
("exterior", "Exterior View"),
|
||||
("queue", "Queue Area"),
|
||||
("station", "Station"),
|
||||
("onride", "On-Ride"),
|
||||
("construction", "Construction"),
|
||||
("other", "Other"),
|
||||
],
|
||||
default="exterior",
|
||||
domain="rides",
|
||||
help_text="Type of photo for categorization and display purposes",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridephotoevent",
|
||||
name="photo_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="photo_types",
|
||||
choices=[
|
||||
("exterior", "Exterior View"),
|
||||
("queue", "Queue Area"),
|
||||
("station", "Station"),
|
||||
("onride", "On-Ride"),
|
||||
("construction", "Construction"),
|
||||
("other", "Other"),
|
||||
],
|
||||
default="exterior",
|
||||
domain="rides",
|
||||
help_text="Type of photo for categorization and display purposes",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rollercoasterstats",
|
||||
name="launch_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="launch_systems",
|
||||
choices=[
|
||||
("CHAIN", "Chain Lift"),
|
||||
("LSM", "LSM Launch"),
|
||||
("HYDRAULIC", "Hydraulic Launch"),
|
||||
("GRAVITY", "Gravity"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="CHAIN",
|
||||
domain="rides",
|
||||
help_text="Launch or lift system type",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rollercoasterstats",
|
||||
name="roller_coaster_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="coaster_types",
|
||||
choices=[
|
||||
("SITDOWN", "Sit Down"),
|
||||
("INVERTED", "Inverted"),
|
||||
("FLYING", "Flying"),
|
||||
("STANDUP", "Stand Up"),
|
||||
("WING", "Wing"),
|
||||
("DIVE", "Dive"),
|
||||
("FAMILY", "Family"),
|
||||
("WILD_MOUSE", "Wild Mouse"),
|
||||
("SPINNING", "Spinning"),
|
||||
("FOURTH_DIMENSION", "4th Dimension"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="SITDOWN",
|
||||
domain="rides",
|
||||
help_text="Roller coaster type classification",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rollercoasterstats",
|
||||
name="track_material",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="track_materials",
|
||||
choices=[("STEEL", "Steel"), ("WOOD", "Wood"), ("HYBRID", "Hybrid")],
|
||||
default="STEEL",
|
||||
domain="rides",
|
||||
help_text="Track construction material type",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rollercoasterstatsevent",
|
||||
name="launch_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="launch_systems",
|
||||
choices=[
|
||||
("CHAIN", "Chain Lift"),
|
||||
("LSM", "LSM Launch"),
|
||||
("HYDRAULIC", "Hydraulic Launch"),
|
||||
("GRAVITY", "Gravity"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="CHAIN",
|
||||
domain="rides",
|
||||
help_text="Launch or lift system type",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rollercoasterstatsevent",
|
||||
name="roller_coaster_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="coaster_types",
|
||||
choices=[
|
||||
("SITDOWN", "Sit Down"),
|
||||
("INVERTED", "Inverted"),
|
||||
("FLYING", "Flying"),
|
||||
("STANDUP", "Stand Up"),
|
||||
("WING", "Wing"),
|
||||
("DIVE", "Dive"),
|
||||
("FAMILY", "Family"),
|
||||
("WILD_MOUSE", "Wild Mouse"),
|
||||
("SPINNING", "Spinning"),
|
||||
("FOURTH_DIMENSION", "4th Dimension"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="SITDOWN",
|
||||
domain="rides",
|
||||
help_text="Roller coaster type classification",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rollercoasterstatsevent",
|
||||
name="track_material",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="track_materials",
|
||||
choices=[("STEEL", "Steel"), ("WOOD", "Wood"), ("HYBRID", "Hybrid")],
|
||||
default="STEEL",
|
||||
domain="rides",
|
||||
help_text="Track construction material type",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,53 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-15 18:07
|
||||
|
||||
import apps.core.choices.fields
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0021_alter_company_roles_alter_companyevent_roles_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="company",
|
||||
name="roles",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="company_roles",
|
||||
choices=[
|
||||
("MANUFACTURER", "Ride Manufacturer"),
|
||||
("DESIGNER", "Ride Designer"),
|
||||
],
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="companyevent",
|
||||
name="roles",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="company_roles",
|
||||
choices=[
|
||||
("MANUFACTURER", "Ride Manufacturer"),
|
||||
("DESIGNER", "Ride Designer"),
|
||||
],
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,94 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-15 19:06
|
||||
|
||||
import apps.core.choices.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0022_alter_company_roles_alter_companyevent_roles"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="ridemodelphoto",
|
||||
name="photo_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="photo_types",
|
||||
choices=[
|
||||
("PROMOTIONAL", "Promotional"),
|
||||
("TECHNICAL", "Technical Drawing"),
|
||||
("INSTALLATION", "Installation Example"),
|
||||
("RENDERING", "3D Rendering"),
|
||||
("CATALOG", "Catalog Image"),
|
||||
],
|
||||
default="PROMOTIONAL",
|
||||
domain="rides",
|
||||
help_text="Type of photo for categorization and display purposes",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodelphotoevent",
|
||||
name="photo_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="photo_types",
|
||||
choices=[
|
||||
("PROMOTIONAL", "Promotional"),
|
||||
("TECHNICAL", "Technical Drawing"),
|
||||
("INSTALLATION", "Installation Example"),
|
||||
("RENDERING", "3D Rendering"),
|
||||
("CATALOG", "Catalog Image"),
|
||||
],
|
||||
default="PROMOTIONAL",
|
||||
domain="rides",
|
||||
help_text="Type of photo for categorization and display purposes",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodeltechnicalspec",
|
||||
name="spec_category",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="spec_categories",
|
||||
choices=[
|
||||
("DIMENSIONS", "Dimensions"),
|
||||
("PERFORMANCE", "Performance"),
|
||||
("CAPACITY", "Capacity"),
|
||||
("SAFETY", "Safety Features"),
|
||||
("ELECTRICAL", "Electrical Requirements"),
|
||||
("FOUNDATION", "Foundation Requirements"),
|
||||
("MAINTENANCE", "Maintenance"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
domain="rides",
|
||||
help_text="Category of technical specification",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodeltechnicalspecevent",
|
||||
name="spec_category",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="spec_categories",
|
||||
choices=[
|
||||
("DIMENSIONS", "Dimensions"),
|
||||
("PERFORMANCE", "Performance"),
|
||||
("CAPACITY", "Capacity"),
|
||||
("SAFETY", "Safety Features"),
|
||||
("ELECTRICAL", "Electrical Requirements"),
|
||||
("FOUNDATION", "Foundation Requirements"),
|
||||
("MAINTENANCE", "Maintenance"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
domain="rides",
|
||||
help_text="Category of technical specification",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,99 +0,0 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-17 01:25
|
||||
|
||||
import apps.core.choices.fields
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0023_alter_ridemodelphoto_photo_type_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="rollercoasterstats",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="rollercoasterstats",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="rollercoasterstats",
|
||||
name="launch_type",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="rollercoasterstatsevent",
|
||||
name="launch_type",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rollercoasterstats",
|
||||
name="propulsion_system",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="propulsion_systems",
|
||||
choices=[
|
||||
("CHAIN", "Chain Lift"),
|
||||
("LSM", "LSM Launch"),
|
||||
("HYDRAULIC", "Hydraulic Launch"),
|
||||
("GRAVITY", "Gravity"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="CHAIN",
|
||||
domain="rides",
|
||||
help_text="Propulsion or lift system type",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rollercoasterstatsevent",
|
||||
name="propulsion_system",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="propulsion_systems",
|
||||
choices=[
|
||||
("CHAIN", "Chain Lift"),
|
||||
("LSM", "LSM Launch"),
|
||||
("HYDRAULIC", "Hydraulic Launch"),
|
||||
("GRAVITY", "Gravity"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="CHAIN",
|
||||
domain="rides",
|
||||
help_text="Propulsion or lift system type",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="rollercoasterstats",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_rollercoasterstatsevent" ("cars_per_train", "height_ft", "id", "inversions", "length_ft", "max_drop_height_ft", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "propulsion_system", "ride_id", "ride_time_seconds", "roller_coaster_type", "seats_per_car", "speed_mph", "track_material", "track_type", "train_style", "trains_count") VALUES (NEW."cars_per_train", NEW."height_ft", NEW."id", NEW."inversions", NEW."length_ft", NEW."max_drop_height_ft", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."propulsion_system", NEW."ride_id", NEW."ride_time_seconds", NEW."roller_coaster_type", NEW."seats_per_car", NEW."speed_mph", NEW."track_material", NEW."track_type", NEW."train_style", NEW."trains_count"); RETURN NULL;',
|
||||
hash="89e2bb56c0befa025a9961f8df34a8a02c09f188",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_96f8b",
|
||||
table="rides_rollercoasterstats",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="rollercoasterstats",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_rollercoasterstatsevent" ("cars_per_train", "height_ft", "id", "inversions", "length_ft", "max_drop_height_ft", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "propulsion_system", "ride_id", "ride_time_seconds", "roller_coaster_type", "seats_per_car", "speed_mph", "track_material", "track_type", "train_style", "trains_count") VALUES (NEW."cars_per_train", NEW."height_ft", NEW."id", NEW."inversions", NEW."length_ft", NEW."max_drop_height_ft", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."propulsion_system", NEW."ride_id", NEW."ride_time_seconds", NEW."roller_coaster_type", NEW."seats_per_car", NEW."speed_mph", NEW."track_material", NEW."track_type", NEW."train_style", NEW."trains_count"); RETURN NULL;',
|
||||
hash="047cc99ae3282202b6dc43c8dbe07690076d5068",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_24e8a",
|
||||
table="rides_rollercoasterstats",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,31 +0,0 @@
|
||||
"""
|
||||
Rides app models with clean import interface.
|
||||
|
||||
This module provides a clean import interface for all rides-related models,
|
||||
enabling imports like: from rides.models import Ride, Manufacturer
|
||||
|
||||
The Company model is aliased as Manufacturer to clarify its role as ride manufacturers,
|
||||
while maintaining backward compatibility through the Company alias.
|
||||
"""
|
||||
|
||||
from .rides import Ride, RideModel, RollerCoasterStats
|
||||
from .company import Company
|
||||
from .location import RideLocation
|
||||
from .reviews import RideReview
|
||||
from .rankings import RideRanking, RidePairComparison, RankingSnapshot
|
||||
from .media import RidePhoto
|
||||
|
||||
__all__ = [
|
||||
# Primary models
|
||||
"Ride",
|
||||
"RideModel",
|
||||
"RollerCoasterStats",
|
||||
"Company",
|
||||
"RideLocation",
|
||||
"RideReview",
|
||||
"RidePhoto",
|
||||
# Rankings
|
||||
"RideRanking",
|
||||
"RidePairComparison",
|
||||
"RankingSnapshot",
|
||||
]
|
||||
@@ -1,96 +0,0 @@
|
||||
import pghistory
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django.conf import settings
|
||||
|
||||
from apps.core.history import HistoricalSlug
|
||||
from apps.core.models import TrackedModel
|
||||
from apps.core.choices.fields import RichChoiceField
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class Company(TrackedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
roles = ArrayField(
|
||||
RichChoiceField(choice_group="company_roles", domain="rides", max_length=20),
|
||||
default=list,
|
||||
blank=True,
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
website = models.URLField(blank=True)
|
||||
|
||||
# General company info
|
||||
founded_date = models.DateField(null=True, blank=True)
|
||||
|
||||
# Manufacturer-specific fields
|
||||
rides_count = models.IntegerField(default=0)
|
||||
coasters_count = models.IntegerField(default=0)
|
||||
|
||||
# Frontend URL
|
||||
url = models.URLField(blank=True, help_text="Frontend URL for this company")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
|
||||
# Generate frontend URL based on primary role
|
||||
# CRITICAL: Only MANUFACTURER and DESIGNER are for rides domain
|
||||
# OPERATOR and PROPERTY_OWNER are for parks domain and handled separately
|
||||
if self.roles:
|
||||
frontend_domain = getattr(
|
||||
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
|
||||
)
|
||||
primary_role = self.roles[0] # Use first role as primary
|
||||
|
||||
if primary_role == "MANUFACTURER":
|
||||
self.url = f"{frontend_domain}/rides/manufacturers/{self.slug}/"
|
||||
elif primary_role == "DESIGNER":
|
||||
self.url = f"{frontend_domain}/rides/designers/{self.slug}/"
|
||||
# OPERATOR and PROPERTY_OWNER URLs are handled by parks domain, not here
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
# This will need to be updated to handle different roles
|
||||
return reverse("companies:detail", kwargs={"slug": self.slug})
|
||||
|
||||
@classmethod
|
||||
def get_by_slug(cls, slug):
|
||||
"""Get company by current or historical slug"""
|
||||
try:
|
||||
return cls.objects.get(slug=slug), False
|
||||
except cls.DoesNotExist:
|
||||
# Check pghistory first
|
||||
try:
|
||||
from django.apps import apps
|
||||
history_model = apps.get_model('rides', f'{cls.__name__}Event')
|
||||
history_entry = (
|
||||
history_model.objects.filter(slug=slug)
|
||||
.order_by("-pgh_created_at")
|
||||
.first()
|
||||
)
|
||||
if history_entry:
|
||||
return cls.objects.get(id=history_entry.pgh_obj_id), True
|
||||
except LookupError:
|
||||
# History model doesn't exist, skip pghistory check
|
||||
pass
|
||||
|
||||
# Check manual slug history as fallback
|
||||
try:
|
||||
historical = HistoricalSlug.objects.get(
|
||||
content_type__model="company", slug=slug
|
||||
)
|
||||
return cls.objects.get(pk=historical.object_id), True
|
||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||
raise cls.DoesNotExist("No company found with this slug")
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
app_label = "rides"
|
||||
ordering = ["name"]
|
||||
verbose_name_plural = "Companies"
|
||||
@@ -1,126 +0,0 @@
|
||||
from django.contrib.gis.db import models as gis_models
|
||||
from django.db import models
|
||||
from django.contrib.gis.geos import Point
|
||||
import pghistory
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RideLocation(models.Model):
|
||||
"""
|
||||
Lightweight location tracking for individual rides within parks.
|
||||
Optional coordinates with focus on practical navigation information.
|
||||
"""
|
||||
|
||||
# Relationships
|
||||
ride = models.OneToOneField(
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="ride_location"
|
||||
)
|
||||
|
||||
# Optional Spatial Data - keep it simple with single point
|
||||
point = gis_models.PointField(
|
||||
srid=4326,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Geographic coordinates for ride location (longitude, latitude)",
|
||||
)
|
||||
|
||||
# Park Area Information
|
||||
park_area = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text=(
|
||||
"Themed area or land within the park (e.g., 'Frontierland', 'Tomorrowland')"
|
||||
),
|
||||
)
|
||||
|
||||
# General notes field to match database schema
|
||||
notes = models.TextField(blank=True, help_text="General location notes")
|
||||
|
||||
# Navigation and Entrance Information
|
||||
entrance_notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="Directions to ride entrance, queue location, or navigation tips",
|
||||
)
|
||||
|
||||
# Accessibility Information
|
||||
accessibility_notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="Information about accessible entrances, wheelchair access, etc.",
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def latitude(self):
|
||||
"""Return latitude from point field for backward compatibility."""
|
||||
if self.point:
|
||||
return self.point.y
|
||||
return None
|
||||
|
||||
@property
|
||||
def longitude(self):
|
||||
"""Return longitude from point field for backward compatibility."""
|
||||
if self.point:
|
||||
return self.point.x
|
||||
return None
|
||||
|
||||
@property
|
||||
def coordinates(self):
|
||||
"""Return (latitude, longitude) tuple."""
|
||||
if self.point:
|
||||
return (self.latitude, self.longitude)
|
||||
return (None, None)
|
||||
|
||||
@property
|
||||
def has_coordinates(self):
|
||||
"""Check if coordinates are set."""
|
||||
return self.point is not None
|
||||
|
||||
def set_coordinates(self, latitude, longitude):
|
||||
"""
|
||||
Set the location's point from latitude and longitude coordinates.
|
||||
Validates coordinate ranges.
|
||||
"""
|
||||
if latitude is None or longitude is None:
|
||||
self.point = None
|
||||
return
|
||||
|
||||
if not -90 <= latitude <= 90:
|
||||
raise ValueError("Latitude must be between -90 and 90.")
|
||||
if not -180 <= longitude <= 180:
|
||||
raise ValueError("Longitude must be between -180 and 180.")
|
||||
|
||||
self.point = Point(longitude, latitude, srid=4326)
|
||||
|
||||
def distance_to_park_location(self):
|
||||
"""
|
||||
Calculate distance to parent park's location if both have coordinates.
|
||||
Returns distance in kilometers.
|
||||
"""
|
||||
if not self.point:
|
||||
return None
|
||||
|
||||
park_location = getattr(self.ride.park, "location", None)
|
||||
if not park_location or not park_location.point:
|
||||
return None
|
||||
|
||||
# Use geodetic distance calculation which returns meters, convert to km
|
||||
distance_m = self.point.distance(park_location.point)
|
||||
return distance_m / 1000.0
|
||||
|
||||
def __str__(self):
|
||||
area_str = f" in {self.park_area}" if self.park_area else ""
|
||||
return f"Location for {self.ride.name}{area_str}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Ride Location"
|
||||
verbose_name_plural = "Ride Locations"
|
||||
ordering = ["ride__name"]
|
||||
indexes = [
|
||||
models.Index(fields=["park_area"]),
|
||||
# Spatial index will be created automatically for PostGIS
|
||||
# PointField
|
||||
]
|
||||
@@ -1,139 +0,0 @@
|
||||
"""
|
||||
Ride-specific media models for ThrillWiki.
|
||||
|
||||
This module contains media models specific to rides domain.
|
||||
"""
|
||||
|
||||
from typing import Any, Optional, List, cast
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from apps.core.history import TrackedModel
|
||||
from apps.core.choices import RichChoiceField
|
||||
from apps.core.services.media_service import MediaService
|
||||
import pghistory
|
||||
|
||||
|
||||
def ride_photo_upload_path(instance: models.Model, filename: str) -> str:
|
||||
"""Generate upload path for ride photos."""
|
||||
photo = cast("RidePhoto", instance)
|
||||
ride = photo.ride
|
||||
|
||||
if ride is None:
|
||||
raise ValueError("Ride cannot be None")
|
||||
|
||||
return MediaService.generate_upload_path(
|
||||
domain="park",
|
||||
identifier=ride.slug,
|
||||
filename=filename,
|
||||
subdirectory=ride.park.slug,
|
||||
)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RidePhoto(TrackedModel):
|
||||
"""Photo model specific to rides."""
|
||||
|
||||
ride = models.ForeignKey(
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="photos"
|
||||
)
|
||||
|
||||
image = models.ForeignKey(
|
||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
||||
on_delete=models.CASCADE,
|
||||
help_text="Ride photo stored on Cloudflare Images"
|
||||
)
|
||||
|
||||
caption = models.CharField(max_length=255, blank=True)
|
||||
alt_text = models.CharField(max_length=255, blank=True)
|
||||
is_primary = models.BooleanField(default=False)
|
||||
is_approved = models.BooleanField(default=False)
|
||||
|
||||
# Ride-specific metadata
|
||||
photo_type = RichChoiceField(
|
||||
choice_group="photo_types",
|
||||
domain="rides",
|
||||
max_length=50,
|
||||
default="exterior",
|
||||
help_text="Type of photo for categorization and display purposes"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
date_taken = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# User who uploaded the photo
|
||||
uploaded_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name="uploaded_ride_photos",
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
app_label = "rides"
|
||||
ordering = ["-is_primary", "-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["ride", "is_primary"]),
|
||||
models.Index(fields=["ride", "is_approved"]),
|
||||
models.Index(fields=["ride", "photo_type"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
]
|
||||
constraints = [
|
||||
# Only one primary photo per ride
|
||||
models.UniqueConstraint(
|
||||
fields=["ride"],
|
||||
condition=models.Q(is_primary=True),
|
||||
name="unique_primary_ride_photo",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Photo of {self.ride.name} - {self.caption or 'No caption'}"
|
||||
|
||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||
# Extract EXIF date if this is a new photo
|
||||
if not self.pk and not self.date_taken and self.image:
|
||||
self.date_taken = MediaService.extract_exif_date(self.image)
|
||||
|
||||
# Set default caption if not provided
|
||||
if not self.caption and self.uploaded_by:
|
||||
self.caption = MediaService.generate_default_caption(
|
||||
self.uploaded_by.username
|
||||
)
|
||||
|
||||
# If this is marked as primary, unmark other primary photos for this ride
|
||||
if self.is_primary:
|
||||
RidePhoto.objects.filter(
|
||||
ride=self.ride,
|
||||
is_primary=True,
|
||||
).exclude(
|
||||
pk=self.pk
|
||||
).update(is_primary=False)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def file_size(self) -> Optional[int]:
|
||||
"""Get file size in bytes."""
|
||||
try:
|
||||
return self.image.size
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
@property
|
||||
def dimensions(self) -> Optional[List[int]]:
|
||||
"""Get image dimensions as [width, height]."""
|
||||
try:
|
||||
return [self.image.width, self.image.height]
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
"""Get absolute URL for this photo."""
|
||||
return f"/parks/{self.ride.park.slug}/rides/{self.ride.slug}/photos/{self.pk}/"
|
||||
|
||||
@property
|
||||
def park(self):
|
||||
"""Get the park this ride belongs to."""
|
||||
return self.ride.park
|
||||
@@ -1,212 +0,0 @@
|
||||
"""
|
||||
Models for ride ranking system using Internet Roller Coaster Poll algorithm.
|
||||
|
||||
This system calculates rankings based on pairwise comparisons between rides,
|
||||
where each ride is compared to every other ride to determine which one
|
||||
more riders preferred.
|
||||
"""
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
import pghistory
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RideRanking(models.Model):
|
||||
"""
|
||||
Stores calculated rankings for rides using the Internet Roller Coaster Poll algorithm.
|
||||
|
||||
Rankings are recalculated daily based on user reviews/ratings.
|
||||
Each ride's rank is determined by its winning percentage in pairwise comparisons.
|
||||
"""
|
||||
|
||||
ride = models.OneToOneField(
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="ranking"
|
||||
)
|
||||
|
||||
# Core ranking metrics
|
||||
rank = models.PositiveIntegerField(
|
||||
db_index=True, help_text="Overall rank position (1 = best)"
|
||||
)
|
||||
wins = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of rides this ride beats in pairwise comparisons"
|
||||
)
|
||||
losses = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of rides that beat this ride in pairwise comparisons",
|
||||
)
|
||||
ties = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of rides with equal preference in pairwise comparisons",
|
||||
)
|
||||
winning_percentage = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=4,
|
||||
validators=[MinValueValidator(0), MaxValueValidator(1)],
|
||||
db_index=True,
|
||||
help_text="Win percentage where ties count as 0.5",
|
||||
)
|
||||
|
||||
# Additional metrics
|
||||
mutual_riders_count = models.PositiveIntegerField(
|
||||
default=0, help_text="Total number of users who have rated this ride"
|
||||
)
|
||||
comparison_count = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of other rides this was compared against"
|
||||
)
|
||||
average_rating = models.DecimalField(
|
||||
max_digits=3,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(10)],
|
||||
help_text="Average rating from all users who have rated this ride",
|
||||
)
|
||||
|
||||
# Metadata
|
||||
last_calculated = models.DateTimeField(
|
||||
default=timezone.now, help_text="When this ranking was last calculated"
|
||||
)
|
||||
calculation_version = models.CharField(
|
||||
max_length=10, default="1.0", help_text="Algorithm version used for calculation"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["rank"]
|
||||
indexes = [
|
||||
models.Index(fields=["rank"]),
|
||||
models.Index(fields=["winning_percentage", "-mutual_riders_count"]),
|
||||
models.Index(fields=["ride", "last_calculated"]),
|
||||
]
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
name="rideranking_winning_percentage_range",
|
||||
check=models.Q(winning_percentage__gte=0)
|
||||
& models.Q(winning_percentage__lte=1),
|
||||
violation_error_message="Winning percentage must be between 0 and 1",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
name="rideranking_average_rating_range",
|
||||
check=models.Q(average_rating__isnull=True)
|
||||
| (models.Q(average_rating__gte=1) & models.Q(average_rating__lte=10)),
|
||||
violation_error_message="Average rating must be between 1 and 10",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.rank} - {self.ride.name} ({self.winning_percentage:.1%})"
|
||||
|
||||
@property
|
||||
def total_comparisons(self):
|
||||
"""Total number of pairwise comparisons (wins + losses + ties)."""
|
||||
return self.wins + self.losses + self.ties
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RidePairComparison(models.Model):
|
||||
"""
|
||||
Caches pairwise comparison results between two rides.
|
||||
|
||||
This model stores the results of comparing two rides based on mutual riders
|
||||
(users who have rated both rides). It's used to speed up ranking calculations.
|
||||
"""
|
||||
|
||||
ride_a = models.ForeignKey(
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="comparisons_as_a"
|
||||
)
|
||||
ride_b = models.ForeignKey(
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="comparisons_as_b"
|
||||
)
|
||||
|
||||
# Comparison results
|
||||
ride_a_wins = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of mutual riders who rated ride_a higher"
|
||||
)
|
||||
ride_b_wins = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of mutual riders who rated ride_b higher"
|
||||
)
|
||||
ties = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of mutual riders who rated both rides equally"
|
||||
)
|
||||
|
||||
# Metrics
|
||||
mutual_riders_count = models.PositiveIntegerField(
|
||||
default=0, help_text="Total number of users who have rated both rides"
|
||||
)
|
||||
ride_a_avg_rating = models.DecimalField(
|
||||
max_digits=3,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Average rating of ride_a from mutual riders",
|
||||
)
|
||||
ride_b_avg_rating = models.DecimalField(
|
||||
max_digits=3,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Average rating of ride_b from mutual riders",
|
||||
)
|
||||
|
||||
# Metadata
|
||||
last_calculated = models.DateTimeField(
|
||||
auto_now=True, help_text="When this comparison was last calculated"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = [["ride_a", "ride_b"]]
|
||||
indexes = [
|
||||
models.Index(fields=["ride_a", "ride_b"]),
|
||||
models.Index(fields=["last_calculated"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
winner = "TIE"
|
||||
if self.ride_a_wins > self.ride_b_wins:
|
||||
winner = self.ride_a.name
|
||||
elif self.ride_b_wins > self.ride_a_wins:
|
||||
winner = self.ride_b.name
|
||||
return f"{self.ride_a.name} vs {self.ride_b.name} - Winner: {winner}"
|
||||
|
||||
@property
|
||||
def winner(self):
|
||||
"""Returns the winning ride or None for a tie."""
|
||||
if self.ride_a_wins > self.ride_b_wins:
|
||||
return self.ride_a
|
||||
elif self.ride_b_wins > self.ride_a_wins:
|
||||
return self.ride_b
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_tie(self):
|
||||
"""Returns True if the comparison resulted in a tie."""
|
||||
return self.ride_a_wins == self.ride_b_wins
|
||||
|
||||
|
||||
class RankingSnapshot(models.Model):
|
||||
"""
|
||||
Stores historical snapshots of rankings for tracking changes over time.
|
||||
|
||||
This allows us to show ranking trends and movements.
|
||||
"""
|
||||
|
||||
ride = models.ForeignKey(
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="ranking_history"
|
||||
)
|
||||
rank = models.PositiveIntegerField()
|
||||
winning_percentage = models.DecimalField(max_digits=5, decimal_places=4)
|
||||
snapshot_date = models.DateField(
|
||||
db_index=True, help_text="Date when this ranking snapshot was taken"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = [["ride", "snapshot_date"]]
|
||||
ordering = ["-snapshot_date", "rank"]
|
||||
indexes = [
|
||||
models.Index(fields=["snapshot_date", "rank"]),
|
||||
models.Index(fields=["ride", "-snapshot_date"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.ride.name} - Rank #{self.rank} on {self.snapshot_date}"
|
||||
@@ -1,73 +0,0 @@
|
||||
from django.db import models
|
||||
from django.db.models import functions
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from apps.core.history import TrackedModel
|
||||
import pghistory
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RideReview(TrackedModel):
|
||||
"""
|
||||
A review of a ride.
|
||||
"""
|
||||
|
||||
ride = models.ForeignKey(
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="reviews"
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
"accounts.User", on_delete=models.CASCADE, related_name="ride_reviews"
|
||||
)
|
||||
rating = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1), MaxValueValidator(10)]
|
||||
)
|
||||
title = models.CharField(max_length=200)
|
||||
content = models.TextField()
|
||||
visit_date = models.DateField()
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Moderation
|
||||
is_published = models.BooleanField(default=True)
|
||||
moderation_notes = models.TextField(blank=True)
|
||||
moderated_by = models.ForeignKey(
|
||||
"accounts.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="moderated_ride_reviews",
|
||||
)
|
||||
moderated_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-created_at"]
|
||||
unique_together = ["ride", "user"]
|
||||
constraints = [
|
||||
# Business rule: Rating must be between 1 and 10 (database level
|
||||
# enforcement)
|
||||
models.CheckConstraint(
|
||||
name="ride_review_rating_range",
|
||||
check=models.Q(rating__gte=1) & models.Q(rating__lte=10),
|
||||
violation_error_message="Rating must be between 1 and 10",
|
||||
),
|
||||
# Business rule: Visit date cannot be in the future
|
||||
models.CheckConstraint(
|
||||
name="ride_review_visit_date_not_future",
|
||||
check=models.Q(visit_date__lte=functions.Now()),
|
||||
violation_error_message="Visit date cannot be in the future",
|
||||
),
|
||||
# Business rule: If moderated, must have moderator and timestamp
|
||||
models.CheckConstraint(
|
||||
name="ride_review_moderation_consistency",
|
||||
check=models.Q(moderated_by__isnull=True, moderated_at__isnull=True)
|
||||
| models.Q(moderated_by__isnull=False, moderated_at__isnull=False),
|
||||
violation_error_message=(
|
||||
"Moderated reviews must have both moderator and moderation "
|
||||
"timestamp"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Review of {self.ride.name} by {self.user.username}"
|
||||
@@ -1,898 +0,0 @@
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from config.django import base as settings
|
||||
from apps.core.models import TrackedModel
|
||||
from apps.core.choices import RichChoiceField
|
||||
from .company import Company
|
||||
import pghistory
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .rides import RollerCoasterStats
|
||||
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RideModel(TrackedModel):
|
||||
"""
|
||||
Represents a specific model/type of ride that can be manufactured by different
|
||||
companies. This serves as a catalog of ride designs that can be referenced
|
||||
by individual ride installations.
|
||||
|
||||
For example: B&M Dive Coaster, Vekoma Boomerang, RMC I-Box, etc.
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=255, help_text="Name of the ride model")
|
||||
slug = models.SlugField(
|
||||
max_length=255, help_text="URL-friendly identifier (unique within manufacturer)"
|
||||
)
|
||||
manufacturer = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="ride_models",
|
||||
null=True,
|
||||
blank=True,
|
||||
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
|
||||
help_text="Primary manufacturer of this ride model",
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True, help_text="Detailed description of the ride model"
|
||||
)
|
||||
category = RichChoiceField(
|
||||
choice_group="categories",
|
||||
domain="rides",
|
||||
max_length=2,
|
||||
default="",
|
||||
blank=True,
|
||||
help_text="Primary category classification",
|
||||
)
|
||||
|
||||
# Technical specifications
|
||||
typical_height_range_min_ft = models.DecimalField(
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Minimum typical height in feet for this model",
|
||||
)
|
||||
typical_height_range_max_ft = models.DecimalField(
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum typical height in feet for this model",
|
||||
)
|
||||
typical_speed_range_min_mph = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Minimum typical speed in mph for this model",
|
||||
)
|
||||
typical_speed_range_max_mph = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum typical speed in mph for this model",
|
||||
)
|
||||
typical_capacity_range_min = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Minimum typical hourly capacity for this model",
|
||||
)
|
||||
typical_capacity_range_max = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum typical hourly capacity for this model",
|
||||
)
|
||||
|
||||
# Design characteristics
|
||||
track_type = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Type of track system (e.g., tubular steel, I-Box, wooden)",
|
||||
)
|
||||
support_structure = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Type of support structure (e.g., steel, wooden, hybrid)",
|
||||
)
|
||||
train_configuration = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
help_text="Typical train configuration (e.g., 2 trains, 7 cars per train, 4 seats per car)",
|
||||
)
|
||||
restraint_system = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Type of restraint system (e.g., over-shoulder, lap bar, vest)",
|
||||
)
|
||||
|
||||
# Market information
|
||||
first_installation_year = models.PositiveIntegerField(
|
||||
null=True, blank=True, help_text="Year of first installation of this model"
|
||||
)
|
||||
last_installation_year = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Year of last installation of this model (if discontinued)",
|
||||
)
|
||||
is_discontinued = models.BooleanField(
|
||||
default=False, help_text="Whether this model is no longer being manufactured"
|
||||
)
|
||||
total_installations = models.PositiveIntegerField(
|
||||
default=0, help_text="Total number of installations worldwide (auto-calculated)"
|
||||
)
|
||||
|
||||
# Design features
|
||||
notable_features = models.TextField(
|
||||
blank=True,
|
||||
help_text="Notable design features or innovations (JSON or comma-separated)",
|
||||
)
|
||||
target_market = RichChoiceField(
|
||||
choice_group="target_markets",
|
||||
domain="rides",
|
||||
max_length=50,
|
||||
blank=True,
|
||||
help_text="Primary target market for this ride model",
|
||||
)
|
||||
|
||||
# Media
|
||||
primary_image = models.ForeignKey(
|
||||
"RideModelPhoto",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="ride_models_as_primary",
|
||||
help_text="Primary promotional image for this ride model",
|
||||
)
|
||||
|
||||
# SEO and metadata
|
||||
meta_title = models.CharField(
|
||||
max_length=60, blank=True, help_text="SEO meta title (auto-generated if blank)"
|
||||
)
|
||||
meta_description = models.CharField(
|
||||
max_length=160,
|
||||
blank=True,
|
||||
help_text="SEO meta description (auto-generated if blank)",
|
||||
)
|
||||
|
||||
# Frontend URL
|
||||
url = models.URLField(blank=True, help_text="Frontend URL for this ride model")
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["manufacturer__name", "name"]
|
||||
unique_together = [["manufacturer", "name"], ["manufacturer", "slug"]]
|
||||
constraints = [
|
||||
# Height range validation
|
||||
models.CheckConstraint(
|
||||
name="ride_model_height_range_logical",
|
||||
condition=models.Q(typical_height_range_min_ft__isnull=True)
|
||||
| models.Q(typical_height_range_max_ft__isnull=True)
|
||||
| models.Q(
|
||||
typical_height_range_min_ft__lte=models.F(
|
||||
"typical_height_range_max_ft"
|
||||
)
|
||||
),
|
||||
violation_error_message="Minimum height cannot exceed maximum height",
|
||||
),
|
||||
# Speed range validation
|
||||
models.CheckConstraint(
|
||||
name="ride_model_speed_range_logical",
|
||||
condition=models.Q(typical_speed_range_min_mph__isnull=True)
|
||||
| models.Q(typical_speed_range_max_mph__isnull=True)
|
||||
| models.Q(
|
||||
typical_speed_range_min_mph__lte=models.F(
|
||||
"typical_speed_range_max_mph"
|
||||
)
|
||||
),
|
||||
violation_error_message="Minimum speed cannot exceed maximum speed",
|
||||
),
|
||||
# Capacity range validation
|
||||
models.CheckConstraint(
|
||||
name="ride_model_capacity_range_logical",
|
||||
condition=models.Q(typical_capacity_range_min__isnull=True)
|
||||
| models.Q(typical_capacity_range_max__isnull=True)
|
||||
| models.Q(
|
||||
typical_capacity_range_min__lte=models.F(
|
||||
"typical_capacity_range_max"
|
||||
)
|
||||
),
|
||||
violation_error_message="Minimum capacity cannot exceed maximum capacity",
|
||||
),
|
||||
# Installation years validation
|
||||
models.CheckConstraint(
|
||||
name="ride_model_installation_years_logical",
|
||||
condition=models.Q(first_installation_year__isnull=True)
|
||||
| models.Q(last_installation_year__isnull=True)
|
||||
| models.Q(
|
||||
first_installation_year__lte=models.F("last_installation_year")
|
||||
),
|
||||
violation_error_message="First installation year cannot be after last installation year",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
self.name
|
||||
if not self.manufacturer
|
||||
else f"{self.manufacturer.name} {self.name}"
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
if not self.slug:
|
||||
from django.utils.text import slugify
|
||||
|
||||
# Only use the ride model name for the slug, not manufacturer
|
||||
base_slug = slugify(self.name)
|
||||
self.slug = base_slug
|
||||
|
||||
# Ensure uniqueness within the same manufacturer
|
||||
counter = 1
|
||||
while (
|
||||
RideModel.objects.filter(manufacturer=self.manufacturer, slug=self.slug)
|
||||
.exclude(pk=self.pk)
|
||||
.exists()
|
||||
):
|
||||
self.slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
# Auto-generate meta fields if blank
|
||||
if not self.meta_title:
|
||||
self.meta_title = str(self)[:60]
|
||||
if not self.meta_description:
|
||||
desc = (
|
||||
f"{self} - {self.description[:100]}" if self.description else str(self)
|
||||
)
|
||||
self.meta_description = desc[:160]
|
||||
|
||||
# Generate frontend URL
|
||||
if self.manufacturer:
|
||||
frontend_domain = getattr(
|
||||
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
|
||||
)
|
||||
self.url = f"{frontend_domain}/rides/manufacturers/{self.manufacturer.slug}/{self.slug}/"
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def update_installation_count(self) -> None:
|
||||
"""Update the total installations count based on actual ride instances."""
|
||||
# Import here to avoid circular import
|
||||
from django.apps import apps
|
||||
|
||||
Ride = apps.get_model("rides", "Ride")
|
||||
self.total_installations = Ride.objects.filter(ride_model=self).count()
|
||||
self.save(update_fields=["total_installations"])
|
||||
|
||||
@property
|
||||
def installation_years_range(self) -> str:
|
||||
"""Get a formatted string of installation years range."""
|
||||
if self.first_installation_year and self.last_installation_year:
|
||||
return f"{self.first_installation_year}-{self.last_installation_year}"
|
||||
elif self.first_installation_year:
|
||||
return (
|
||||
f"{self.first_installation_year}-present"
|
||||
if not self.is_discontinued
|
||||
else f"{self.first_installation_year}+"
|
||||
)
|
||||
return "Unknown"
|
||||
|
||||
@property
|
||||
def height_range_display(self) -> str:
|
||||
"""Get a formatted string of height range."""
|
||||
if self.typical_height_range_min_ft and self.typical_height_range_max_ft:
|
||||
return f"{self.typical_height_range_min_ft}-{self.typical_height_range_max_ft} ft"
|
||||
elif self.typical_height_range_min_ft:
|
||||
return f"{self.typical_height_range_min_ft}+ ft"
|
||||
elif self.typical_height_range_max_ft:
|
||||
return f"Up to {self.typical_height_range_max_ft} ft"
|
||||
return "Variable"
|
||||
|
||||
@property
|
||||
def speed_range_display(self) -> str:
|
||||
"""Get a formatted string of speed range."""
|
||||
if self.typical_speed_range_min_mph and self.typical_speed_range_max_mph:
|
||||
return f"{self.typical_speed_range_min_mph}-{self.typical_speed_range_max_mph} mph"
|
||||
elif self.typical_speed_range_min_mph:
|
||||
return f"{self.typical_speed_range_min_mph}+ mph"
|
||||
elif self.typical_speed_range_max_mph:
|
||||
return f"Up to {self.typical_speed_range_max_mph} mph"
|
||||
return "Variable"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RideModelVariant(TrackedModel):
|
||||
"""
|
||||
Represents specific variants or configurations of a ride model.
|
||||
For example: B&M Hyper Coaster might have variants like "Mega Coaster", "Giga Coaster"
|
||||
"""
|
||||
|
||||
ride_model = models.ForeignKey(
|
||||
RideModel, on_delete=models.CASCADE, related_name="variants"
|
||||
)
|
||||
name = models.CharField(max_length=255, help_text="Name of this variant")
|
||||
description = models.TextField(
|
||||
blank=True, help_text="Description of variant differences"
|
||||
)
|
||||
|
||||
# Variant-specific specifications
|
||||
min_height_ft = models.DecimalField(
|
||||
max_digits=6, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
max_height_ft = models.DecimalField(
|
||||
max_digits=6, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
min_speed_mph = models.DecimalField(
|
||||
max_digits=5, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
max_speed_mph = models.DecimalField(
|
||||
max_digits=5, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
|
||||
# Distinguishing features
|
||||
distinguishing_features = models.TextField(
|
||||
blank=True, help_text="What makes this variant unique from the base model"
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["ride_model", "name"]
|
||||
unique_together = ["ride_model", "name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.ride_model} - {self.name}"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RideModelPhoto(TrackedModel):
|
||||
"""Photos associated with ride models for catalog/promotional purposes."""
|
||||
|
||||
ride_model = models.ForeignKey(
|
||||
RideModel, on_delete=models.CASCADE, related_name="photos"
|
||||
)
|
||||
image = models.ForeignKey(
|
||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
||||
on_delete=models.CASCADE,
|
||||
help_text="Photo of the ride model stored on Cloudflare Images"
|
||||
)
|
||||
caption = models.CharField(max_length=500, blank=True)
|
||||
alt_text = models.CharField(max_length=255, blank=True)
|
||||
|
||||
# Photo metadata
|
||||
photo_type = RichChoiceField(
|
||||
choice_group="photo_types",
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
default="PROMOTIONAL",
|
||||
help_text="Type of photo for categorization and display purposes",
|
||||
)
|
||||
|
||||
is_primary = models.BooleanField(
|
||||
default=False, help_text="Whether this is the primary photo for the ride model"
|
||||
)
|
||||
|
||||
# Attribution
|
||||
photographer = models.CharField(max_length=255, blank=True)
|
||||
source = models.CharField(max_length=255, blank=True)
|
||||
copyright_info = models.CharField(max_length=255, blank=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-is_primary", "-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Photo of {self.ride_model.name}"
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
# Ensure only one primary photo per ride model
|
||||
if self.is_primary:
|
||||
RideModelPhoto.objects.filter(
|
||||
ride_model=self.ride_model, is_primary=True
|
||||
).exclude(pk=self.pk).update(is_primary=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RideModelTechnicalSpec(TrackedModel):
|
||||
"""
|
||||
Technical specifications for ride models that don't fit in the main model.
|
||||
This allows for flexible specification storage.
|
||||
"""
|
||||
|
||||
ride_model = models.ForeignKey(
|
||||
RideModel, on_delete=models.CASCADE, related_name="technical_specs"
|
||||
)
|
||||
|
||||
spec_category = RichChoiceField(
|
||||
choice_group="spec_categories",
|
||||
domain="rides",
|
||||
max_length=50,
|
||||
help_text="Category of technical specification",
|
||||
)
|
||||
|
||||
spec_name = models.CharField(max_length=100, help_text="Name of the specification")
|
||||
spec_value = models.CharField(
|
||||
max_length=255, help_text="Value of the specification"
|
||||
)
|
||||
spec_unit = models.CharField(
|
||||
max_length=20, blank=True, help_text="Unit of measurement"
|
||||
)
|
||||
notes = models.TextField(
|
||||
blank=True, help_text="Additional notes about this specification"
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["spec_category", "spec_name"]
|
||||
unique_together = ["ride_model", "spec_category", "spec_name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
unit_str = f" {self.spec_unit}" if self.spec_unit else ""
|
||||
return f"{self.ride_model.name} - {self.spec_name}: {self.spec_value}{unit_str}"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class Ride(TrackedModel):
|
||||
"""Model for individual ride installations at parks
|
||||
|
||||
Note: The average_rating field is denormalized and refreshed by background
|
||||
jobs. Use selectors or annotations for real-time calculations if needed.
|
||||
"""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
coaster_stats: 'RollerCoasterStats'
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
park = models.ForeignKey(
|
||||
"parks.Park", on_delete=models.CASCADE, related_name="rides"
|
||||
)
|
||||
park_area = models.ForeignKey(
|
||||
"parks.ParkArea",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="rides",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
category = RichChoiceField(
|
||||
choice_group="categories",
|
||||
domain="rides",
|
||||
max_length=2,
|
||||
default="",
|
||||
blank=True,
|
||||
help_text="Ride category classification"
|
||||
)
|
||||
manufacturer = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="manufactured_rides",
|
||||
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
|
||||
)
|
||||
designer = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="designed_rides",
|
||||
null=True,
|
||||
blank=True,
|
||||
limit_choices_to={"roles__contains": ["DESIGNER"]},
|
||||
)
|
||||
ride_model = models.ForeignKey(
|
||||
"RideModel",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="rides",
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="The specific model/type of this ride",
|
||||
)
|
||||
status = RichChoiceField(
|
||||
choice_group="statuses",
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
default="OPERATING",
|
||||
help_text="Current operational status of the ride"
|
||||
)
|
||||
post_closing_status = RichChoiceField(
|
||||
choice_group="post_closing_statuses",
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Status to change to after closing date",
|
||||
)
|
||||
opening_date = models.DateField(null=True, blank=True)
|
||||
closing_date = models.DateField(null=True, blank=True)
|
||||
status_since = models.DateField(null=True, blank=True)
|
||||
min_height_in = models.PositiveIntegerField(null=True, blank=True)
|
||||
max_height_in = models.PositiveIntegerField(null=True, blank=True)
|
||||
capacity_per_hour = models.PositiveIntegerField(null=True, blank=True)
|
||||
ride_duration_seconds = models.PositiveIntegerField(null=True, blank=True)
|
||||
average_rating = models.DecimalField(
|
||||
max_digits=3, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
|
||||
# Computed fields for hybrid filtering
|
||||
opening_year = models.IntegerField(null=True, blank=True, db_index=True)
|
||||
search_text = models.TextField(blank=True, db_index=True)
|
||||
|
||||
# Image settings - references to existing photos
|
||||
banner_image = models.ForeignKey(
|
||||
"RidePhoto",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="rides_using_as_banner",
|
||||
help_text="Photo to use as banner image for this ride",
|
||||
)
|
||||
card_image = models.ForeignKey(
|
||||
"RidePhoto",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="rides_using_as_card",
|
||||
help_text="Photo to use as card image for this ride",
|
||||
)
|
||||
|
||||
# Frontend URL
|
||||
url = models.URLField(blank=True, help_text="Frontend URL for this ride")
|
||||
park_url = models.URLField(
|
||||
blank=True, help_text="Frontend URL for this ride's park"
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["name"]
|
||||
unique_together = ["park", "slug"]
|
||||
constraints = [
|
||||
# Business rule: Closing date must be after opening date
|
||||
models.CheckConstraint(
|
||||
name="ride_closing_after_opening",
|
||||
condition=models.Q(closing_date__isnull=True)
|
||||
| models.Q(opening_date__isnull=True)
|
||||
| models.Q(closing_date__gte=models.F("opening_date")),
|
||||
violation_error_message="Closing date must be after opening date",
|
||||
),
|
||||
# Business rule: Height requirements must be logical
|
||||
models.CheckConstraint(
|
||||
name="ride_height_requirements_logical",
|
||||
condition=models.Q(min_height_in__isnull=True)
|
||||
| models.Q(max_height_in__isnull=True)
|
||||
| models.Q(min_height_in__lte=models.F("max_height_in")),
|
||||
violation_error_message="Minimum height cannot exceed maximum height",
|
||||
),
|
||||
# Business rule: Height requirements must be reasonable (between 30
|
||||
# and 90 inches)
|
||||
models.CheckConstraint(
|
||||
name="ride_min_height_reasonable",
|
||||
condition=models.Q(min_height_in__isnull=True)
|
||||
| (models.Q(min_height_in__gte=30) & models.Q(min_height_in__lte=90)),
|
||||
violation_error_message=(
|
||||
"Minimum height must be between 30 and 90 inches"
|
||||
),
|
||||
),
|
||||
models.CheckConstraint(
|
||||
name="ride_max_height_reasonable",
|
||||
condition=models.Q(max_height_in__isnull=True)
|
||||
| (models.Q(max_height_in__gte=30) & models.Q(max_height_in__lte=90)),
|
||||
violation_error_message=(
|
||||
"Maximum height must be between 30 and 90 inches"
|
||||
),
|
||||
),
|
||||
# Business rule: Rating must be between 1 and 10
|
||||
models.CheckConstraint(
|
||||
name="ride_rating_range",
|
||||
condition=models.Q(average_rating__isnull=True)
|
||||
| (models.Q(average_rating__gte=1) & models.Q(average_rating__lte=10)),
|
||||
violation_error_message="Average rating must be between 1 and 10",
|
||||
),
|
||||
# Business rule: Capacity and duration must be positive
|
||||
models.CheckConstraint(
|
||||
name="ride_capacity_positive",
|
||||
condition=models.Q(capacity_per_hour__isnull=True)
|
||||
| models.Q(capacity_per_hour__gt=0),
|
||||
violation_error_message="Hourly capacity must be positive",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
name="ride_duration_positive",
|
||||
condition=models.Q(ride_duration_seconds__isnull=True)
|
||||
| models.Q(ride_duration_seconds__gt=0),
|
||||
violation_error_message="Ride duration must be positive",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} at {self.park.name}"
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
# Handle slug generation and conflicts
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
|
||||
# Check for slug conflicts when park changes or slug is new
|
||||
original_ride = None
|
||||
if self.pk:
|
||||
try:
|
||||
original_ride = Ride.objects.get(pk=self.pk)
|
||||
except Ride.DoesNotExist:
|
||||
pass
|
||||
|
||||
# If park changed or this is a new ride, ensure slug uniqueness within the park
|
||||
park_changed = original_ride and original_ride.park.id != self.park.id
|
||||
if not self.pk or park_changed:
|
||||
self._ensure_unique_slug_in_park()
|
||||
|
||||
# Handle park area validation when park changes
|
||||
if park_changed and self.park_area:
|
||||
# Check if park_area belongs to the new park
|
||||
if self.park_area.park.id != self.park.id:
|
||||
# Clear park_area if it doesn't belong to the new park
|
||||
self.park_area = None
|
||||
|
||||
# Sync manufacturer with ride model's manufacturer
|
||||
if self.ride_model and self.ride_model.manufacturer:
|
||||
self.manufacturer = self.ride_model.manufacturer
|
||||
elif self.ride_model and not self.ride_model.manufacturer:
|
||||
# If ride model has no manufacturer, clear the ride's manufacturer
|
||||
# to maintain consistency
|
||||
self.manufacturer = None
|
||||
|
||||
# Generate frontend URLs
|
||||
if self.park:
|
||||
frontend_domain = getattr(
|
||||
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
|
||||
)
|
||||
self.url = f"{frontend_domain}/parks/{self.park.slug}/rides/{self.slug}/"
|
||||
self.park_url = f"{frontend_domain}/parks/{self.park.slug}/"
|
||||
|
||||
# Populate computed fields
|
||||
self._populate_computed_fields()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def _populate_computed_fields(self) -> None:
|
||||
"""Populate computed fields for hybrid filtering."""
|
||||
# Extract opening year from opening_date
|
||||
if self.opening_date:
|
||||
self.opening_year = self.opening_date.year
|
||||
else:
|
||||
self.opening_year = None
|
||||
|
||||
# Build comprehensive search text
|
||||
search_parts = []
|
||||
|
||||
# Basic ride info
|
||||
if self.name:
|
||||
search_parts.append(self.name)
|
||||
if self.description:
|
||||
search_parts.append(self.description)
|
||||
|
||||
# Park info
|
||||
if self.park:
|
||||
search_parts.append(self.park.name)
|
||||
if hasattr(self.park, 'location') and self.park.location:
|
||||
if self.park.location.city:
|
||||
search_parts.append(self.park.location.city)
|
||||
if self.park.location.state:
|
||||
search_parts.append(self.park.location.state)
|
||||
if self.park.location.country:
|
||||
search_parts.append(self.park.location.country)
|
||||
|
||||
# Park area
|
||||
if self.park_area:
|
||||
search_parts.append(self.park_area.name)
|
||||
|
||||
# Category
|
||||
if self.category:
|
||||
category_choice = self.get_category_rich_choice()
|
||||
if category_choice:
|
||||
search_parts.append(category_choice.label)
|
||||
|
||||
# Status
|
||||
if self.status:
|
||||
status_choice = self.get_status_rich_choice()
|
||||
if status_choice:
|
||||
search_parts.append(status_choice.label)
|
||||
|
||||
# Companies
|
||||
if self.manufacturer:
|
||||
search_parts.append(self.manufacturer.name)
|
||||
if self.designer:
|
||||
search_parts.append(self.designer.name)
|
||||
|
||||
# Ride model
|
||||
if self.ride_model:
|
||||
search_parts.append(self.ride_model.name)
|
||||
if self.ride_model.manufacturer:
|
||||
search_parts.append(self.ride_model.manufacturer.name)
|
||||
|
||||
# Roller coaster stats if available
|
||||
try:
|
||||
if hasattr(self, 'coaster_stats') and self.coaster_stats:
|
||||
stats = self.coaster_stats
|
||||
if stats.track_type:
|
||||
search_parts.append(stats.track_type)
|
||||
if stats.track_material:
|
||||
material_choice = stats.get_track_material_rich_choice()
|
||||
if material_choice:
|
||||
search_parts.append(material_choice.label)
|
||||
if stats.roller_coaster_type:
|
||||
type_choice = stats.get_roller_coaster_type_rich_choice()
|
||||
if type_choice:
|
||||
search_parts.append(type_choice.label)
|
||||
if stats.propulsion_system:
|
||||
propulsion_choice = stats.get_propulsion_system_rich_choice()
|
||||
if propulsion_choice:
|
||||
search_parts.append(propulsion_choice.label)
|
||||
if stats.train_style:
|
||||
search_parts.append(stats.train_style)
|
||||
except Exception:
|
||||
# Ignore if coaster_stats doesn't exist or has issues
|
||||
pass
|
||||
|
||||
self.search_text = ' '.join(filter(None, search_parts)).lower()
|
||||
|
||||
def _ensure_unique_slug_in_park(self) -> None:
|
||||
"""Ensure the ride's slug is unique within its park."""
|
||||
base_slug = slugify(self.name)
|
||||
self.slug = base_slug
|
||||
|
||||
counter = 1
|
||||
while (
|
||||
Ride.objects.filter(park=self.park, slug=self.slug)
|
||||
.exclude(pk=self.pk)
|
||||
.exists()
|
||||
):
|
||||
self.slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
def move_to_park(self, new_park, clear_park_area=True):
|
||||
"""
|
||||
Move this ride to a different park with proper handling of related data.
|
||||
|
||||
Args:
|
||||
new_park: The new Park instance to move the ride to
|
||||
clear_park_area: Whether to clear park_area (default True, since areas are park-specific)
|
||||
|
||||
Returns:
|
||||
dict: Summary of changes made
|
||||
"""
|
||||
|
||||
old_park = self.park
|
||||
old_url = self.url
|
||||
old_park_area = self.park_area
|
||||
|
||||
# Update park
|
||||
self.park = new_park
|
||||
|
||||
# Handle park area
|
||||
if clear_park_area:
|
||||
self.park_area = None
|
||||
|
||||
# Save will handle slug conflicts and URL updates
|
||||
self.save()
|
||||
|
||||
# Return summary of changes
|
||||
changes = {
|
||||
'old_park': {
|
||||
'id': old_park.id,
|
||||
'name': old_park.name,
|
||||
'slug': old_park.slug
|
||||
},
|
||||
'new_park': {
|
||||
'id': new_park.id,
|
||||
'name': new_park.name,
|
||||
'slug': new_park.slug
|
||||
},
|
||||
'url_changed': old_url != self.url,
|
||||
'old_url': old_url,
|
||||
'new_url': self.url,
|
||||
'park_area_cleared': clear_park_area and old_park_area is not None,
|
||||
'old_park_area': {
|
||||
'id': old_park_area.id,
|
||||
'name': old_park_area.name
|
||||
} if old_park_area else None,
|
||||
'slug_changed': self.slug != slugify(self.name),
|
||||
'final_slug': self.slug
|
||||
}
|
||||
|
||||
return changes
|
||||
|
||||
@classmethod
|
||||
def get_by_slug(cls, slug: str, park=None) -> tuple["Ride", bool]:
|
||||
"""Get ride by current or historical slug, optionally within a specific park"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from apps.core.history import HistoricalSlug
|
||||
|
||||
# Build base query
|
||||
base_query = cls.objects
|
||||
if park:
|
||||
base_query = base_query.filter(park=park)
|
||||
|
||||
try:
|
||||
ride = base_query.get(slug=slug)
|
||||
return ride, False
|
||||
except cls.DoesNotExist:
|
||||
# Try historical slugs in HistoricalSlug model
|
||||
content_type = ContentType.objects.get_for_model(cls)
|
||||
historical_query = HistoricalSlug.objects.filter(
|
||||
content_type=content_type, slug=slug
|
||||
).order_by("-created_at")
|
||||
|
||||
for historical in historical_query:
|
||||
try:
|
||||
ride = base_query.get(pk=historical.object_id)
|
||||
return ride, True
|
||||
except cls.DoesNotExist:
|
||||
continue
|
||||
|
||||
# Try pghistory events
|
||||
event_model = getattr(cls, "event_model", None)
|
||||
if event_model:
|
||||
historical_events = event_model.objects.filter(slug=slug).order_by("-pgh_created_at")
|
||||
|
||||
for historical_event in historical_events:
|
||||
try:
|
||||
ride = base_query.get(pk=historical_event.pgh_obj_id)
|
||||
return ride, True
|
||||
except cls.DoesNotExist:
|
||||
continue
|
||||
|
||||
raise cls.DoesNotExist("No ride found with this slug")
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RollerCoasterStats(models.Model):
|
||||
"""Model for tracking roller coaster specific statistics"""
|
||||
|
||||
|
||||
ride = models.OneToOneField(
|
||||
Ride, on_delete=models.CASCADE, related_name="coaster_stats"
|
||||
)
|
||||
height_ft = models.DecimalField(
|
||||
max_digits=6, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
length_ft = models.DecimalField(
|
||||
max_digits=7, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
speed_mph = models.DecimalField(
|
||||
max_digits=5, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
inversions = models.PositiveIntegerField(default=0)
|
||||
ride_time_seconds = models.PositiveIntegerField(null=True, blank=True)
|
||||
track_type = models.CharField(max_length=255, blank=True)
|
||||
track_material = RichChoiceField(
|
||||
choice_group="track_materials",
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
default="STEEL",
|
||||
blank=True,
|
||||
help_text="Track construction material type"
|
||||
)
|
||||
roller_coaster_type = RichChoiceField(
|
||||
choice_group="coaster_types",
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
default="SITDOWN",
|
||||
blank=True,
|
||||
help_text="Roller coaster type classification"
|
||||
)
|
||||
max_drop_height_ft = models.DecimalField(
|
||||
max_digits=6, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
propulsion_system = RichChoiceField(
|
||||
choice_group="propulsion_systems",
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
default="CHAIN",
|
||||
help_text="Propulsion or lift system type"
|
||||
)
|
||||
train_style = models.CharField(max_length=255, blank=True)
|
||||
trains_count = models.PositiveIntegerField(null=True, blank=True)
|
||||
cars_per_train = models.PositiveIntegerField(null=True, blank=True)
|
||||
seats_per_car = models.PositiveIntegerField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Roller Coaster Statistics"
|
||||
verbose_name_plural = "Roller Coaster Statistics"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Stats for {self.ride.name}"
|
||||
@@ -1,22 +0,0 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = "rides"
|
||||
|
||||
urlpatterns = [
|
||||
# Park-specific list views
|
||||
path("", views.RideListView.as_view(), name="ride_list"),
|
||||
path("create/", views.RideCreateView.as_view(), name="ride_create"),
|
||||
# Park-specific detail views
|
||||
path("<slug:ride_slug>/", views.RideDetailView.as_view(), name="ride_detail"),
|
||||
path(
|
||||
"<slug:ride_slug>/update/",
|
||||
views.RideUpdateView.as_view(),
|
||||
name="ride_update",
|
||||
),
|
||||
path("search/companies/", views.search_companies, name="search_companies"),
|
||||
# Search endpoints
|
||||
path("search/models/", views.search_ride_models, name="search_ride_models"),
|
||||
# HTMX endpoints
|
||||
path("coaster-fields/", views.show_coaster_fields, name="coaster_fields"),
|
||||
]
|
||||
@@ -1,302 +0,0 @@
|
||||
"""
|
||||
Selectors for ride-related data retrieval.
|
||||
Following Django styleguide pattern for separating data access from business logic.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from django.db.models import QuerySet, Q, Count, Avg, Prefetch
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import Distance
|
||||
|
||||
from .models import Ride, RideModel, RideReview
|
||||
from .choices import RIDE_CATEGORIES
|
||||
|
||||
|
||||
def ride_list_for_display(
|
||||
*, filters: Optional[Dict[str, Any]] = None
|
||||
) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get rides optimized for list display with related data.
|
||||
|
||||
Args:
|
||||
filters: Optional dictionary of filter parameters
|
||||
|
||||
Returns:
|
||||
QuerySet of rides with optimized queries
|
||||
"""
|
||||
queryset = (
|
||||
Ride.objects.select_related(
|
||||
"park",
|
||||
"park__operator",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"ride_model",
|
||||
"park_area",
|
||||
)
|
||||
.prefetch_related("park__location")
|
||||
.annotate(average_rating_calculated=Avg("reviews__rating"))
|
||||
)
|
||||
|
||||
if filters:
|
||||
if "status" in filters and filters["status"]:
|
||||
queryset = queryset.filter(status__in=filters["status"])
|
||||
if "category" in filters and filters["category"]:
|
||||
queryset = queryset.filter(category__in=filters["category"])
|
||||
if "manufacturer" in filters:
|
||||
queryset = queryset.filter(manufacturer=filters["manufacturer"])
|
||||
if "park" in filters:
|
||||
queryset = queryset.filter(park=filters["park"])
|
||||
if "search" in filters:
|
||||
search_term = filters["search"]
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=search_term)
|
||||
| Q(description__icontains=search_term)
|
||||
| Q(park__name__icontains=search_term)
|
||||
)
|
||||
|
||||
return queryset.order_by("park__name", "name")
|
||||
|
||||
|
||||
def ride_detail_optimized(*, slug: str, park_slug: str) -> Ride:
|
||||
"""
|
||||
Get a single ride with all related data optimized for detail view.
|
||||
|
||||
Args:
|
||||
slug: Ride slug identifier
|
||||
park_slug: Park slug for the ride
|
||||
|
||||
Returns:
|
||||
Ride instance with optimized prefetches
|
||||
|
||||
Raises:
|
||||
Ride.DoesNotExist: If ride doesn't exist
|
||||
"""
|
||||
return (
|
||||
Ride.objects.select_related(
|
||||
"park",
|
||||
"park__operator",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"ride_model",
|
||||
"park_area",
|
||||
)
|
||||
.prefetch_related(
|
||||
"park__location",
|
||||
Prefetch(
|
||||
"reviews",
|
||||
queryset=RideReview.objects.select_related("user").filter(
|
||||
is_published=True
|
||||
),
|
||||
),
|
||||
"photos",
|
||||
)
|
||||
.get(slug=slug, park__slug=park_slug)
|
||||
)
|
||||
|
||||
|
||||
def rides_by_category(*, category: str) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get all rides in a specific category.
|
||||
|
||||
Args:
|
||||
category: Ride category code
|
||||
|
||||
Returns:
|
||||
QuerySet of rides in the category
|
||||
"""
|
||||
return (
|
||||
Ride.objects.filter(category=category)
|
||||
.select_related("park", "manufacturer", "designer")
|
||||
.prefetch_related("park__location")
|
||||
.annotate(average_rating_calculated=Avg("reviews__rating"))
|
||||
.order_by("park__name", "name")
|
||||
)
|
||||
|
||||
|
||||
def rides_by_manufacturer(*, manufacturer_id: int) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get all rides manufactured by a specific company.
|
||||
|
||||
Args:
|
||||
manufacturer_id: Company ID of the manufacturer
|
||||
|
||||
Returns:
|
||||
QuerySet of rides by the manufacturer
|
||||
"""
|
||||
return (
|
||||
Ride.objects.filter(manufacturer_id=manufacturer_id)
|
||||
.select_related("park", "manufacturer", "ride_model")
|
||||
.prefetch_related("park__location")
|
||||
.annotate(average_rating_calculated=Avg("reviews__rating"))
|
||||
.order_by("park__name", "name")
|
||||
)
|
||||
|
||||
|
||||
def rides_by_designer(*, designer_id: int) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get all rides designed by a specific company.
|
||||
|
||||
Args:
|
||||
designer_id: Company ID of the designer
|
||||
|
||||
Returns:
|
||||
QuerySet of rides by the designer
|
||||
"""
|
||||
return (
|
||||
Ride.objects.filter(designer_id=designer_id)
|
||||
.select_related("park", "designer", "ride_model")
|
||||
.prefetch_related("park__location")
|
||||
.annotate(average_rating_calculated=Avg("reviews__rating"))
|
||||
.order_by("park__name", "name")
|
||||
)
|
||||
|
||||
|
||||
def rides_in_park(*, park_slug: str) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get all rides in a specific park.
|
||||
|
||||
Args:
|
||||
park_slug: Slug of the park
|
||||
|
||||
Returns:
|
||||
QuerySet of rides in the park
|
||||
"""
|
||||
return (
|
||||
Ride.objects.filter(park__slug=park_slug)
|
||||
.select_related("manufacturer", "designer", "ride_model", "park_area")
|
||||
.prefetch_related()
|
||||
.annotate(average_rating_calculated=Avg("reviews__rating"))
|
||||
.order_by("park_area__name", "name")
|
||||
)
|
||||
|
||||
|
||||
def rides_near_location(
|
||||
*, point: Point, distance_km: float = 50, limit: int = 10
|
||||
) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get rides near a specific geographic location.
|
||||
|
||||
Args:
|
||||
point: Geographic point (longitude, latitude)
|
||||
distance_km: Maximum distance in kilometers
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
QuerySet of nearby rides ordered by distance
|
||||
"""
|
||||
return (
|
||||
Ride.objects.filter(
|
||||
park__location__coordinates__distance_lte=(
|
||||
point,
|
||||
Distance(km=distance_km),
|
||||
)
|
||||
)
|
||||
.select_related("park", "manufacturer")
|
||||
.prefetch_related("park__location")
|
||||
.distance(point)
|
||||
.order_by("distance")[:limit]
|
||||
)
|
||||
|
||||
|
||||
def ride_models_with_installations() -> QuerySet[RideModel]:
|
||||
"""
|
||||
Get ride models that have installations with counts.
|
||||
|
||||
Returns:
|
||||
QuerySet of ride models with installation counts
|
||||
"""
|
||||
return (
|
||||
RideModel.objects.annotate(installation_count=Count("rides"))
|
||||
.filter(installation_count__gt=0)
|
||||
.select_related("manufacturer")
|
||||
.order_by("-installation_count", "name")
|
||||
)
|
||||
|
||||
|
||||
def ride_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get rides matching a search query for autocomplete functionality.
|
||||
|
||||
Args:
|
||||
query: Search string
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
QuerySet of matching rides for autocomplete
|
||||
"""
|
||||
return (
|
||||
Ride.objects.filter(
|
||||
Q(name__icontains=query)
|
||||
| Q(park__name__icontains=query)
|
||||
| Q(manufacturer__name__icontains=query)
|
||||
)
|
||||
.select_related("park", "manufacturer")
|
||||
.prefetch_related("park__location")
|
||||
.order_by("park__name", "name")[:limit]
|
||||
)
|
||||
|
||||
|
||||
def rides_with_recent_reviews(*, days: int = 30) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get rides that have received reviews in the last N days.
|
||||
|
||||
Args:
|
||||
days: Number of days to look back for reviews
|
||||
|
||||
Returns:
|
||||
QuerySet of rides with recent reviews
|
||||
"""
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
return (
|
||||
Ride.objects.filter(
|
||||
reviews__created_at__gte=cutoff_date, reviews__is_published=True
|
||||
)
|
||||
.select_related("park", "manufacturer")
|
||||
.prefetch_related("park__location")
|
||||
.annotate(
|
||||
recent_review_count=Count(
|
||||
"reviews", filter=Q(reviews__created_at__gte=cutoff_date)
|
||||
)
|
||||
)
|
||||
.order_by("-recent_review_count")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
def ride_statistics_by_category() -> Dict[str, Any]:
|
||||
"""
|
||||
Get ride statistics grouped by category.
|
||||
|
||||
Returns:
|
||||
Dictionary containing ride statistics by category
|
||||
"""
|
||||
|
||||
stats = {}
|
||||
for category in RIDE_CATEGORIES:
|
||||
if category.value: # Skip empty choice
|
||||
count = Ride.objects.filter(category=category.value).count()
|
||||
stats[category.value] = {"name": category.label, "count": count}
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def rides_by_opening_year(*, year: int) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get rides that opened in a specific year.
|
||||
|
||||
Args:
|
||||
year: The opening year
|
||||
|
||||
Returns:
|
||||
QuerySet of rides that opened in the specified year
|
||||
"""
|
||||
return (
|
||||
Ride.objects.filter(opening_date__year=year)
|
||||
.select_related("park", "manufacturer")
|
||||
.prefetch_related("park__location")
|
||||
.order_by("opening_date", "park__name", "name")
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
from .location_service import RideLocationService
|
||||
from .media_service import RideMediaService
|
||||
|
||||
__all__ = ["RideLocationService", "RideMediaService"]
|
||||
@@ -1,784 +0,0 @@
|
||||
"""
|
||||
Smart Ride Loader for Hybrid Filtering Strategy
|
||||
|
||||
This service implements intelligent data loading for rides, automatically choosing
|
||||
between client-side and server-side filtering based on data size and complexity.
|
||||
|
||||
Key Features:
|
||||
- Automatic strategy selection (≤200 records = client-side, >200 = server-side)
|
||||
- Progressive loading for large datasets
|
||||
- Intelligent caching with automatic invalidation
|
||||
- Comprehensive filter metadata generation
|
||||
- Optimized database queries with strategic prefetching
|
||||
|
||||
Architecture:
|
||||
- Client-side: Load all data once, filter in frontend
|
||||
- Server-side: Apply filters in database, paginate results
|
||||
- Hybrid: Combine both approaches based on data characteristics
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
from django.core.cache import cache
|
||||
from django.db import models
|
||||
from django.db.models import Q, Min, Max
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmartRideLoader:
|
||||
"""
|
||||
Intelligent ride data loader that chooses optimal filtering strategy.
|
||||
|
||||
Strategy Selection:
|
||||
- ≤200 total records: Client-side filtering (load all data)
|
||||
- >200 total records: Server-side filtering (database filtering + pagination)
|
||||
|
||||
Features:
|
||||
- Progressive loading for large datasets
|
||||
- 5-minute intelligent caching
|
||||
- Comprehensive filter metadata
|
||||
- Optimized queries with prefetch_related
|
||||
"""
|
||||
|
||||
# Configuration constants
|
||||
INITIAL_LOAD_SIZE = 50
|
||||
PROGRESSIVE_LOAD_SIZE = 25
|
||||
MAX_CLIENT_SIDE_RECORDS = 200
|
||||
CACHE_TIMEOUT = 300 # 5 minutes
|
||||
|
||||
def __init__(self):
|
||||
self.cache_prefix = "rides_hybrid_"
|
||||
|
||||
def get_initial_load(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get initial data load with automatic strategy selection.
|
||||
|
||||
Args:
|
||||
filters: Optional filter parameters
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- strategy: 'client_side' or 'server_side'
|
||||
- data: List of ride records
|
||||
- total_count: Total number of records
|
||||
- has_more: Whether more data is available
|
||||
- filter_metadata: Available filter options
|
||||
"""
|
||||
|
||||
# Get total count for strategy decision
|
||||
total_count = self._get_total_count(filters)
|
||||
|
||||
# Choose strategy based on total count
|
||||
if total_count <= self.MAX_CLIENT_SIDE_RECORDS:
|
||||
return self._get_client_side_data(filters, total_count)
|
||||
else:
|
||||
return self._get_server_side_data(filters, total_count)
|
||||
|
||||
def get_progressive_load(self, offset: int, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get additional data for progressive loading (server-side strategy only).
|
||||
|
||||
Args:
|
||||
offset: Number of records to skip
|
||||
filters: Filter parameters
|
||||
|
||||
Returns:
|
||||
Dict containing additional ride records
|
||||
"""
|
||||
|
||||
# Build queryset with filters
|
||||
queryset = self._build_filtered_queryset(filters)
|
||||
|
||||
# Get total count for this filtered set
|
||||
total_count = queryset.count()
|
||||
|
||||
# Get progressive batch
|
||||
rides = list(queryset[offset:offset + self.PROGRESSIVE_LOAD_SIZE])
|
||||
|
||||
return {
|
||||
'rides': self._serialize_rides(rides),
|
||||
'total_count': total_count,
|
||||
'has_more': len(rides) == self.PROGRESSIVE_LOAD_SIZE,
|
||||
'next_offset': offset + len(rides) if len(rides) == self.PROGRESSIVE_LOAD_SIZE else None
|
||||
}
|
||||
|
||||
def get_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive filter metadata for dynamic filter generation.
|
||||
|
||||
Args:
|
||||
filters: Optional filters to scope the metadata
|
||||
|
||||
Returns:
|
||||
Dict containing all available filter options and ranges
|
||||
"""
|
||||
cache_key = f"{self.cache_prefix}filter_metadata_{hash(str(filters))}"
|
||||
metadata = cache.get(cache_key)
|
||||
|
||||
if metadata is None:
|
||||
metadata = self._generate_filter_metadata(filters)
|
||||
cache.set(cache_key, metadata, self.CACHE_TIMEOUT)
|
||||
|
||||
return metadata
|
||||
|
||||
def invalidate_cache(self) -> None:
|
||||
"""Invalidate all cached data for rides."""
|
||||
# Note: In production, you might want to use cache versioning
|
||||
# or more sophisticated cache invalidation
|
||||
cache_keys = [
|
||||
f"{self.cache_prefix}client_side_all",
|
||||
f"{self.cache_prefix}filter_metadata",
|
||||
f"{self.cache_prefix}total_count",
|
||||
]
|
||||
|
||||
for key in cache_keys:
|
||||
cache.delete(key)
|
||||
|
||||
def _get_total_count(self, filters: Optional[Dict[str, Any]] = None) -> int:
|
||||
"""Get total count of rides matching filters."""
|
||||
cache_key = f"{self.cache_prefix}total_count_{hash(str(filters))}"
|
||||
count = cache.get(cache_key)
|
||||
|
||||
if count is None:
|
||||
queryset = self._build_filtered_queryset(filters)
|
||||
count = queryset.count()
|
||||
cache.set(cache_key, count, self.CACHE_TIMEOUT)
|
||||
|
||||
return count
|
||||
|
||||
def _get_client_side_data(self, filters: Optional[Dict[str, Any]],
|
||||
total_count: int) -> Dict[str, Any]:
|
||||
"""Get all data for client-side filtering."""
|
||||
cache_key = f"{self.cache_prefix}client_side_all"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is None:
|
||||
from apps.rides.models import Ride
|
||||
|
||||
# Load all rides with optimized query
|
||||
queryset = Ride.objects.select_related(
|
||||
'park',
|
||||
'park__location',
|
||||
'park_area',
|
||||
'manufacturer',
|
||||
'designer',
|
||||
'ride_model',
|
||||
'ride_model__manufacturer'
|
||||
).prefetch_related(
|
||||
'coaster_stats'
|
||||
).order_by('name')
|
||||
|
||||
rides = list(queryset)
|
||||
cached_data = self._serialize_rides(rides)
|
||||
cache.set(cache_key, cached_data, self.CACHE_TIMEOUT)
|
||||
|
||||
return {
|
||||
'strategy': 'client_side',
|
||||
'rides': cached_data,
|
||||
'total_count': total_count,
|
||||
'has_more': False,
|
||||
'filter_metadata': self.get_filter_metadata(filters)
|
||||
}
|
||||
|
||||
def _get_server_side_data(self, filters: Optional[Dict[str, Any]],
|
||||
total_count: int) -> Dict[str, Any]:
|
||||
"""Get initial batch for server-side filtering."""
|
||||
# Build filtered queryset
|
||||
queryset = self._build_filtered_queryset(filters)
|
||||
|
||||
# Get initial batch
|
||||
rides = list(queryset[:self.INITIAL_LOAD_SIZE])
|
||||
|
||||
return {
|
||||
'strategy': 'server_side',
|
||||
'rides': self._serialize_rides(rides),
|
||||
'total_count': total_count,
|
||||
'has_more': len(rides) == self.INITIAL_LOAD_SIZE,
|
||||
'next_offset': len(rides) if len(rides) == self.INITIAL_LOAD_SIZE else None
|
||||
}
|
||||
|
||||
def _build_filtered_queryset(self, filters: Optional[Dict[str, Any]]):
|
||||
"""Build Django queryset with applied filters."""
|
||||
from apps.rides.models import Ride
|
||||
|
||||
# Start with optimized base queryset
|
||||
queryset = Ride.objects.select_related(
|
||||
'park',
|
||||
'park__location',
|
||||
'park_area',
|
||||
'manufacturer',
|
||||
'designer',
|
||||
'ride_model',
|
||||
'ride_model__manufacturer'
|
||||
).prefetch_related(
|
||||
'coaster_stats'
|
||||
)
|
||||
|
||||
if not filters:
|
||||
return queryset.order_by('name')
|
||||
|
||||
# Apply filters
|
||||
q_objects = Q()
|
||||
|
||||
# Text search using computed search_text field
|
||||
if 'search' in filters and filters['search']:
|
||||
search_term = filters['search'].lower()
|
||||
q_objects &= Q(search_text__icontains=search_term)
|
||||
|
||||
# Park filters
|
||||
if 'park_slug' in filters and filters['park_slug']:
|
||||
q_objects &= Q(park__slug=filters['park_slug'])
|
||||
|
||||
if 'park_id' in filters and filters['park_id']:
|
||||
q_objects &= Q(park_id=filters['park_id'])
|
||||
|
||||
# Category filters
|
||||
if 'category' in filters and filters['category']:
|
||||
q_objects &= Q(category__in=filters['category'])
|
||||
|
||||
# Status filters
|
||||
if 'status' in filters and filters['status']:
|
||||
q_objects &= Q(status__in=filters['status'])
|
||||
|
||||
# Company filters
|
||||
if 'manufacturer_ids' in filters and filters['manufacturer_ids']:
|
||||
q_objects &= Q(manufacturer_id__in=filters['manufacturer_ids'])
|
||||
|
||||
if 'designer_ids' in filters and filters['designer_ids']:
|
||||
q_objects &= Q(designer_id__in=filters['designer_ids'])
|
||||
|
||||
# Ride model filters
|
||||
if 'ride_model_ids' in filters and filters['ride_model_ids']:
|
||||
q_objects &= Q(ride_model_id__in=filters['ride_model_ids'])
|
||||
|
||||
# Opening year filters using computed opening_year field
|
||||
if 'opening_year' in filters and filters['opening_year']:
|
||||
q_objects &= Q(opening_year=filters['opening_year'])
|
||||
|
||||
if 'min_opening_year' in filters and filters['min_opening_year']:
|
||||
q_objects &= Q(opening_year__gte=filters['min_opening_year'])
|
||||
|
||||
if 'max_opening_year' in filters and filters['max_opening_year']:
|
||||
q_objects &= Q(opening_year__lte=filters['max_opening_year'])
|
||||
|
||||
# Rating filters
|
||||
if 'min_rating' in filters and filters['min_rating']:
|
||||
q_objects &= Q(average_rating__gte=filters['min_rating'])
|
||||
|
||||
if 'max_rating' in filters and filters['max_rating']:
|
||||
q_objects &= Q(average_rating__lte=filters['max_rating'])
|
||||
|
||||
# Height requirement filters
|
||||
if 'min_height_requirement' in filters and filters['min_height_requirement']:
|
||||
q_objects &= Q(min_height_in__gte=filters['min_height_requirement'])
|
||||
|
||||
if 'max_height_requirement' in filters and filters['max_height_requirement']:
|
||||
q_objects &= Q(max_height_in__lte=filters['max_height_requirement'])
|
||||
|
||||
# Capacity filters
|
||||
if 'min_capacity' in filters and filters['min_capacity']:
|
||||
q_objects &= Q(capacity_per_hour__gte=filters['min_capacity'])
|
||||
|
||||
if 'max_capacity' in filters and filters['max_capacity']:
|
||||
q_objects &= Q(capacity_per_hour__lte=filters['max_capacity'])
|
||||
|
||||
# Roller coaster specific filters
|
||||
if 'roller_coaster_type' in filters and filters['roller_coaster_type']:
|
||||
q_objects &= Q(coaster_stats__roller_coaster_type__in=filters['roller_coaster_type'])
|
||||
|
||||
if 'track_material' in filters and filters['track_material']:
|
||||
q_objects &= Q(coaster_stats__track_material__in=filters['track_material'])
|
||||
|
||||
if 'propulsion_system' in filters and filters['propulsion_system']:
|
||||
q_objects &= Q(coaster_stats__propulsion_system__in=filters['propulsion_system'])
|
||||
|
||||
# Roller coaster height filters
|
||||
if 'min_height_ft' in filters and filters['min_height_ft']:
|
||||
q_objects &= Q(coaster_stats__height_ft__gte=filters['min_height_ft'])
|
||||
|
||||
if 'max_height_ft' in filters and filters['max_height_ft']:
|
||||
q_objects &= Q(coaster_stats__height_ft__lte=filters['max_height_ft'])
|
||||
|
||||
# Roller coaster speed filters
|
||||
if 'min_speed_mph' in filters and filters['min_speed_mph']:
|
||||
q_objects &= Q(coaster_stats__speed_mph__gte=filters['min_speed_mph'])
|
||||
|
||||
if 'max_speed_mph' in filters and filters['max_speed_mph']:
|
||||
q_objects &= Q(coaster_stats__speed_mph__lte=filters['max_speed_mph'])
|
||||
|
||||
# Inversion filters
|
||||
if 'min_inversions' in filters and filters['min_inversions']:
|
||||
q_objects &= Q(coaster_stats__inversions__gte=filters['min_inversions'])
|
||||
|
||||
if 'max_inversions' in filters and filters['max_inversions']:
|
||||
q_objects &= Q(coaster_stats__inversions__lte=filters['max_inversions'])
|
||||
|
||||
if 'has_inversions' in filters and filters['has_inversions'] is not None:
|
||||
if filters['has_inversions']:
|
||||
q_objects &= Q(coaster_stats__inversions__gt=0)
|
||||
else:
|
||||
q_objects &= Q(coaster_stats__inversions=0)
|
||||
|
||||
# Apply filters and ordering
|
||||
queryset = queryset.filter(q_objects)
|
||||
|
||||
# Apply ordering
|
||||
ordering = filters.get('ordering', 'name')
|
||||
if ordering in ['height_ft', '-height_ft', 'speed_mph', '-speed_mph']:
|
||||
# For coaster stats ordering, we need to join and order by the stats
|
||||
ordering_field = ordering.replace('height_ft', 'coaster_stats__height_ft').replace('speed_mph', 'coaster_stats__speed_mph')
|
||||
queryset = queryset.order_by(ordering_field)
|
||||
else:
|
||||
queryset = queryset.order_by(ordering)
|
||||
|
||||
return queryset
|
||||
|
||||
def _serialize_rides(self, rides: List) -> List[Dict[str, Any]]:
|
||||
"""Serialize ride objects to dictionaries."""
|
||||
serialized = []
|
||||
|
||||
for ride in rides:
|
||||
# Basic ride data
|
||||
ride_data = {
|
||||
'id': ride.id,
|
||||
'name': ride.name,
|
||||
'slug': ride.slug,
|
||||
'description': ride.description,
|
||||
'category': ride.category,
|
||||
'status': ride.status,
|
||||
'opening_date': ride.opening_date.isoformat() if ride.opening_date else None,
|
||||
'closing_date': ride.closing_date.isoformat() if ride.closing_date else None,
|
||||
'opening_year': ride.opening_year,
|
||||
'min_height_in': ride.min_height_in,
|
||||
'max_height_in': ride.max_height_in,
|
||||
'capacity_per_hour': ride.capacity_per_hour,
|
||||
'ride_duration_seconds': ride.ride_duration_seconds,
|
||||
'average_rating': float(ride.average_rating) if ride.average_rating else None,
|
||||
'url': ride.url,
|
||||
'park_url': ride.park_url,
|
||||
'created_at': ride.created_at.isoformat(),
|
||||
'updated_at': ride.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
# Park data
|
||||
if ride.park:
|
||||
ride_data['park'] = {
|
||||
'id': ride.park.id,
|
||||
'name': ride.park.name,
|
||||
'slug': ride.park.slug,
|
||||
}
|
||||
|
||||
# Park location data
|
||||
if hasattr(ride.park, 'location') and ride.park.location:
|
||||
ride_data['park']['location'] = {
|
||||
'city': ride.park.location.city,
|
||||
'state': ride.park.location.state,
|
||||
'country': ride.park.location.country,
|
||||
}
|
||||
|
||||
# Park area data
|
||||
if ride.park_area:
|
||||
ride_data['park_area'] = {
|
||||
'id': ride.park_area.id,
|
||||
'name': ride.park_area.name,
|
||||
'slug': ride.park_area.slug,
|
||||
}
|
||||
|
||||
# Company data
|
||||
if ride.manufacturer:
|
||||
ride_data['manufacturer'] = {
|
||||
'id': ride.manufacturer.id,
|
||||
'name': ride.manufacturer.name,
|
||||
'slug': ride.manufacturer.slug,
|
||||
}
|
||||
|
||||
if ride.designer:
|
||||
ride_data['designer'] = {
|
||||
'id': ride.designer.id,
|
||||
'name': ride.designer.name,
|
||||
'slug': ride.designer.slug,
|
||||
}
|
||||
|
||||
# Ride model data
|
||||
if ride.ride_model:
|
||||
ride_data['ride_model'] = {
|
||||
'id': ride.ride_model.id,
|
||||
'name': ride.ride_model.name,
|
||||
'slug': ride.ride_model.slug,
|
||||
'category': ride.ride_model.category,
|
||||
}
|
||||
|
||||
if ride.ride_model.manufacturer:
|
||||
ride_data['ride_model']['manufacturer'] = {
|
||||
'id': ride.ride_model.manufacturer.id,
|
||||
'name': ride.ride_model.manufacturer.name,
|
||||
'slug': ride.ride_model.manufacturer.slug,
|
||||
}
|
||||
|
||||
# Roller coaster stats
|
||||
if hasattr(ride, 'coaster_stats') and ride.coaster_stats:
|
||||
stats = ride.coaster_stats
|
||||
ride_data['coaster_stats'] = {
|
||||
'height_ft': float(stats.height_ft) if stats.height_ft else None,
|
||||
'length_ft': float(stats.length_ft) if stats.length_ft else None,
|
||||
'speed_mph': float(stats.speed_mph) if stats.speed_mph else None,
|
||||
'inversions': stats.inversions,
|
||||
'ride_time_seconds': stats.ride_time_seconds,
|
||||
'track_type': stats.track_type,
|
||||
'track_material': stats.track_material,
|
||||
'roller_coaster_type': stats.roller_coaster_type,
|
||||
'max_drop_height_ft': float(stats.max_drop_height_ft) if stats.max_drop_height_ft else None,
|
||||
'propulsion_system': stats.propulsion_system,
|
||||
'train_style': stats.train_style,
|
||||
'trains_count': stats.trains_count,
|
||||
'cars_per_train': stats.cars_per_train,
|
||||
'seats_per_car': stats.seats_per_car,
|
||||
}
|
||||
|
||||
serialized.append(ride_data)
|
||||
|
||||
return serialized
|
||||
|
||||
def _generate_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Generate comprehensive filter metadata."""
|
||||
from apps.rides.models import Ride, RideModel
|
||||
from apps.rides.models.company import Company
|
||||
from apps.rides.models.rides import RollerCoasterStats
|
||||
|
||||
# Get unique values from database with counts
|
||||
parks_data = list(Ride.objects.exclude(
|
||||
park__isnull=True
|
||||
).select_related('park').values(
|
||||
'park__id', 'park__name', 'park__slug'
|
||||
).annotate(count=models.Count('id')).distinct().order_by('park__name'))
|
||||
|
||||
park_areas_data = list(Ride.objects.exclude(
|
||||
park_area__isnull=True
|
||||
).select_related('park_area').values(
|
||||
'park_area__id', 'park_area__name', 'park_area__slug'
|
||||
).annotate(count=models.Count('id')).distinct().order_by('park_area__name'))
|
||||
|
||||
manufacturers_data = list(Company.objects.filter(
|
||||
roles__contains=['MANUFACTURER']
|
||||
).values('id', 'name', 'slug').annotate(
|
||||
count=models.Count('manufactured_rides')
|
||||
).order_by('name'))
|
||||
|
||||
designers_data = list(Company.objects.filter(
|
||||
roles__contains=['DESIGNER']
|
||||
).values('id', 'name', 'slug').annotate(
|
||||
count=models.Count('designed_rides')
|
||||
).order_by('name'))
|
||||
|
||||
ride_models_data = list(RideModel.objects.select_related(
|
||||
'manufacturer'
|
||||
).values(
|
||||
'id', 'name', 'slug', 'manufacturer__name', 'manufacturer__slug', 'category'
|
||||
).annotate(count=models.Count('rides')).order_by('manufacturer__name', 'name'))
|
||||
|
||||
# Get categories and statuses with counts
|
||||
categories_data = list(Ride.objects.values('category').annotate(
|
||||
count=models.Count('id')
|
||||
).order_by('category'))
|
||||
|
||||
statuses_data = list(Ride.objects.values('status').annotate(
|
||||
count=models.Count('id')
|
||||
).order_by('status'))
|
||||
|
||||
# Get roller coaster specific data with counts
|
||||
rc_types_data = list(RollerCoasterStats.objects.values('roller_coaster_type').annotate(
|
||||
count=models.Count('ride')
|
||||
).exclude(roller_coaster_type__isnull=True).order_by('roller_coaster_type'))
|
||||
|
||||
track_materials_data = list(RollerCoasterStats.objects.values('track_material').annotate(
|
||||
count=models.Count('ride')
|
||||
).exclude(track_material__isnull=True).order_by('track_material'))
|
||||
|
||||
propulsion_systems_data = list(RollerCoasterStats.objects.values('propulsion_system').annotate(
|
||||
count=models.Count('ride')
|
||||
).exclude(propulsion_system__isnull=True).order_by('propulsion_system'))
|
||||
|
||||
# Convert to frontend-expected format with value/label/count
|
||||
categories = [
|
||||
{
|
||||
'value': item['category'],
|
||||
'label': self._get_category_label(item['category']),
|
||||
'count': item['count']
|
||||
}
|
||||
for item in categories_data
|
||||
]
|
||||
|
||||
statuses = [
|
||||
{
|
||||
'value': item['status'],
|
||||
'label': self._get_status_label(item['status']),
|
||||
'count': item['count']
|
||||
}
|
||||
for item in statuses_data
|
||||
]
|
||||
|
||||
roller_coaster_types = [
|
||||
{
|
||||
'value': item['roller_coaster_type'],
|
||||
'label': self._get_rc_type_label(item['roller_coaster_type']),
|
||||
'count': item['count']
|
||||
}
|
||||
for item in rc_types_data
|
||||
]
|
||||
|
||||
track_materials = [
|
||||
{
|
||||
'value': item['track_material'],
|
||||
'label': self._get_track_material_label(item['track_material']),
|
||||
'count': item['count']
|
||||
}
|
||||
for item in track_materials_data
|
||||
]
|
||||
|
||||
propulsion_systems = [
|
||||
{
|
||||
'value': item['propulsion_system'],
|
||||
'label': self._get_propulsion_system_label(item['propulsion_system']),
|
||||
'count': item['count']
|
||||
}
|
||||
for item in propulsion_systems_data
|
||||
]
|
||||
|
||||
# Convert other data to expected format
|
||||
parks = [
|
||||
{
|
||||
'value': str(item['park__id']),
|
||||
'label': item['park__name'],
|
||||
'count': item['count']
|
||||
}
|
||||
for item in parks_data
|
||||
]
|
||||
|
||||
park_areas = [
|
||||
{
|
||||
'value': str(item['park_area__id']),
|
||||
'label': item['park_area__name'],
|
||||
'count': item['count']
|
||||
}
|
||||
for item in park_areas_data
|
||||
]
|
||||
|
||||
manufacturers = [
|
||||
{
|
||||
'value': str(item['id']),
|
||||
'label': item['name'],
|
||||
'count': item['count']
|
||||
}
|
||||
for item in manufacturers_data
|
||||
]
|
||||
|
||||
designers = [
|
||||
{
|
||||
'value': str(item['id']),
|
||||
'label': item['name'],
|
||||
'count': item['count']
|
||||
}
|
||||
for item in designers_data
|
||||
]
|
||||
|
||||
ride_models = [
|
||||
{
|
||||
'value': str(item['id']),
|
||||
'label': f"{item['manufacturer__name']} {item['name']}",
|
||||
'count': item['count']
|
||||
}
|
||||
for item in ride_models_data
|
||||
]
|
||||
|
||||
# Calculate ranges from actual data
|
||||
ride_stats = Ride.objects.aggregate(
|
||||
min_rating=Min('average_rating'),
|
||||
max_rating=Max('average_rating'),
|
||||
min_height_req=Min('min_height_in'),
|
||||
max_height_req=Max('max_height_in'),
|
||||
min_capacity=Min('capacity_per_hour'),
|
||||
max_capacity=Max('capacity_per_hour'),
|
||||
min_duration=Min('ride_duration_seconds'),
|
||||
max_duration=Max('ride_duration_seconds'),
|
||||
min_year=Min('opening_year'),
|
||||
max_year=Max('opening_year'),
|
||||
)
|
||||
|
||||
# Calculate roller coaster specific ranges
|
||||
coaster_stats = RollerCoasterStats.objects.aggregate(
|
||||
min_height_ft=Min('height_ft'),
|
||||
max_height_ft=Max('height_ft'),
|
||||
min_length_ft=Min('length_ft'),
|
||||
max_length_ft=Max('length_ft'),
|
||||
min_speed_mph=Min('speed_mph'),
|
||||
max_speed_mph=Max('speed_mph'),
|
||||
min_inversions=Min('inversions'),
|
||||
max_inversions=Max('inversions'),
|
||||
min_ride_time=Min('ride_time_seconds'),
|
||||
max_ride_time=Max('ride_time_seconds'),
|
||||
min_drop_height=Min('max_drop_height_ft'),
|
||||
max_drop_height=Max('max_drop_height_ft'),
|
||||
min_trains=Min('trains_count'),
|
||||
max_trains=Max('trains_count'),
|
||||
min_cars=Min('cars_per_train'),
|
||||
max_cars=Max('cars_per_train'),
|
||||
min_seats=Min('seats_per_car'),
|
||||
max_seats=Max('seats_per_car'),
|
||||
)
|
||||
|
||||
return {
|
||||
'categorical': {
|
||||
'categories': categories,
|
||||
'statuses': statuses,
|
||||
'roller_coaster_types': roller_coaster_types,
|
||||
'track_materials': track_materials,
|
||||
'propulsion_systems': propulsion_systems,
|
||||
'parks': parks,
|
||||
'park_areas': park_areas,
|
||||
'manufacturers': manufacturers,
|
||||
'designers': designers,
|
||||
'ride_models': ride_models,
|
||||
},
|
||||
'ranges': {
|
||||
'rating': {
|
||||
'min': float(ride_stats['min_rating'] or 1),
|
||||
'max': float(ride_stats['max_rating'] or 10),
|
||||
'step': 0.1,
|
||||
'unit': 'stars'
|
||||
},
|
||||
'height_requirement': {
|
||||
'min': ride_stats['min_height_req'] or 30,
|
||||
'max': ride_stats['max_height_req'] or 90,
|
||||
'step': 1,
|
||||
'unit': 'inches'
|
||||
},
|
||||
'capacity': {
|
||||
'min': ride_stats['min_capacity'] or 0,
|
||||
'max': ride_stats['max_capacity'] or 5000,
|
||||
'step': 50,
|
||||
'unit': 'riders/hour'
|
||||
},
|
||||
'ride_duration': {
|
||||
'min': ride_stats['min_duration'] or 0,
|
||||
'max': ride_stats['max_duration'] or 600,
|
||||
'step': 10,
|
||||
'unit': 'seconds'
|
||||
},
|
||||
'opening_year': {
|
||||
'min': ride_stats['min_year'] or 1800,
|
||||
'max': ride_stats['max_year'] or 2030,
|
||||
'step': 1,
|
||||
'unit': 'year'
|
||||
},
|
||||
'height_ft': {
|
||||
'min': float(coaster_stats['min_height_ft'] or 0),
|
||||
'max': float(coaster_stats['max_height_ft'] or 500),
|
||||
'step': 5,
|
||||
'unit': 'feet'
|
||||
},
|
||||
'length_ft': {
|
||||
'min': float(coaster_stats['min_length_ft'] or 0),
|
||||
'max': float(coaster_stats['max_length_ft'] or 10000),
|
||||
'step': 100,
|
||||
'unit': 'feet'
|
||||
},
|
||||
'speed_mph': {
|
||||
'min': float(coaster_stats['min_speed_mph'] or 0),
|
||||
'max': float(coaster_stats['max_speed_mph'] or 150),
|
||||
'step': 5,
|
||||
'unit': 'mph'
|
||||
},
|
||||
'inversions': {
|
||||
'min': coaster_stats['min_inversions'] or 0,
|
||||
'max': coaster_stats['max_inversions'] or 20,
|
||||
'step': 1,
|
||||
'unit': 'inversions'
|
||||
},
|
||||
},
|
||||
'total_count': Ride.objects.count(),
|
||||
}
|
||||
|
||||
def _get_category_label(self, category: str) -> str:
|
||||
"""Convert category code to human-readable label."""
|
||||
category_labels = {
|
||||
'RC': 'Roller Coaster',
|
||||
'DR': 'Dark Ride',
|
||||
'FR': 'Flat Ride',
|
||||
'WR': 'Water Ride',
|
||||
'TR': 'Transport Ride',
|
||||
'OT': 'Other',
|
||||
}
|
||||
if category in category_labels:
|
||||
return category_labels[category]
|
||||
else:
|
||||
raise ValueError(f"Unknown ride category: {category}")
|
||||
|
||||
def _get_status_label(self, status: str) -> str:
|
||||
"""Convert status code to human-readable label."""
|
||||
status_labels = {
|
||||
'OPERATING': 'Operating',
|
||||
'CLOSED_TEMP': 'Temporarily Closed',
|
||||
'SBNO': 'Standing But Not Operating',
|
||||
'CLOSING': 'Closing Soon',
|
||||
'CLOSED_PERM': 'Permanently Closed',
|
||||
'UNDER_CONSTRUCTION': 'Under Construction',
|
||||
'DEMOLISHED': 'Demolished',
|
||||
'RELOCATED': 'Relocated',
|
||||
}
|
||||
if status in status_labels:
|
||||
return status_labels[status]
|
||||
else:
|
||||
raise ValueError(f"Unknown ride status: {status}")
|
||||
|
||||
def _get_rc_type_label(self, rc_type: str) -> str:
|
||||
"""Convert roller coaster type to human-readable label."""
|
||||
rc_type_labels = {
|
||||
'SITDOWN': 'Sit Down',
|
||||
'INVERTED': 'Inverted',
|
||||
'SUSPENDED': 'Suspended',
|
||||
'FLOORLESS': 'Floorless',
|
||||
'FLYING': 'Flying',
|
||||
'WING': 'Wing',
|
||||
'DIVE': 'Dive',
|
||||
'SPINNING': 'Spinning',
|
||||
'WILD_MOUSE': 'Wild Mouse',
|
||||
'BOBSLED': 'Bobsled',
|
||||
'PIPELINE': 'Pipeline',
|
||||
'FOURTH_DIMENSION': '4th Dimension',
|
||||
'FAMILY': 'Family',
|
||||
}
|
||||
if rc_type in rc_type_labels:
|
||||
return rc_type_labels[rc_type]
|
||||
else:
|
||||
raise ValueError(f"Unknown roller coaster type: {rc_type}")
|
||||
|
||||
def _get_track_material_label(self, material: str) -> str:
|
||||
"""Convert track material to human-readable label."""
|
||||
material_labels = {
|
||||
'STEEL': 'Steel',
|
||||
'WOOD': 'Wood',
|
||||
'HYBRID': 'Hybrid (Steel/Wood)',
|
||||
}
|
||||
if material in material_labels:
|
||||
return material_labels[material]
|
||||
else:
|
||||
raise ValueError(f"Unknown track material: {material}")
|
||||
|
||||
def _get_propulsion_system_label(self, propulsion_system: str) -> str:
|
||||
"""Convert propulsion system to human-readable label."""
|
||||
propulsion_labels = {
|
||||
'CHAIN': 'Chain Lift',
|
||||
'LSM': 'Linear Synchronous Motor',
|
||||
'LIM': 'Linear Induction Motor',
|
||||
'HYDRAULIC': 'Hydraulic Launch',
|
||||
'PNEUMATIC': 'Pneumatic Launch',
|
||||
'CABLE': 'Cable Lift',
|
||||
'FLYWHEEL': 'Flywheel Launch',
|
||||
'GRAVITY': 'Gravity',
|
||||
'NONE': 'No Propulsion System',
|
||||
}
|
||||
if propulsion_system in propulsion_labels:
|
||||
return propulsion_labels[propulsion_system]
|
||||
else:
|
||||
raise ValueError(f"Unknown propulsion system: {propulsion_system}")
|
||||
@@ -1,359 +0,0 @@
|
||||
"""
|
||||
Rides-specific location services with OpenStreetMap integration.
|
||||
Handles location management for individual rides within parks.
|
||||
"""
|
||||
|
||||
import requests
|
||||
from typing import List, Dict, Any, Optional
|
||||
from django.db import transaction
|
||||
import logging
|
||||
|
||||
from ..models import RideLocation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RideLocationService:
|
||||
"""
|
||||
Location service specifically for rides using OpenStreetMap integration.
|
||||
Focuses on precise positioning within parks and navigation assistance.
|
||||
"""
|
||||
|
||||
NOMINATIM_BASE_URL = "https://nominatim.openstreetmap.org"
|
||||
USER_AGENT = "ThrillWiki/1.0 (https://thrillwiki.com)"
|
||||
|
||||
@classmethod
|
||||
def create_ride_location(
|
||||
cls,
|
||||
*,
|
||||
ride,
|
||||
latitude: Optional[float] = None,
|
||||
longitude: Optional[float] = None,
|
||||
park_area: str = "",
|
||||
notes: str = "",
|
||||
entrance_notes: str = "",
|
||||
accessibility_notes: str = "",
|
||||
) -> RideLocation:
|
||||
"""
|
||||
Create a location for a ride within a park.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
latitude: Latitude coordinate (optional for rides)
|
||||
longitude: Longitude coordinate (optional for rides)
|
||||
park_area: Themed area within the park
|
||||
notes: General location notes
|
||||
entrance_notes: Entrance and navigation notes
|
||||
accessibility_notes: Accessibility information
|
||||
|
||||
Returns:
|
||||
Created RideLocation instance
|
||||
"""
|
||||
with transaction.atomic():
|
||||
ride_location = RideLocation(
|
||||
ride=ride,
|
||||
park_area=park_area,
|
||||
notes=notes,
|
||||
entrance_notes=entrance_notes,
|
||||
accessibility_notes=accessibility_notes,
|
||||
)
|
||||
|
||||
# Set coordinates if provided
|
||||
if latitude is not None and longitude is not None:
|
||||
ride_location.set_coordinates(latitude, longitude)
|
||||
|
||||
ride_location.full_clean()
|
||||
ride_location.save()
|
||||
|
||||
return ride_location
|
||||
|
||||
@classmethod
|
||||
def update_ride_location(
|
||||
cls, ride_location: RideLocation, **updates
|
||||
) -> RideLocation:
|
||||
"""
|
||||
Update ride location with validation.
|
||||
|
||||
Args:
|
||||
ride_location: RideLocation instance to update
|
||||
**updates: Fields to update
|
||||
|
||||
Returns:
|
||||
Updated RideLocation instance
|
||||
"""
|
||||
with transaction.atomic():
|
||||
# Handle coordinates separately
|
||||
latitude = updates.pop("latitude", None)
|
||||
longitude = updates.pop("longitude", None)
|
||||
|
||||
# Update regular fields
|
||||
for field, value in updates.items():
|
||||
if hasattr(ride_location, field):
|
||||
setattr(ride_location, field, value)
|
||||
|
||||
# Update coordinates if provided
|
||||
if latitude is not None and longitude is not None:
|
||||
ride_location.set_coordinates(latitude, longitude)
|
||||
|
||||
ride_location.full_clean()
|
||||
ride_location.save()
|
||||
|
||||
return ride_location
|
||||
|
||||
@classmethod
|
||||
def find_rides_in_area(cls, park, park_area: str) -> List[RideLocation]:
|
||||
"""
|
||||
Find all rides in a specific park area.
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
park_area: Name of the park area/land
|
||||
|
||||
Returns:
|
||||
List of RideLocation instances in the area
|
||||
"""
|
||||
return list(
|
||||
RideLocation.objects.filter(ride__park=park, park_area__icontains=park_area)
|
||||
.select_related("ride")
|
||||
.order_by("ride__name")
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def find_nearby_rides(
|
||||
cls, latitude: float, longitude: float, park=None, radius_meters: float = 500
|
||||
) -> List[RideLocation]:
|
||||
"""
|
||||
Find rides near given coordinates using PostGIS.
|
||||
Useful for finding rides near a specific location within a park.
|
||||
|
||||
Args:
|
||||
latitude: Center latitude
|
||||
longitude: Center longitude
|
||||
park: Optional park to limit search to
|
||||
radius_meters: Search radius in meters (default: 500m)
|
||||
|
||||
Returns:
|
||||
List of nearby RideLocation instances
|
||||
"""
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import Distance
|
||||
|
||||
center_point = Point(longitude, latitude, srid=4326)
|
||||
|
||||
queryset = RideLocation.objects.filter(
|
||||
point__distance_lte=(center_point, Distance(m=radius_meters)),
|
||||
point__isnull=False,
|
||||
)
|
||||
|
||||
if park:
|
||||
queryset = queryset.filter(ride__park=park)
|
||||
|
||||
return list(
|
||||
queryset.select_related("ride", "ride__park").order_by("point__distance")
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_ride_navigation_info(cls, ride_location: RideLocation) -> Dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive navigation information for a ride.
|
||||
|
||||
Args:
|
||||
ride_location: RideLocation instance
|
||||
|
||||
Returns:
|
||||
Dictionary with navigation information
|
||||
"""
|
||||
info = {
|
||||
"ride_name": ride_location.ride.name,
|
||||
"park_name": ride_location.ride.park.name,
|
||||
"park_area": ride_location.park_area,
|
||||
"has_coordinates": ride_location.has_coordinates,
|
||||
"entrance_notes": ride_location.entrance_notes,
|
||||
"accessibility_notes": ride_location.accessibility_notes,
|
||||
"general_notes": ride_location.notes,
|
||||
}
|
||||
|
||||
# Add coordinate information if available
|
||||
if ride_location.has_coordinates:
|
||||
info.update(
|
||||
{
|
||||
"latitude": ride_location.latitude,
|
||||
"longitude": ride_location.longitude,
|
||||
"coordinates": ride_location.coordinates,
|
||||
}
|
||||
)
|
||||
|
||||
# Calculate distance to park entrance if park has location
|
||||
park_location = getattr(ride_location.ride.park, "location", None)
|
||||
if park_location and park_location.point:
|
||||
distance_km = ride_location.distance_to_park_location()
|
||||
if distance_km is not None:
|
||||
info["distance_from_park_entrance_km"] = round(distance_km, 2)
|
||||
|
||||
return info
|
||||
|
||||
@classmethod
|
||||
def estimate_ride_coordinates_from_park(
|
||||
cls,
|
||||
ride_location: RideLocation,
|
||||
area_offset_meters: Optional[Dict[str, List[float]]] = None,
|
||||
) -> Optional[List[float]]:
|
||||
"""
|
||||
Estimate ride coordinates based on park location and area.
|
||||
Useful when exact ride coordinates are not available.
|
||||
|
||||
Args:
|
||||
ride_location: RideLocation instance
|
||||
area_offset_meters: Dictionary mapping area names to [north_offset, east_offset] in meters
|
||||
|
||||
Returns:
|
||||
Estimated [latitude, longitude] list or None
|
||||
"""
|
||||
park_location = getattr(ride_location.ride.park, "location", None)
|
||||
if not park_location or not park_location.point:
|
||||
return None
|
||||
|
||||
# Default area offsets (rough estimates for common themed areas)
|
||||
default_offsets = {
|
||||
"main street": (0, 0), # Usually at entrance
|
||||
"fantasyland": (200, 100), # Often north-east
|
||||
"tomorrowland": (100, 200), # Often east
|
||||
"frontierland": (-100, -200), # Often south-west
|
||||
"adventureland": (-200, 100), # Often south-east
|
||||
"new orleans square": (-150, -100),
|
||||
"critter country": (-200, -200),
|
||||
"galaxy's edge": (300, 300), # Often on periphery
|
||||
"cars land": (200, -200),
|
||||
"pixar pier": (0, 300), # Often waterfront
|
||||
}
|
||||
|
||||
offsets = area_offset_meters or default_offsets
|
||||
|
||||
# Find matching area offset
|
||||
area_lower = ride_location.park_area.lower()
|
||||
offset = None
|
||||
|
||||
for area_name, area_offset in offsets.items():
|
||||
if area_name in area_lower:
|
||||
offset = area_offset
|
||||
break
|
||||
|
||||
if not offset:
|
||||
# Default small random offset if no specific area match
|
||||
import random
|
||||
|
||||
offset = (random.randint(-100, 100), random.randint(-100, 100))
|
||||
|
||||
# Convert meter offsets to coordinate offsets
|
||||
# Rough conversion: 1 degree latitude ≈ 111,000 meters
|
||||
# 1 degree longitude varies by latitude, but we'll use a rough approximation
|
||||
lat_offset = offset[0] / 111000 # North offset in degrees
|
||||
lon_offset = offset[1] / (
|
||||
111000 * abs(park_location.latitude) * 0.01
|
||||
) # East offset
|
||||
|
||||
estimated_lat = park_location.latitude + lat_offset
|
||||
estimated_lon = park_location.longitude + lon_offset
|
||||
|
||||
return [estimated_lat, estimated_lon]
|
||||
|
||||
@classmethod
|
||||
def bulk_update_ride_areas_from_osm(cls, park) -> int:
|
||||
"""
|
||||
Bulk update ride locations for a park using OSM data.
|
||||
Attempts to find more precise locations for rides within the park.
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
|
||||
Returns:
|
||||
Number of ride locations updated
|
||||
"""
|
||||
updated_count = 0
|
||||
park_location = getattr(park, "location", None)
|
||||
|
||||
if not park_location or not park_location.point:
|
||||
return updated_count
|
||||
|
||||
# Get all rides in the park that don't have precise coordinates
|
||||
ride_locations = RideLocation.objects.filter(
|
||||
ride__park=park, point__isnull=True
|
||||
).select_related("ride")
|
||||
|
||||
for ride_location in ride_locations:
|
||||
# Try to search for the specific ride within the park area
|
||||
search_query = f"{ride_location.ride.name} {park.name}"
|
||||
|
||||
try:
|
||||
# Search for the ride specifically
|
||||
params = {
|
||||
"q": search_query,
|
||||
"format": "json",
|
||||
"limit": 5,
|
||||
"addressdetails": 1,
|
||||
"bounded": 1, # Restrict to viewbox
|
||||
# Create a bounding box around the park (roughly 2km radius)
|
||||
"viewbox": f"{park_location.longitude - 0.02},{park_location.latitude + 0.02},{park_location.longitude + 0.02},{park_location.latitude - 0.02}",
|
||||
}
|
||||
|
||||
headers = {"User-Agent": cls.USER_AGENT}
|
||||
|
||||
response = requests.get(
|
||||
f"{cls.NOMINATIM_BASE_URL}/search",
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
results = response.json()
|
||||
|
||||
# Look for results that might be the ride
|
||||
for result in results:
|
||||
display_name = result.get("display_name", "").lower()
|
||||
if (
|
||||
ride_location.ride.name.lower() in display_name
|
||||
and park.name.lower() in display_name
|
||||
):
|
||||
# Update the ride location
|
||||
ride_location.set_coordinates(
|
||||
float(result["lat"]), float(result["lon"])
|
||||
)
|
||||
ride_location.save()
|
||||
updated_count += 1
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error updating ride location for {ride_location.ride.name}: {str(e)}"
|
||||
)
|
||||
continue
|
||||
|
||||
return updated_count
|
||||
|
||||
@classmethod
|
||||
def generate_park_area_map(cls, park) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Generate a map of park areas and the rides in each area.
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
|
||||
Returns:
|
||||
Dictionary mapping area names to lists of ride names
|
||||
"""
|
||||
area_map = {}
|
||||
|
||||
ride_locations = (
|
||||
RideLocation.objects.filter(ride__park=park)
|
||||
.select_related("ride")
|
||||
.order_by("park_area", "ride__name")
|
||||
)
|
||||
|
||||
for ride_location in ride_locations:
|
||||
area = ride_location.park_area or "Unknown Area"
|
||||
if area not in area_map:
|
||||
area_map[area] = []
|
||||
area_map[area].append(ride_location.ride.name)
|
||||
|
||||
return area_map
|
||||
@@ -1,305 +0,0 @@
|
||||
"""
|
||||
Ride-specific media service for ThrillWiki.
|
||||
|
||||
This module provides media management functionality specific to rides.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db import transaction
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.core.services.media_service import MediaService
|
||||
from ..models import Ride, RidePhoto
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RideMediaService:
|
||||
"""Service for managing ride-specific media operations."""
|
||||
|
||||
@staticmethod
|
||||
def upload_photo(
|
||||
ride: Ride,
|
||||
image_file: UploadedFile,
|
||||
user: User,
|
||||
caption: str = "",
|
||||
alt_text: str = "",
|
||||
photo_type: str = "exterior",
|
||||
is_primary: bool = False,
|
||||
auto_approve: bool = False,
|
||||
) -> RidePhoto:
|
||||
"""
|
||||
Upload a photo for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
image_file: Uploaded image file
|
||||
user: User uploading the photo
|
||||
caption: Photo caption
|
||||
alt_text: Alt text for accessibility
|
||||
photo_type: Type of photo (exterior, queue, station, etc.)
|
||||
is_primary: Whether this should be the primary photo
|
||||
auto_approve: Whether to auto-approve the photo
|
||||
|
||||
Returns:
|
||||
Created RidePhoto instance
|
||||
|
||||
Raises:
|
||||
ValueError: If image validation fails
|
||||
"""
|
||||
# Validate image file
|
||||
is_valid, error_message = MediaService.validate_image_file(image_file)
|
||||
if not is_valid:
|
||||
raise ValueError(error_message)
|
||||
|
||||
# Process image
|
||||
processed_image = MediaService.process_image(image_file)
|
||||
|
||||
with transaction.atomic():
|
||||
# Create photo instance
|
||||
photo = RidePhoto(
|
||||
ride=ride,
|
||||
image=processed_image,
|
||||
caption=caption or MediaService.generate_default_caption(user.username),
|
||||
alt_text=alt_text,
|
||||
photo_type=photo_type,
|
||||
is_primary=is_primary,
|
||||
is_approved=auto_approve,
|
||||
uploaded_by=user,
|
||||
)
|
||||
|
||||
# Extract EXIF date
|
||||
photo.date_taken = MediaService.extract_exif_date(processed_image)
|
||||
|
||||
photo.save()
|
||||
|
||||
logger.info(f"Photo uploaded for ride {ride.slug} by user {user.username}")
|
||||
return photo
|
||||
|
||||
@staticmethod
|
||||
def get_ride_photos(
|
||||
ride: Ride,
|
||||
approved_only: bool = True,
|
||||
primary_first: bool = True,
|
||||
photo_type: Optional[str] = None,
|
||||
) -> List[RidePhoto]:
|
||||
"""
|
||||
Get photos for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
approved_only: Whether to only return approved photos
|
||||
primary_first: Whether to order primary photos first
|
||||
photo_type: Filter by photo type (optional)
|
||||
|
||||
Returns:
|
||||
List of RidePhoto instances
|
||||
"""
|
||||
queryset = ride.photos.all()
|
||||
|
||||
if approved_only:
|
||||
queryset = queryset.filter(is_approved=True)
|
||||
|
||||
if photo_type:
|
||||
queryset = queryset.filter(photo_type=photo_type)
|
||||
|
||||
if primary_first:
|
||||
queryset = queryset.order_by("-is_primary", "-created_at")
|
||||
else:
|
||||
queryset = queryset.order_by("-created_at")
|
||||
|
||||
return list(queryset)
|
||||
|
||||
@staticmethod
|
||||
def get_primary_photo(ride: Ride) -> Optional[RidePhoto]:
|
||||
"""
|
||||
Get the primary photo for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
|
||||
Returns:
|
||||
Primary RidePhoto instance or None
|
||||
"""
|
||||
try:
|
||||
return ride.photos.filter(is_primary=True, is_approved=True).first()
|
||||
except RidePhoto.DoesNotExist:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_photos_by_type(ride: Ride, photo_type: str) -> List[RidePhoto]:
|
||||
"""
|
||||
Get photos of a specific type for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
photo_type: Type of photos to retrieve
|
||||
|
||||
Returns:
|
||||
List of RidePhoto instances
|
||||
"""
|
||||
return list(
|
||||
ride.photos.filter(photo_type=photo_type, is_approved=True).order_by(
|
||||
"-created_at"
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def set_primary_photo(ride: Ride, photo: RidePhoto) -> bool:
|
||||
"""
|
||||
Set a photo as the primary photo for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
photo: RidePhoto to set as primary
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if photo.ride != ride:
|
||||
return False
|
||||
|
||||
with transaction.atomic():
|
||||
# Unset current primary
|
||||
ride.photos.filter(is_primary=True).update(is_primary=False)
|
||||
|
||||
# Set new primary
|
||||
photo.is_primary = True
|
||||
photo.save()
|
||||
|
||||
logger.info(f"Set photo {photo.pk} as primary for ride {ride.slug}")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def approve_photo(photo: RidePhoto, approved_by: User) -> bool:
|
||||
"""
|
||||
Approve a ride photo.
|
||||
|
||||
Args:
|
||||
photo: RidePhoto to approve
|
||||
approved_by: User approving the photo
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
photo.is_approved = True
|
||||
photo.save()
|
||||
|
||||
logger.info(f"Photo {photo.pk} approved by user {approved_by.username}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to approve photo {photo.pk}: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def delete_photo(photo: RidePhoto, deleted_by: User) -> bool:
|
||||
"""
|
||||
Delete a ride photo.
|
||||
|
||||
Args:
|
||||
photo: RidePhoto to delete
|
||||
deleted_by: User deleting the photo
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
ride_slug = photo.ride.slug
|
||||
photo_id = photo.pk
|
||||
|
||||
# Delete the file and database record
|
||||
if photo.image:
|
||||
photo.image.delete(save=False)
|
||||
photo.delete()
|
||||
|
||||
logger.info(
|
||||
f"Photo {photo_id} deleted from ride {ride_slug} by user {deleted_by.username}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete photo {photo.pk}: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_photo_stats(ride: Ride) -> Dict[str, Any]:
|
||||
"""
|
||||
Get photo statistics for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
|
||||
Returns:
|
||||
Dictionary with photo statistics
|
||||
"""
|
||||
photos = ride.photos.all()
|
||||
|
||||
# Get counts by photo type
|
||||
type_counts = {}
|
||||
for photo_type, _ in RidePhoto._meta.get_field("photo_type").choices:
|
||||
type_counts[photo_type] = photos.filter(photo_type=photo_type).count()
|
||||
|
||||
return {
|
||||
"total_photos": photos.count(),
|
||||
"approved_photos": photos.filter(is_approved=True).count(),
|
||||
"pending_photos": photos.filter(is_approved=False).count(),
|
||||
"has_primary": photos.filter(is_primary=True).exists(),
|
||||
"recent_uploads": photos.order_by("-created_at")[:5].count(),
|
||||
"by_type": type_counts,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def bulk_approve_photos(photos: List[RidePhoto], approved_by: User) -> int:
|
||||
"""
|
||||
Bulk approve multiple photos.
|
||||
|
||||
Args:
|
||||
photos: List of RidePhoto instances to approve
|
||||
approved_by: User approving the photos
|
||||
|
||||
Returns:
|
||||
Number of photos successfully approved
|
||||
"""
|
||||
approved_count = 0
|
||||
|
||||
with transaction.atomic():
|
||||
for photo in photos:
|
||||
if RideMediaService.approve_photo(photo, approved_by):
|
||||
approved_count += 1
|
||||
|
||||
logger.info(
|
||||
f"Bulk approved {approved_count} photos by user {approved_by.username}"
|
||||
)
|
||||
return approved_count
|
||||
|
||||
@staticmethod
|
||||
def get_construction_timeline(ride: Ride) -> List[RidePhoto]:
|
||||
"""
|
||||
Get construction photos ordered chronologically.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
|
||||
Returns:
|
||||
List of construction RidePhoto instances ordered by date taken
|
||||
"""
|
||||
return list(
|
||||
ride.photos.filter(photo_type="construction", is_approved=True).order_by(
|
||||
"date_taken", "created_at"
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_onride_photos(ride: Ride) -> List[RidePhoto]:
|
||||
"""
|
||||
Get on-ride photos for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
|
||||
Returns:
|
||||
List of on-ride RidePhoto instances
|
||||
"""
|
||||
return RideMediaService.get_photos_by_type(ride, "onride")
|
||||
@@ -1,550 +0,0 @@
|
||||
"""
|
||||
Service for calculating ride rankings using the Internet Roller Coaster Poll algorithm.
|
||||
|
||||
This service implements a pairwise comparison system where each ride is compared
|
||||
to every other ride based on mutual riders (users who have rated both rides).
|
||||
Rankings are determined by winning percentage in these comparisons.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Avg, Count, Q
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.rides.models import (
|
||||
Ride,
|
||||
RideReview,
|
||||
RideRanking,
|
||||
RidePairComparison,
|
||||
RankingSnapshot,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RideRankingService:
|
||||
"""
|
||||
Calculates ride rankings using the Internet Roller Coaster Poll algorithm.
|
||||
|
||||
Algorithm Overview:
|
||||
1. For each pair of rides, find users who have rated both
|
||||
2. Count how many users preferred each ride (higher rating)
|
||||
3. Calculate wins, losses, and ties for each ride
|
||||
4. Rank rides by winning percentage (ties count as 0.5 wins)
|
||||
5. Break ties by head-to-head comparison
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
|
||||
self.calculation_version = "1.0"
|
||||
|
||||
def update_all_rankings(self, category: Optional[str] = None) -> Dict[str, any]:
|
||||
"""
|
||||
Main entry point to update all ride rankings.
|
||||
|
||||
Args:
|
||||
category: Optional ride category to filter ('RC' for roller coasters, etc.)
|
||||
If None, ranks all rides.
|
||||
|
||||
Returns:
|
||||
Dictionary with statistics about the ranking calculation
|
||||
"""
|
||||
start_time = timezone.now()
|
||||
self.logger.info(
|
||||
f"Starting ranking calculation for category: {category or 'ALL'}"
|
||||
)
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Get rides to rank
|
||||
rides = self._get_eligible_rides(category)
|
||||
if not rides:
|
||||
self.logger.warning("No eligible rides found for ranking")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"message": "No eligible rides found",
|
||||
"duration": (timezone.now() - start_time).total_seconds(),
|
||||
}
|
||||
|
||||
self.logger.info(f"Found {len(rides)} rides to rank")
|
||||
|
||||
# Calculate pairwise comparisons
|
||||
comparisons = self._calculate_all_comparisons(rides)
|
||||
|
||||
# Calculate rankings from comparisons
|
||||
rankings = self._calculate_rankings_from_comparisons(rides, comparisons)
|
||||
|
||||
# Save rankings
|
||||
self._save_rankings(rankings)
|
||||
|
||||
# Save snapshots for historical tracking
|
||||
self._save_ranking_snapshots(rankings)
|
||||
|
||||
# Clean up old data
|
||||
self._cleanup_old_data()
|
||||
|
||||
duration = (timezone.now() - start_time).total_seconds()
|
||||
self.logger.info(
|
||||
f"Ranking calculation completed in {duration:.2f} seconds"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"rides_ranked": len(rides),
|
||||
"comparisons_made": len(comparisons),
|
||||
"duration": duration,
|
||||
"timestamp": timezone.now(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating rankings: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def _get_eligible_rides(self, category: Optional[str] = None) -> List[Ride]:
|
||||
"""
|
||||
Get rides that are eligible for ranking.
|
||||
|
||||
Only includes rides that:
|
||||
- Are currently operating
|
||||
- Have at least one review/rating
|
||||
"""
|
||||
queryset = (
|
||||
Ride.objects.filter(status="OPERATING", reviews__is_published=True)
|
||||
.annotate(
|
||||
review_count=Count("reviews", filter=Q(reviews__is_published=True))
|
||||
)
|
||||
.filter(review_count__gt=0)
|
||||
)
|
||||
|
||||
if category:
|
||||
queryset = queryset.filter(category=category)
|
||||
|
||||
return list(queryset.distinct())
|
||||
|
||||
def _calculate_all_comparisons(
|
||||
self, rides: List[Ride]
|
||||
) -> Dict[tuple[int, int], RidePairComparison]:
|
||||
"""
|
||||
Calculate pairwise comparisons for all ride pairs.
|
||||
|
||||
Returns a dictionary keyed by (ride_a_id, ride_b_id) tuples.
|
||||
"""
|
||||
comparisons = {}
|
||||
total_pairs = len(rides) * (len(rides) - 1) // 2
|
||||
processed = 0
|
||||
|
||||
for i, ride_a in enumerate(rides):
|
||||
for ride_b in rides[i + 1 :]:
|
||||
comparison = self._calculate_pairwise_comparison(ride_a, ride_b)
|
||||
if comparison:
|
||||
# Store both directions for easy lookup
|
||||
comparisons[(ride_a.id, ride_b.id)] = comparison
|
||||
comparisons[(ride_b.id, ride_a.id)] = comparison
|
||||
|
||||
processed += 1
|
||||
if processed % 100 == 0:
|
||||
self.logger.debug(
|
||||
f"Processed {processed}/{total_pairs} comparisons"
|
||||
)
|
||||
|
||||
return comparisons
|
||||
|
||||
def _calculate_pairwise_comparison(
|
||||
self, ride_a: Ride, ride_b: Ride
|
||||
) -> Optional[RidePairComparison]:
|
||||
"""
|
||||
Calculate the pairwise comparison between two rides.
|
||||
|
||||
Finds users who have rated both rides and determines which ride
|
||||
they preferred based on their ratings.
|
||||
"""
|
||||
# Get mutual riders (users who have rated both rides)
|
||||
ride_a_reviewers = set(
|
||||
RideReview.objects.filter(ride=ride_a, is_published=True).values_list(
|
||||
"user_id", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
ride_b_reviewers = set(
|
||||
RideReview.objects.filter(ride=ride_b, is_published=True).values_list(
|
||||
"user_id", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
mutual_riders = ride_a_reviewers & ride_b_reviewers
|
||||
|
||||
if not mutual_riders:
|
||||
# No mutual riders, no comparison possible
|
||||
return None
|
||||
|
||||
# Get ratings from mutual riders
|
||||
ride_a_ratings = {
|
||||
review.user_id: review.rating
|
||||
for review in RideReview.objects.filter(
|
||||
ride=ride_a, user_id__in=mutual_riders, is_published=True
|
||||
)
|
||||
}
|
||||
|
||||
ride_b_ratings = {
|
||||
review.user_id: review.rating
|
||||
for review in RideReview.objects.filter(
|
||||
ride=ride_b, user_id__in=mutual_riders, is_published=True
|
||||
)
|
||||
}
|
||||
|
||||
# Count wins and ties
|
||||
ride_a_wins = 0
|
||||
ride_b_wins = 0
|
||||
ties = 0
|
||||
|
||||
for user_id in mutual_riders:
|
||||
rating_a = ride_a_ratings.get(user_id, 0)
|
||||
rating_b = ride_b_ratings.get(user_id, 0)
|
||||
|
||||
if rating_a > rating_b:
|
||||
ride_a_wins += 1
|
||||
elif rating_b > rating_a:
|
||||
ride_b_wins += 1
|
||||
else:
|
||||
ties += 1
|
||||
|
||||
# Calculate average ratings from mutual riders
|
||||
ride_a_avg = (
|
||||
sum(ride_a_ratings.values()) / len(ride_a_ratings) if ride_a_ratings else 0
|
||||
)
|
||||
ride_b_avg = (
|
||||
sum(ride_b_ratings.values()) / len(ride_b_ratings) if ride_b_ratings else 0
|
||||
)
|
||||
|
||||
# Create or update comparison record
|
||||
comparison, created = RidePairComparison.objects.update_or_create(
|
||||
ride_a=ride_a if ride_a.id < ride_b.id else ride_b,
|
||||
ride_b=ride_b if ride_a.id < ride_b.id else ride_a,
|
||||
defaults={
|
||||
"ride_a_wins": ride_a_wins if ride_a.id < ride_b.id else ride_b_wins,
|
||||
"ride_b_wins": ride_b_wins if ride_a.id < ride_b.id else ride_a_wins,
|
||||
"ties": ties,
|
||||
"mutual_riders_count": len(mutual_riders),
|
||||
"ride_a_avg_rating": (
|
||||
Decimal(str(ride_a_avg))
|
||||
if ride_a.id < ride_b.id
|
||||
else Decimal(str(ride_b_avg))
|
||||
),
|
||||
"ride_b_avg_rating": (
|
||||
Decimal(str(ride_b_avg))
|
||||
if ride_a.id < ride_b.id
|
||||
else Decimal(str(ride_a_avg))
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return comparison
|
||||
|
||||
def _calculate_rankings_from_comparisons(
|
||||
self, rides: List[Ride], comparisons: Dict[tuple[int, int], RidePairComparison]
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Calculate final rankings from pairwise comparisons.
|
||||
|
||||
Returns a list of dictionaries containing ranking data for each ride.
|
||||
"""
|
||||
rankings = []
|
||||
|
||||
for ride in rides:
|
||||
wins = 0
|
||||
losses = 0
|
||||
ties = 0
|
||||
comparison_count = 0
|
||||
|
||||
# Count wins, losses, and ties
|
||||
for other_ride in rides:
|
||||
if ride.id == other_ride.id:
|
||||
continue
|
||||
|
||||
comparison_key = (
|
||||
min(ride.id, other_ride.id),
|
||||
max(ride.id, other_ride.id),
|
||||
)
|
||||
comparison = comparisons.get(comparison_key)
|
||||
|
||||
if not comparison:
|
||||
continue
|
||||
|
||||
comparison_count += 1
|
||||
|
||||
# Determine win/loss/tie for this ride
|
||||
if comparison.ride_a_id == ride.id:
|
||||
if comparison.ride_a_wins > comparison.ride_b_wins:
|
||||
wins += 1
|
||||
elif comparison.ride_a_wins < comparison.ride_b_wins:
|
||||
losses += 1
|
||||
else:
|
||||
ties += 1
|
||||
else: # ride_b_id == ride.id
|
||||
if comparison.ride_b_wins > comparison.ride_a_wins:
|
||||
wins += 1
|
||||
elif comparison.ride_b_wins < comparison.ride_a_wins:
|
||||
losses += 1
|
||||
else:
|
||||
ties += 1
|
||||
|
||||
# Calculate winning percentage (ties count as 0.5)
|
||||
total_comparisons = wins + losses + ties
|
||||
if total_comparisons > 0:
|
||||
winning_percentage = Decimal(
|
||||
str((wins + 0.5 * ties) / total_comparisons)
|
||||
)
|
||||
else:
|
||||
winning_percentage = Decimal("0.5")
|
||||
|
||||
# Get average rating and reviewer count
|
||||
ride_stats = RideReview.objects.filter(
|
||||
ride=ride, is_published=True
|
||||
).aggregate(
|
||||
avg_rating=Avg("rating"), reviewer_count=Count("user", distinct=True)
|
||||
)
|
||||
|
||||
rankings.append(
|
||||
{
|
||||
"ride": ride,
|
||||
"wins": wins,
|
||||
"losses": losses,
|
||||
"ties": ties,
|
||||
"winning_percentage": winning_percentage,
|
||||
"comparison_count": comparison_count,
|
||||
"average_rating": ride_stats["avg_rating"],
|
||||
"mutual_riders_count": ride_stats["reviewer_count"] or 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by winning percentage (descending), then by mutual riders count for ties
|
||||
rankings.sort(
|
||||
key=lambda x: (
|
||||
x["winning_percentage"],
|
||||
x["mutual_riders_count"],
|
||||
x["average_rating"] or 0,
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# Handle tie-breaking with head-to-head comparisons
|
||||
rankings = self._apply_tiebreakers(rankings, comparisons)
|
||||
|
||||
# Assign final ranks
|
||||
for i, ranking_data in enumerate(rankings, 1):
|
||||
ranking_data["rank"] = i
|
||||
|
||||
return rankings
|
||||
|
||||
def _apply_tiebreakers(
|
||||
self,
|
||||
rankings: List[Dict],
|
||||
comparisons: Dict[tuple[int, int], RidePairComparison],
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Apply head-to-head tiebreaker for rides with identical winning percentages.
|
||||
|
||||
If two rides have the same winning percentage, the one that beat the other
|
||||
in their head-to-head comparison gets the higher rank.
|
||||
"""
|
||||
i = 0
|
||||
while i < len(rankings) - 1:
|
||||
# Find rides with same winning percentage
|
||||
tied_group = [rankings[i]]
|
||||
j = i + 1
|
||||
|
||||
while (
|
||||
j < len(rankings)
|
||||
and rankings[j]["winning_percentage"]
|
||||
== rankings[i]["winning_percentage"]
|
||||
):
|
||||
tied_group.append(rankings[j])
|
||||
j += 1
|
||||
|
||||
if len(tied_group) > 1:
|
||||
# Apply head-to-head tiebreaker within the group
|
||||
tied_group = self._sort_tied_group(tied_group, comparisons)
|
||||
|
||||
# Replace the tied section with sorted group
|
||||
rankings[i:j] = tied_group
|
||||
|
||||
i = j
|
||||
|
||||
return rankings
|
||||
|
||||
def _sort_tied_group(
|
||||
self,
|
||||
tied_group: List[Dict],
|
||||
comparisons: Dict[tuple[int, int], RidePairComparison],
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Sort a group of tied rides using head-to-head comparisons.
|
||||
"""
|
||||
# Create mini-rankings within the tied group
|
||||
for ride_data in tied_group:
|
||||
mini_wins = 0
|
||||
mini_losses = 0
|
||||
|
||||
for other_data in tied_group:
|
||||
if ride_data["ride"].id == other_data["ride"].id:
|
||||
continue
|
||||
|
||||
comparison_key = (
|
||||
min(ride_data["ride"].id, other_data["ride"].id),
|
||||
max(ride_data["ride"].id, other_data["ride"].id),
|
||||
)
|
||||
comparison = comparisons.get(comparison_key)
|
||||
|
||||
if comparison:
|
||||
if comparison.ride_a_id == ride_data["ride"].id:
|
||||
if comparison.ride_a_wins > comparison.ride_b_wins:
|
||||
mini_wins += 1
|
||||
elif comparison.ride_a_wins < comparison.ride_b_wins:
|
||||
mini_losses += 1
|
||||
else:
|
||||
if comparison.ride_b_wins > comparison.ride_a_wins:
|
||||
mini_wins += 1
|
||||
elif comparison.ride_b_wins < comparison.ride_a_wins:
|
||||
mini_losses += 1
|
||||
|
||||
ride_data["tiebreaker_score"] = mini_wins - mini_losses
|
||||
|
||||
# Sort by tiebreaker score, then by mutual riders count, then by average rating
|
||||
tied_group.sort(
|
||||
key=lambda x: (
|
||||
x["tiebreaker_score"],
|
||||
x["mutual_riders_count"],
|
||||
x["average_rating"] or 0,
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
return tied_group
|
||||
|
||||
def _save_rankings(self, rankings: List[Dict]):
|
||||
"""Save calculated rankings to the database."""
|
||||
for ranking_data in rankings:
|
||||
RideRanking.objects.update_or_create(
|
||||
ride=ranking_data["ride"],
|
||||
defaults={
|
||||
"rank": ranking_data["rank"],
|
||||
"wins": ranking_data["wins"],
|
||||
"losses": ranking_data["losses"],
|
||||
"ties": ranking_data["ties"],
|
||||
"winning_percentage": ranking_data["winning_percentage"],
|
||||
"mutual_riders_count": ranking_data["mutual_riders_count"],
|
||||
"comparison_count": ranking_data["comparison_count"],
|
||||
"average_rating": ranking_data["average_rating"],
|
||||
"last_calculated": timezone.now(),
|
||||
"calculation_version": self.calculation_version,
|
||||
},
|
||||
)
|
||||
|
||||
def _save_ranking_snapshots(self, rankings: List[Dict]):
|
||||
"""Save ranking snapshots for historical tracking."""
|
||||
today = date.today()
|
||||
|
||||
for ranking_data in rankings:
|
||||
RankingSnapshot.objects.update_or_create(
|
||||
ride=ranking_data["ride"],
|
||||
snapshot_date=today,
|
||||
defaults={
|
||||
"rank": ranking_data["rank"],
|
||||
"winning_percentage": ranking_data["winning_percentage"],
|
||||
},
|
||||
)
|
||||
|
||||
def _cleanup_old_data(self, days_to_keep: int = 365):
|
||||
"""Clean up old comparison and snapshot data."""
|
||||
cutoff_date = timezone.now() - timezone.timedelta(days=days_to_keep)
|
||||
|
||||
# Delete old snapshots
|
||||
deleted_snapshots = RankingSnapshot.objects.filter(
|
||||
snapshot_date__lt=cutoff_date.date()
|
||||
).delete()
|
||||
|
||||
if deleted_snapshots[0] > 0:
|
||||
self.logger.info(f"Deleted {deleted_snapshots[0]} old ranking snapshots")
|
||||
|
||||
def get_ride_ranking_details(self, ride: Ride) -> Optional[Dict]:
|
||||
"""
|
||||
Get detailed ranking information for a specific ride.
|
||||
|
||||
Returns dictionary with ranking details or None if not ranked.
|
||||
"""
|
||||
try:
|
||||
ranking = RideRanking.objects.get(ride=ride)
|
||||
|
||||
# Get recent head-to-head comparisons
|
||||
comparisons = (
|
||||
RidePairComparison.objects.filter(Q(ride_a=ride) | Q(ride_b=ride))
|
||||
.select_related("ride_a", "ride_b")
|
||||
.order_by("-mutual_riders_count")[:10]
|
||||
)
|
||||
|
||||
# Get ranking history
|
||||
history = RankingSnapshot.objects.filter(ride=ride).order_by(
|
||||
"-snapshot_date"
|
||||
)[:30]
|
||||
|
||||
return {
|
||||
"current_rank": ranking.rank,
|
||||
"winning_percentage": ranking.winning_percentage,
|
||||
"wins": ranking.wins,
|
||||
"losses": ranking.losses,
|
||||
"ties": ranking.ties,
|
||||
"average_rating": ranking.average_rating,
|
||||
"mutual_riders_count": ranking.mutual_riders_count,
|
||||
"last_calculated": ranking.last_calculated,
|
||||
"head_to_head": [
|
||||
{
|
||||
"opponent": (
|
||||
comp.ride_b if comp.ride_a_id == ride.id else comp.ride_a
|
||||
),
|
||||
"result": (
|
||||
"win"
|
||||
if (
|
||||
(
|
||||
comp.ride_a_id == ride.id
|
||||
and comp.ride_a_wins > comp.ride_b_wins
|
||||
)
|
||||
or (
|
||||
comp.ride_b_id == ride.id
|
||||
and comp.ride_b_wins > comp.ride_a_wins
|
||||
)
|
||||
)
|
||||
else (
|
||||
"loss"
|
||||
if (
|
||||
(
|
||||
comp.ride_a_id == ride.id
|
||||
and comp.ride_a_wins < comp.ride_b_wins
|
||||
)
|
||||
or (
|
||||
comp.ride_b_id == ride.id
|
||||
and comp.ride_b_wins < comp.ride_a_wins
|
||||
)
|
||||
)
|
||||
else "tie"
|
||||
)
|
||||
),
|
||||
"mutual_riders": comp.mutual_riders_count,
|
||||
}
|
||||
for comp in comparisons
|
||||
],
|
||||
"ranking_history": [
|
||||
{
|
||||
"date": snapshot.snapshot_date,
|
||||
"rank": snapshot.rank,
|
||||
"winning_percentage": snapshot.winning_percentage,
|
||||
}
|
||||
for snapshot in history
|
||||
],
|
||||
}
|
||||
except RideRanking.DoesNotExist:
|
||||
return None
|
||||
@@ -1,730 +0,0 @@
|
||||
"""
|
||||
Ride Search Service
|
||||
|
||||
Provides comprehensive search and filtering capabilities for rides using PostgreSQL's
|
||||
advanced full-text search features including SearchVector, SearchQuery, SearchRank,
|
||||
and TrigramSimilarity for fuzzy matching.
|
||||
|
||||
This service implements the filtering design specified in:
|
||||
backend/docs/ride_filtering_design.md
|
||||
"""
|
||||
|
||||
from django.contrib.postgres.search import (
|
||||
SearchVector,
|
||||
SearchQuery,
|
||||
SearchRank,
|
||||
TrigramSimilarity,
|
||||
)
|
||||
from django.db import models
|
||||
from django.db.models import Q, F, Value
|
||||
from django.db.models.functions import Greatest
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from apps.rides.models import Ride
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models.company import Company
|
||||
|
||||
|
||||
class RideSearchService:
|
||||
"""
|
||||
Advanced search service for rides with PostgreSQL full-text search capabilities.
|
||||
|
||||
Features:
|
||||
- Full-text search with ranking and highlighting
|
||||
- Fuzzy matching with trigram similarity
|
||||
- Comprehensive filtering across 8 categories
|
||||
- Range filtering for numeric fields
|
||||
- Date range filtering
|
||||
- Multi-select filtering
|
||||
- Sorting with multiple options
|
||||
- Search suggestions and autocomplete
|
||||
"""
|
||||
|
||||
# Search configuration
|
||||
SEARCH_LANGUAGES = ["english"]
|
||||
TRIGRAM_SIMILARITY_THRESHOLD = 0.3
|
||||
SEARCH_RANK_WEIGHTS = [0.1, 0.2, 0.4, 1.0] # D, C, B, A weights
|
||||
|
||||
# Filter categories from our design
|
||||
FILTER_CATEGORIES = {
|
||||
"search_text": ["global_search", "name_search", "description_search"],
|
||||
"basic_info": ["category", "status", "park", "park_area"],
|
||||
"dates": ["opening_date_range", "closing_date_range", "status_since_range"],
|
||||
"height_safety": ["min_height_range", "max_height_range"],
|
||||
"performance": ["capacity_range", "duration_range", "rating_range"],
|
||||
"relationships": ["manufacturer", "designer", "ride_model"],
|
||||
"roller_coaster": [
|
||||
"height_ft_range",
|
||||
"length_ft_range",
|
||||
"speed_mph_range",
|
||||
"inversions_range",
|
||||
"track_material",
|
||||
"coaster_type",
|
||||
"propulsion_system",
|
||||
],
|
||||
"company": ["manufacturer_roles", "designer_roles", "founded_date_range"],
|
||||
}
|
||||
|
||||
# Sorting options
|
||||
SORT_OPTIONS = {
|
||||
"relevance": "search_rank",
|
||||
"name_asc": "name",
|
||||
"name_desc": "-name",
|
||||
"opening_date_asc": "opening_date",
|
||||
"opening_date_desc": "-opening_date",
|
||||
"rating_asc": "average_rating",
|
||||
"rating_desc": "-average_rating",
|
||||
"height_asc": "rollercoasterstats__height_ft",
|
||||
"height_desc": "-rollercoasterstats__height_ft",
|
||||
"speed_asc": "rollercoasterstats__speed_mph",
|
||||
"speed_desc": "-rollercoasterstats__speed_mph",
|
||||
"capacity_asc": "capacity_per_hour",
|
||||
"capacity_desc": "-capacity_per_hour",
|
||||
"created_asc": "created_at",
|
||||
"created_desc": "-created_at",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the search service."""
|
||||
self.base_queryset = self._get_base_queryset()
|
||||
|
||||
def _get_base_queryset(self):
|
||||
"""
|
||||
Get the base queryset with all necessary relationships pre-loaded
|
||||
for optimal performance.
|
||||
"""
|
||||
return Ride.objects.select_related(
|
||||
"park",
|
||||
"park_area",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"ride_model",
|
||||
"rollercoasterstats",
|
||||
).prefetch_related("manufacturer__roles", "designer__roles")
|
||||
|
||||
def search_and_filter(
|
||||
self,
|
||||
filters: Dict[str, Any],
|
||||
sort_by: str = "relevance",
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Main search and filter method that combines all capabilities.
|
||||
|
||||
Args:
|
||||
filters: Dictionary of filter parameters
|
||||
sort_by: Sorting option key
|
||||
page: Page number for pagination
|
||||
page_size: Number of results per page
|
||||
|
||||
Returns:
|
||||
Dictionary containing results, pagination info, and metadata
|
||||
"""
|
||||
queryset = self.base_queryset
|
||||
search_metadata = {}
|
||||
|
||||
# Apply text search with ranking
|
||||
if filters.get("global_search"):
|
||||
queryset, search_rank = self._apply_full_text_search(
|
||||
queryset, filters["global_search"]
|
||||
)
|
||||
search_metadata["search_applied"] = True
|
||||
search_metadata["search_term"] = filters["global_search"]
|
||||
else:
|
||||
search_rank = Value(0)
|
||||
|
||||
# Apply all filter categories
|
||||
queryset = self._apply_basic_info_filters(queryset, filters)
|
||||
queryset = self._apply_date_filters(queryset, filters)
|
||||
queryset = self._apply_height_safety_filters(queryset, filters)
|
||||
queryset = self._apply_performance_filters(queryset, filters)
|
||||
queryset = self._apply_relationship_filters(queryset, filters)
|
||||
queryset = self._apply_roller_coaster_filters(queryset, filters)
|
||||
queryset = self._apply_company_filters(queryset, filters)
|
||||
|
||||
# Add search rank to queryset for sorting
|
||||
queryset = queryset.annotate(search_rank=search_rank)
|
||||
|
||||
# Apply sorting
|
||||
queryset = self._apply_sorting(queryset, sort_by)
|
||||
|
||||
# Get total count before pagination
|
||||
total_count = queryset.count()
|
||||
|
||||
# Apply pagination
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
results = list(queryset[start_idx:end_idx])
|
||||
|
||||
# Generate search highlights if search was applied
|
||||
if filters.get("global_search"):
|
||||
results = self._add_search_highlights(results, filters["global_search"])
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_count": total_count,
|
||||
"total_pages": (total_count + page_size - 1) // page_size,
|
||||
"has_next": end_idx < total_count,
|
||||
"has_previous": page > 1,
|
||||
},
|
||||
"metadata": search_metadata,
|
||||
"applied_filters": self._get_applied_filters_summary(filters),
|
||||
}
|
||||
|
||||
def _apply_full_text_search(
|
||||
self, queryset, search_term: str
|
||||
) -> tuple[models.QuerySet, models.Expression]:
|
||||
"""
|
||||
Apply PostgreSQL full-text search with ranking and fuzzy matching.
|
||||
"""
|
||||
if not search_term or not search_term.strip():
|
||||
return queryset, Value(0)
|
||||
|
||||
search_term = search_term.strip()
|
||||
|
||||
# Create search vector combining multiple fields with different weights
|
||||
search_vector = (
|
||||
SearchVector("name", weight="A")
|
||||
+ SearchVector("description", weight="B")
|
||||
+ SearchVector("park__name", weight="C")
|
||||
+ SearchVector("manufacturer__name", weight="C")
|
||||
+ SearchVector("designer__name", weight="C")
|
||||
+ SearchVector("ride_model__name", weight="D")
|
||||
)
|
||||
|
||||
# Create search query - try different query types for best results
|
||||
search_query = SearchQuery(search_term, config="english")
|
||||
|
||||
# Calculate search rank
|
||||
search_rank = SearchRank(
|
||||
search_vector, search_query, weights=self.SEARCH_RANK_WEIGHTS
|
||||
)
|
||||
|
||||
# Apply trigram similarity for fuzzy matching on name
|
||||
trigram_similarity = TrigramSimilarity("name", search_term)
|
||||
|
||||
# Combine full-text search with trigram similarity
|
||||
queryset = queryset.annotate(trigram_similarity=trigram_similarity).filter(
|
||||
Q(search_vector=search_query)
|
||||
| Q(trigram_similarity__gte=self.TRIGRAM_SIMILARITY_THRESHOLD)
|
||||
)
|
||||
|
||||
# Use the greatest of search rank and trigram similarity for final ranking
|
||||
final_rank = Greatest(search_rank, F("trigram_similarity"))
|
||||
|
||||
return queryset, final_rank
|
||||
|
||||
def _apply_basic_info_filters(
|
||||
self, queryset, filters: Dict[str, Any]
|
||||
) -> models.QuerySet:
|
||||
"""Apply basic information filters."""
|
||||
|
||||
# Category filter (multi-select)
|
||||
if filters.get("category"):
|
||||
categories = (
|
||||
filters["category"]
|
||||
if isinstance(filters["category"], list)
|
||||
else [filters["category"]]
|
||||
)
|
||||
queryset = queryset.filter(category__in=categories)
|
||||
|
||||
# Status filter (multi-select)
|
||||
if filters.get("status"):
|
||||
statuses = (
|
||||
filters["status"]
|
||||
if isinstance(filters["status"], list)
|
||||
else [filters["status"]]
|
||||
)
|
||||
queryset = queryset.filter(status__in=statuses)
|
||||
|
||||
# Park filter (multi-select)
|
||||
if filters.get("park"):
|
||||
parks = (
|
||||
filters["park"]
|
||||
if isinstance(filters["park"], list)
|
||||
else [filters["park"]]
|
||||
)
|
||||
if isinstance(parks[0], str): # If slugs provided
|
||||
queryset = queryset.filter(park__slug__in=parks)
|
||||
else: # If IDs provided
|
||||
queryset = queryset.filter(park_id__in=parks)
|
||||
|
||||
# Park area filter (multi-select)
|
||||
if filters.get("park_area"):
|
||||
areas = (
|
||||
filters["park_area"]
|
||||
if isinstance(filters["park_area"], list)
|
||||
else [filters["park_area"]]
|
||||
)
|
||||
if isinstance(areas[0], str): # If slugs provided
|
||||
queryset = queryset.filter(park_area__slug__in=areas)
|
||||
else: # If IDs provided
|
||||
queryset = queryset.filter(park_area_id__in=areas)
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_date_filters(self, queryset, filters: Dict[str, Any]) -> models.QuerySet:
|
||||
"""Apply date range filters."""
|
||||
|
||||
# Opening date range
|
||||
if filters.get("opening_date_range"):
|
||||
date_range = filters["opening_date_range"]
|
||||
if date_range.get("start"):
|
||||
queryset = queryset.filter(opening_date__gte=date_range["start"])
|
||||
if date_range.get("end"):
|
||||
queryset = queryset.filter(opening_date__lte=date_range["end"])
|
||||
|
||||
# Closing date range
|
||||
if filters.get("closing_date_range"):
|
||||
date_range = filters["closing_date_range"]
|
||||
if date_range.get("start"):
|
||||
queryset = queryset.filter(closing_date__gte=date_range["start"])
|
||||
if date_range.get("end"):
|
||||
queryset = queryset.filter(closing_date__lte=date_range["end"])
|
||||
|
||||
# Status since range
|
||||
if filters.get("status_since_range"):
|
||||
date_range = filters["status_since_range"]
|
||||
if date_range.get("start"):
|
||||
queryset = queryset.filter(status_since__gte=date_range["start"])
|
||||
if date_range.get("end"):
|
||||
queryset = queryset.filter(status_since__lte=date_range["end"])
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_height_safety_filters(
|
||||
self, queryset, filters: Dict[str, Any]
|
||||
) -> models.QuerySet:
|
||||
"""Apply height and safety requirement filters."""
|
||||
|
||||
# Minimum height range
|
||||
if filters.get("min_height_range"):
|
||||
height_range = filters["min_height_range"]
|
||||
if height_range.get("min") is not None:
|
||||
queryset = queryset.filter(min_height_in__gte=height_range["min"])
|
||||
if height_range.get("max") is not None:
|
||||
queryset = queryset.filter(min_height_in__lte=height_range["max"])
|
||||
|
||||
# Maximum height range
|
||||
if filters.get("max_height_range"):
|
||||
height_range = filters["max_height_range"]
|
||||
if height_range.get("min") is not None:
|
||||
queryset = queryset.filter(max_height_in__gte=height_range["min"])
|
||||
if height_range.get("max") is not None:
|
||||
queryset = queryset.filter(max_height_in__lte=height_range["max"])
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_performance_filters(
|
||||
self, queryset, filters: Dict[str, Any]
|
||||
) -> models.QuerySet:
|
||||
"""Apply performance metric filters."""
|
||||
|
||||
# Capacity range
|
||||
if filters.get("capacity_range"):
|
||||
capacity_range = filters["capacity_range"]
|
||||
if capacity_range.get("min") is not None:
|
||||
queryset = queryset.filter(capacity_per_hour__gte=capacity_range["min"])
|
||||
if capacity_range.get("max") is not None:
|
||||
queryset = queryset.filter(capacity_per_hour__lte=capacity_range["max"])
|
||||
|
||||
# Duration range
|
||||
if filters.get("duration_range"):
|
||||
duration_range = filters["duration_range"]
|
||||
if duration_range.get("min") is not None:
|
||||
queryset = queryset.filter(
|
||||
ride_duration_seconds__gte=duration_range["min"]
|
||||
)
|
||||
if duration_range.get("max") is not None:
|
||||
queryset = queryset.filter(
|
||||
ride_duration_seconds__lte=duration_range["max"]
|
||||
)
|
||||
|
||||
# Rating range
|
||||
if filters.get("rating_range"):
|
||||
rating_range = filters["rating_range"]
|
||||
if rating_range.get("min") is not None:
|
||||
queryset = queryset.filter(average_rating__gte=rating_range["min"])
|
||||
if rating_range.get("max") is not None:
|
||||
queryset = queryset.filter(average_rating__lte=rating_range["max"])
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_relationship_filters(
|
||||
self, queryset, filters: Dict[str, Any]
|
||||
) -> models.QuerySet:
|
||||
"""Apply relationship filters (manufacturer, designer, ride model)."""
|
||||
|
||||
# Manufacturer filter (multi-select)
|
||||
if filters.get("manufacturer"):
|
||||
manufacturers = (
|
||||
filters["manufacturer"]
|
||||
if isinstance(filters["manufacturer"], list)
|
||||
else [filters["manufacturer"]]
|
||||
)
|
||||
if isinstance(manufacturers[0], str): # If slugs provided
|
||||
queryset = queryset.filter(manufacturer__slug__in=manufacturers)
|
||||
else: # If IDs provided
|
||||
queryset = queryset.filter(manufacturer_id__in=manufacturers)
|
||||
|
||||
# Designer filter (multi-select)
|
||||
if filters.get("designer"):
|
||||
designers = (
|
||||
filters["designer"]
|
||||
if isinstance(filters["designer"], list)
|
||||
else [filters["designer"]]
|
||||
)
|
||||
if isinstance(designers[0], str): # If slugs provided
|
||||
queryset = queryset.filter(designer__slug__in=designers)
|
||||
else: # If IDs provided
|
||||
queryset = queryset.filter(designer_id__in=designers)
|
||||
|
||||
# Ride model filter (multi-select)
|
||||
if filters.get("ride_model"):
|
||||
models_list = (
|
||||
filters["ride_model"]
|
||||
if isinstance(filters["ride_model"], list)
|
||||
else [filters["ride_model"]]
|
||||
)
|
||||
if isinstance(models_list[0], str): # If slugs provided
|
||||
queryset = queryset.filter(ride_model__slug__in=models_list)
|
||||
else: # If IDs provided
|
||||
queryset = queryset.filter(ride_model_id__in=models_list)
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_roller_coaster_filters(
|
||||
self, queryset, filters: Dict[str, Any]
|
||||
) -> models.QuerySet:
|
||||
"""Apply roller coaster specific filters."""
|
||||
queryset = self._apply_numeric_range_filter(
|
||||
queryset, filters, "height_ft_range", "rollercoasterstats__height_ft"
|
||||
)
|
||||
queryset = self._apply_numeric_range_filter(
|
||||
queryset, filters, "length_ft_range", "rollercoasterstats__length_ft"
|
||||
)
|
||||
queryset = self._apply_numeric_range_filter(
|
||||
queryset, filters, "speed_mph_range", "rollercoasterstats__speed_mph"
|
||||
)
|
||||
queryset = self._apply_numeric_range_filter(
|
||||
queryset, filters, "inversions_range", "rollercoasterstats__inversions"
|
||||
)
|
||||
|
||||
# Track material filter (multi-select)
|
||||
if filters.get("track_material"):
|
||||
materials = (
|
||||
filters["track_material"]
|
||||
if isinstance(filters["track_material"], list)
|
||||
else [filters["track_material"]]
|
||||
)
|
||||
queryset = queryset.filter(rollercoasterstats__track_material__in=materials)
|
||||
|
||||
# Coaster type filter (multi-select)
|
||||
if filters.get("coaster_type"):
|
||||
types = (
|
||||
filters["coaster_type"]
|
||||
if isinstance(filters["coaster_type"], list)
|
||||
else [filters["coaster_type"]]
|
||||
)
|
||||
queryset = queryset.filter(
|
||||
rollercoasterstats__roller_coaster_type__in=types
|
||||
)
|
||||
|
||||
# Propulsion system filter (multi-select)
|
||||
if filters.get("propulsion_system"):
|
||||
propulsion_systems = (
|
||||
filters["propulsion_system"]
|
||||
if isinstance(filters["propulsion_system"], list)
|
||||
else [filters["propulsion_system"]]
|
||||
)
|
||||
queryset = queryset.filter(rollercoasterstats__propulsion_system__in=propulsion_systems)
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_numeric_range_filter(
|
||||
self,
|
||||
queryset,
|
||||
filters: Dict[str, Any],
|
||||
filter_key: str,
|
||||
field_name: str,
|
||||
) -> models.QuerySet:
|
||||
"""Apply numeric range filter to reduce complexity."""
|
||||
if filters.get(filter_key):
|
||||
range_filter = filters[filter_key]
|
||||
if range_filter.get("min") is not None:
|
||||
queryset = queryset.filter(
|
||||
**{f"{field_name}__gte": range_filter["min"]}
|
||||
)
|
||||
if range_filter.get("max") is not None:
|
||||
queryset = queryset.filter(
|
||||
**{f"{field_name}__lte": range_filter["max"]}
|
||||
)
|
||||
return queryset
|
||||
|
||||
def _apply_company_filters(
|
||||
self, queryset, filters: Dict[str, Any]
|
||||
) -> models.QuerySet:
|
||||
"""Apply company-related filters."""
|
||||
|
||||
# Manufacturer roles filter
|
||||
if filters.get("manufacturer_roles"):
|
||||
roles = (
|
||||
filters["manufacturer_roles"]
|
||||
if isinstance(filters["manufacturer_roles"], list)
|
||||
else [filters["manufacturer_roles"]]
|
||||
)
|
||||
queryset = queryset.filter(manufacturer__roles__overlap=roles)
|
||||
|
||||
# Designer roles filter
|
||||
if filters.get("designer_roles"):
|
||||
roles = (
|
||||
filters["designer_roles"]
|
||||
if isinstance(filters["designer_roles"], list)
|
||||
else [filters["designer_roles"]]
|
||||
)
|
||||
queryset = queryset.filter(designer__roles__overlap=roles)
|
||||
|
||||
# Founded date range
|
||||
if filters.get("founded_date_range"):
|
||||
date_range = filters["founded_date_range"]
|
||||
if date_range.get("start"):
|
||||
queryset = queryset.filter(
|
||||
Q(manufacturer__founded_date__gte=date_range["start"])
|
||||
| Q(designer__founded_date__gte=date_range["start"])
|
||||
)
|
||||
if date_range.get("end"):
|
||||
queryset = queryset.filter(
|
||||
Q(manufacturer__founded_date__lte=date_range["end"])
|
||||
| Q(designer__founded_date__lte=date_range["end"])
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_sorting(self, queryset, sort_by: str) -> models.QuerySet:
|
||||
"""Apply sorting to the queryset."""
|
||||
|
||||
if sort_by not in self.SORT_OPTIONS:
|
||||
sort_by = "relevance"
|
||||
|
||||
sort_field = self.SORT_OPTIONS[sort_by]
|
||||
|
||||
# Handle special case for relevance sorting
|
||||
if sort_by == "relevance":
|
||||
return queryset.order_by("-search_rank", "name")
|
||||
|
||||
# Apply the sorting
|
||||
return queryset.order_by(
|
||||
sort_field, "name"
|
||||
) # Always add name as secondary sort
|
||||
|
||||
def _add_search_highlights(
|
||||
self, results: List[Ride], search_term: str
|
||||
) -> List[Ride]:
|
||||
"""Add search highlights to results using SearchHeadline."""
|
||||
|
||||
if not search_term or not results:
|
||||
return results
|
||||
|
||||
# Create search query for highlighting
|
||||
SearchQuery(search_term, config="english")
|
||||
|
||||
# Add highlights to each result
|
||||
# (note: highlights would need to be processed at query time)
|
||||
for ride in results:
|
||||
# Store highlighted versions as dynamic attributes (for template use)
|
||||
setattr(ride, "highlighted_name", ride.name)
|
||||
setattr(ride, "highlighted_description", ride.description)
|
||||
|
||||
return results
|
||||
|
||||
def _get_applied_filters_summary(self, filters: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Generate a summary of applied filters for the frontend."""
|
||||
|
||||
applied = {}
|
||||
|
||||
# Count filters in each category
|
||||
for category, filter_keys in self.FILTER_CATEGORIES.items():
|
||||
category_filters = []
|
||||
for key in filter_keys:
|
||||
if filters.get(key):
|
||||
category_filters.append(
|
||||
{
|
||||
"key": key,
|
||||
"value": filters[key],
|
||||
"display_name": self._get_filter_display_name(key),
|
||||
}
|
||||
)
|
||||
if category_filters:
|
||||
applied[category] = category_filters
|
||||
|
||||
return applied
|
||||
|
||||
def _get_filter_display_name(self, filter_key: str) -> str:
|
||||
"""Convert filter key to human-readable display name."""
|
||||
|
||||
display_names = {
|
||||
"global_search": "Search",
|
||||
"category": "Category",
|
||||
"status": "Status",
|
||||
"park": "Park",
|
||||
"park_area": "Park Area",
|
||||
"opening_date_range": "Opening Date",
|
||||
"closing_date_range": "Closing Date",
|
||||
"status_since_range": "Status Since",
|
||||
"min_height_range": "Minimum Height",
|
||||
"max_height_range": "Maximum Height",
|
||||
"capacity_range": "Capacity",
|
||||
"duration_range": "Duration",
|
||||
"rating_range": "Rating",
|
||||
"manufacturer": "Manufacturer",
|
||||
"designer": "Designer",
|
||||
"ride_model": "Ride Model",
|
||||
"height_ft_range": "Height (ft)",
|
||||
"length_ft_range": "Length (ft)",
|
||||
"speed_mph_range": "Speed (mph)",
|
||||
"inversions_range": "Inversions",
|
||||
"track_material": "Track Material",
|
||||
"coaster_type": "Coaster Type",
|
||||
"propulsion_system": "Propulsion System",
|
||||
"manufacturer_roles": "Manufacturer Roles",
|
||||
"designer_roles": "Designer Roles",
|
||||
"founded_date_range": "Founded Date",
|
||||
}
|
||||
|
||||
if filter_key in display_names:
|
||||
return display_names[filter_key]
|
||||
else:
|
||||
raise ValueError(f"Unknown filter key: {filter_key}")
|
||||
|
||||
def get_search_suggestions(
|
||||
self, query: str, limit: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get search suggestions for autocomplete functionality.
|
||||
"""
|
||||
|
||||
if not query or len(query) < 2:
|
||||
return []
|
||||
|
||||
suggestions = []
|
||||
|
||||
# Ride names with trigram similarity
|
||||
ride_suggestions = (
|
||||
Ride.objects.annotate(similarity=TrigramSimilarity("name", query))
|
||||
.filter(similarity__gte=0.1)
|
||||
.order_by("-similarity")
|
||||
.values("name", "slug", "similarity")[: limit // 2]
|
||||
)
|
||||
|
||||
for ride in ride_suggestions:
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "ride",
|
||||
"text": ride["name"],
|
||||
"slug": ride["slug"],
|
||||
"score": ride["similarity"],
|
||||
}
|
||||
)
|
||||
|
||||
# Park names
|
||||
park_suggestions = (
|
||||
Park.objects.annotate(similarity=TrigramSimilarity("name", query))
|
||||
.filter(similarity__gte=0.1)
|
||||
.order_by("-similarity")
|
||||
.values("name", "slug", "similarity")[: limit // 4]
|
||||
)
|
||||
|
||||
for park in park_suggestions:
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "park",
|
||||
"text": park["name"],
|
||||
"slug": park["slug"],
|
||||
"score": park["similarity"],
|
||||
}
|
||||
)
|
||||
|
||||
# Manufacturer names
|
||||
manufacturer_suggestions = (
|
||||
Company.objects.filter(roles__contains=["MANUFACTURER"])
|
||||
.annotate(similarity=TrigramSimilarity("name", query))
|
||||
.filter(similarity__gte=0.1)
|
||||
.order_by("-similarity")
|
||||
.values("name", "slug", "similarity")[: limit // 4]
|
||||
)
|
||||
|
||||
for manufacturer in manufacturer_suggestions:
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "manufacturer",
|
||||
"text": manufacturer["name"],
|
||||
"slug": manufacturer["slug"],
|
||||
"score": manufacturer["similarity"],
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by score and return top results
|
||||
suggestions.sort(key=lambda x: x["score"], reverse=True)
|
||||
return suggestions[:limit]
|
||||
|
||||
def get_filter_options(
|
||||
self, filter_type: str, context_filters: Optional[Dict[str, Any]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get available options for a specific filter type.
|
||||
Optionally filter options based on current context.
|
||||
"""
|
||||
|
||||
context_filters = context_filters or {}
|
||||
base_queryset = self.base_queryset
|
||||
|
||||
# Apply context filters to narrow down options
|
||||
if context_filters:
|
||||
temp_filters = context_filters.copy()
|
||||
temp_filters.pop(
|
||||
filter_type, None
|
||||
) # Remove the filter we're getting options for
|
||||
base_queryset = self._apply_all_filters(base_queryset, temp_filters)
|
||||
|
||||
if filter_type == "park":
|
||||
return list(
|
||||
base_queryset.values("park__name", "park__slug")
|
||||
.distinct()
|
||||
.order_by("park__name")
|
||||
)
|
||||
|
||||
elif filter_type == "manufacturer":
|
||||
return list(
|
||||
base_queryset.filter(manufacturer__isnull=False)
|
||||
.values("manufacturer__name", "manufacturer__slug")
|
||||
.distinct()
|
||||
.order_by("manufacturer__name")
|
||||
)
|
||||
|
||||
elif filter_type == "designer":
|
||||
return list(
|
||||
base_queryset.filter(designer__isnull=False)
|
||||
.values("designer__name", "designer__slug")
|
||||
.distinct()
|
||||
.order_by("designer__name")
|
||||
)
|
||||
|
||||
# Add more filter options as needed
|
||||
return []
|
||||
|
||||
def _apply_all_filters(self, queryset, filters: Dict[str, Any]) -> models.QuerySet:
|
||||
"""Apply all filters except search ranking."""
|
||||
|
||||
queryset = self._apply_basic_info_filters(queryset, filters)
|
||||
queryset = self._apply_date_filters(queryset, filters)
|
||||
queryset = self._apply_height_safety_filters(queryset, filters)
|
||||
queryset = self._apply_performance_filters(queryset, filters)
|
||||
queryset = self._apply_relationship_filters(queryset, filters)
|
||||
queryset = self._apply_roller_coaster_filters(queryset, filters)
|
||||
queryset = self._apply_company_filters(queryset, filters)
|
||||
|
||||
return queryset
|
||||
@@ -1,17 +0,0 @@
|
||||
from django.db.models.signals import pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from .models import Ride
|
||||
|
||||
|
||||
@receiver(pre_save, sender=Ride)
|
||||
def handle_ride_status(sender, instance, **kwargs):
|
||||
"""Handle ride status changes based on closing date"""
|
||||
if instance.closing_date:
|
||||
today = timezone.now().date()
|
||||
|
||||
# If we've reached the closing date and status is "Closing"
|
||||
if today >= instance.closing_date and instance.status == "CLOSING":
|
||||
# Change to the selected post-closing status
|
||||
instance.status = instance.post_closing_status or "SBNO"
|
||||
instance.status_since = instance.closing_date
|
||||
@@ -1,3 +0,0 @@
|
||||
{% for company in companies %}
|
||||
<div>{{ company.name }}</div>
|
||||
{% endfor %}
|
||||
@@ -1,26 +0,0 @@
|
||||
{% if suggestions %}
|
||||
<div id="search-suggestions" class="search-suggestions bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
{% for suggestion in suggestions %}
|
||||
<div class="suggestion px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer flex items-center gap-2"
|
||||
data-type="{{ suggestion.type }}"
|
||||
data-suggestion="{{ suggestion.text|escape }}"
|
||||
role="option">
|
||||
{% if suggestion.type == 'ride' %}
|
||||
<span class="icon text-xl">🎢</span>
|
||||
<span class="text flex-grow text-gray-900 dark:text-white">{{ suggestion.text }}</span>
|
||||
<span class="count text-sm text-gray-500 dark:text-gray-400">({{ suggestion.count }} rides)</span>
|
||||
{% elif suggestion.type == 'park' %}
|
||||
<span class="icon text-xl">🎪</span>
|
||||
<span class="text flex-grow text-gray-900 dark:text-white">{{ suggestion.text }}</span>
|
||||
{% if suggestion.location %}
|
||||
<span class="location text-sm text-gray-500 dark:text-gray-400">{{ suggestion.location }}</span>
|
||||
{% endif %}
|
||||
{% elif suggestion.type == 'category' %}
|
||||
<span class="icon text-xl">📂</span>
|
||||
<span class="text flex-grow text-gray-900 dark:text-white">{{ suggestion.text }}</span>
|
||||
<span class="count text-sm text-gray-500 dark:text-gray-400">({{ suggestion.count }} rides)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,214 +0,0 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
{% load ride_tags %}
|
||||
|
||||
{% block title %}
|
||||
{% if park %}
|
||||
Rides at {{ park.name }} - ThrillWiki
|
||||
{% else %}
|
||||
All Rides - ThrillWiki
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold mb-4">
|
||||
{% if park %}
|
||||
Rides at {{ park.name }}
|
||||
{% else %}
|
||||
All Rides
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
{# Search Section #}
|
||||
<div class="relative">
|
||||
<div class="flex items-center">
|
||||
<input type="text"
|
||||
id="ride-search"
|
||||
name="q"
|
||||
class="w-full p-4 border rounded-lg shadow-sm"
|
||||
placeholder="Search rides by name, park, or category..."
|
||||
hx-get="{% url 'rides:global_ride_list' %}"
|
||||
hx-trigger="keyup changed delay:500ms, search"
|
||||
hx-target="#ride-list-results"
|
||||
hx-push-url="false"
|
||||
hx-indicator="#search-loading"
|
||||
autocomplete="off">
|
||||
|
||||
<div id="search-loading" class="loading-indicator htmx-indicator">
|
||||
<i class="ml-3 fa fa-spinner fa-spin text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Search Suggestions #}
|
||||
<div id="search-suggestions-wrapper"
|
||||
hx-get="{% url 'rides:search_suggestions' %}"
|
||||
hx-trigger="keyup from:#ride-search delay:200ms"
|
||||
class="absolute w-full bg-white border rounded-lg shadow-lg mt-1 z-50">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Quick Filter Buttons #}
|
||||
<div class="flex flex-wrap gap-2 mt-4">
|
||||
<button class="filter-btn active"
|
||||
hx-get="{% url 'rides:global_ride_list' %}"
|
||||
hx-target="#ride-list-results"
|
||||
hx-push-url="false"
|
||||
hx-include="[name='q']">
|
||||
All Rides
|
||||
</button>
|
||||
<button class="filter-btn"
|
||||
hx-get="{% url 'rides:global_ride_list' %}"
|
||||
hx-target="#ride-list-results"
|
||||
hx-push-url="false"
|
||||
hx-include="[name='q']"
|
||||
hx-vals='{"operating": "true"}'>
|
||||
Operating
|
||||
</button>
|
||||
{% for code, name in category_choices %}
|
||||
<button class="filter-btn"
|
||||
hx-get="{% url 'rides:global_ride_list' %}"
|
||||
hx-target="#ride-list-results"
|
||||
hx-push-url="false"
|
||||
hx-include="[name='q']"
|
||||
hx-vals='{"category": "{{ code }}"}'>
|
||||
{{ name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Active Filter Tags #}
|
||||
<div id="active-filters" class="flex flex-wrap gap-2 mt-4">
|
||||
{% if request.GET.q %}
|
||||
<span class="filter-tag">
|
||||
Search: {{ request.GET.q }}
|
||||
<button class="ml-2"
|
||||
hx-get="{% url 'rides:global_ride_list' %}"
|
||||
hx-target="#ride-list-results"
|
||||
hx-push-url="true"
|
||||
title="Clear search">×</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if request.GET.category %}
|
||||
<span class="filter-tag">
|
||||
Category: {{ request.GET.category|get_category_display }}
|
||||
<button class="ml-2"
|
||||
hx-get="{% url 'rides:global_ride_list' %}"
|
||||
hx-target="#ride-list-results"
|
||||
hx-push-url="true"
|
||||
hx-include="[name='q']"
|
||||
title="Clear category">×</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if request.GET.operating %}
|
||||
<span class="filter-tag">
|
||||
Operating Only
|
||||
<button class="ml-2"
|
||||
hx-get="{% url 'rides:global_ride_list' %}"
|
||||
hx-target="#ride-list-results"
|
||||
hx-push-url="true"
|
||||
hx-include="[name='q']"
|
||||
title="Clear operating filter">×</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Results Section #}
|
||||
<div id="ride-list-results">
|
||||
{% include "rides/partials/ride_list_results.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Handle suggestion selection
|
||||
document.addEventListener('click', function(e) {
|
||||
const suggestion = e.target.closest('.suggestion');
|
||||
if (suggestion) {
|
||||
const searchInput = document.getElementById('ride-search');
|
||||
searchInput.value = suggestion.dataset.suggestion;
|
||||
searchInput.dispatchEvent(new Event('keyup')); // Trigger HTMX search
|
||||
document.getElementById('search-suggestions-wrapper').innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle filter button UI
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize active state based on URL params
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const category = urlParams.get('category');
|
||||
const operating = urlParams.get('operating');
|
||||
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
const btnVals = btn.getAttribute('hx-vals');
|
||||
if (
|
||||
(category && btnVals && btnVals.includes(category)) ||
|
||||
(operating && btnVals && btnVals.includes('operating')) ||
|
||||
(!category && !operating && btn.textContent.trim() === 'All Rides')
|
||||
) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.filter-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.filter-tag button {
|
||||
color: #6b7280;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-tag button:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-request .loading-indicator {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: #f3f4f6;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1,38 +0,0 @@
|
||||
from django import template
|
||||
from django.templatetags.static import static
|
||||
from ..choices import RIDE_CATEGORIES
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def get_ride_placeholder_image(category):
|
||||
"""Return placeholder image based on ride category"""
|
||||
category_images = {
|
||||
"RC": "images/placeholders/roller-coaster.jpg",
|
||||
"DR": "images/placeholders/dark-ride.jpg",
|
||||
"FR": "images/placeholders/flat-ride.jpg",
|
||||
"WR": "images/placeholders/water-ride.jpg",
|
||||
"TR": "images/placeholders/transport.jpg",
|
||||
"OT": "images/placeholders/other-ride.jpg",
|
||||
}
|
||||
if category in category_images:
|
||||
return static(category_images[category])
|
||||
else:
|
||||
raise ValueError(f"Unknown ride category: {category}")
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def get_park_placeholder_image():
|
||||
"""Return placeholder image for parks"""
|
||||
return static("images/placeholders/default-park.jpg")
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_category_display(code):
|
||||
"""Convert category code to display name"""
|
||||
choices = {choice.value: choice.label for choice in RIDE_CATEGORIES}
|
||||
if code in choices:
|
||||
return choices[code]
|
||||
else:
|
||||
raise ValueError(f"Unknown ride category code: {code}")
|
||||
@@ -1 +0,0 @@
|
||||
# Create your tests here.
|
||||
@@ -1,86 +0,0 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = "rides"
|
||||
|
||||
urlpatterns = [
|
||||
# Global list views
|
||||
path("", views.RideListView.as_view(), name="global_ride_list"),
|
||||
# Global category views
|
||||
path(
|
||||
"roller-coasters/",
|
||||
views.SingleCategoryListView.as_view(),
|
||||
{"category": "RC"},
|
||||
name="global_roller_coasters",
|
||||
),
|
||||
path(
|
||||
"dark-rides/",
|
||||
views.SingleCategoryListView.as_view(),
|
||||
{"category": "DR"},
|
||||
name="global_dark_rides",
|
||||
),
|
||||
path(
|
||||
"flat-rides/",
|
||||
views.SingleCategoryListView.as_view(),
|
||||
{"category": "FR"},
|
||||
name="global_flat_rides",
|
||||
),
|
||||
path(
|
||||
"water-rides/",
|
||||
views.SingleCategoryListView.as_view(),
|
||||
{"category": "WR"},
|
||||
name="global_water_rides",
|
||||
),
|
||||
path(
|
||||
"transports/",
|
||||
views.SingleCategoryListView.as_view(),
|
||||
{"category": "TR"},
|
||||
name="global_transports",
|
||||
),
|
||||
path(
|
||||
"others/",
|
||||
views.SingleCategoryListView.as_view(),
|
||||
{"category": "OT"},
|
||||
name="global_others",
|
||||
),
|
||||
# Search endpoints (must come before slug patterns)
|
||||
path("search/models/", views.search_ride_models, name="search_ride_models"),
|
||||
path("search/companies/", views.search_companies, name="search_companies"),
|
||||
# HTMX endpoints (must come before slug patterns)
|
||||
path("coaster-fields/", views.show_coaster_fields, name="coaster_fields"),
|
||||
path(
|
||||
"search-suggestions/",
|
||||
views.get_search_suggestions,
|
||||
name="search_suggestions",
|
||||
),
|
||||
# Ranking endpoints
|
||||
path("rankings/", views.RideRankingsView.as_view(), name="rankings"),
|
||||
path(
|
||||
"rankings/<slug:ride_slug>/",
|
||||
views.RideRankingDetailView.as_view(),
|
||||
name="ranking_detail",
|
||||
),
|
||||
path(
|
||||
"rankings/<slug:ride_slug>/history-chart/",
|
||||
views.ranking_history_chart,
|
||||
name="ranking_history_chart",
|
||||
),
|
||||
path(
|
||||
"rankings/<slug:ride_slug>/comparisons/",
|
||||
views.ranking_comparisons,
|
||||
name="ranking_comparisons",
|
||||
),
|
||||
# Company list views
|
||||
path("manufacturers/", views.ManufacturerListView.as_view(), name="manufacturer_list"),
|
||||
path("designers/", views.DesignerListView.as_view(), name="designer_list"),
|
||||
# API endpoints moved to centralized backend/api/v1/rides/ structure
|
||||
# Frontend requests to /api/ are proxied to /api/v1/ by Vite
|
||||
# Park-specific URLs
|
||||
path("create/", views.RideCreateView.as_view(), name="ride_create"),
|
||||
path("<slug:ride_slug>/", views.RideDetailView.as_view(), name="ride_detail"),
|
||||
path(
|
||||
"<slug:ride_slug>/update/",
|
||||
views.RideUpdateView.as_view(),
|
||||
name="ride_update",
|
||||
),
|
||||
]
|
||||
@@ -1,700 +0,0 @@
|
||||
from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpRequest, HttpResponse, Http404
|
||||
from django.db.models import Count
|
||||
from .models.rides import Ride, RideModel
|
||||
from .choices import RIDE_CATEGORIES
|
||||
from .models.company import Company
|
||||
from .forms import RideForm, RideSearchForm
|
||||
from .forms.search import MasterFilterForm
|
||||
from .services.search import RideSearchService
|
||||
from apps.parks.models import Park
|
||||
from apps.moderation.mixins import EditSubmissionMixin, HistoryMixin
|
||||
from apps.moderation.services import ModerationService
|
||||
from .models.rankings import RideRanking, RankingSnapshot
|
||||
from .services.ranking_service import RideRankingService
|
||||
|
||||
|
||||
class ParkContextRequired:
|
||||
"""Mixin to require park context for views"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if "park_slug" not in self.kwargs:
|
||||
raise Http404("Park context is required")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
def show_coaster_fields(request: HttpRequest) -> HttpResponse:
|
||||
"""Show roller coaster specific fields based on category selection"""
|
||||
category = request.GET.get("category")
|
||||
if category != "RC": # Only show for roller coasters
|
||||
return HttpResponse("")
|
||||
return render(request, "rides/partials/coaster_fields.html")
|
||||
|
||||
|
||||
class RideDetailView(HistoryMixin, DetailView):
|
||||
"""View for displaying ride details"""
|
||||
|
||||
model = Ride
|
||||
template_name = "rides/ride_detail.html"
|
||||
slug_url_kwarg = "ride_slug"
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get ride for the specific park if park_slug is provided"""
|
||||
queryset = (
|
||||
Ride.objects.all()
|
||||
.select_related("park", "ride_model", "ride_model__manufacturer")
|
||||
.prefetch_related("photos")
|
||||
)
|
||||
|
||||
if "park_slug" in self.kwargs:
|
||||
queryset = queryset.filter(park__slug=self.kwargs["park_slug"])
|
||||
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add context data"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
if "park_slug" in self.kwargs:
|
||||
context["park_slug"] = self.kwargs["park_slug"]
|
||||
context["park"] = self.object.park
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class RideCreateView(LoginRequiredMixin, ParkContextRequired, CreateView):
|
||||
"""View for creating a new ride"""
|
||||
|
||||
model = Ride
|
||||
form_class = RideForm
|
||||
template_name = "rides/ride_form.html"
|
||||
|
||||
def get_success_url(self):
|
||||
"""Get URL to redirect to after successful creation"""
|
||||
return reverse(
|
||||
"parks:rides:ride_detail",
|
||||
kwargs={
|
||||
"park_slug": self.park.slug,
|
||||
"ride_slug": self.object.slug,
|
||||
},
|
||||
)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
"""Pass park to the form"""
|
||||
kwargs = super().get_form_kwargs()
|
||||
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
||||
kwargs["park"] = self.park
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add park and park_slug to context"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["park"] = self.park
|
||||
context["park_slug"] = self.park.slug
|
||||
context["is_edit"] = False
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Handle form submission including new items"""
|
||||
# Check for new manufacturer
|
||||
manufacturer_name = form.cleaned_data.get("manufacturer_search")
|
||||
if manufacturer_name and not form.cleaned_data.get("manufacturer"):
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={"name": manufacturer_name, "roles": ["MANUFACTURER"]},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New manufacturer suggested during ride creation: {manufacturer_name}",
|
||||
)
|
||||
|
||||
# Check for new designer
|
||||
designer_name = form.cleaned_data.get("designer_search")
|
||||
if designer_name and not form.cleaned_data.get("designer"):
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={"name": designer_name, "roles": ["DESIGNER"]},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New designer suggested during ride creation: {designer_name}",
|
||||
)
|
||||
|
||||
# Check for new ride model
|
||||
ride_model_name = form.cleaned_data.get("ride_model_search")
|
||||
manufacturer = form.cleaned_data.get("manufacturer")
|
||||
if ride_model_name and not form.cleaned_data.get("ride_model") and manufacturer:
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={
|
||||
"name": ride_model_name,
|
||||
"manufacturer": manufacturer.id,
|
||||
},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New ride model suggested during ride creation: {ride_model_name}",
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class RideUpdateView(
|
||||
LoginRequiredMixin, ParkContextRequired, EditSubmissionMixin, UpdateView
|
||||
):
|
||||
"""View for updating an existing ride"""
|
||||
|
||||
model = Ride
|
||||
form_class = RideForm
|
||||
template_name = "rides/ride_form.html"
|
||||
slug_url_kwarg = "ride_slug"
|
||||
|
||||
def get_success_url(self):
|
||||
"""Get URL to redirect to after successful update"""
|
||||
return reverse(
|
||||
"parks:rides:ride_detail",
|
||||
kwargs={
|
||||
"park_slug": self.park.slug,
|
||||
"ride_slug": self.object.slug,
|
||||
},
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get ride for the specific park"""
|
||||
return Ride.objects.filter(park__slug=self.kwargs["park_slug"])
|
||||
|
||||
def get_form_kwargs(self):
|
||||
"""Pass park to the form"""
|
||||
kwargs = super().get_form_kwargs()
|
||||
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
||||
kwargs["park"] = self.park
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add park and park_slug to context"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["park"] = self.park
|
||||
context["park_slug"] = self.park.slug
|
||||
context["is_edit"] = True
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Handle form submission including new items"""
|
||||
# Check for new manufacturer
|
||||
manufacturer_name = form.cleaned_data.get("manufacturer_search")
|
||||
if manufacturer_name and not form.cleaned_data.get("manufacturer"):
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={"name": manufacturer_name, "roles": ["MANUFACTURER"]},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New manufacturer suggested during ride update: {manufacturer_name}",
|
||||
)
|
||||
|
||||
# Check for new designer
|
||||
designer_name = form.cleaned_data.get("designer_search")
|
||||
if designer_name and not form.cleaned_data.get("designer"):
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={"name": designer_name, "roles": ["DESIGNER"]},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New designer suggested during ride update: {designer_name}",
|
||||
)
|
||||
|
||||
# Check for new ride model
|
||||
ride_model_name = form.cleaned_data.get("ride_model_search")
|
||||
manufacturer = form.cleaned_data.get("manufacturer")
|
||||
if ride_model_name and not form.cleaned_data.get("ride_model") and manufacturer:
|
||||
ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None,
|
||||
changes={
|
||||
"name": ride_model_name,
|
||||
"manufacturer": manufacturer.id,
|
||||
},
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
reason=f"New ride model suggested during ride update: {ride_model_name}",
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class RideListView(ListView):
|
||||
"""Enhanced view for displaying a list of rides with advanced filtering"""
|
||||
|
||||
model = Ride
|
||||
template_name = "rides/ride_list.html"
|
||||
context_object_name = "rides"
|
||||
paginate_by = 24
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get filtered rides using the advanced search service"""
|
||||
# Initialize search service
|
||||
search_service = RideSearchService()
|
||||
|
||||
# Parse filters from request
|
||||
filter_form = MasterFilterForm(self.request.GET)
|
||||
|
||||
# Apply park context if available
|
||||
park = None
|
||||
if "park_slug" in self.kwargs:
|
||||
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
||||
park = self.park
|
||||
|
||||
# For now, use a simpler approach until we can properly integrate the search service
|
||||
queryset = (
|
||||
Ride.objects.all()
|
||||
.select_related("park", "ride_model", "ride_model__manufacturer")
|
||||
.prefetch_related("photos")
|
||||
)
|
||||
|
||||
if park:
|
||||
queryset = queryset.filter(park=park)
|
||||
|
||||
# Apply basic search if provided
|
||||
search_query = self.request.GET.get('search', '').strip()
|
||||
if search_query:
|
||||
queryset = queryset.filter(name__icontains=search_query)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_template_names(self):
|
||||
"""Return appropriate template based on request type"""
|
||||
if hasattr(self.request, "htmx") and self.request.htmx:
|
||||
return ["rides/partials/ride_list_results.html"]
|
||||
return [self.template_name]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add filter form and context data"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Add park context
|
||||
if hasattr(self, "park"):
|
||||
context["park"] = self.park
|
||||
context["park_slug"] = self.kwargs["park_slug"]
|
||||
|
||||
# Add filter form
|
||||
filter_form = MasterFilterForm(self.request.GET)
|
||||
context["filter_form"] = filter_form
|
||||
# Use Rich Choice registry directly
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("categories", "rides")
|
||||
context["category_choices"] = [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
# Add filter summary for display
|
||||
if filter_form.is_valid():
|
||||
context["active_filters"] = filter_form.get_filter_summary()
|
||||
context["has_filters"] = filter_form.has_active_filters()
|
||||
else:
|
||||
context["active_filters"] = {}
|
||||
context["has_filters"] = False
|
||||
|
||||
# Add total count before filtering (for display purposes)
|
||||
if hasattr(self, "park"):
|
||||
context["total_rides"] = Ride.objects.filter(park=self.park).count()
|
||||
else:
|
||||
context["total_rides"] = Ride.objects.count()
|
||||
|
||||
# Add filtered count
|
||||
context["filtered_count"] = self.get_queryset().count()
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class SingleCategoryListView(ListView):
|
||||
"""View for displaying rides of a specific category"""
|
||||
|
||||
model = Ride
|
||||
template_name = "rides/park_category_list.html"
|
||||
context_object_name = "rides"
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get rides filtered by category and optionally by park"""
|
||||
category = self.kwargs.get("category")
|
||||
queryset = Ride.objects.filter(category=category).select_related(
|
||||
"park", "ride_model", "ride_model__manufacturer"
|
||||
)
|
||||
|
||||
if "park_slug" in self.kwargs:
|
||||
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
||||
queryset = queryset.filter(park=self.park)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add park and category information to context"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
if hasattr(self, "park"):
|
||||
context["park"] = self.park
|
||||
context["park_slug"] = self.kwargs["park_slug"]
|
||||
# Find the category choice by value using Rich Choice registry
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("categories", "rides")
|
||||
category_choice = next((choice for choice in choices if choice.value == self.kwargs["category"]), None)
|
||||
context["category"] = category_choice.label if category_choice else "Unknown"
|
||||
return context
|
||||
|
||||
|
||||
# Alias for parks app to maintain backward compatibility
|
||||
ParkSingleCategoryListView = SingleCategoryListView
|
||||
|
||||
|
||||
def search_companies(request: HttpRequest) -> HttpResponse:
|
||||
"""Search companies and return results for HTMX"""
|
||||
query = request.GET.get("q", "").strip()
|
||||
role = request.GET.get("role", "").upper()
|
||||
|
||||
companies = Company.objects.all().order_by("name")
|
||||
if role:
|
||||
companies = companies.filter(roles__contains=[role])
|
||||
if query:
|
||||
companies = companies.filter(name__icontains=query)
|
||||
companies = companies[:10]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rides/partials/company_search_results.html",
|
||||
{"companies": companies, "search_term": query},
|
||||
)
|
||||
|
||||
|
||||
def search_ride_models(request: HttpRequest) -> HttpResponse:
|
||||
"""Search ride models and return results for HTMX"""
|
||||
query = request.GET.get("q", "").strip()
|
||||
manufacturer_id = request.GET.get("manufacturer")
|
||||
|
||||
# Show all ride models on click, filter on input
|
||||
ride_models = RideModel.objects.select_related("manufacturer").order_by("name")
|
||||
if query:
|
||||
ride_models = ride_models.filter(name__icontains=query)
|
||||
if manufacturer_id:
|
||||
ride_models = ride_models.filter(manufacturer_id=manufacturer_id)
|
||||
ride_models = ride_models[:10]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rides/partials/ride_model_search_results.html",
|
||||
{
|
||||
"ride_models": ride_models,
|
||||
"search_term": query,
|
||||
"manufacturer_id": manufacturer_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_search_suggestions(request: HttpRequest) -> HttpResponse:
|
||||
"""Get smart search suggestions for rides
|
||||
|
||||
Returns suggestions including:
|
||||
- Common matching ride names
|
||||
- Matching parks
|
||||
- Matching categories
|
||||
"""
|
||||
query = request.GET.get("q", "").strip().lower()
|
||||
suggestions = []
|
||||
|
||||
if query:
|
||||
# Get common ride names
|
||||
matching_names = (
|
||||
Ride.objects.filter(name__icontains=query)
|
||||
.values("name")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:3]
|
||||
)
|
||||
|
||||
for match in matching_names:
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "ride",
|
||||
"text": match["name"],
|
||||
"count": match["count"],
|
||||
}
|
||||
)
|
||||
|
||||
# Get matching parks
|
||||
matching_parks = Park.objects.filter(
|
||||
Q(name__icontains=query) | Q(location__city__icontains=query)
|
||||
)[:3]
|
||||
|
||||
for park in matching_parks:
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "park",
|
||||
"text": park.name,
|
||||
"location": park.location.city if park.location else None,
|
||||
}
|
||||
)
|
||||
|
||||
# Add category matches
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("categories", "rides")
|
||||
for choice in choices:
|
||||
if query in choice.label.lower():
|
||||
ride_count = Ride.objects.filter(category=choice.value).count()
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "category",
|
||||
"code": choice.value,
|
||||
"text": choice.label,
|
||||
"count": ride_count,
|
||||
}
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rides/partials/search_suggestions.html",
|
||||
{"suggestions": suggestions, "query": query},
|
||||
)
|
||||
|
||||
|
||||
class RideSearchView(ListView):
|
||||
"""View for ride search functionality with HTMX support."""
|
||||
|
||||
model = Ride
|
||||
template_name = "search/partials/ride_search_results.html"
|
||||
context_object_name = "rides"
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get filtered rides based on search form."""
|
||||
queryset = Ride.objects.select_related("park").order_by("name")
|
||||
|
||||
# Process search form
|
||||
form = RideSearchForm(self.request.GET)
|
||||
if form.is_valid():
|
||||
ride = form.cleaned_data.get("ride")
|
||||
if ride:
|
||||
# If specific ride selected, return just that ride
|
||||
queryset = queryset.filter(id=ride.id)
|
||||
else:
|
||||
# If no specific ride, filter by search term
|
||||
search_term = self.request.GET.get("ride", "").strip()
|
||||
if search_term:
|
||||
queryset = queryset.filter(name__icontains=search_term)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_template_names(self):
|
||||
"""Return appropriate template based on request type."""
|
||||
if self.request.htmx:
|
||||
return ["search/partials/ride_search_results.html"]
|
||||
return ["search/ride_search.html"]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add search form to context."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["search_form"] = RideSearchForm(self.request.GET)
|
||||
return context
|
||||
|
||||
|
||||
class RideRankingsView(ListView):
|
||||
"""View for displaying ride rankings using the Internet Roller Coaster Poll."""
|
||||
|
||||
model = RideRanking
|
||||
template_name = "rides/rankings.html"
|
||||
context_object_name = "rankings"
|
||||
paginate_by = 50
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get rankings with optimized queries."""
|
||||
queryset = RideRanking.objects.select_related(
|
||||
"ride", "ride__park", "ride__manufacturer", "ride__ride_model"
|
||||
).order_by("rank")
|
||||
|
||||
# Filter by category if specified
|
||||
category = self.request.GET.get("category")
|
||||
if category and category != "all":
|
||||
queryset = queryset.filter(ride__category=category)
|
||||
|
||||
# Filter by minimum mutual riders
|
||||
min_riders = self.request.GET.get("min_riders")
|
||||
if min_riders:
|
||||
try:
|
||||
min_riders = int(min_riders)
|
||||
queryset = queryset.filter(mutual_riders_count__gte=min_riders)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return queryset
|
||||
|
||||
def get_template_names(self):
|
||||
"""Return appropriate template based on request type."""
|
||||
if self.request.htmx:
|
||||
return ["rides/partials/rankings_table.html"]
|
||||
return [self.template_name]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add context for rankings view."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
# Use Rich Choice registry directly
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("categories", "rides")
|
||||
context["category_choices"] = [(choice.value, choice.label) for choice in choices]
|
||||
context["selected_category"] = self.request.GET.get("category", "all")
|
||||
context["min_riders"] = self.request.GET.get("min_riders", "")
|
||||
|
||||
# Add statistics
|
||||
if self.object_list:
|
||||
context["total_ranked"] = RideRanking.objects.count()
|
||||
context["last_updated"] = (
|
||||
self.object_list[0].last_calculated if self.object_list else None
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class RideRankingDetailView(DetailView):
|
||||
"""View for displaying detailed ranking information for a specific ride."""
|
||||
|
||||
model = Ride
|
||||
template_name = "rides/ranking_detail.html"
|
||||
slug_url_kwarg = "ride_slug"
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get ride with ranking data."""
|
||||
return Ride.objects.select_related(
|
||||
"park", "manufacturer", "ranking"
|
||||
).prefetch_related("comparisons_as_a", "comparisons_as_b", "ranking_history")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add ranking details to context."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Get ranking details from service
|
||||
service = RideRankingService()
|
||||
ranking_details = service.get_ride_ranking_details(self.object)
|
||||
|
||||
if ranking_details:
|
||||
context.update(ranking_details)
|
||||
|
||||
# Get recent movement
|
||||
recent_snapshots = RankingSnapshot.objects.filter(
|
||||
ride=self.object
|
||||
).order_by("-snapshot_date")[:7]
|
||||
|
||||
if len(recent_snapshots) >= 2:
|
||||
context["rank_change"] = (
|
||||
recent_snapshots[0].rank - recent_snapshots[1].rank
|
||||
)
|
||||
context["previous_rank"] = recent_snapshots[1].rank
|
||||
else:
|
||||
context["not_ranked"] = True
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def ranking_history_chart(request: HttpRequest, ride_slug: str) -> HttpResponse:
|
||||
"""HTMX endpoint for ranking history chart data."""
|
||||
ride = get_object_or_404(Ride, slug=ride_slug)
|
||||
|
||||
# Get last 30 days of ranking history
|
||||
history = RankingSnapshot.objects.filter(ride=ride).order_by("-snapshot_date")[:30]
|
||||
|
||||
# Prepare data for chart
|
||||
chart_data = [
|
||||
{
|
||||
"date": snapshot.snapshot_date.isoformat(),
|
||||
"rank": snapshot.rank,
|
||||
"win_pct": float(snapshot.winning_percentage) * 100,
|
||||
}
|
||||
for snapshot in reversed(history)
|
||||
]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rides/partials/ranking_chart.html",
|
||||
{"chart_data": chart_data, "ride": ride},
|
||||
)
|
||||
|
||||
|
||||
def ranking_comparisons(request: HttpRequest, ride_slug: str) -> HttpResponse:
|
||||
"""HTMX endpoint for ride head-to-head comparisons."""
|
||||
ride = get_object_or_404(Ride, slug=ride_slug)
|
||||
|
||||
# Get head-to-head comparisons
|
||||
from django.db.models import Q
|
||||
from .models.rankings import RidePairComparison
|
||||
|
||||
comparisons = (
|
||||
RidePairComparison.objects.filter(Q(ride_a=ride) | Q(ride_b=ride))
|
||||
.select_related("ride_a", "ride_b", "ride_a__park", "ride_b__park")
|
||||
.order_by("-mutual_riders_count")[:20]
|
||||
)
|
||||
|
||||
# Format comparisons for display
|
||||
comparison_data = []
|
||||
for comp in comparisons:
|
||||
if comp.ride_a == ride:
|
||||
opponent = comp.ride_b
|
||||
wins = comp.ride_a_wins
|
||||
losses = comp.ride_b_wins
|
||||
else:
|
||||
opponent = comp.ride_a
|
||||
wins = comp.ride_b_wins
|
||||
losses = comp.ride_a_wins
|
||||
|
||||
result = "win" if wins > losses else "loss" if losses > wins else "tie"
|
||||
|
||||
comparison_data.append(
|
||||
{
|
||||
"opponent": opponent,
|
||||
"wins": wins,
|
||||
"losses": losses,
|
||||
"ties": comp.ties,
|
||||
"result": result,
|
||||
"mutual_riders": comp.mutual_riders_count,
|
||||
}
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rides/partials/ranking_comparisons.html",
|
||||
{"comparisons": comparison_data, "ride": ride},
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerListView(ListView):
|
||||
"""View for displaying a list of ride manufacturers"""
|
||||
|
||||
model = Company
|
||||
template_name = "manufacturers/manufacturer_list.html"
|
||||
context_object_name = "manufacturers"
|
||||
paginate_by = 24
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get companies that are manufacturers"""
|
||||
return (
|
||||
Company.objects.filter(roles__contains=["MANUFACTURER"])
|
||||
.annotate(ride_count=Count("manufactured_rides"))
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add context data"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["total_manufacturers"] = self.get_queryset().count()
|
||||
return context
|
||||
|
||||
|
||||
class DesignerListView(ListView):
|
||||
"""View for displaying a list of ride designers"""
|
||||
|
||||
model = Company
|
||||
template_name = "designers/designer_list.html"
|
||||
context_object_name = "designers"
|
||||
paginate_by = 24
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get companies that are designers"""
|
||||
return (
|
||||
Company.objects.filter(roles__contains=["DESIGNER"])
|
||||
.annotate(ride_count=Count("designed_rides"))
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add context data"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["total_designers"] = self.get_queryset().count()
|
||||
return context
|
||||
Reference in New Issue
Block a user