mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:11:13 -05:00
- Created a base email template (base.html) for consistent styling across all emails. - Added moderation approval email template (moderation_approved.html) to notify users of approved submissions. - Added moderation rejection email template (moderation_rejected.html) to inform users of required changes for their submissions. - Created password reset email template (password_reset.html) for users requesting to reset their passwords. - Developed a welcome email template (welcome.html) to greet new users and provide account details and tips for using ThrillWiki.
931 lines
27 KiB
Python
931 lines
27 KiB
Python
"""
|
|
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."
|
|
)
|
|
)
|