import contextlib import pghistory from django.contrib.auth.models import AbstractBaseUser from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.db import models from django.utils.text import slugify from apps.core.choices import RichChoiceField from apps.core.models import TrackedModel from apps.core.state_machine import RichFSMField, StateMachineMixin from config.django import base as settings from .company import Company @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", ) ride_type = models.CharField( max_length=100, blank=True, help_text="Specific ride type within the category (e.g., 'Flying Coaster', 'Inverted Coaster')", ) # 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") # Submission metadata fields (from frontend schema) source_url = models.URLField( blank=True, help_text="Source URL for the data (e.g., manufacturer website)", ) is_test_data = models.BooleanField( default=False, help_text="Whether this is test/development data", ) 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. """ # Type hint for the reverse relation from RollerCoasterStats coaster_stats: "RollerCoasterStats" state_field_name = "status" name = models.CharField(max_length=255) slug = models.SlugField(max_length=255) description = models.TextField(blank=True) park = models.ForeignKey("parks.Park", on_delete=models.CASCADE, related_name="rides") park_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) opening_date_precision = models.CharField( max_length=20, choices=[ ("exact", "Exact Date"), ("month", "Month and Year"), ("year", "Year Only"), ("decade", "Decade"), ("century", "Century"), ("approximate", "Approximate"), ], default="exact", blank=True, help_text="Precision of the opening date", ) closing_date = models.DateField(null=True, blank=True) closing_date_precision = models.CharField( max_length=20, choices=[ ("exact", "Exact Date"), ("month", "Month and Year"), ("year", "Year Only"), ("decade", "Decade"), ("century", "Century"), ("approximate", "Approximate"), ], default="exact", blank=True, help_text="Precision of the closing date", ) 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) # Additional ride classification ride_sub_type = models.CharField( max_length=100, blank=True, help_text="Sub-category of ride (e.g., 'Flying Coaster', 'Inverted Coaster', 'Log Flume')", ) age_requirement = models.PositiveIntegerField( null=True, blank=True, help_text="Minimum age requirement in years (if any)", ) height_requirement_cm = models.PositiveIntegerField( null=True, blank=True, help_text="Minimum height requirement in centimeters", ) duration_seconds = models.PositiveIntegerField( null=True, blank=True, help_text="Ride duration in seconds", ) inversions_count = models.PositiveIntegerField( null=True, blank=True, help_text="Number of inversions (for coasters)", ) # 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) # ===== CATEGORY-SPECIFIC FIELDS ===== # These fields support the frontend validation schemas in entityValidationSchemas.ts # Fields are nullable since they only apply to specific ride categories # --- Core Stats (6 fields) --- max_speed_kmh = models.DecimalField( max_digits=6, decimal_places=2, null=True, blank=True, help_text="Maximum speed in kilometers per hour", ) height_meters = models.DecimalField( max_digits=6, decimal_places=2, null=True, blank=True, help_text="Height of the ride structure in meters", ) length_meters = models.DecimalField( max_digits=8, decimal_places=2, null=True, blank=True, help_text="Total track/ride length in meters", ) drop_meters = models.DecimalField( max_digits=6, decimal_places=2, null=True, blank=True, help_text="Maximum drop height in meters", ) gforce_max = models.DecimalField( max_digits=4, decimal_places=2, null=True, blank=True, help_text="Maximum G-force experienced", ) intensity_level = models.CharField( max_length=20, blank=True, help_text="Intensity classification: family, thrill, or extreme", ) # --- Coaster-Specific (5 fields) --- coaster_type = models.CharField( max_length=20, blank=True, help_text="Coaster structure type: steel, wood, or hybrid", ) seating_type = models.CharField( max_length=20, blank=True, help_text="Seating configuration: sit_down, inverted, flying, stand_up, etc.", ) track_material = ArrayField( models.CharField(max_length=50), blank=True, default=list, help_text="Track material types (e.g., ['steel', 'wood'])", ) support_material = ArrayField( models.CharField(max_length=50), blank=True, default=list, help_text="Support structure material types", ) propulsion_method = ArrayField( models.CharField(max_length=50), blank=True, default=list, help_text="Propulsion methods (e.g., ['chain_lift', 'lsm'])", ) # --- Water Ride (5 fields) --- water_depth_cm = models.PositiveIntegerField( null=True, blank=True, help_text="Water depth in centimeters", ) splash_height_meters = models.DecimalField( max_digits=5, decimal_places=2, null=True, blank=True, help_text="Maximum splash height in meters", ) wetness_level = models.CharField( max_length=20, blank=True, help_text="Expected wetness: dry, light, moderate, or soaked", ) flume_type = models.CharField( max_length=100, blank=True, help_text="Type of flume or water channel", ) boat_capacity = models.PositiveIntegerField( null=True, blank=True, help_text="Number of passengers per boat/vehicle", ) # --- Dark Ride (7 fields) --- theme_name = models.CharField( max_length=200, blank=True, help_text="Primary theme or IP name", ) story_description = models.TextField( blank=True, help_text="Narrative or story description for the ride", ) show_duration_seconds = models.PositiveIntegerField( null=True, blank=True, help_text="Duration of show elements in seconds", ) animatronics_count = models.PositiveIntegerField( null=True, blank=True, help_text="Number of animatronic figures", ) projection_type = models.CharField( max_length=100, blank=True, help_text="Type of projection technology used", ) ride_system = models.CharField( max_length=100, blank=True, help_text="Ride system type (e.g., trackless, omnimover)", ) scenes_count = models.PositiveIntegerField( null=True, blank=True, help_text="Number of distinct scenes or show sections", ) # --- Flat Ride (7 fields) --- rotation_type = models.CharField( max_length=20, blank=True, help_text="Rotation axis: horizontal, vertical, multi_axis, pendulum, or none", ) motion_pattern = models.CharField( max_length=200, blank=True, help_text="Description of the ride's motion pattern", ) platform_count = models.PositiveIntegerField( null=True, blank=True, help_text="Number of ride platforms or gondolas", ) swing_angle_degrees = models.DecimalField( max_digits=5, decimal_places=2, null=True, blank=True, help_text="Maximum swing angle in degrees", ) rotation_speed_rpm = models.DecimalField( max_digits=6, decimal_places=2, null=True, blank=True, help_text="Rotation speed in revolutions per minute", ) arm_length_meters = models.DecimalField( max_digits=5, decimal_places=2, null=True, blank=True, help_text="Length of ride arm in meters", ) max_height_reached_meters = models.DecimalField( max_digits=6, decimal_places=2, null=True, blank=True, help_text="Maximum height reached during ride cycle in meters", ) # --- Kiddie Ride (4 fields) --- min_age = models.PositiveIntegerField( null=True, blank=True, help_text="Minimum recommended age in years", ) max_age = models.PositiveIntegerField( null=True, blank=True, help_text="Maximum recommended age in years", ) educational_theme = models.CharField( max_length=200, blank=True, help_text="Educational or learning theme if applicable", ) character_theme = models.CharField( max_length=200, blank=True, help_text="Character or IP theme (e.g., Paw Patrol, Sesame Street)", ) # --- Transportation (6 fields) --- transport_type = models.CharField( max_length=20, blank=True, help_text="Transport mode: train, monorail, skylift, ferry, peoplemover, or cable_car", ) route_length_meters = models.DecimalField( max_digits=8, decimal_places=2, null=True, blank=True, help_text="Total route length in meters", ) stations_count = models.PositiveIntegerField( null=True, blank=True, help_text="Number of stations or stops", ) vehicle_capacity = models.PositiveIntegerField( null=True, blank=True, help_text="Passenger capacity per vehicle", ) vehicles_count = models.PositiveIntegerField( null=True, blank=True, help_text="Number of vehicles in operation", ) round_trip_duration_seconds = models.PositiveIntegerField( null=True, blank=True, help_text="Duration of a complete round trip in seconds", ) # 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") # Submission metadata fields (from frontend schema) source_url = models.URLField( blank=True, help_text="Source URL for the data (e.g., official website, RCDB)", ) is_test_data = models.BooleanField( default=False, help_text="Whether this is test/development data", ) 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: AbstractBaseUser | None = None) -> None: """Transition ride to OPERATING status.""" self.transition_to_operating(user=user) self.save() def close_temporarily(self, *, user: AbstractBaseUser | None = None) -> None: """Transition ride to CLOSED_TEMP status.""" self.transition_to_closed_temp(user=user) self.save() def mark_sbno(self, *, user: AbstractBaseUser | None = 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: AbstractBaseUser | None = 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: AbstractBaseUser | None = None) -> None: """Transition ride to CLOSED_PERM status.""" self.transition_to_closed_perm(user=user) self.save() def demolish(self, *, user: AbstractBaseUser | None = None) -> None: """Transition ride to DEMOLISHED status.""" self.transition_to_demolished(user=user) self.save() def relocate(self, *, user: AbstractBaseUser | None = None) -> None: """Transition ride to RELOCATED status.""" self.transition_to_relocated(user=user) self.save() def apply_post_closing_status(self, *, user: AbstractBaseUser | None = None) -> None: """Apply post_closing_status if closing_date has been reached.""" from django.core.exceptions import ValidationError from django.utils import timezone 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() @property def is_closing(self) -> bool: """Returns True if this ride has a closing date in the future (announced closure).""" from django.utils import timezone if self.closing_date: return self.closing_date > timezone.now().date() return False 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: with contextlib.suppress(Ride.DoesNotExist): original_ride = Ride.objects.get(pk=self.pk) # 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: # noqa: SIM102 # Check if park_area belongs to the new park if self.park_area.park.id != self.park.id: # Clear park_area if it doesn't belong to the new park 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") from None @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}"