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 .company import Company import pghistory from typing import TYPE_CHECKING 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(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' 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 = RichChoiceField( 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}" 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.launch_type: launch_choice = stats.get_launch_type_rich_choice() if launch_choice: search_parts.append(launch_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 ) launch_type = RichChoiceField( choice_group="launch_systems", domain="rides", max_length=20, default="CHAIN", help_text="Launch 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}"