Add comprehensive API documentation for ThrillWiki integration and features

- Introduced Next.js integration guide for ThrillWiki API, detailing authentication, core domain APIs, data structures, and implementation patterns.
- Documented the migration to Rich Choice Objects, highlighting changes for frontend developers and enhanced metadata availability.
- Fixed the missing `get_by_slug` method in the Ride model, ensuring proper functionality of ride detail endpoints.
- Created a test script to verify manufacturer syncing with ride models, ensuring data integrity across related models.
This commit is contained in:
pacnpal
2025-09-16 11:29:17 -04:00
parent 61d73a2147
commit c2c26cfd1d
98 changed files with 11476 additions and 4803 deletions

View File

@@ -8,7 +8,7 @@ The Company model is aliased as Manufacturer to clarify its role as ride manufac
while maintaining backward compatibility through the Company alias.
"""
from .rides import Ride, RideModel, RollerCoasterStats, Categories, CATEGORY_CHOICES
from .rides import Ride, RideModel, RollerCoasterStats
from .company import Company
from .location import RideLocation
from .reviews import RideReview
@@ -28,7 +28,4 @@ __all__ = [
"RideRanking",
"RidePairComparison",
"RankingSnapshot",
# Shared constants
"Categories",
"CATEGORY_CHOICES",
]

View File

@@ -7,20 +7,15 @@ 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):
class CompanyRole(models.TextChoices):
MANUFACTURER = "MANUFACTURER", "Ride Manufacturer"
DESIGNER = "DESIGNER", "Ride Designer"
OPERATOR = "OPERATOR", "Park Operator"
PROPERTY_OWNER = "PROPERTY_OWNER", "Property Owner"
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
roles = ArrayField(
models.CharField(max_length=20, choices=CompanyRole.choices),
RichChoiceField(choice_group="company_roles", domain="rides", max_length=20),
default=list,
blank=True,
)
@@ -64,7 +59,6 @@ class Company(TrackedModel):
def get_absolute_url(self):
# This will need to be updated to handle different roles
return reverse("companies:detail", kwargs={"slug": self.slug})
return "#"
@classmethod
def get_by_slug(cls, slug):
@@ -73,14 +67,19 @@ class Company(TrackedModel):
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check pghistory first
history_model = cls.get_history_model()
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
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:
@@ -91,7 +90,7 @@ class Company(TrackedModel):
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
raise cls.DoesNotExist("No company found with this slug")
class Meta:
class Meta(TrackedModel.Meta):
app_label = "rides"
ordering = ["name"]
verbose_name_plural = "Companies"

View File

@@ -8,6 +8,7 @@ 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
@@ -48,17 +49,12 @@ class RidePhoto(TrackedModel):
is_approved = models.BooleanField(default=False)
# Ride-specific metadata
photo_type = models.CharField(
photo_type = RichChoiceField(
choice_group="photo_types",
domain="rides",
max_length=50,
choices=[
("exterior", "Exterior View"),
("queue", "Queue Area"),
("station", "Station"),
("onride", "On-Ride"),
("construction", "Construction"),
("other", "Other"),
],
default="exterior",
help_text="Type of photo for categorization and display purposes"
)
# Metadata

View File

@@ -40,7 +40,7 @@ class RideReview(TrackedModel):
)
moderated_at = models.DateTimeField(null=True, blank=True)
class Meta:
class Meta(TrackedModel.Meta):
ordering = ["-created_at"]
unique_together = ["ride", "user"]
constraints = [

View File

@@ -2,22 +2,14 @@ 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
# Shared choices that will be used by multiple models
CATEGORY_CHOICES = [
("", "Select ride type"),
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
]
if TYPE_CHECKING:
from .rides import RollerCoasterStats
# Legacy alias for backward compatibility
Categories = CATEGORY_CHOICES
@pghistory.track()
@@ -46,9 +38,10 @@ class RideModel(TrackedModel):
description = models.TextField(
blank=True, help_text="Detailed description of the ride model"
)
category = models.CharField(
category = RichChoiceField(
choice_group="categories",
domain="rides",
max_length=2,
choices=CATEGORY_CHOICES,
default="",
blank=True,
help_text="Primary category classification",
@@ -137,16 +130,11 @@ class RideModel(TrackedModel):
blank=True,
help_text="Notable design features or innovations (JSON or comma-separated)",
)
target_market = models.CharField(
target_market = RichChoiceField(
choice_group="target_markets",
domain="rides",
max_length=50,
blank=True,
choices=[
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
help_text="Primary target market for this ride model",
)
@@ -371,16 +359,12 @@ class RideModelPhoto(TrackedModel):
alt_text = models.CharField(max_length=255, blank=True)
# Photo metadata
photo_type = models.CharField(
photo_type = RichChoiceField(
choice_group="photo_types",
domain="rides",
max_length=20,
choices=[
("PROMOTIONAL", "Promotional"),
("TECHNICAL", "Technical Drawing"),
("INSTALLATION", "Installation Example"),
("RENDERING", "3D Rendering"),
("CATALOG", "Catalog Image"),
],
default="PROMOTIONAL",
help_text="Type of photo for categorization and display purposes",
)
is_primary = models.BooleanField(
@@ -418,18 +402,11 @@ class RideModelTechnicalSpec(TrackedModel):
RideModel, on_delete=models.CASCADE, related_name="technical_specs"
)
spec_category = models.CharField(
spec_category = RichChoiceField(
choice_group="spec_categories",
domain="rides",
max_length=50,
choices=[
("DIMENSIONS", "Dimensions"),
("PERFORMANCE", "Performance"),
("CAPACITY", "Capacity"),
("SAFETY", "Safety Features"),
("ELECTRICAL", "Electrical Requirements"),
("FOUNDATION", "Foundation Requirements"),
("MAINTENANCE", "Maintenance"),
("OTHER", "Other"),
],
help_text="Category of technical specification",
)
spec_name = models.CharField(max_length=100, help_text="Name of the specification")
@@ -459,23 +436,9 @@ class Ride(TrackedModel):
Note: The average_rating field is denormalized and refreshed by background
jobs. Use selectors or annotations for real-time calculations if needed.
"""
STATUS_CHOICES = [
("", "Select status"),
("OPERATING", "Operating"),
("CLOSED_TEMP", "Temporarily Closed"),
("SBNO", "Standing But Not Operating"),
("CLOSING", "Closing"),
("CLOSED_PERM", "Permanently Closed"),
("UNDER_CONSTRUCTION", "Under Construction"),
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
]
POST_CLOSING_STATUS_CHOICES = [
("SBNO", "Standing But Not Operating"),
("CLOSED_PERM", "Permanently Closed"),
]
if TYPE_CHECKING:
coaster_stats: 'RollerCoasterStats'
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255)
@@ -490,8 +453,13 @@ class Ride(TrackedModel):
null=True,
blank=True,
)
category = models.CharField(
max_length=2, choices=CATEGORY_CHOICES, default="", blank=True
category = RichChoiceField(
choice_group="categories",
domain="rides",
max_length=2,
default="",
blank=True,
help_text="Ride category classification"
)
manufacturer = models.ForeignKey(
Company,
@@ -517,12 +485,17 @@ class Ride(TrackedModel):
blank=True,
help_text="The specific model/type of this ride",
)
status = models.CharField(
max_length=20, choices=STATUS_CHOICES, default="OPERATING"
)
post_closing_status = models.CharField(
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,
choices=POST_CLOSING_STATUS_CHOICES,
null=True,
blank=True,
help_text="Status to change to after closing date",
@@ -654,6 +627,14 @@ class Ride(TrackedModel):
# 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(
@@ -701,15 +682,15 @@ class Ride(TrackedModel):
# Category
if self.category:
category_display = dict(CATEGORY_CHOICES).get(self.category, '')
if category_display:
search_parts.append(category_display)
category_choice = self.get_category_rich_choice()
if category_choice:
search_parts.append(category_choice.label)
# Status
if self.status:
status_display = dict(self.STATUS_CHOICES).get(self.status, '')
if status_display:
search_parts.append(status_display)
status_choice = self.get_status_rich_choice()
if status_choice:
search_parts.append(status_choice.label)
# Companies
if self.manufacturer:
@@ -725,22 +706,22 @@ class Ride(TrackedModel):
# Roller coaster stats if available
try:
if hasattr(self, 'coaster_stats') and self.coaster_stats:
stats = self.coaster_stats
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_display = dict(RollerCoasterStats.TRACK_MATERIAL_CHOICES).get(stats.track_material, '')
if material_display:
search_parts.append(material_display)
material_choice = stats.get_track_material_rich_choice()
if material_choice:
search_parts.append(material_choice.label)
if stats.roller_coaster_type:
type_display = dict(RollerCoasterStats.COASTER_TYPE_CHOICES).get(stats.roller_coaster_type, '')
if type_display:
search_parts.append(type_display)
type_choice = stats.get_roller_coaster_type_rich_choice()
if type_choice:
search_parts.append(type_choice.label)
if stats.launch_type:
launch_display = dict(RollerCoasterStats.LAUNCH_CHOICES).get(stats.launch_type, '')
if launch_display:
search_parts.append(launch_display)
launch_choice = stats.get_launch_type_rich_choice()
if launch_choice:
search_parts.append(launch_choice.label)
if stats.train_style:
search_parts.append(stats.train_style)
except Exception:
@@ -815,38 +796,53 @@ class Ride(TrackedModel):
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"""
TRACK_MATERIAL_CHOICES = [
("STEEL", "Steel"),
("WOOD", "Wood"),
("HYBRID", "Hybrid"),
]
COASTER_TYPE_CHOICES = [
("SITDOWN", "Sit Down"),
("INVERTED", "Inverted"),
("FLYING", "Flying"),
("STANDUP", "Stand Up"),
("WING", "Wing"),
("DIVE", "Dive"),
("FAMILY", "Family"),
("WILD_MOUSE", "Wild Mouse"),
("SPINNING", "Spinning"),
("FOURTH_DIMENSION", "4th Dimension"),
("OTHER", "Other"),
]
LAUNCH_CHOICES = [
("CHAIN", "Chain Lift"),
("LSM", "LSM Launch"),
("HYDRAULIC", "Hydraulic Launch"),
("GRAVITY", "Gravity"),
("OTHER", "Other"),
]
ride = models.OneToOneField(
Ride, on_delete=models.CASCADE, related_name="coaster_stats"
@@ -863,23 +859,31 @@ class RollerCoasterStats(models.Model):
inversions = models.PositiveIntegerField(default=0)
ride_time_seconds = models.PositiveIntegerField(null=True, blank=True)
track_type = models.CharField(max_length=255, blank=True)
track_material = models.CharField(
track_material = RichChoiceField(
choice_group="track_materials",
domain="rides",
max_length=20,
choices=TRACK_MATERIAL_CHOICES,
default="STEEL",
blank=True,
help_text="Track construction material type"
)
roller_coaster_type = models.CharField(
roller_coaster_type = RichChoiceField(
choice_group="coaster_types",
domain="rides",
max_length=20,
choices=COASTER_TYPE_CHOICES,
default="SITDOWN",
blank=True,
help_text="Roller coaster type classification"
)
max_drop_height_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True
)
launch_type = models.CharField(
max_length=20, choices=LAUNCH_CHOICES, default="CHAIN"
launch_type = RichChoiceField(
choice_group="launch_systems",
domain="rides",
max_length=20,
default="CHAIN",
help_text="Launch or lift system type"
)
train_style = models.CharField(max_length=255, blank=True)
trains_count = models.PositiveIntegerField(null=True, blank=True)