from django.db import models from django.utils.text import slugify 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. For example: B&M Dive Coaster, Vekoma Boomerang, etc. """ name = models.CharField(max_length=255) manufacturer = models.ForeignKey( Company, on_delete=models.SET_NULL, related_name="ride_models", null=True, blank=True, limit_choices_to={"roles__contains": ["MANUFACTURER"]}, ) description = models.TextField(blank=True) category = models.CharField( max_length=2, choices=CATEGORY_CHOICES, default="", blank=True ) class Meta(TrackedModel.Meta): ordering = ["manufacturer", "name"] unique_together = ["manufacturer", "name"] def __str__(self) -> str: return ( self.name if not self.manufacturer else f"{self.manufacturer.name} {self.name}" ) @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 ) 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: if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) @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}"