from django.db import models from django.utils.text import slugify from django.contrib.contenttypes.fields import GenericRelation from core.models import TrackedModel from .company import Company # 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'), ] 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: 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}" class Ride(TrackedModel): """Model for individual ride installations at parks""" 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 ) photos = GenericRelation('media.Photo') class Meta: ordering = ['name'] unique_together = ['park', 'slug'] constraints = [ # Business rule: Closing date must be after opening date models.CheckConstraint( name="ride_closing_after_opening", check=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", check=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", check=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", check=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", check=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", check=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", check=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) 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}"