feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.

This commit is contained in:
pacnpal
2025-12-28 17:32:53 -05:00
parent aa56c46c27
commit c95f99ca10
452 changed files with 7948 additions and 6073 deletions

View File

@@ -8,19 +8,23 @@ 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
from .company import Company
from .location import RideLocation
from .reviews import RideReview
from .rankings import RideRanking, RidePairComparison, RankingSnapshot
from .media import RidePhoto
from .credits import RideCredit
from .location import RideLocation
from .media import RidePhoto
from .rankings import RankingSnapshot, RidePairComparison, RideRanking
from .reviews import RideReview
from .rides import Ride, RideModel, RollerCoasterStats
from .stats import DarkRideStats, FlatRideStats, WaterRideStats
__all__ = [
# Primary models
"Ride",
"RideModel",
"RollerCoasterStats",
"WaterRideStats",
"DarkRideStats",
"FlatRideStats",
"Company",
"RideLocation",
"RideReview",

View File

@@ -1,13 +1,13 @@
import pghistory
from django.conf import settings
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.choices.fields import RichChoiceField
from apps.core.history import HistoricalSlug
from apps.core.models import TrackedModel
from apps.core.choices.fields import RichChoiceField
@pghistory.track()

View File

@@ -1,10 +1,11 @@
from django.db import models
from django.conf import settings
from django.core.validators import MinValueValidator, MaxValueValidator
import pghistory
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from apps.core.history import TrackedModel
@pghistory.track()
class RideCredit(TrackedModel):
"""
@@ -44,12 +45,15 @@ class RideCredit(TrackedModel):
notes = models.TextField(
blank=True, help_text="Personal notes about the experience"
)
display_order = models.PositiveIntegerField(
default=0, help_text="User-defined display order for drag-drop sorting"
)
class Meta(TrackedModel.Meta):
verbose_name = "Ride Credit"
verbose_name_plural = "Ride Credits"
unique_together = ["user", "ride"]
ordering = ["-last_ridden_at", "-first_ridden_at", "-created_at"]
ordering = ["display_order", "-last_ridden_at", "-first_ridden_at", "-created_at"]
def __str__(self):
return f"{self.user} - {self.ride}"

View File

@@ -1,7 +1,7 @@
from django.contrib.gis.db import models as gis_models
from django.db import models
from django.contrib.gis.geos import Point
import pghistory
from django.contrib.gis.db import models as gis_models
from django.contrib.gis.geos import Point
from django.db import models
@pghistory.track()

View File

@@ -4,13 +4,15 @@ 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
from typing import Any, cast
import pghistory
from django.conf import settings
from django.db import models
from apps.core.choices import RichChoiceField
from apps.core.history import TrackedModel
from apps.core.services.media_service import MediaService
def ride_photo_upload_path(instance: models.Model, filename: str) -> str:
@@ -114,7 +116,7 @@ class RidePhoto(TrackedModel):
super().save(*args, **kwargs)
@property
def file_size(self) -> Optional[int]:
def file_size(self) -> int | None:
"""Get file size in bytes."""
try:
return self.image.size
@@ -122,7 +124,7 @@ class RidePhoto(TrackedModel):
return None
@property
def dimensions(self) -> Optional[List[int]]:
def dimensions(self) -> list[int] | None:
"""Get image dimensions as [width, height]."""
try:
return [self.image.width, self.image.height]

View File

@@ -6,10 +6,10 @@ where each ride is compared to every other ride to determine which one
more riders preferred.
"""
import pghistory
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils import timezone
from django.core.validators import MinValueValidator, MaxValueValidator
import pghistory
@pghistory.track()

View File

@@ -1,8 +1,9 @@
import pghistory
from django.core.validators import MaxValueValidator, MinValueValidator
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()

View File

@@ -1,14 +1,18 @@
import contextlib
from typing import TYPE_CHECKING
import pghistory
from django.contrib.auth.models import AbstractBaseUser
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.text import slugify
from django.core.exceptions import ValidationError
from config.django import base as settings
from apps.core.models import TrackedModel
from apps.core.choices import RichChoiceField
from apps.core.models import TrackedModel
from apps.core.state_machine import RichFSMField, StateMachineMixin
from config.django import base as settings
from .company import Company
import pghistory
from typing import TYPE_CHECKING, Optional
from django.contrib.auth.models import AbstractBaseUser
if TYPE_CHECKING:
from .rides import RollerCoasterStats
@@ -498,7 +502,7 @@ class Ride(StateMachineMixin, TrackedModel):
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'
@@ -669,17 +673,17 @@ class Ride(StateMachineMixin, TrackedModel):
return f"{self.name} at {self.park.name}"
# FSM Transition Wrapper Methods
def open(self, *, user: Optional[AbstractBaseUser] = None) -> None:
def open(self, *, user: AbstractBaseUser | None = None) -> None:
"""Transition ride to OPERATING status."""
self.transition_to_operating(user=user)
self.save()
def close_temporarily(self, *, user: Optional[AbstractBaseUser] = None) -> None:
def close_temporarily(self, *, user: AbstractBaseUser | None = None) -> None:
"""Transition ride to CLOSED_TEMP status."""
self.transition_to_closed_temp(user=user)
self.save()
def mark_sbno(self, *, user: Optional[AbstractBaseUser] = None) -> None:
def mark_sbno(self, *, user: AbstractBaseUser | None = None) -> None:
"""Transition ride to SBNO (Standing But Not Operating) status."""
self.transition_to_sbno(user=user)
self.save()
@@ -689,7 +693,7 @@ class Ride(StateMachineMixin, TrackedModel):
*,
closing_date,
post_closing_status: str,
user: Optional[AbstractBaseUser] = None,
user: AbstractBaseUser | None = None,
) -> None:
"""Transition ride to CLOSING status with closing date and target status."""
from django.core.exceptions import ValidationError
@@ -703,25 +707,25 @@ class Ride(StateMachineMixin, TrackedModel):
self.post_closing_status = post_closing_status
self.save()
def close_permanently(self, *, user: Optional[AbstractBaseUser] = None) -> None:
def close_permanently(self, *, user: AbstractBaseUser | None = None) -> None:
"""Transition ride to CLOSED_PERM status."""
self.transition_to_closed_perm(user=user)
self.save()
def demolish(self, *, user: Optional[AbstractBaseUser] = None) -> None:
def demolish(self, *, user: AbstractBaseUser | None = None) -> None:
"""Transition ride to DEMOLISHED status."""
self.transition_to_demolished(user=user)
self.save()
def relocate(self, *, user: Optional[AbstractBaseUser] = None) -> None:
def relocate(self, *, user: AbstractBaseUser | None = None) -> None:
"""Transition ride to RELOCATED status."""
self.transition_to_relocated(user=user)
self.save()
def apply_post_closing_status(self, *, user: Optional[AbstractBaseUser] = None) -> None:
def apply_post_closing_status(self, *, user: AbstractBaseUser | None = None) -> None:
"""Apply post_closing_status if closing_date has been reached."""
from django.utils import timezone
from django.core.exceptions import ValidationError
from django.utils import timezone
if self.status != "CLOSING":
raise ValidationError("Ride must be in CLOSING status")
@@ -757,10 +761,8 @@ class Ride(StateMachineMixin, TrackedModel):
# Check for slug conflicts when park changes or slug is new
original_ride = None
if self.pk:
try:
with contextlib.suppress(Ride.DoesNotExist):
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
@@ -805,13 +807,13 @@ class Ride(StateMachineMixin, TrackedModel):
# 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)
@@ -822,39 +824,39 @@ class Ride(StateMachineMixin, TrackedModel):
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 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:
@@ -874,7 +876,7 @@ class Ride(StateMachineMixin, TrackedModel):
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:
@@ -947,6 +949,7 @@ class Ride(StateMachineMixin, TrackedModel):
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
@@ -975,7 +978,7 @@ class Ride(StateMachineMixin, TrackedModel):
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)

