mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-30 15:07:03 -05:00
feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
226
backend/apps/rides/models/stats.py
Normal file
226
backend/apps/rides/models/stats.py
Normal 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}"
|
||||
Reference in New Issue
Block a user