from django.db import models from django.utils.text import slugify from config.django import base as settings from apps.core.models import TrackedModel from apps.core.choices import RichChoiceField from 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): ordering = ["manufacturer__name", "name"] unique_together = [["manufacturer", "name"], ["manufacturer", "slug"]] constraints = [ # Height range validation models.CheckConstraint( name="ride_model_height_range_logical", condition=models.Q(typical_height_range_min_ft__isnull=True) | models.Q(typical_height_range_max_ft__isnull=True) | models.Q( typical_height_range_min_ft__lte=models.F( "typical_height_range_max_ft" ) ), violation_error_message="Minimum height cannot exceed maximum height", ), # Speed range validation models.CheckConstraint( name="ride_model_speed_range_logical", condition=models.Q(typical_speed_range_min_mph__isnull=True) | models.Q(typical_speed_range_max_mph__isnull=True) | models.Q( typical_speed_range_min_mph__lte=models.F( "typical_speed_range_max_mph" ) ), violation_error_message="Minimum speed cannot exceed maximum speed", ), # Capacity range validation models.CheckConstraint( name="ride_model_capacity_range_logical", condition=models.Q(typical_capacity_range_min__isnull=True) | models.Q(typical_capacity_range_max__isnull=True) | models.Q( typical_capacity_range_min__lte=models.F( "typical_capacity_range_max" ) ), violation_error_message="Minimum capacity cannot exceed maximum capacity", ), # Installation years validation models.CheckConstraint( name="ride_model_installation_years_logical", condition=models.Q(first_installation_year__isnull=True) | models.Q(last_installation_year__isnull=True) | models.Q( first_installation_year__lte=models.F("last_installation_year") ), violation_error_message="First installation year cannot be after last installation year", ), ] def __str__(self) -> str: return ( self.name if not self.manufacturer else f"{self.manufacturer.name} {self.name}" ) def save(self, *args, **kwargs) -> None: if not self.slug: from django.utils.text import slugify # Only use the ride model name for the slug, not manufacturer base_slug = slugify(self.name) self.slug = base_slug # Ensure uniqueness within the same manufacturer counter = 1 while ( RideModel.objects.filter(manufacturer=self.manufacturer, slug=self.slug) .exclude(pk=self.pk) .exists() ): self.slug = f"{base_slug}-{counter}" counter += 1 # Auto-generate meta fields if blank if not self.meta_title: self.meta_title = str(self)[:60] if not self.meta_description: desc = ( f"{self} - {self.description[:100]}" if self.description else str(self) ) self.meta_description = desc[:160] # Generate frontend URL if self.manufacturer: frontend_domain = getattr( settings, "FRONTEND_DOMAIN", "https://thrillwiki.com" ) self.url = f"{frontend_domain}/rides/manufacturers/{self.manufacturer.slug}/{self.slug}/" super().save(*args, **kwargs) def update_installation_count(self) -> None: """Update the total installations count based on actual ride instances.""" # Import here to avoid circular import from django.apps import apps Ride = apps.get_model("rides", "Ride") self.total_installations = Ride.objects.filter(ride_model=self).count() self.save(update_fields=["total_installations"]) @property def installation_years_range(self) -> str: """Get a formatted string of installation years range.""" if self.first_installation_year and self.last_installation_year: return f"{self.first_installation_year}-{self.last_installation_year}" elif self.first_installation_year: return ( f"{self.first_installation_year}-present" if not self.is_discontinued else f"{self.first_installation_year}+" ) return "Unknown" @property def height_range_display(self) -> str: """Get a formatted string of height range.""" if self.typical_height_range_min_ft and self.typical_height_range_max_ft: return f"{self.typical_height_range_min_ft}-{self.typical_height_range_max_ft} ft" elif self.typical_height_range_min_ft: return f"{self.typical_height_range_min_ft}+ ft" elif self.typical_height_range_max_ft: return f"Up to {self.typical_height_range_max_ft} ft" return "Variable" @property def speed_range_display(self) -> str: """Get a formatted string of speed range.""" if self.typical_speed_range_min_mph and self.typical_speed_range_max_mph: return f"{self.typical_speed_range_min_mph}-{self.typical_speed_range_max_mph} mph" elif self.typical_speed_range_min_mph: return f"{self.typical_speed_range_min_mph}+ mph" elif self.typical_speed_range_max_mph: return f"Up to {self.typical_speed_range_max_mph} mph" return "Variable" @pghistory.track() class RideModelVariant(TrackedModel): """ Represents specific variants or configurations of a ride model. For example: B&M Hyper Coaster might have variants like "Mega Coaster", "Giga Coaster" """ ride_model = models.ForeignKey( RideModel, on_delete=models.CASCADE, related_name="variants" ) name = models.CharField(max_length=255, help_text="Name of this variant") description = models.TextField( blank=True, help_text="Description of variant differences" ) # Variant-specific specifications min_height_ft = models.DecimalField( max_digits=6, decimal_places=2, null=True, blank=True ) max_height_ft = models.DecimalField( max_digits=6, decimal_places=2, null=True, blank=True ) min_speed_mph = models.DecimalField( max_digits=5, decimal_places=2, null=True, blank=True ) max_speed_mph = models.DecimalField( max_digits=5, decimal_places=2, null=True, blank=True ) # Distinguishing features distinguishing_features = models.TextField( blank=True, help_text="What makes this variant unique from the base model" ) class Meta(TrackedModel.Meta): ordering = ["ride_model", "name"] unique_together = ["ride_model", "name"] def __str__(self) -> str: return f"{self.ride_model} - {self.name}" @pghistory.track() class RideModelPhoto(TrackedModel): """Photos associated with ride models for catalog/promotional purposes.""" ride_model = models.ForeignKey( RideModel, on_delete=models.CASCADE, related_name="photos" ) image = models.ForeignKey( 'django_cloudflareimages_toolkit.CloudflareImage', on_delete=models.CASCADE, help_text="Photo of the ride model stored on Cloudflare Images" ) caption = models.CharField(max_length=500, blank=True) alt_text = models.CharField(max_length=255, blank=True) # Photo metadata photo_type = RichChoiceField( choice_group="photo_types", domain="rides", max_length=20, default="PROMOTIONAL", help_text="Type of photo for categorization and display purposes", ) is_primary = models.BooleanField( default=False, help_text="Whether this is the primary photo for the ride model" ) # Attribution photographer = models.CharField(max_length=255, blank=True) source = models.CharField(max_length=255, blank=True) copyright_info = models.CharField(max_length=255, blank=True) class Meta(TrackedModel.Meta): ordering = ["-is_primary", "-created_at"] def __str__(self) -> str: return f"Photo of {self.ride_model.name}" def save(self, *args, **kwargs) -> None: # Ensure only one primary photo per ride model if self.is_primary: RideModelPhoto.objects.filter( ride_model=self.ride_model, is_primary=True ).exclude(pk=self.pk).update(is_primary=False) super().save(*args, **kwargs) @pghistory.track() class RideModelTechnicalSpec(TrackedModel): """ Technical specifications for ride models that don't fit in the main model. This allows for flexible specification storage. """ ride_model = models.ForeignKey( RideModel, on_delete=models.CASCADE, related_name="technical_specs" ) spec_category = RichChoiceField( choice_group="spec_categories", domain="rides", max_length=50, help_text="Category of technical specification", ) spec_name = models.CharField(max_length=100, help_text="Name of the specification") spec_value = models.CharField( max_length=255, help_text="Value of the specification" ) spec_unit = models.CharField( max_length=20, blank=True, help_text="Unit of measurement" ) notes = models.TextField( blank=True, help_text="Additional notes about this specification" ) class Meta(TrackedModel.Meta): ordering = ["spec_category", "spec_name"] unique_together = ["ride_model", "spec_category", "spec_name"] def __str__(self) -> str: unit_str = f" {self.spec_unit}" if self.spec_unit else "" return f"{self.ride_model.name} - {self.spec_name}: {self.spec_value}{unit_str}" @pghistory.track() class Ride(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): 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" ) height_ft = models.DecimalField( max_digits=6, decimal_places=2, null=True, blank=True ) length_ft = models.DecimalField( max_digits=7, decimal_places=2, null=True, blank=True ) speed_mph = models.DecimalField( max_digits=5, decimal_places=2, null=True, blank=True ) inversions = models.PositiveIntegerField(default=0) ride_time_seconds = models.PositiveIntegerField(null=True, blank=True) track_type = models.CharField(max_length=255, blank=True) track_material = RichChoiceField( choice_group="track_materials", domain="rides", max_length=20, default="STEEL", blank=True, help_text="Track construction material type" ) roller_coaster_type = RichChoiceField( choice_group="coaster_types", domain="rides", max_length=20, default="SITDOWN", blank=True, help_text="Roller coaster type classification" ) max_drop_height_ft = models.DecimalField( max_digits=6, decimal_places=2, null=True, blank=True ) propulsion_system = RichChoiceField( choice_group="propulsion_systems", domain="rides", max_length=20, default="CHAIN", help_text="Propulsion or lift system type" ) train_style = models.CharField(max_length=255, blank=True) trains_count = models.PositiveIntegerField(null=True, blank=True) cars_per_train = models.PositiveIntegerField(null=True, blank=True) seats_per_car = models.PositiveIntegerField(null=True, blank=True) class Meta: verbose_name = "Roller Coaster Statistics" verbose_name_plural = "Roller Coaster Statistics" def __str__(self) -> str: return f"Stats for {self.ride.name}"