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 ) # 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}/" super().save(*args, **kwargs) 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 """ from django.apps import apps 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}"