mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-24 05:31:09 -05:00
- Introduced a comprehensive Secret Management Guide detailing best practices, secret classification, development setup, production management, rotation procedures, and emergency protocols. - Implemented a client-side performance monitoring script to track various metrics including page load performance, paint metrics, layout shifts, and memory usage. - Enhanced search accessibility with keyboard navigation support for search results, ensuring compliance with WCAG standards and improving user experience.
1080 lines
38 KiB
Python
1080 lines
38 KiB
Python
from django.db import models
|
|
from django.utils.text import slugify
|
|
from django.core.exceptions import ValidationError
|
|
from config.django import base as settings
|
|
from apps.core.models import TrackedModel
|
|
from apps.core.choices import RichChoiceField
|
|
from apps.core.state_machine import RichFSMField, StateMachineMixin
|
|
from .company import Company
|
|
import pghistory
|
|
from typing import TYPE_CHECKING, Optional
|
|
from django.contrib.auth.models import AbstractBaseUser
|
|
|
|
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):
|
|
verbose_name = "Ride Model"
|
|
verbose_name_plural = "Ride Models"
|
|
ordering = ["manufacturer__name", "name"]
|
|
constraints = [
|
|
# Unique constraints (replacing unique_together for better error messages)
|
|
models.UniqueConstraint(
|
|
fields=['manufacturer', 'name'],
|
|
name='ridemodel_manufacturer_name_unique',
|
|
violation_error_message='A ride model with this name already exists for this manufacturer'
|
|
),
|
|
models.UniqueConstraint(
|
|
fields=['manufacturer', 'slug'],
|
|
name='ridemodel_manufacturer_slug_unique',
|
|
violation_error_message='A ride model with this slug already exists for this manufacturer'
|
|
),
|
|
# Height range validation
|
|
models.CheckConstraint(
|
|
name="ride_model_height_range_logical",
|
|
condition=models.Q(typical_height_range_min_ft__isnull=True)
|
|
| models.Q(typical_height_range_max_ft__isnull=True)
|
|
| models.Q(
|
|
typical_height_range_min_ft__lte=models.F(
|
|
"typical_height_range_max_ft"
|
|
)
|
|
),
|
|
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 clean(self) -> None:
|
|
"""Validate RideModel business rules."""
|
|
super().clean()
|
|
if self.is_discontinued and not self.last_installation_year:
|
|
raise ValidationError({
|
|
'last_installation_year': 'Discontinued models must have a last installation year'
|
|
})
|
|
|
|
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",
|
|
help_text="Base ride model this variant belongs to",
|
|
)
|
|
name = models.CharField(max_length=255, help_text="Name of this variant")
|
|
description = models.TextField(
|
|
blank=True, help_text="Description of variant differences"
|
|
)
|
|
|
|
# Variant-specific specifications
|
|
min_height_ft = models.DecimalField(
|
|
max_digits=6,
|
|
decimal_places=2,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Minimum height for this variant",
|
|
)
|
|
max_height_ft = models.DecimalField(
|
|
max_digits=6,
|
|
decimal_places=2,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Maximum height for this variant",
|
|
)
|
|
min_speed_mph = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=2,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Minimum speed for this variant",
|
|
)
|
|
max_speed_mph = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=2,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Maximum speed for this variant",
|
|
)
|
|
|
|
# Distinguishing features
|
|
distinguishing_features = models.TextField(
|
|
blank=True, help_text="What makes this variant unique from the base model"
|
|
)
|
|
|
|
class Meta(TrackedModel.Meta):
|
|
verbose_name = "Ride Model Variant"
|
|
verbose_name_plural = "Ride Model Variants"
|
|
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",
|
|
help_text="Ride model this photo belongs to",
|
|
)
|
|
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, help_text="Photo caption or description"
|
|
)
|
|
alt_text = models.CharField(
|
|
max_length=255, blank=True, help_text="Alternative text for accessibility"
|
|
)
|
|
|
|
# Photo metadata
|
|
photo_type = RichChoiceField(
|
|
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, help_text="Name of the photographer"
|
|
)
|
|
source = models.CharField(max_length=255, blank=True, help_text="Source of the photo")
|
|
copyright_info = models.CharField(
|
|
max_length=255, blank=True, help_text="Copyright information"
|
|
)
|
|
|
|
class Meta(TrackedModel.Meta):
|
|
verbose_name = "Ride Model Photo"
|
|
verbose_name_plural = "Ride Model Photos"
|
|
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",
|
|
help_text="Ride model this specification belongs to",
|
|
)
|
|
|
|
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):
|
|
verbose_name = "Ride Model Technical Specification"
|
|
verbose_name_plural = "Ride Model Technical Specifications"
|
|
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(StateMachineMixin, 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'
|
|
|
|
state_field_name = "status"
|
|
|
|
name = models.CharField(max_length=255)
|
|
slug = models.SlugField(max_length=255)
|
|
description = models.TextField(blank=True)
|
|
park = models.ForeignKey(
|
|
"parks.Park", on_delete=models.CASCADE, related_name="rides"
|
|
)
|
|
park_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 = RichFSMField(
|
|
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):
|
|
verbose_name = "Ride"
|
|
verbose_name_plural = "Rides"
|
|
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}"
|
|
|
|
# FSM Transition Wrapper Methods
|
|
def open(self, *, user: Optional[AbstractBaseUser] = None) -> None:
|
|
"""Transition ride to OPERATING status."""
|
|
self.transition_to_operating(user=user)
|
|
self.save()
|
|
|
|
def close_temporarily(self, *, user: Optional[AbstractBaseUser] = None) -> None:
|
|
"""Transition ride to CLOSED_TEMP status."""
|
|
self.transition_to_closed_temp(user=user)
|
|
self.save()
|
|
|
|
def mark_sbno(self, *, user: Optional[AbstractBaseUser] = None) -> None:
|
|
"""Transition ride to SBNO (Standing But Not Operating) status."""
|
|
self.transition_to_sbno(user=user)
|
|
self.save()
|
|
|
|
def mark_closing(
|
|
self,
|
|
*,
|
|
closing_date,
|
|
post_closing_status: str,
|
|
user: Optional[AbstractBaseUser] = None,
|
|
) -> None:
|
|
"""Transition ride to CLOSING status with closing date and target status."""
|
|
from django.core.exceptions import ValidationError
|
|
|
|
if not post_closing_status:
|
|
raise ValidationError(
|
|
"post_closing_status must be set when entering CLOSING status"
|
|
)
|
|
self.transition_to_closing(user=user)
|
|
self.closing_date = closing_date
|
|
self.post_closing_status = post_closing_status
|
|
self.save()
|
|
|
|
def close_permanently(self, *, user: Optional[AbstractBaseUser] = None) -> None:
|
|
"""Transition ride to CLOSED_PERM status."""
|
|
self.transition_to_closed_perm(user=user)
|
|
self.save()
|
|
|
|
def demolish(self, *, user: Optional[AbstractBaseUser] = None) -> None:
|
|
"""Transition ride to DEMOLISHED status."""
|
|
self.transition_to_demolished(user=user)
|
|
self.save()
|
|
|
|
def relocate(self, *, user: Optional[AbstractBaseUser] = None) -> None:
|
|
"""Transition ride to RELOCATED status."""
|
|
self.transition_to_relocated(user=user)
|
|
self.save()
|
|
|
|
def apply_post_closing_status(self, *, user: Optional[AbstractBaseUser] = None) -> None:
|
|
"""Apply post_closing_status if closing_date has been reached."""
|
|
from django.utils import timezone
|
|
from django.core.exceptions import ValidationError
|
|
|
|
if self.status != "CLOSING":
|
|
raise ValidationError("Ride must be in CLOSING status")
|
|
|
|
if not self.closing_date:
|
|
raise ValidationError("closing_date must be set")
|
|
|
|
if not self.post_closing_status:
|
|
raise ValidationError("post_closing_status must be set")
|
|
|
|
if timezone.now().date() < self.closing_date:
|
|
return # Not yet time to transition
|
|
|
|
# Transition to the target status
|
|
if self.post_closing_status == "SBNO":
|
|
self.transition_to_sbno(user=user)
|
|
elif self.post_closing_status == "CLOSED_PERM":
|
|
self.transition_to_closed_perm(user=user)
|
|
elif self.post_closing_status == "DEMOLISHED":
|
|
self.transition_to_demolished(user=user)
|
|
elif self.post_closing_status == "RELOCATED":
|
|
self.transition_to_relocated(user=user)
|
|
else:
|
|
raise ValidationError(f"Invalid post_closing_status: {self.post_closing_status}")
|
|
|
|
self.save()
|
|
|
|
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",
|
|
help_text="Ride these statistics belong to",
|
|
)
|
|
height_ft = models.DecimalField(
|
|
max_digits=6,
|
|
decimal_places=2,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Maximum height in feet",
|
|
)
|
|
length_ft = models.DecimalField(
|
|
max_digits=7,
|
|
decimal_places=2,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Track length in feet",
|
|
)
|
|
speed_mph = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=2,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Maximum speed in mph",
|
|
)
|
|
inversions = models.PositiveIntegerField(
|
|
default=0, help_text="Number of inversions"
|
|
)
|
|
ride_time_seconds = models.PositiveIntegerField(
|
|
null=True, blank=True, help_text="Duration of the ride in seconds"
|
|
)
|
|
track_type = models.CharField(
|
|
max_length=255, blank=True, help_text="Type of track (e.g., tubular steel, wooden)"
|
|
)
|
|
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,
|
|
help_text="Maximum drop height in feet",
|
|
)
|
|
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, help_text="Style of train (e.g., floorless, inverted)"
|
|
)
|
|
trains_count = models.PositiveIntegerField(
|
|
null=True, blank=True, help_text="Number of trains"
|
|
)
|
|
cars_per_train = models.PositiveIntegerField(
|
|
null=True, blank=True, help_text="Number of cars per train"
|
|
)
|
|
seats_per_car = models.PositiveIntegerField(
|
|
null=True, blank=True, help_text="Number of seats per car"
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "Roller Coaster Statistics"
|
|
verbose_name_plural = "Roller Coaster Statistics"
|
|
ordering = ["ride"]
|
|
|
|
def __str__(self) -> str:
|
|
return f"Stats for {self.ride.name}"
|