View File

@@ -0,0 +1,226 @@
"""
Ride-Specific Statistics Models
This module contains specialized statistics models for different ride categories:
- WaterRideStats: For water rides (WR)
- DarkRideStats: For dark rides (DR)
- FlatRideStats: For flat rides (FR)
These complement the existing RollerCoasterStats model in rides.py.
"""
import pghistory
from django.db import models
from apps.core.history import TrackedModel
# Wetness Level Choices for Water Rides
WETNESS_LEVELS = [
("DRY", "Dry - No water contact"),
("MILD", "Mild - Light misting"),
("MODERATE", "Moderate - Some splashing"),
("SOAKING", "Soaking - Prepare to get drenched"),
]
# Motion Type Choices for Flat Rides
MOTION_TYPES = [
("SPINNING", "Spinning"),
("SWINGING", "Swinging"),
("BOUNCING", "Bouncing"),
("ROTATING", "Rotating"),
("DROPPING", "Dropping"),
("MIXED", "Mixed Motion"),
]
@pghistory.track()
class WaterRideStats(TrackedModel):
"""
Statistics specific to water rides (category=WR).
Tracks water-related attributes like wetness level and splash characteristics.
"""
ride = models.OneToOneField(
"rides.Ride",
on_delete=models.CASCADE,
related_name="water_stats",
help_text="Ride these water statistics belong to",
)
wetness_level = models.CharField(
max_length=10,
choices=WETNESS_LEVELS,
default="MODERATE",
help_text="How wet riders typically get",
)
splash_height_ft = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text="Maximum splash height in feet",
)
has_splash_zone = models.BooleanField(
default=False,
help_text="Whether there is a designated splash zone for spectators",
)
boat_capacity = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Number of riders per boat/raft",
)
uses_flume = models.BooleanField(
default=False,
help_text="Whether the ride uses a flume/log system",
)
rapids_sections = models.PositiveIntegerField(
default=0,
help_text="Number of rapids/whitewater sections",
)
class Meta(TrackedModel.Meta):
verbose_name = "Water Ride Statistics"
verbose_name_plural = "Water Ride Statistics"
ordering = ["ride"]
def __str__(self) -> str:
return f"Water Stats for {self.ride.name}"
@pghistory.track()
class DarkRideStats(TrackedModel):
"""
Statistics specific to dark rides (category=DR).
Tracks theming elements like scenes, animatronics, and technology.
"""
ride = models.OneToOneField(
"rides.Ride",
on_delete=models.CASCADE,
related_name="dark_stats",
help_text="Ride these dark ride statistics belong to",
)
scene_count = models.PositiveIntegerField(
default=0,
help_text="Number of themed scenes",
)
animatronic_count = models.PositiveIntegerField(
default=0,
help_text="Number of animatronic figures",
)
has_projection_technology = models.BooleanField(
default=False,
help_text="Whether the ride uses projection mapping or screens",
)
is_interactive = models.BooleanField(
default=False,
help_text="Whether riders can interact with elements (shooting, etc.)",
)
ride_system = models.CharField(
max_length=100,
blank=True,
help_text="Type of ride system (Omnimover, Trackless, Classic Track, etc.)",
)
uses_practical_effects = models.BooleanField(
default=True,
help_text="Whether the ride uses practical/physical effects",
)
uses_motion_base = models.BooleanField(
default=False,
help_text="Whether vehicles have motion simulation capability",
)
class Meta(TrackedModel.Meta):
verbose_name = "Dark Ride Statistics"
verbose_name_plural = "Dark Ride Statistics"
ordering = ["ride"]
def __str__(self) -> str:
return f"Dark Ride Stats for {self.ride.name}"
@pghistory.track()
class FlatRideStats(TrackedModel):
"""
Statistics specific to flat rides (category=FR).
Tracks motion characteristics like rotation, swing angles, and height.
"""
ride = models.OneToOneField(
"rides.Ride",
on_delete=models.CASCADE,
related_name="flat_stats",
help_text="Ride these flat ride statistics belong to",
)
max_height_ft = models.DecimalField(
max_digits=6,
decimal_places=2,
null=True,
blank=True,
help_text="Maximum ride height in feet",
)
rotation_speed_rpm = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text="Maximum rotation speed in RPM",
)
swing_angle_degrees = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Maximum swing angle in degrees (for swinging rides)",
)
motion_type = models.CharField(
max_length=20,
choices=MOTION_TYPES,
default="SPINNING",
help_text="Primary type of motion",
)
arm_count = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Number of arms/gondolas",
)
seats_per_gondola = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Number of seats per gondola/arm",
)
max_g_force = models.DecimalField(
max_digits=4,
decimal_places=2,
null=True,
blank=True,
help_text="Maximum G-force experienced",
)
class Meta(TrackedModel.Meta):
verbose_name = "Flat Ride Statistics"
verbose_name_plural = "Flat Ride Statistics"
ordering = ["ride"]
def __str__(self) -> str:
return f"Flat Ride Stats for {self.ride.name}"