mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-21 09:51:09 -05:00
update
This commit is contained in:
@@ -1,31 +0,0 @@
|
||||
"""
|
||||
Rides app models with clean import interface.
|
||||
|
||||
This module provides a clean import interface for all rides-related models,
|
||||
enabling imports like: from rides.models import Ride, Manufacturer
|
||||
|
||||
The Company model is aliased as Manufacturer to clarify its role as ride manufacturers,
|
||||
while maintaining backward compatibility through the Company alias.
|
||||
"""
|
||||
|
||||
from .rides import Ride, RideModel, RollerCoasterStats
|
||||
from .company import Company
|
||||
from .location import RideLocation
|
||||
from .reviews import RideReview
|
||||
from .rankings import RideRanking, RidePairComparison, RankingSnapshot
|
||||
from .media import RidePhoto
|
||||
|
||||
__all__ = [
|
||||
# Primary models
|
||||
"Ride",
|
||||
"RideModel",
|
||||
"RollerCoasterStats",
|
||||
"Company",
|
||||
"RideLocation",
|
||||
"RideReview",
|
||||
"RidePhoto",
|
||||
# Rankings
|
||||
"RideRanking",
|
||||
"RidePairComparison",
|
||||
"RankingSnapshot",
|
||||
]
|
||||
@@ -1,96 +0,0 @@
|
||||
import pghistory
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django.conf import settings
|
||||
|
||||
from apps.core.history import HistoricalSlug
|
||||
from apps.core.models import TrackedModel
|
||||
from apps.core.choices.fields import RichChoiceField
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class Company(TrackedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
roles = ArrayField(
|
||||
RichChoiceField(choice_group="company_roles", domain="rides", max_length=20),
|
||||
default=list,
|
||||
blank=True,
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
website = models.URLField(blank=True)
|
||||
|
||||
# General company info
|
||||
founded_date = models.DateField(null=True, blank=True)
|
||||
|
||||
# Manufacturer-specific fields
|
||||
rides_count = models.IntegerField(default=0)
|
||||
coasters_count = models.IntegerField(default=0)
|
||||
|
||||
# Frontend URL
|
||||
url = models.URLField(blank=True, help_text="Frontend URL for this company")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
|
||||
# Generate frontend URL based on primary role
|
||||
# CRITICAL: Only MANUFACTURER and DESIGNER are for rides domain
|
||||
# OPERATOR and PROPERTY_OWNER are for parks domain and handled separately
|
||||
if self.roles:
|
||||
frontend_domain = getattr(
|
||||
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
|
||||
)
|
||||
primary_role = self.roles[0] # Use first role as primary
|
||||
|
||||
if primary_role == "MANUFACTURER":
|
||||
self.url = f"{frontend_domain}/rides/manufacturers/{self.slug}/"
|
||||
elif primary_role == "DESIGNER":
|
||||
self.url = f"{frontend_domain}/rides/designers/{self.slug}/"
|
||||
# OPERATOR and PROPERTY_OWNER URLs are handled by parks domain, not here
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
# This will need to be updated to handle different roles
|
||||
return reverse("companies:detail", kwargs={"slug": self.slug})
|
||||
|
||||
@classmethod
|
||||
def get_by_slug(cls, slug):
|
||||
"""Get company by current or historical slug"""
|
||||
try:
|
||||
return cls.objects.get(slug=slug), False
|
||||
except cls.DoesNotExist:
|
||||
# Check pghistory first
|
||||
try:
|
||||
from django.apps import apps
|
||||
history_model = apps.get_model('rides', f'{cls.__name__}Event')
|
||||
history_entry = (
|
||||
history_model.objects.filter(slug=slug)
|
||||
.order_by("-pgh_created_at")
|
||||
.first()
|
||||
)
|
||||
if history_entry:
|
||||
return cls.objects.get(id=history_entry.pgh_obj_id), True
|
||||
except LookupError:
|
||||
# History model doesn't exist, skip pghistory check
|
||||
pass
|
||||
|
||||
# Check manual slug history as fallback
|
||||
try:
|
||||
historical = HistoricalSlug.objects.get(
|
||||
content_type__model="company", slug=slug
|
||||
)
|
||||
return cls.objects.get(pk=historical.object_id), True
|
||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||
raise cls.DoesNotExist("No company found with this slug")
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
app_label = "rides"
|
||||
ordering = ["name"]
|
||||
verbose_name_plural = "Companies"
|
||||
@@ -1,126 +0,0 @@
|
||||
from django.contrib.gis.db import models as gis_models
|
||||
from django.db import models
|
||||
from django.contrib.gis.geos import Point
|
||||
import pghistory
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RideLocation(models.Model):
|
||||
"""
|
||||
Lightweight location tracking for individual rides within parks.
|
||||
Optional coordinates with focus on practical navigation information.
|
||||
"""
|
||||
|
||||
# Relationships
|
||||
ride = models.OneToOneField(
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="ride_location"
|
||||
)
|
||||
|
||||
# Optional Spatial Data - keep it simple with single point
|
||||
point = gis_models.PointField(
|
||||
srid=4326,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Geographic coordinates for ride location (longitude, latitude)",
|
||||
)
|
||||
|
||||
# Park Area Information
|
||||
park_area = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text=(
|
||||
"Themed area or land within the park (e.g., 'Frontierland', 'Tomorrowland')"
|
||||
),
|
||||
)
|
||||
|
||||
# General notes field to match database schema
|
||||
notes = models.TextField(blank=True, help_text="General location notes")
|
||||
|
||||
# Navigation and Entrance Information
|
||||
entrance_notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="Directions to ride entrance, queue location, or navigation tips",
|
||||
)
|
||||
|
||||
# Accessibility Information
|
||||
accessibility_notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="Information about accessible entrances, wheelchair access, etc.",
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def latitude(self):
|
||||
"""Return latitude from point field for backward compatibility."""
|
||||
if self.point:
|
||||
return self.point.y
|
||||
return None
|
||||
|
||||
@property
|
||||
def longitude(self):
|
||||
"""Return longitude from point field for backward compatibility."""
|
||||
if self.point:
|
||||
return self.point.x
|
||||
return None
|
||||
|
||||
@property
|
||||
def coordinates(self):
|
||||
"""Return (latitude, longitude) tuple."""
|
||||
if self.point:
|
||||
return (self.latitude, self.longitude)
|
||||
return (None, None)
|
||||
|
||||
@property
|
||||
def has_coordinates(self):
|
||||
"""Check if coordinates are set."""
|
||||
return self.point is not None
|
||||
|
||||
def set_coordinates(self, latitude, longitude):
|
||||
"""
|
||||
Set the location's point from latitude and longitude coordinates.
|
||||
Validates coordinate ranges.
|
||||
"""
|
||||
if latitude is None or longitude is None:
|
||||
self.point = None
|
||||
return
|
||||
|
||||
if not -90 <= latitude <= 90:
|
||||
raise ValueError("Latitude must be between -90 and 90.")
|
||||
if not -180 <= longitude <= 180:
|
||||
raise ValueError("Longitude must be between -180 and 180.")
|
||||
|
||||
self.point = Point(longitude, latitude, srid=4326)
|
||||
|
||||
def distance_to_park_location(self):
|
||||
"""
|
||||
Calculate distance to parent park's location if both have coordinates.
|
||||
Returns distance in kilometers.
|
||||
"""
|
||||
if not self.point:
|
||||
return None
|
||||
|
||||
park_location = getattr(self.ride.park, "location", None)
|
||||
if not park_location or not park_location.point:
|
||||
return None
|
||||
|
||||
# Use geodetic distance calculation which returns meters, convert to km
|
||||
distance_m = self.point.distance(park_location.point)
|
||||
return distance_m / 1000.0
|
||||
|
||||
def __str__(self):
|
||||
area_str = f" in {self.park_area}" if self.park_area else ""
|
||||
return f"Location for {self.ride.name}{area_str}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Ride Location"
|
||||
verbose_name_plural = "Ride Locations"
|
||||
ordering = ["ride__name"]
|
||||
indexes = [
|
||||
models.Index(fields=["park_area"]),
|
||||
# Spatial index will be created automatically for PostGIS
|
||||
# PointField
|
||||
]
|
||||
@@ -1,139 +0,0 @@
|
||||
"""
|
||||
Ride-specific media models for ThrillWiki.
|
||||
|
||||
This module contains media models specific to rides domain.
|
||||
"""
|
||||
|
||||
from typing import Any, Optional, List, cast
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from apps.core.history import TrackedModel
|
||||
from apps.core.choices import RichChoiceField
|
||||
from apps.core.services.media_service import MediaService
|
||||
import pghistory
|
||||
|
||||
|
||||
def ride_photo_upload_path(instance: models.Model, filename: str) -> str:
|
||||
"""Generate upload path for ride photos."""
|
||||
photo = cast("RidePhoto", instance)
|
||||
ride = photo.ride
|
||||
|
||||
if ride is None:
|
||||
raise ValueError("Ride cannot be None")
|
||||
|
||||
return MediaService.generate_upload_path(
|
||||
domain="park",
|
||||
identifier=ride.slug,
|
||||
filename=filename,
|
||||
subdirectory=ride.park.slug,
|
||||
)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RidePhoto(TrackedModel):
|
||||
"""Photo model specific to rides."""
|
||||
|
||||
ride = models.ForeignKey(
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="photos"
|
||||
)
|
||||
|
||||
image = models.ForeignKey(
|
||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
||||
on_delete=models.CASCADE,
|
||||
help_text="Ride photo stored on Cloudflare Images"
|
||||
)
|
||||
|
||||
caption = models.CharField(max_length=255, blank=True)
|
||||
alt_text = models.CharField(max_length=255, blank=True)
|
||||
is_primary = models.BooleanField(default=False)
|
||||
is_approved = models.BooleanField(default=False)
|
||||
|
||||
# Ride-specific metadata
|
||||
photo_type = RichChoiceField(
|
||||
choice_group="photo_types",
|
||||
domain="rides",
|
||||
max_length=50,
|
||||
default="exterior",
|
||||
help_text="Type of photo for categorization and display purposes"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
date_taken = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# User who uploaded the photo
|
||||
uploaded_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name="uploaded_ride_photos",
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
app_label = "rides"
|
||||
ordering = ["-is_primary", "-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["ride", "is_primary"]),
|
||||
models.Index(fields=["ride", "is_approved"]),
|
||||
models.Index(fields=["ride", "photo_type"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
]
|
||||
constraints = [
|
||||
# Only one primary photo per ride
|
||||
models.UniqueConstraint(
|
||||
fields=["ride"],
|
||||
condition=models.Q(is_primary=True),
|
||||
name="unique_primary_ride_photo",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Photo of {self.ride.name} - {self.caption or 'No caption'}"
|
||||
|
||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||
# Extract EXIF date if this is a new photo
|
||||
if not self.pk and not self.date_taken and self.image:
|
||||
self.date_taken = MediaService.extract_exif_date(self.image)
|
||||
|
||||
# Set default caption if not provided
|
||||
if not self.caption and self.uploaded_by:
|
||||
self.caption = MediaService.generate_default_caption(
|
||||
self.uploaded_by.username
|
||||
)
|
||||
|
||||
# If this is marked as primary, unmark other primary photos for this ride
|
||||
if self.is_primary:
|
||||
RidePhoto.objects.filter(
|
||||
ride=self.ride,
|
||||
is_primary=True,
|
||||
).exclude(
|
||||
pk=self.pk
|
||||
).update(is_primary=False)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def file_size(self) -> Optional[int]:
|
||||
"""Get file size in bytes."""
|
||||
try:
|
||||
return self.image.size
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
@property
|
||||
def dimensions(self) -> Optional[List[int]]:
|
||||
"""Get image dimensions as [width, height]."""
|
||||
try:
|
||||
return [self.image.width, self.image.height]
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
"""Get absolute URL for this photo."""
|
||||
return f"/parks/{self.ride.park.slug}/rides/{self.ride.slug}/photos/{self.pk}/"
|
||||
|
||||
@property
|
||||
def park(self):
|
||||
"""Get the park this ride belongs to."""
|
||||
return self.ride.park
|
||||
@@ -1,212 +0,0 @@
|
||||
"""
|
||||
Models for ride ranking system using Internet Roller Coaster Poll algorithm.
|
||||
|
||||
This system calculates rankings based on pairwise comparisons between rides,
|
||||
where each ride is compared to every other ride to determine which one
|
||||
more riders preferred.
|
||||
"""
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
import pghistory
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RideRanking(models.Model):
|
||||
"""
|
||||
Stores calculated rankings for rides using the Internet Roller Coaster Poll algorithm.
|
||||
|
||||
Rankings are recalculated daily based on user reviews/ratings.
|
||||
Each ride's rank is determined by its winning percentage in pairwise comparisons.
|
||||
"""
|
||||
|
||||
ride = models.OneToOneField(
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="ranking"
|
||||
)
|
||||
|
||||
# Core ranking metrics
|
||||
rank = models.PositiveIntegerField(
|
||||
db_index=True, help_text="Overall rank position (1 = best)"
|
||||
)
|
||||
wins = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of rides this ride beats in pairwise comparisons"
|
||||
)
|
||||
losses = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of rides that beat this ride in pairwise comparisons",
|
||||
)
|
||||
ties = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of rides with equal preference in pairwise comparisons",
|
||||
)
|
||||
winning_percentage = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=4,
|
||||
validators=[MinValueValidator(0), MaxValueValidator(1)],
|
||||
db_index=True,
|
||||
help_text="Win percentage where ties count as 0.5",
|
||||
)
|
||||
|
||||
# Additional metrics
|
||||
mutual_riders_count = models.PositiveIntegerField(
|
||||
default=0, help_text="Total number of users who have rated this ride"
|
||||
)
|
||||
comparison_count = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of other rides this was compared against"
|
||||
)
|
||||
average_rating = models.DecimalField(
|
||||
max_digits=3,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(10)],
|
||||
help_text="Average rating from all users who have rated this ride",
|
||||
)
|
||||
|
||||
# Metadata
|
||||
last_calculated = models.DateTimeField(
|
||||
default=timezone.now, help_text="When this ranking was last calculated"
|
||||
)
|
||||
calculation_version = models.CharField(
|
||||
max_length=10, default="1.0", help_text="Algorithm version used for calculation"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["rank"]
|
||||
indexes = [
|
||||
models.Index(fields=["rank"]),
|
||||
models.Index(fields=["winning_percentage", "-mutual_riders_count"]),
|
||||
models.Index(fields=["ride", "last_calculated"]),
|
||||
]
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
name="rideranking_winning_percentage_range",
|
||||
check=models.Q(winning_percentage__gte=0)
|
||||
& models.Q(winning_percentage__lte=1),
|
||||
violation_error_message="Winning percentage must be between 0 and 1",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
name="rideranking_average_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",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.rank} - {self.ride.name} ({self.winning_percentage:.1%})"
|
||||
|
||||
@property
|
||||
def total_comparisons(self):
|
||||
"""Total number of pairwise comparisons (wins + losses + ties)."""
|
||||
return self.wins + self.losses + self.ties
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RidePairComparison(models.Model):
|
||||
"""
|
||||
Caches pairwise comparison results between two rides.
|
||||
|
||||
This model stores the results of comparing two rides based on mutual riders
|
||||
(users who have rated both rides). It's used to speed up ranking calculations.
|
||||
"""
|
||||
|
||||
ride_a = models.ForeignKey(
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="comparisons_as_a"
|
||||
)
|
||||
ride_b = models.ForeignKey(
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="comparisons_as_b"
|
||||
)
|
||||
|
||||
# Comparison results
|
||||
ride_a_wins = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of mutual riders who rated ride_a higher"
|
||||
)
|
||||
ride_b_wins = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of mutual riders who rated ride_b higher"
|
||||
)
|
||||
ties = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of mutual riders who rated both rides equally"
|
||||
)
|
||||
|
||||
# Metrics
|
||||
mutual_riders_count = models.PositiveIntegerField(
|
||||
default=0, help_text="Total number of users who have rated both rides"
|
||||
)
|
||||
ride_a_avg_rating = models.DecimalField(
|
||||
max_digits=3,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Average rating of ride_a from mutual riders",
|
||||
)
|
||||
ride_b_avg_rating = models.DecimalField(
|
||||
max_digits=3,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Average rating of ride_b from mutual riders",
|
||||
)
|
||||
|
||||
# Metadata
|
||||
last_calculated = models.DateTimeField(
|
||||
auto_now=True, help_text="When this comparison was last calculated"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = [["ride_a", "ride_b"]]
|
||||
indexes = [
|
||||
models.Index(fields=["ride_a", "ride_b"]),
|
||||
models.Index(fields=["last_calculated"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
winner = "TIE"
|
||||
if self.ride_a_wins > self.ride_b_wins:
|
||||
winner = self.ride_a.name
|
||||
elif self.ride_b_wins > self.ride_a_wins:
|
||||
winner = self.ride_b.name
|
||||
return f"{self.ride_a.name} vs {self.ride_b.name} - Winner: {winner}"
|
||||
|
||||
@property
|
||||
def winner(self):
|
||||
"""Returns the winning ride or None for a tie."""
|
||||
if self.ride_a_wins > self.ride_b_wins:
|
||||
return self.ride_a
|
||||
elif self.ride_b_wins > self.ride_a_wins:
|
||||
return self.ride_b
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_tie(self):
|
||||
"""Returns True if the comparison resulted in a tie."""
|
||||
return self.ride_a_wins == self.ride_b_wins
|
||||
|
||||
|
||||
class RankingSnapshot(models.Model):
|
||||
"""
|
||||
Stores historical snapshots of rankings for tracking changes over time.
|
||||
|
||||
This allows us to show ranking trends and movements.
|
||||
"""
|
||||
|
||||
ride = models.ForeignKey(
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="ranking_history"
|
||||
)
|
||||
rank = models.PositiveIntegerField()
|
||||
winning_percentage = models.DecimalField(max_digits=5, decimal_places=4)
|
||||
snapshot_date = models.DateField(
|
||||
db_index=True, help_text="Date when this ranking snapshot was taken"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = [["ride", "snapshot_date"]]
|
||||
ordering = ["-snapshot_date", "rank"]
|
||||
indexes = [
|
||||
models.Index(fields=["snapshot_date", "rank"]),
|
||||
models.Index(fields=["ride", "-snapshot_date"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.ride.name} - Rank #{self.rank} on {self.snapshot_date}"
|
||||
@@ -1,73 +0,0 @@
|
||||
from django.db import models
|
||||
from django.db.models import functions
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from apps.core.history import TrackedModel
|
||||
import pghistory
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RideReview(TrackedModel):
|
||||
"""
|
||||
A review of a ride.
|
||||
"""
|
||||
|
||||
ride = models.ForeignKey(
|
||||
"rides.Ride", on_delete=models.CASCADE, related_name="reviews"
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
"accounts.User", on_delete=models.CASCADE, related_name="ride_reviews"
|
||||
)
|
||||
rating = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1), MaxValueValidator(10)]
|
||||
)
|
||||
title = models.CharField(max_length=200)
|
||||
content = models.TextField()
|
||||
visit_date = models.DateField()
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Moderation
|
||||
is_published = models.BooleanField(default=True)
|
||||
moderation_notes = models.TextField(blank=True)
|
||||
moderated_by = models.ForeignKey(
|
||||
"accounts.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="moderated_ride_reviews",
|
||||
)
|
||||
moderated_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-created_at"]
|
||||
unique_together = ["ride", "user"]
|
||||
constraints = [
|
||||
# Business rule: Rating must be between 1 and 10 (database level
|
||||
# enforcement)
|
||||
models.CheckConstraint(
|
||||
name="ride_review_rating_range",
|
||||
check=models.Q(rating__gte=1) & models.Q(rating__lte=10),
|
||||
violation_error_message="Rating must be between 1 and 10",
|
||||
),
|
||||
# Business rule: Visit date cannot be in the future
|
||||
models.CheckConstraint(
|
||||
name="ride_review_visit_date_not_future",
|
||||
check=models.Q(visit_date__lte=functions.Now()),
|
||||
violation_error_message="Visit date cannot be in the future",
|
||||
),
|
||||
# Business rule: If moderated, must have moderator and timestamp
|
||||
models.CheckConstraint(
|
||||
name="ride_review_moderation_consistency",
|
||||
check=models.Q(moderated_by__isnull=True, moderated_at__isnull=True)
|
||||
| models.Q(moderated_by__isnull=False, moderated_at__isnull=False),
|
||||
violation_error_message=(
|
||||
"Moderated reviews must have both moderator and moderation "
|
||||
"timestamp"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Review of {self.ride.name} by {self.user.username}"
|
||||
@@ -1,898 +0,0 @@
|
||||
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.propulsion_system:
|
||||
propulsion_choice = stats.get_propulsion_system_rich_choice()
|
||||
if propulsion_choice:
|
||||
search_parts.append(propulsion_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
|
||||
)
|
||||
propulsion_system = RichChoiceField(
|
||||
choice_group="propulsion_systems",
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
default="CHAIN",
|
||||
help_text="Propulsion 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}"
|
||||
Reference in New Issue
Block a user