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

@@ -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)