mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 05:51:08 -05:00
- Added comprehensive documentation for hybrid filtering implementation, including architecture, API endpoints, performance characteristics, and usage examples. - Developed a hybrid pagination and client-side filtering recommendation, detailing server-side responsibilities and client-side logic. - Created a test script for hybrid filtering endpoints, covering various test cases including basic filtering, search functionality, pagination, and edge cases.
895 lines
32 KiB
Python
895 lines
32 KiB
Python
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 .company import Company
|
|
import pghistory
|
|
|
|
# Shared choices that will be used by multiple models
|
|
CATEGORY_CHOICES = [
|
|
("", "Select ride type"),
|
|
("RC", "Roller Coaster"),
|
|
("DR", "Dark Ride"),
|
|
("FR", "Flat Ride"),
|
|
("WR", "Water Ride"),
|
|
("TR", "Transport"),
|
|
("OT", "Other"),
|
|
]
|
|
|
|
# Legacy alias for backward compatibility
|
|
Categories = CATEGORY_CHOICES
|
|
|
|
|
|
@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 = models.CharField(
|
|
max_length=2,
|
|
choices=CATEGORY_CHOICES,
|
|
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 = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
choices=[
|
|
("FAMILY", "Family"),
|
|
("THRILL", "Thrill"),
|
|
("EXTREME", "Extreme"),
|
|
("KIDDIE", "Kiddie"),
|
|
("ALL_AGES", "All Ages"),
|
|
],
|
|
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 = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
("PROMOTIONAL", "Promotional"),
|
|
("TECHNICAL", "Technical Drawing"),
|
|
("INSTALLATION", "Installation Example"),
|
|
("RENDERING", "3D Rendering"),
|
|
("CATALOG", "Catalog Image"),
|
|
],
|
|
default="PROMOTIONAL",
|
|
)
|
|
|
|
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 = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
("DIMENSIONS", "Dimensions"),
|
|
("PERFORMANCE", "Performance"),
|
|
("CAPACITY", "Capacity"),
|
|
("SAFETY", "Safety Features"),
|
|
("ELECTRICAL", "Electrical Requirements"),
|
|
("FOUNDATION", "Foundation Requirements"),
|
|
("MAINTENANCE", "Maintenance"),
|
|
("OTHER", "Other"),
|
|
],
|
|
)
|
|
|
|
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.
|
|
"""
|
|
|
|
STATUS_CHOICES = [
|
|
("", "Select status"),
|
|
("OPERATING", "Operating"),
|
|
("CLOSED_TEMP", "Temporarily Closed"),
|
|
("SBNO", "Standing But Not Operating"),
|
|
("CLOSING", "Closing"),
|
|
("CLOSED_PERM", "Permanently Closed"),
|
|
("UNDER_CONSTRUCTION", "Under Construction"),
|
|
("DEMOLISHED", "Demolished"),
|
|
("RELOCATED", "Relocated"),
|
|
]
|
|
|
|
POST_CLOSING_STATUS_CHOICES = [
|
|
("SBNO", "Standing But Not Operating"),
|
|
("CLOSED_PERM", "Permanently Closed"),
|
|
]
|
|
|
|
name = models.CharField(max_length=255)
|
|
slug = models.SlugField(max_length=255)
|
|
description = models.TextField(blank=True)
|
|
park = models.ForeignKey(
|
|
"parks.Park", on_delete=models.CASCADE, related_name="rides"
|
|
)
|
|
park_area = models.ForeignKey(
|
|
"parks.ParkArea",
|
|
on_delete=models.SET_NULL,
|
|
related_name="rides",
|
|
null=True,
|
|
blank=True,
|
|
)
|
|
category = models.CharField(
|
|
max_length=2, choices=CATEGORY_CHOICES, default="", blank=True
|
|
)
|
|
manufacturer = models.ForeignKey(
|
|
Company,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="manufactured_rides",
|
|
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
|
|
)
|
|
designer = models.ForeignKey(
|
|
Company,
|
|
on_delete=models.SET_NULL,
|
|
related_name="designed_rides",
|
|
null=True,
|
|
blank=True,
|
|
limit_choices_to={"roles__contains": ["DESIGNER"]},
|
|
)
|
|
ride_model = models.ForeignKey(
|
|
"RideModel",
|
|
on_delete=models.SET_NULL,
|
|
related_name="rides",
|
|
null=True,
|
|
blank=True,
|
|
help_text="The specific model/type of this ride",
|
|
)
|
|
status = models.CharField(
|
|
max_length=20, choices=STATUS_CHOICES, default="OPERATING"
|
|
)
|
|
post_closing_status = models.CharField(
|
|
max_length=20,
|
|
choices=POST_CLOSING_STATUS_CHOICES,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Status to change to after closing date",
|
|
)
|
|
opening_date = models.DateField(null=True, blank=True)
|
|
closing_date = models.DateField(null=True, blank=True)
|
|
status_since = models.DateField(null=True, blank=True)
|
|
min_height_in = models.PositiveIntegerField(null=True, blank=True)
|
|
max_height_in = models.PositiveIntegerField(null=True, blank=True)
|
|
capacity_per_hour = models.PositiveIntegerField(null=True, blank=True)
|
|
ride_duration_seconds = models.PositiveIntegerField(null=True, blank=True)
|
|
average_rating = models.DecimalField(
|
|
max_digits=3, decimal_places=2, null=True, blank=True
|
|
)
|
|
|
|
# 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
|
|
|
|
# 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_display = dict(CATEGORY_CHOICES).get(self.category, '')
|
|
if category_display:
|
|
search_parts.append(category_display)
|
|
|
|
# Status
|
|
if self.status:
|
|
status_display = dict(self.STATUS_CHOICES).get(self.status, '')
|
|
if status_display:
|
|
search_parts.append(status_display)
|
|
|
|
# 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_display = dict(RollerCoasterStats.TRACK_MATERIAL_CHOICES).get(stats.track_material, '')
|
|
if material_display:
|
|
search_parts.append(material_display)
|
|
if stats.roller_coaster_type:
|
|
type_display = dict(RollerCoasterStats.COASTER_TYPE_CHOICES).get(stats.roller_coaster_type, '')
|
|
if type_display:
|
|
search_parts.append(type_display)
|
|
if stats.launch_type:
|
|
launch_display = dict(RollerCoasterStats.LAUNCH_CHOICES).get(stats.launch_type, '')
|
|
if launch_display:
|
|
search_parts.append(launch_display)
|
|
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
|
|
|
|
|
|
@pghistory.track()
|
|
class RollerCoasterStats(models.Model):
|
|
"""Model for tracking roller coaster specific statistics"""
|
|
|
|
TRACK_MATERIAL_CHOICES = [
|
|
("STEEL", "Steel"),
|
|
("WOOD", "Wood"),
|
|
("HYBRID", "Hybrid"),
|
|
]
|
|
|
|
COASTER_TYPE_CHOICES = [
|
|
("SITDOWN", "Sit Down"),
|
|
("INVERTED", "Inverted"),
|
|
("FLYING", "Flying"),
|
|
("STANDUP", "Stand Up"),
|
|
("WING", "Wing"),
|
|
("DIVE", "Dive"),
|
|
("FAMILY", "Family"),
|
|
("WILD_MOUSE", "Wild Mouse"),
|
|
("SPINNING", "Spinning"),
|
|
("FOURTH_DIMENSION", "4th Dimension"),
|
|
("OTHER", "Other"),
|
|
]
|
|
|
|
LAUNCH_CHOICES = [
|
|
("CHAIN", "Chain Lift"),
|
|
("LSM", "LSM Launch"),
|
|
("HYDRAULIC", "Hydraulic Launch"),
|
|
("GRAVITY", "Gravity"),
|
|
("OTHER", "Other"),
|
|
]
|
|
|
|
ride = models.OneToOneField(
|
|
Ride, on_delete=models.CASCADE, related_name="coaster_stats"
|
|
)
|
|
height_ft = models.DecimalField(
|
|
max_digits=6, decimal_places=2, null=True, blank=True
|
|
)
|
|
length_ft = models.DecimalField(
|
|
max_digits=7, decimal_places=2, null=True, blank=True
|
|
)
|
|
speed_mph = models.DecimalField(
|
|
max_digits=5, decimal_places=2, null=True, blank=True
|
|
)
|
|
inversions = models.PositiveIntegerField(default=0)
|
|
ride_time_seconds = models.PositiveIntegerField(null=True, blank=True)
|
|
track_type = models.CharField(max_length=255, blank=True)
|
|
track_material = models.CharField(
|
|
max_length=20,
|
|
choices=TRACK_MATERIAL_CHOICES,
|
|
default="STEEL",
|
|
blank=True,
|
|
)
|
|
roller_coaster_type = models.CharField(
|
|
max_length=20,
|
|
choices=COASTER_TYPE_CHOICES,
|
|
default="SITDOWN",
|
|
blank=True,
|
|
)
|
|
max_drop_height_ft = models.DecimalField(
|
|
max_digits=6, decimal_places=2, null=True, blank=True
|
|
)
|
|
launch_type = models.CharField(
|
|
max_length=20, choices=LAUNCH_CHOICES, default="CHAIN"
|
|
)
|
|
train_style = models.CharField(max_length=255, blank=True)
|
|
trains_count = models.PositiveIntegerField(null=True, blank=True)
|
|
cars_per_train = models.PositiveIntegerField(null=True, blank=True)
|
|
seats_per_car = models.PositiveIntegerField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
verbose_name = "Roller Coaster Statistics"
|
|
verbose_name_plural = "Roller Coaster Statistics"
|
|
|
|
def __str__(self) -> str:
|
|
return f"Stats for {self.ride.name}"
|