""" Entity models for ThrillWiki Django backend. This module contains the core entity models: - Company: Manufacturers, operators, designers - RideModel: Specific ride models from manufacturers - Park: Theme parks, amusement parks, water parks, FECs - Ride: Individual rides and roller coasters """ from django.db import models from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.utils.text import slugify from django_lifecycle import hook, AFTER_CREATE, AFTER_UPDATE, BEFORE_SAVE from apps.core.models import VersionedModel, BaseModel # Conditionally import GIS models only if using PostGIS backend # This allows migrations to run on SQLite during local development _using_postgis = ( 'postgis' in settings.DATABASES['default']['ENGINE'] ) if _using_postgis: from django.contrib.gis.db import models as gis_models from django.contrib.gis.geos import Point from django.contrib.postgres.search import SearchVectorField class Company(VersionedModel): """ Represents a company in the amusement industry. Can be a manufacturer, operator, designer, or combination. """ COMPANY_TYPE_CHOICES = [ ('manufacturer', 'Manufacturer'), ('operator', 'Operator'), ('designer', 'Designer'), ('supplier', 'Supplier'), ('contractor', 'Contractor'), ] # Basic Info name = models.CharField( max_length=255, unique=True, db_index=True, help_text="Official company name" ) slug = models.SlugField( max_length=255, unique=True, db_index=True, help_text="URL-friendly identifier" ) description = models.TextField( blank=True, help_text="Company description and history" ) # Company Types (can be multiple) company_types = models.JSONField( default=list, help_text="List of company types (manufacturer, operator, etc.)" ) # Location location = models.ForeignKey( 'core.Locality', on_delete=models.SET_NULL, null=True, blank=True, related_name='companies', help_text="Company headquarters location" ) # Dates with precision tracking founded_date = models.DateField( null=True, blank=True, help_text="Company founding date" ) founded_date_precision = models.CharField( max_length=20, default='day', choices=[ ('year', 'Year'), ('month', 'Month'), ('day', 'Day'), ], help_text="Precision of founded date" ) closed_date = models.DateField( null=True, blank=True, help_text="Company closure date (if applicable)" ) closed_date_precision = models.CharField( max_length=20, default='day', choices=[ ('year', 'Year'), ('month', 'Month'), ('day', 'Day'), ], help_text="Precision of closed date" ) # External Links website = models.URLField( blank=True, help_text="Official company website" ) # CloudFlare Images logo_image_id = models.CharField( max_length=255, blank=True, help_text="CloudFlare image ID for company logo" ) logo_image_url = models.URLField( blank=True, help_text="CloudFlare image URL for company logo" ) # Cached statistics park_count = models.IntegerField( default=0, help_text="Number of parks operated (for operators)" ) ride_count = models.IntegerField( default=0, help_text="Number of rides manufactured (for manufacturers)" ) # Generic relation to photos photos = GenericRelation( 'media.Photo', related_query_name='company' ) # Full-text search vector (PostgreSQL only) # Populated automatically via signals or database triggers # Includes: name (weight A) + description (weight B) class Meta: verbose_name = 'Company' verbose_name_plural = 'Companies' ordering = ['name'] indexes = [ models.Index(fields=['name']), models.Index(fields=['slug']), ] def __str__(self): return self.name @hook(BEFORE_SAVE, when='slug', is_now=None) def auto_generate_slug(self): """Auto-generate slug from name if not provided.""" if not self.slug and self.name: base_slug = slugify(self.name) slug = base_slug counter = 1 while Company.objects.filter(slug=slug).exists(): slug = f"{base_slug}-{counter}" counter += 1 self.slug = slug def update_counts(self): """Update cached park and ride counts.""" self.park_count = self.operated_parks.count() self.ride_count = self.manufactured_rides.count() self.save(update_fields=['park_count', 'ride_count']) def get_photos(self, photo_type=None, approved_only=True): """Get photos for this company.""" from apps.media.services import PhotoService service = PhotoService() return service.get_entity_photos(self, photo_type=photo_type, approved_only=approved_only) @property def main_photo(self): """Get the main photo.""" photos = self.photos.filter(photo_type='main', moderation_status='approved').first() return photos @property def logo_photo(self): """Get the logo photo.""" photos = self.photos.filter(photo_type='logo', moderation_status='approved').first() return photos class RideModel(VersionedModel): """ Represents a specific ride model from a manufacturer. E.g., "B&M Inverted Coaster", "Vekoma Boomerang", "Zamperla Family Gravity Coaster" """ MODEL_TYPE_CHOICES = [ ('coaster_model', 'Roller Coaster Model'), ('flat_ride_model', 'Flat Ride Model'), ('water_ride_model', 'Water Ride Model'), ('dark_ride_model', 'Dark Ride Model'), ('transport_ride_model', 'Transport Ride Model'), ] # Basic Info name = models.CharField( max_length=255, db_index=True, help_text="Model name (e.g., 'Inverted Coaster', 'Boomerang')" ) slug = models.SlugField( max_length=255, unique=True, db_index=True, help_text="URL-friendly identifier" ) description = models.TextField( blank=True, help_text="Model description and technical details" ) # Manufacturer manufacturer = models.ForeignKey( 'Company', on_delete=models.CASCADE, related_name='ride_models', help_text="Manufacturer of this ride model" ) # Model Type model_type = models.CharField( max_length=50, choices=MODEL_TYPE_CHOICES, db_index=True, help_text="Type of ride model" ) # Technical Specifications (common to most instances) typical_height = models.DecimalField( max_digits=6, decimal_places=1, null=True, blank=True, help_text="Typical height in feet" ) typical_speed = models.DecimalField( max_digits=6, decimal_places=1, null=True, blank=True, help_text="Typical speed in mph" ) typical_capacity = models.IntegerField( null=True, blank=True, help_text="Typical hourly capacity" ) # CloudFlare Images image_id = models.CharField( max_length=255, blank=True, help_text="CloudFlare image ID" ) image_url = models.URLField( blank=True, help_text="CloudFlare image URL" ) # Cached statistics installation_count = models.IntegerField( default=0, help_text="Number of installations worldwide" ) # Generic relation to photos photos = GenericRelation( 'media.Photo', related_query_name='ride_model' ) class Meta: verbose_name = 'Ride Model' verbose_name_plural = 'Ride Models' ordering = ['manufacturer__name', 'name'] unique_together = [['manufacturer', 'name']] indexes = [ models.Index(fields=['manufacturer', 'name']), models.Index(fields=['model_type']), ] def __str__(self): return f"{self.manufacturer.name} {self.name}" @hook(BEFORE_SAVE, when='slug', is_now=None) def auto_generate_slug(self): """Auto-generate slug from manufacturer and name if not provided.""" if not self.slug and self.manufacturer and self.name: base_slug = slugify(f"{self.manufacturer.name} {self.name}") slug = base_slug counter = 1 while RideModel.objects.filter(slug=slug).exists(): slug = f"{base_slug}-{counter}" counter += 1 self.slug = slug def update_installation_count(self): """Update cached installation count.""" self.installation_count = self.rides.count() self.save(update_fields=['installation_count']) def get_photos(self, photo_type=None, approved_only=True): """Get photos for this ride model.""" from apps.media.services import PhotoService service = PhotoService() return service.get_entity_photos(self, photo_type=photo_type, approved_only=approved_only) @property def main_photo(self): """Get the main photo.""" photos = self.photos.filter(photo_type='main', moderation_status='approved').first() return photos class Park(VersionedModel): """ Represents an amusement park, theme park, water park, or FEC. Note: Geographic coordinates are stored differently based on database backend: - Production (PostGIS): Uses location_point PointField with full GIS capabilities - Local Dev (SQLite): Uses latitude/longitude DecimalFields (no spatial queries) """ PARK_TYPE_CHOICES = [ ('theme_park', 'Theme Park'), ('amusement_park', 'Amusement Park'), ('water_park', 'Water Park'), ('family_entertainment_center', 'Family Entertainment Center'), ('traveling_park', 'Traveling Park'), ('zoo', 'Zoo'), ('aquarium', 'Aquarium'), ] STATUS_CHOICES = [ ('operating', 'Operating'), ('closed', 'Closed'), ('sbno', 'Standing But Not Operating'), ('under_construction', 'Under Construction'), ('planned', 'Planned'), ] # Basic Info name = models.CharField( max_length=255, db_index=True, help_text="Official park name" ) slug = models.SlugField( max_length=255, unique=True, db_index=True, help_text="URL-friendly identifier" ) description = models.TextField( blank=True, help_text="Park description and history" ) # Type & Status park_type = models.CharField( max_length=50, choices=PARK_TYPE_CHOICES, db_index=True, help_text="Type of park" ) status = models.CharField( max_length=50, choices=STATUS_CHOICES, default='operating', db_index=True, help_text="Current operational status" ) # Dates with precision tracking opening_date = models.DateField( null=True, blank=True, db_index=True, help_text="Park opening date" ) opening_date_precision = models.CharField( max_length=20, default='day', choices=[ ('year', 'Year'), ('month', 'Month'), ('day', 'Day'), ], help_text="Precision of opening date" ) closing_date = models.DateField( null=True, blank=True, help_text="Park closing date (if closed)" ) closing_date_precision = models.CharField( max_length=20, default='day', choices=[ ('year', 'Year'), ('month', 'Month'), ('day', 'Day'), ], help_text="Precision of closing date" ) # Location location = models.ForeignKey( 'core.Locality', on_delete=models.SET_NULL, null=True, blank=True, related_name='parks', help_text="Park location" ) # Precise coordinates for mapping # Primary in local dev (SQLite), deprecated in production (PostGIS) latitude = models.DecimalField( max_digits=10, decimal_places=7, null=True, blank=True, help_text="Latitude coordinate. Primary in local dev, use location_point in production." ) longitude = models.DecimalField( max_digits=10, decimal_places=7, null=True, blank=True, help_text="Longitude coordinate. Primary in local dev, use location_point in production." ) # NOTE: location_point PointField is added conditionally below if using PostGIS # Relationships operator = models.ForeignKey( 'Company', on_delete=models.SET_NULL, null=True, blank=True, related_name='operated_parks', help_text="Current park operator" ) # External Links website = models.URLField( blank=True, help_text="Official park website" ) # CloudFlare Images banner_image_id = models.CharField( max_length=255, blank=True, help_text="CloudFlare image ID for park banner" ) banner_image_url = models.URLField( blank=True, help_text="CloudFlare image URL for park banner" ) logo_image_id = models.CharField( max_length=255, blank=True, help_text="CloudFlare image ID for park logo" ) logo_image_url = models.URLField( blank=True, help_text="CloudFlare image URL for park logo" ) # Cached statistics (for performance) ride_count = models.IntegerField( default=0, help_text="Total number of rides" ) coaster_count = models.IntegerField( default=0, help_text="Number of roller coasters" ) # Custom fields for flexible data custom_fields = models.JSONField( default=dict, blank=True, help_text="Additional park-specific data" ) # Generic relation to photos photos = GenericRelation( 'media.Photo', related_query_name='park' ) class Meta: verbose_name = 'Park' verbose_name_plural = 'Parks' ordering = ['name'] indexes = [ models.Index(fields=['name']), models.Index(fields=['slug']), models.Index(fields=['status']), models.Index(fields=['park_type']), models.Index(fields=['opening_date']), models.Index(fields=['location']), ] def __str__(self): return self.name @hook(BEFORE_SAVE, when='slug', is_now=None) def auto_generate_slug(self): """Auto-generate slug from name if not provided.""" if not self.slug and self.name: base_slug = slugify(self.name) slug = base_slug counter = 1 while Park.objects.filter(slug=slug).exists(): slug = f"{base_slug}-{counter}" counter += 1 self.slug = slug def update_counts(self): """Update cached ride counts.""" self.ride_count = self.rides.count() self.coaster_count = self.rides.filter(is_coaster=True).count() self.save(update_fields=['ride_count', 'coaster_count']) def set_location(self, longitude, latitude): """ Set park location from coordinates. Args: longitude: Longitude coordinate (X) latitude: Latitude coordinate (Y) Note: Works in both PostGIS and non-PostGIS modes. - PostGIS: Sets location_point and syncs to lat/lng - SQLite: Sets lat/lng directly """ if longitude is not None and latitude is not None: # Always update lat/lng fields self.longitude = longitude self.latitude = latitude # If using PostGIS, also update location_point if _using_postgis and hasattr(self, 'location_point'): self.location_point = Point(float(longitude), float(latitude), srid=4326) @property def coordinates(self): """ Get coordinates as (longitude, latitude) tuple. Returns: tuple: (longitude, latitude) or None if no location set """ # Try PostGIS field first if available if _using_postgis and hasattr(self, 'location_point') and self.location_point: return (self.location_point.x, self.location_point.y) # Fall back to lat/lng fields elif self.longitude and self.latitude: return (float(self.longitude), float(self.latitude)) return None @property def latitude_value(self): """Get latitude value (from location_point if PostGIS, else from latitude field).""" if _using_postgis and hasattr(self, 'location_point') and self.location_point: return self.location_point.y return float(self.latitude) if self.latitude else None @property def longitude_value(self): """Get longitude value (from location_point if PostGIS, else from longitude field).""" if _using_postgis and hasattr(self, 'location_point') and self.location_point: return self.location_point.x return float(self.longitude) if self.longitude else None def get_photos(self, photo_type=None, approved_only=True): """Get photos for this park.""" from apps.media.services import PhotoService service = PhotoService() return service.get_entity_photos(self, photo_type=photo_type, approved_only=approved_only) @property def main_photo(self): """Get the main photo.""" photos = self.photos.filter(photo_type='main', moderation_status='approved').first() return photos @property def banner_photo(self): """Get the banner photo.""" photos = self.photos.filter(photo_type='banner', moderation_status='approved').first() return photos @property def logo_photo(self): """Get the logo photo.""" photos = self.photos.filter(photo_type='logo', moderation_status='approved').first() return photos @property def gallery_photos(self): """Get gallery photos.""" return self.photos.filter(photo_type='gallery', moderation_status='approved').order_by('display_order') # Conditionally add PostGIS PointField to Park model if using PostGIS backend if _using_postgis: Park.add_to_class( 'location_point', gis_models.PointField( geography=True, null=True, blank=True, srid=4326, help_text="Geographic coordinates (PostGIS Point). Production only." ) ) class Ride(VersionedModel): """ Represents an individual ride or roller coaster. """ RIDE_CATEGORY_CHOICES = [ ('roller_coaster', 'Roller Coaster'), ('flat_ride', 'Flat Ride'), ('water_ride', 'Water Ride'), ('dark_ride', 'Dark Ride'), ('transport_ride', 'Transport Ride'), ('other', 'Other'), ] STATUS_CHOICES = [ ('operating', 'Operating'), ('closed', 'Closed'), ('sbno', 'Standing But Not Operating'), ('relocated', 'Relocated'), ('under_construction', 'Under Construction'), ('planned', 'Planned'), ] # Basic Info name = models.CharField( max_length=255, db_index=True, help_text="Ride name" ) slug = models.SlugField( max_length=255, unique=True, db_index=True, help_text="URL-friendly identifier" ) description = models.TextField( blank=True, help_text="Ride description and history" ) # Park Relationship park = models.ForeignKey( 'Park', on_delete=models.CASCADE, related_name='rides', db_index=True, help_text="Park where ride is located" ) # Ride Classification ride_category = models.CharField( max_length=50, choices=RIDE_CATEGORY_CHOICES, db_index=True, help_text="Broad ride category" ) ride_type = models.CharField( max_length=100, blank=True, db_index=True, help_text="Specific ride type (e.g., 'Inverted Coaster', 'Drop Tower')" ) # Quick coaster identification is_coaster = models.BooleanField( default=False, db_index=True, help_text="Is this ride a roller coaster?" ) # Status status = models.CharField( max_length=50, choices=STATUS_CHOICES, default='operating', db_index=True, help_text="Current operational status" ) # Dates with precision tracking opening_date = models.DateField( null=True, blank=True, db_index=True, help_text="Ride opening date" ) opening_date_precision = models.CharField( max_length=20, default='day', choices=[ ('year', 'Year'), ('month', 'Month'), ('day', 'Day'), ], help_text="Precision of opening date" ) closing_date = models.DateField( null=True, blank=True, help_text="Ride closing date (if closed)" ) closing_date_precision = models.CharField( max_length=20, default='day', choices=[ ('year', 'Year'), ('month', 'Month'), ('day', 'Day'), ], help_text="Precision of closing date" ) # Manufacturer & Model manufacturer = models.ForeignKey( 'Company', on_delete=models.SET_NULL, null=True, blank=True, related_name='manufactured_rides', help_text="Ride manufacturer" ) model = models.ForeignKey( 'RideModel', on_delete=models.SET_NULL, null=True, blank=True, related_name='rides', help_text="Specific ride model" ) # Statistics height = models.DecimalField( max_digits=6, decimal_places=1, null=True, blank=True, help_text="Height in feet" ) speed = models.DecimalField( max_digits=6, decimal_places=1, null=True, blank=True, help_text="Top speed in mph" ) length = models.DecimalField( max_digits=8, decimal_places=1, null=True, blank=True, help_text="Track/ride length in feet" ) duration = models.IntegerField( null=True, blank=True, help_text="Ride duration in seconds" ) inversions = models.IntegerField( null=True, blank=True, help_text="Number of inversions (for coasters)" ) capacity = models.IntegerField( null=True, blank=True, help_text="Hourly capacity (riders per hour)" ) # CloudFlare Images image_id = models.CharField( max_length=255, blank=True, help_text="CloudFlare image ID for main photo" ) image_url = models.URLField( blank=True, help_text="CloudFlare image URL for main photo" ) # Custom fields for flexible data custom_fields = models.JSONField( default=dict, blank=True, help_text="Additional ride-specific data" ) # Generic relation to photos photos = GenericRelation( 'media.Photo', related_query_name='ride' ) class Meta: verbose_name = 'Ride' verbose_name_plural = 'Rides' ordering = ['park__name', 'name'] indexes = [ models.Index(fields=['park', 'name']), models.Index(fields=['slug']), models.Index(fields=['status']), models.Index(fields=['is_coaster']), models.Index(fields=['ride_category']), models.Index(fields=['opening_date']), models.Index(fields=['manufacturer']), ] def __str__(self): return f"{self.name} ({self.park.name})" @hook(BEFORE_SAVE, when='slug', is_now=None) def auto_generate_slug(self): """Auto-generate slug from park and name if not provided.""" if not self.slug and self.park and self.name: base_slug = slugify(f"{self.park.name} {self.name}") slug = base_slug counter = 1 while Ride.objects.filter(slug=slug).exists(): slug = f"{base_slug}-{counter}" counter += 1 self.slug = slug @hook(BEFORE_SAVE) def set_is_coaster_flag(self): """Auto-set is_coaster flag based on ride_category.""" self.is_coaster = (self.ride_category == 'roller_coaster') @hook(AFTER_CREATE) @hook(AFTER_UPDATE, when='park', has_changed=True) def update_park_counts(self): """Update parent park's ride counts when ride is created or moved.""" if self.park: self.park.update_counts() def get_photos(self, photo_type=None, approved_only=True): """Get photos for this ride.""" from apps.media.services import PhotoService service = PhotoService() return service.get_entity_photos(self, photo_type=photo_type, approved_only=approved_only) @property def main_photo(self): """Get the main photo.""" photos = self.photos.filter(photo_type='main', moderation_status='approved').first() return photos @property def gallery_photos(self): """Get gallery photos.""" return self.photos.filter(photo_type='gallery', moderation_status='approved').order_by('display_order') # Add SearchVectorField to all models for full-text search (PostgreSQL only) # Must be at the very end after ALL class definitions if _using_postgis: Company.add_to_class( 'search_vector', SearchVectorField( null=True, blank=True, help_text="Pre-computed search vector for full-text search. Auto-updated via signals." ) ) RideModel.add_to_class( 'search_vector', SearchVectorField( null=True, blank=True, help_text="Pre-computed search vector for full-text search. Auto-updated via signals." ) ) Park.add_to_class( 'search_vector', SearchVectorField( null=True, blank=True, help_text="Pre-computed search vector for full-text search. Auto-updated via signals." ) ) Ride.add_to_class( 'search_vector', SearchVectorField( null=True, blank=True, help_text="Pre-computed search vector for full-text search. Auto-updated via signals." ) )