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

@@ -2,7 +2,6 @@ import logging
from django.apps import AppConfig
logger = logging.getLogger(__name__)
@@ -30,15 +29,15 @@ class RidesConfig(AppConfig):
def _register_callbacks(self):
"""Register FSM transition callbacks for ride models."""
from apps.core.state_machine.registry import register_callback
from apps.core.state_machine.callbacks.cache import (
RideCacheInvalidation,
APICacheInvalidation,
RideCacheInvalidation,
)
from apps.core.state_machine.callbacks.related_updates import (
ParkCountUpdateCallback,
SearchTextUpdateCallback,
)
from apps.core.state_machine.registry import register_callback
from apps.rides.models import Ride
# Cache invalidation for all ride status changes

View File

@@ -5,10 +5,9 @@ This module defines all choice objects for the rides domain, replacing
the legacy tuple-based choices with rich choice objects.
"""
from apps.core.choices import RichChoice, ChoiceCategory
from apps.core.choices import ChoiceCategory, RichChoice
from apps.core.choices.registry import register_choices
# Ride Category Choices
RIDE_CATEGORIES = [
RichChoice(
@@ -762,7 +761,7 @@ RIDES_COMPANY_ROLES = [
def register_rides_choices():
"""Register all rides domain choices with the global registry"""
register_choices(
name="categories",
choices=RIDE_CATEGORIES,
@@ -770,7 +769,7 @@ def register_rides_choices():
description="Ride category classifications",
metadata={'domain': 'rides', 'type': 'category'}
)
register_choices(
name="statuses",
choices=RIDE_STATUSES,
@@ -778,7 +777,7 @@ def register_rides_choices():
description="Ride operational status options",
metadata={'domain': 'rides', 'type': 'status'}
)
register_choices(
name="post_closing_statuses",
choices=POST_CLOSING_STATUSES,
@@ -786,7 +785,7 @@ def register_rides_choices():
description="Status options after ride closure",
metadata={'domain': 'rides', 'type': 'post_closing_status'}
)
register_choices(
name="track_materials",
choices=TRACK_MATERIALS,
@@ -794,7 +793,7 @@ def register_rides_choices():
description="Roller coaster track material types",
metadata={'domain': 'rides', 'type': 'track_material', 'applies_to': 'roller_coasters'}
)
register_choices(
name="coaster_types",
choices=COASTER_TYPES,
@@ -802,7 +801,7 @@ def register_rides_choices():
description="Roller coaster type classifications",
metadata={'domain': 'rides', 'type': 'coaster_type', 'applies_to': 'roller_coasters'}
)
register_choices(
name="propulsion_systems",
choices=PROPULSION_SYSTEMS,
@@ -810,7 +809,7 @@ def register_rides_choices():
description="Roller coaster propulsion and lift systems",
metadata={'domain': 'rides', 'type': 'propulsion_system', 'applies_to': 'roller_coasters'}
)
register_choices(
name="target_markets",
choices=TARGET_MARKETS,
@@ -818,7 +817,7 @@ def register_rides_choices():
description="Target market classifications for ride models",
metadata={'domain': 'rides', 'type': 'target_market', 'applies_to': 'ride_models'}
)
register_choices(
name="photo_types",
choices=PHOTO_TYPES,
@@ -826,7 +825,7 @@ def register_rides_choices():
description="Photo type classifications for ride model images",
metadata={'domain': 'rides', 'type': 'photo_type', 'applies_to': 'ride_model_photos'}
)
register_choices(
name="spec_categories",
choices=SPEC_CATEGORIES,
@@ -834,7 +833,7 @@ def register_rides_choices():
description="Technical specification category classifications",
metadata={'domain': 'rides', 'type': 'spec_category', 'applies_to': 'ride_model_specs'}
)
register_choices(
name="company_roles",
choices=RIDES_COMPANY_ROLES,

View File

@@ -1,7 +1,6 @@
from typing import Dict
def get_ride_display_changes(changes: Dict) -> Dict:
def get_ride_display_changes(changes: dict) -> dict:
"""Returns a human-readable version of the ride changes"""
field_names = {
"name": "Name",
@@ -26,7 +25,7 @@ def get_ride_display_changes(changes: Dict) -> Dict:
# Format specific fields
if field == "status":
from .choices import RIDE_STATUSES
choices = {choice.value: choice.label for choice in RIDE_STATUSES}
if old_value in choices:
old_value = choices[old_value]
@@ -34,7 +33,7 @@ def get_ride_display_changes(changes: Dict) -> Dict:
new_value = choices[new_value]
elif field == "post_closing_status":
from .choices import POST_CLOSING_STATUSES
choices = {choice.value: choice.label for choice in POST_CLOSING_STATUSES}
if old_value in choices:
old_value = choices[old_value]
@@ -49,7 +48,7 @@ def get_ride_display_changes(changes: Dict) -> Dict:
return display_changes
def get_ride_model_display_changes(changes: Dict) -> Dict:
def get_ride_model_display_changes(changes: dict) -> dict:
"""Returns a human-readable version of the ride model changes"""
field_names = {
"name": "Name",

View File

@@ -1,7 +1,9 @@
from apps.parks.models import Park, ParkArea
from django import forms
from django.forms import ModelChoiceField
from django.urls import reverse_lazy
from apps.parks.models import Park, ParkArea
from .models.company import Company
from .models.rides import Ride, RideModel

View File

@@ -7,10 +7,9 @@ This package contains form classes for ride-related functionality including:
"""
# Import forms from the search module in this package
from .search import MasterFilterForm
# Import forms from the base module in this package
from .base import RideForm, RideSearchForm
from .search import MasterFilterForm
__all__ = [
"MasterFilterForm",

View File

@@ -1,7 +1,9 @@
from apps.parks.models import Park, ParkArea
from django import forms
from django.forms import ModelChoiceField
from django.urls import reverse_lazy
from apps.parks.models import Park, ParkArea
from ..models.company import Company
from ..models.rides import Ride, RideModel

View File

@@ -14,9 +14,10 @@ Forms for the comprehensive ride filtering system with 8 categories:
Each form handles validation and provides clean data for the RideSearchService.
"""
from typing import Any
from django import forms
from django.core.exceptions import ValidationError
from typing import Dict, Any
from apps.parks.models import Park, ParkArea
from apps.rides.models.company import Company
@@ -95,14 +96,14 @@ class BasicInfoForm(BaseFilterForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Get choices from Rich Choice registry
from apps.core.choices.registry import get_choices
# Get choices - let exceptions propagate if registry fails
category_choices = [(choice.value, choice.label) for choice in get_choices("categories", "rides")]
status_choices = [(choice.value, choice.label) for choice in get_choices("statuses", "rides")]
# Update field choices dynamically
self.fields['category'].choices = category_choices
self.fields['status'].choices = status_choices
@@ -189,9 +190,8 @@ class DateRangeField(forms.MultiValueField):
def validate(self, value):
super().validate(value)
if value and value.get("start") and value.get("end"):
if value["start"] > value["end"]:
raise ValidationError("Start date must be before end date.")
if value and value.get("start") and value.get("end") and value["start"] > value["end"]:
raise ValidationError("Start date must be before end date.")
class DateFiltersForm(BaseFilterForm):
@@ -340,15 +340,15 @@ class RollerCoasterForm(BaseFilterForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Get choices from Rich Choice registry
from apps.core.choices.registry import get_choices
# Get choices - let exceptions propagate if registry fails
track_material_choices = [(choice.value, choice.label) for choice in get_choices("track_materials", "rides")]
coaster_type_choices = [(choice.value, choice.label) for choice in get_choices("coaster_types", "rides")]
propulsion_system_choices = [(choice.value, choice.label) for choice in get_choices("propulsion_systems", "rides")]
# Update field choices dynamically
self.fields['track_material'].choices = track_material_choices
self.fields['coaster_type'].choices = coaster_type_choices
@@ -398,15 +398,15 @@ class CompanyForm(BaseFilterForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Get choices from Rich Choice registry
from apps.core.choices.registry import get_choices
# Get both rides and parks company roles - let exceptions propagate if registry fails
rides_roles = [(choice.value, choice.label) for choice in get_choices("company_roles", "rides")]
parks_roles = [(choice.value, choice.label) for choice in get_choices("company_roles", "parks")]
role_choices = rides_roles + parks_roles
# Update field choices dynamically
self.fields['manufacturer_roles'].choices = role_choices
self.fields['designer_roles'].choices = role_choices
@@ -434,7 +434,7 @@ class SortingForm(BaseFilterForm):
# Static sorting choices - these are UI-specific and don't need Rich Choice Objects
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Static sort choices for UI functionality
sort_choices = [
("relevance", "Relevance"),
@@ -451,7 +451,7 @@ class SortingForm(BaseFilterForm):
("capacity_asc", "Capacity (Lowest)"),
("capacity_desc", "Capacity (Highest)"),
]
self.fields['sort_by'].choices = sort_choices
sort_by = forms.ChoiceField(
@@ -524,7 +524,7 @@ class MasterFilterForm(BaseFilterForm):
return cleaned_data
def get_filter_dict(self) -> Dict[str, Any]:
def get_filter_dict(self) -> dict[str, Any]:
"""Convert form data to search service filter format."""
if not self.is_valid():
return {}
@@ -538,11 +538,11 @@ class MasterFilterForm(BaseFilterForm):
return filters
def get_search_filters(self) -> Dict[str, Any]:
def get_search_filters(self) -> dict[str, Any]:
"""Alias for get_filter_dict for backward compatibility."""
return self.get_filter_dict()
def get_filter_summary(self) -> Dict[str, Any]:
def get_filter_summary(self) -> dict[str, Any]:
"""Get summary of active filters for display."""
active_filters = {}
@@ -588,7 +588,7 @@ class MasterFilterForm(BaseFilterForm):
return active_filters
def get_active_filters_summary(self) -> Dict[str, Any]:
def get_active_filters_summary(self) -> dict[str, Any]:
"""Alias for get_filter_summary for backward compatibility."""
return self.get_filter_summary()
@@ -597,8 +597,4 @@ class MasterFilterForm(BaseFilterForm):
if not self.is_valid():
return False
for field_name, value in self.cleaned_data.items():
if value: # If any field has a value, we have active filters
return True
return False
return any(value for field_name, value in self.cleaned_data.items())

View File

@@ -3,23 +3,23 @@ Custom managers and QuerySets for Rides models.
Optimized queries following Django styleguide patterns.
"""
from typing import Optional, List, Union
from django.db.models import Q, F, Count, Prefetch
from django.db.models import Count, F, Prefetch, Q
from apps.core.managers import (
BaseQuerySet,
BaseManager,
ReviewableQuerySet,
BaseQuerySet,
ReviewableManager,
StatusQuerySet,
ReviewableQuerySet,
StatusManager,
StatusQuerySet,
)
class RideQuerySet(StatusQuerySet, ReviewableQuerySet):
"""Optimized QuerySet for Ride model."""
def by_category(self, *, category: Union[str, List[str]]):
def by_category(self, *, category: str | list[str]):
"""Filter rides by category."""
if isinstance(category, list):
return self.filter(category__in=category)
@@ -119,10 +119,10 @@ class RideQuerySet(StatusQuerySet, ReviewableQuerySet):
def search_by_specs(
self,
*,
min_height: Optional[int] = None,
max_height: Optional[int] = None,
min_speed: Optional[float] = None,
inversions: Optional[bool] = None,
min_height: int | None = None,
max_height: int | None = None,
min_speed: float | None = None,
inversions: bool | None = None,
):
"""Search rides by physical specifications."""
queryset = self

View File

@@ -1,12 +1,13 @@
# Generated by Django 5.2.5 on 2025-08-26 17:39
import apps.rides.models.media
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
import apps.rides.models.media
class Migration(migrations.Migration):
dependencies = [

View File

@@ -6,36 +6,36 @@ in the previous migration. These fields enable efficient hybrid filtering by
pre-computing commonly filtered and searched data.
"""
from django.db import migrations
import pghistory
from django.db import migrations
def populate_computed_fields(apps, schema_editor):
"""Populate computed fields for all existing rides."""
Ride = apps.get_model('rides', 'Ride')
# Disable pghistory triggers during bulk operations to avoid performance issues
with pghistory.context(disable=True):
rides = list(Ride.objects.all().select_related(
'park', 'park__location', 'park_area', 'manufacturer', 'designer', 'ride_model'
))
for ride in rides:
# Extract opening year from opening_date
if ride.opening_date:
ride.opening_year = ride.opening_date.year
else:
ride.opening_year = None
# Build comprehensive search text
search_parts = []
# Basic ride info
if ride.name:
search_parts.append(ride.name)
if ride.description:
search_parts.append(ride.description)
# Park info
if ride.park:
search_parts.append(ride.park.name)
@@ -46,11 +46,11 @@ def populate_computed_fields(apps, schema_editor):
search_parts.append(ride.park.location.state)
if ride.park.location.country:
search_parts.append(ride.park.location.country)
# Park area
if ride.park_area:
search_parts.append(ride.park_area.name)
# Category
if ride.category:
category_choices = [
@@ -65,7 +65,7 @@ def populate_computed_fields(apps, schema_editor):
category_display = dict(category_choices).get(ride.category, '')
if category_display:
search_parts.append(category_display)
# Status
if ride.status:
status_choices = [
@@ -82,21 +82,21 @@ def populate_computed_fields(apps, schema_editor):
status_display = dict(status_choices).get(ride.status, '')
if status_display:
search_parts.append(status_display)
# Companies
if ride.manufacturer:
search_parts.append(ride.manufacturer.name)
if ride.designer:
search_parts.append(ride.designer.name)
# Ride model
if ride.ride_model:
search_parts.append(ride.ride_model.name)
if ride.ride_model.manufacturer:
search_parts.append(ride.ride_model.manufacturer.name)
ride.search_text = ' '.join(filter(None, search_parts)).lower()
# Bulk update all rides
Ride.objects.bulk_update(rides, ['opening_year', 'search_text'], batch_size=1000)
@@ -104,7 +104,7 @@ def populate_computed_fields(apps, schema_editor):
def reverse_populate_computed_fields(apps, schema_editor):
"""Clear computed fields (reverse operation)."""
Ride = apps.get_model('rides', 'Ride')
# Disable pghistory triggers during bulk operations
with pghistory.context(disable=True):
Ride.objects.all().update(opening_year=None, search_text='')

View File

@@ -28,151 +28,151 @@ class Migration(migrations.Migration):
"CREATE INDEX rides_ride_park_category_idx ON rides_ride (park_id, category) WHERE category != '';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_park_category_idx;"
),
# Composite index for park + status filtering (common)
migrations.RunSQL(
"CREATE INDEX rides_ride_park_status_idx ON rides_ride (park_id, status);",
reverse_sql="DROP INDEX IF EXISTS rides_ride_park_status_idx;"
),
# Composite index for category + status filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_category_status_idx ON rides_ride (category, status) WHERE category != '';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_category_status_idx;"
),
# Composite index for manufacturer + category
migrations.RunSQL(
"CREATE INDEX rides_ride_manufacturer_category_idx ON rides_ride (manufacturer_id, category) WHERE manufacturer_id IS NOT NULL AND category != '';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_manufacturer_category_idx;"
),
# Composite index for opening year + category (for timeline filtering)
migrations.RunSQL(
"CREATE INDEX rides_ride_opening_year_category_idx ON rides_ride (opening_year, category) WHERE opening_year IS NOT NULL AND category != '';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_opening_year_category_idx;"
),
# Partial index for operating rides only (most common filter)
migrations.RunSQL(
"CREATE INDEX rides_ride_operating_only_idx ON rides_ride (park_id, category, opening_year) WHERE status = 'OPERATING';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_operating_only_idx;"
),
# Partial index for roller coasters only (popular category)
migrations.RunSQL(
"CREATE INDEX rides_ride_roller_coasters_idx ON rides_ride (park_id, status, opening_year) WHERE category = 'RC';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_roller_coasters_idx;"
),
# Covering index for list views (includes commonly displayed fields)
migrations.RunSQL(
"CREATE INDEX rides_ride_list_covering_idx ON rides_ride (park_id, category, status) INCLUDE (name, opening_date, average_rating);",
reverse_sql="DROP INDEX IF EXISTS rides_ride_list_covering_idx;"
),
# GIN index for full-text search on computed search_text field
migrations.RunSQL(
"CREATE INDEX rides_ride_search_text_gin_idx ON rides_ride USING gin(to_tsvector('english', search_text));",
reverse_sql="DROP INDEX IF EXISTS rides_ride_search_text_gin_idx;"
),
# Trigram index for fuzzy text search
migrations.RunSQL(
"CREATE INDEX rides_ride_search_text_trgm_idx ON rides_ride USING gin(search_text gin_trgm_ops);",
reverse_sql="DROP INDEX IF EXISTS rides_ride_search_text_trgm_idx;"
),
# Index for rating-based filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_rating_idx ON rides_ride (average_rating) WHERE average_rating IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_rating_idx;"
),
# Index for capacity-based filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_capacity_idx ON rides_ride (capacity_per_hour) WHERE capacity_per_hour IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_capacity_idx;"
),
# Index for height requirement filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_height_req_idx ON rides_ride (min_height_in, max_height_in) WHERE min_height_in IS NOT NULL OR max_height_in IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_height_req_idx;"
),
# Composite index for ride model filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_model_manufacturer_idx ON rides_ride (ride_model_id, manufacturer_id) WHERE ride_model_id IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_model_manufacturer_idx;"
),
# Index for designer filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_designer_idx ON rides_ride (designer_id, category) WHERE designer_id IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_designer_idx;"
),
# Index for park area filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_park_area_idx ON rides_ride (park_area_id, status) WHERE park_area_id IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_park_area_idx;"
),
# Roller coaster stats indexes for performance
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_height_idx ON rides_rollercoasterstats (height_ft) WHERE height_ft IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_height_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_speed_idx ON rides_rollercoasterstats (speed_mph) WHERE speed_mph IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_speed_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_inversions_idx ON rides_rollercoasterstats (inversions);",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_inversions_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_type_material_idx ON rides_rollercoasterstats (roller_coaster_type, track_material);",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_type_material_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_launch_type_idx ON rides_rollercoasterstats (launch_type);",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_launch_type_idx;"
),
# Composite index for complex roller coaster filtering
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_complex_idx ON rides_rollercoasterstats (roller_coaster_type, track_material, launch_type) INCLUDE (height_ft, speed_mph, inversions);",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_complex_idx;"
),
# Index for ride model filtering and search
migrations.RunSQL(
"CREATE INDEX rides_ridemodel_manufacturer_category_idx ON rides_ridemodel (manufacturer_id, category) WHERE manufacturer_id IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ridemodel_manufacturer_category_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_ridemodel_name_trgm_idx ON rides_ridemodel USING gin(name gin_trgm_ops);",
reverse_sql="DROP INDEX IF EXISTS rides_ridemodel_name_trgm_idx;"
),
# Index for company role-based filtering
migrations.RunSQL(
"CREATE INDEX rides_company_manufacturer_role_idx ON rides_company USING gin(roles) WHERE 'MANUFACTURER' = ANY(roles);",
reverse_sql="DROP INDEX IF EXISTS rides_company_manufacturer_role_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_company_designer_role_idx ON rides_company USING gin(roles) WHERE 'DESIGNER' = ANY(roles);",
reverse_sql="DROP INDEX IF EXISTS rides_company_designer_role_idx;"
),
# Ensure trigram extension is available for fuzzy search
migrations.RunSQL(
"CREATE EXTENSION IF NOT EXISTS pg_trgm;",

View File

@@ -1,9 +1,10 @@
# Generated by Django 5.2.5 on 2025-09-15 17:35
import apps.core.choices.fields
import django.contrib.postgres.fields
from django.db import migrations, models
import apps.core.choices.fields
class Migration(migrations.Migration):

View File

@@ -1,9 +1,10 @@
# Generated by Django 5.2.5 on 2025-09-15 18:07
import apps.core.choices.fields
import django.contrib.postgres.fields
from django.db import migrations
import apps.core.choices.fields
class Migration(migrations.Migration):

View File

@@ -1,8 +1,9 @@
# Generated by Django 5.2.5 on 2025-09-15 19:06
import apps.core.choices.fields
from django.db import migrations
import apps.core.choices.fields
class Migration(migrations.Migration):

View File

@@ -1,10 +1,11 @@
# Generated by Django 5.2.5 on 2025-09-17 01:25
import apps.core.choices.fields
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations
import apps.core.choices.fields
class Migration(migrations.Migration):

View File

@@ -1,9 +1,10 @@
# Generated by Django 5.1.3 on 2025-12-21 03:20
import apps.core.state_machine.fields
import django.db.models.deletion
from django.db import migrations, models
import apps.core.state_machine.fields
class Migration(migrations.Migration):

View File

@@ -1,10 +1,11 @@
# Generated by Django 5.1.6 on 2025-12-26 14:10
import apps.core.choices.fields
import django.contrib.postgres.fields
import django.db.models.deletion
from django.db import migrations, models
import apps.core.choices.fields
class Migration(migrations.Migration):

View File

@@ -0,0 +1,533 @@
# Generated by Django 5.2.9 on 2025-12-27 20:58
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pghistory", "0007_auto_20250421_0444"),
("rides", "0028_ridecredit_ridecreditevent_ridecredit_insert_insert_and_more"),
]
operations = [
migrations.CreateModel(
name="DarkRideStats",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("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(
blank=True,
help_text="Type of ride system (Omnimover, Trackless, Classic Track, etc.)",
max_length=100,
),
),
(
"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"),
),
(
"ride",
models.OneToOneField(
help_text="Ride these dark ride statistics belong to",
on_delete=django.db.models.deletion.CASCADE,
related_name="dark_stats",
to="rides.ride",
),
),
],
options={
"verbose_name": "Dark Ride Statistics",
"verbose_name_plural": "Dark Ride Statistics",
"ordering": ["ride"],
"abstract": False,
},
),
migrations.CreateModel(
name="DarkRideStatsEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("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(
blank=True,
help_text="Type of ride system (Omnimover, Trackless, Classic Track, etc.)",
max_length=100,
),
),
(
"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"),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.darkridestats",
),
),
(
"ride",
models.ForeignKey(
db_constraint=False,
help_text="Ride these dark ride statistics belong to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="FlatRideStats",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"max_height_ft",
models.DecimalField(
blank=True, decimal_places=2, help_text="Maximum ride height in feet", max_digits=6, null=True
),
),
(
"rotation_speed_rpm",
models.DecimalField(
blank=True, decimal_places=2, help_text="Maximum rotation speed in RPM", max_digits=5, null=True
),
),
(
"swing_angle_degrees",
models.PositiveIntegerField(
blank=True, help_text="Maximum swing angle in degrees (for swinging rides)", null=True
),
),
(
"motion_type",
models.CharField(
choices=[
("SPINNING", "Spinning"),
("SWINGING", "Swinging"),
("BOUNCING", "Bouncing"),
("ROTATING", "Rotating"),
("DROPPING", "Dropping"),
("MIXED", "Mixed Motion"),
],
default="SPINNING",
help_text="Primary type of motion",
max_length=20,
),
),
("arm_count", models.PositiveIntegerField(blank=True, help_text="Number of arms/gondolas", null=True)),
(
"seats_per_gondola",
models.PositiveIntegerField(blank=True, help_text="Number of seats per gondola/arm", null=True),
),
(
"max_g_force",
models.DecimalField(
blank=True, decimal_places=2, help_text="Maximum G-force experienced", max_digits=4, null=True
),
),
(
"ride",
models.OneToOneField(
help_text="Ride these flat ride statistics belong to",
on_delete=django.db.models.deletion.CASCADE,
related_name="flat_stats",
to="rides.ride",
),
),
],
options={
"verbose_name": "Flat Ride Statistics",
"verbose_name_plural": "Flat Ride Statistics",
"ordering": ["ride"],
"abstract": False,
},
),
migrations.CreateModel(
name="FlatRideStatsEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"max_height_ft",
models.DecimalField(
blank=True, decimal_places=2, help_text="Maximum ride height in feet", max_digits=6, null=True
),
),
(
"rotation_speed_rpm",
models.DecimalField(
blank=True, decimal_places=2, help_text="Maximum rotation speed in RPM", max_digits=5, null=True
),
),
(
"swing_angle_degrees",
models.PositiveIntegerField(
blank=True, help_text="Maximum swing angle in degrees (for swinging rides)", null=True
),
),
(
"motion_type",
models.CharField(
choices=[
("SPINNING", "Spinning"),
("SWINGING", "Swinging"),
("BOUNCING", "Bouncing"),
("ROTATING", "Rotating"),
("DROPPING", "Dropping"),
("MIXED", "Mixed Motion"),
],
default="SPINNING",
help_text="Primary type of motion",
max_length=20,
),
),
("arm_count", models.PositiveIntegerField(blank=True, help_text="Number of arms/gondolas", null=True)),
(
"seats_per_gondola",
models.PositiveIntegerField(blank=True, help_text="Number of seats per gondola/arm", null=True),
),
(
"max_g_force",
models.DecimalField(
blank=True, decimal_places=2, help_text="Maximum G-force experienced", max_digits=4, null=True
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.flatridestats",
),
),
(
"ride",
models.ForeignKey(
db_constraint=False,
help_text="Ride these flat ride statistics belong to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="WaterRideStats",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"wetness_level",
models.CharField(
choices=[
("DRY", "Dry - No water contact"),
("MILD", "Mild - Light misting"),
("MODERATE", "Moderate - Some splashing"),
("SOAKING", "Soaking - Prepare to get drenched"),
],
default="MODERATE",
help_text="How wet riders typically get",
max_length=10,
),
),
(
"splash_height_ft",
models.DecimalField(
blank=True, decimal_places=2, help_text="Maximum splash height in feet", max_digits=5, null=True
),
),
(
"has_splash_zone",
models.BooleanField(
default=False, help_text="Whether there is a designated splash zone for spectators"
),
),
(
"boat_capacity",
models.PositiveIntegerField(blank=True, help_text="Number of riders per boat/raft", null=True),
),
(
"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"),
),
(
"ride",
models.OneToOneField(
help_text="Ride these water statistics belong to",
on_delete=django.db.models.deletion.CASCADE,
related_name="water_stats",
to="rides.ride",
),
),
],
options={
"verbose_name": "Water Ride Statistics",
"verbose_name_plural": "Water Ride Statistics",
"ordering": ["ride"],
"abstract": False,
},
),
migrations.CreateModel(
name="WaterRideStatsEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"wetness_level",
models.CharField(
choices=[
("DRY", "Dry - No water contact"),
("MILD", "Mild - Light misting"),
("MODERATE", "Moderate - Some splashing"),
("SOAKING", "Soaking - Prepare to get drenched"),
],
default="MODERATE",
help_text="How wet riders typically get",
max_length=10,
),
),
(
"splash_height_ft",
models.DecimalField(
blank=True, decimal_places=2, help_text="Maximum splash height in feet", max_digits=5, null=True
),
),
(
"has_splash_zone",
models.BooleanField(
default=False, help_text="Whether there is a designated splash zone for spectators"
),
),
(
"boat_capacity",
models.PositiveIntegerField(blank=True, help_text="Number of riders per boat/raft", null=True),
),
(
"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"),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.waterridestats",
),
),
(
"ride",
models.ForeignKey(
db_constraint=False,
help_text="Ride these water statistics belong to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="darkridestats",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_darkridestatsevent" ("animatronic_count", "created_at", "has_projection_technology", "id", "is_interactive", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "ride_system", "scene_count", "updated_at", "uses_motion_base", "uses_practical_effects") VALUES (NEW."animatronic_count", NEW."created_at", NEW."has_projection_technology", NEW."id", NEW."is_interactive", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_id", NEW."ride_system", NEW."scene_count", NEW."updated_at", NEW."uses_motion_base", NEW."uses_practical_effects"); RETURN NULL;',
hash="055ece465263a190b67dda7258c4bf26fa939107",
operation="INSERT",
pgid="pgtrigger_insert_insert_78d6d",
table="rides_darkridestats",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="darkridestats",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_darkridestatsevent" ("animatronic_count", "created_at", "has_projection_technology", "id", "is_interactive", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "ride_system", "scene_count", "updated_at", "uses_motion_base", "uses_practical_effects") VALUES (NEW."animatronic_count", NEW."created_at", NEW."has_projection_technology", NEW."id", NEW."is_interactive", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_id", NEW."ride_system", NEW."scene_count", NEW."updated_at", NEW."uses_motion_base", NEW."uses_practical_effects"); RETURN NULL;',
hash="e7421fdd5dd3f044c5cc324db3e5d948e8f39afe",
operation="UPDATE",
pgid="pgtrigger_update_update_8eac1",
table="rides_darkridestats",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="flatridestats",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_flatridestatsevent" ("arm_count", "created_at", "id", "max_g_force", "max_height_ft", "motion_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "rotation_speed_rpm", "seats_per_gondola", "swing_angle_degrees", "updated_at") VALUES (NEW."arm_count", NEW."created_at", NEW."id", NEW."max_g_force", NEW."max_height_ft", NEW."motion_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_id", NEW."rotation_speed_rpm", NEW."seats_per_gondola", NEW."swing_angle_degrees", NEW."updated_at"); RETURN NULL;',
hash="04edef9ce6235f57c5b53f052a7fe392835f2971",
operation="INSERT",
pgid="pgtrigger_insert_insert_a589a",
table="rides_flatridestats",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="flatridestats",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_flatridestatsevent" ("arm_count", "created_at", "id", "max_g_force", "max_height_ft", "motion_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "rotation_speed_rpm", "seats_per_gondola", "swing_angle_degrees", "updated_at") VALUES (NEW."arm_count", NEW."created_at", NEW."id", NEW."max_g_force", NEW."max_height_ft", NEW."motion_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_id", NEW."rotation_speed_rpm", NEW."seats_per_gondola", NEW."swing_angle_degrees", NEW."updated_at"); RETURN NULL;',
hash="6f48930fae214744ad60cf8755ee0d50897d1040",
operation="UPDATE",
pgid="pgtrigger_update_update_f949f",
table="rides_flatridestats",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="waterridestats",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_waterridestatsevent" ("boat_capacity", "created_at", "has_splash_zone", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rapids_sections", "ride_id", "splash_height_ft", "updated_at", "uses_flume", "wetness_level") VALUES (NEW."boat_capacity", NEW."created_at", NEW."has_splash_zone", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rapids_sections", NEW."ride_id", NEW."splash_height_ft", NEW."updated_at", NEW."uses_flume", NEW."wetness_level"); RETURN NULL;',
hash="bb61031dc606f90bf9970f76417b30a72d8469ce",
operation="INSERT",
pgid="pgtrigger_insert_insert_fb731",
table="rides_waterridestats",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="waterridestats",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_waterridestatsevent" ("boat_capacity", "created_at", "has_splash_zone", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rapids_sections", "ride_id", "splash_height_ft", "updated_at", "uses_flume", "wetness_level") VALUES (NEW."boat_capacity", NEW."created_at", NEW."has_splash_zone", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rapids_sections", NEW."ride_id", NEW."splash_height_ft", NEW."updated_at", NEW."uses_flume", NEW."wetness_level"); RETURN NULL;',
hash="98404ec47c9b8d577f0a408a1182f9adffda7784",
operation="UPDATE",
pgid="pgtrigger_update_update_4f7a3",
table="rides_waterridestats",
when="AFTER",
),
),
),
]

View File

@@ -5,7 +5,7 @@ This module contains mixins that provide reusable functionality
for ride-related views, reducing code duplication.
"""
from typing import Any, Dict
from typing import Any
from django.contrib import messages
@@ -20,7 +20,7 @@ class RideFormMixin:
to handle new manufacturer, designer, and ride model suggestions.
"""
def handle_entity_suggestions(self, form) -> Dict[str, Any]:
def handle_entity_suggestions(self, form) -> dict[str, Any]:
"""
Process new entity suggestions from form.

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}"

View File

@@ -1,4 +1,5 @@
from django.urls import path
from . import views
app_name = "rides"

View File

@@ -3,17 +3,18 @@ Selectors for ride-related data retrieval.
Following Django styleguide pattern for separating data access from business logic.
"""
from typing import Optional, Dict, Any
from django.db.models import QuerySet, Q, Count, Avg, Prefetch
from typing import Any
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import Distance
from django.db.models import Avg, Count, Prefetch, Q, QuerySet
from .models import Ride, RideModel, RideReview
from .choices import RIDE_CATEGORIES
from .models import Ride, RideModel, RideReview
def ride_list_for_display(
*, filters: Optional[Dict[str, Any]] = None
*, filters: dict[str, Any] | None = None
) -> QuerySet[Ride]:
"""
Get rides optimized for list display with related data.
@@ -246,9 +247,10 @@ def rides_with_recent_reviews(*, days: int = 30) -> QuerySet[Ride]:
Returns:
QuerySet of rides with recent reviews
"""
from django.utils import timezone
from datetime import timedelta
from django.utils import timezone
cutoff_date = timezone.now() - timedelta(days=days)
return (
@@ -267,7 +269,7 @@ def rides_with_recent_reviews(*, days: int = 30) -> QuerySet[Ride]:
)
def ride_statistics_by_category() -> Dict[str, Any]:
def ride_statistics_by_category() -> dict[str, Any]:
"""
Get ride statistics grouped by category.

View File

@@ -1,7 +1,7 @@
from .location_service import RideLocationService
from .media_service import RideMediaService
# Import from the services_core module (was services.py, renamed to avoid package collision)
from ..services_core import RideService
from .location_service import RideLocationService
from .media_service import RideMediaService
__all__ = ["RideLocationService", "RideMediaService", "RideService"]

View File

@@ -17,11 +17,12 @@ Architecture:
- Hybrid: Combine both approaches based on data characteristics
"""
from typing import Dict, List, Any, Optional
import logging
from typing import Any
from django.core.cache import cache
from django.db import models
from django.db.models import Q, Min, Max
import logging
from django.db.models import Max, Min, Q
logger = logging.getLogger(__name__)
@@ -29,34 +30,34 @@ logger = logging.getLogger(__name__)
class SmartRideLoader:
"""
Intelligent ride data loader that chooses optimal filtering strategy.
Strategy Selection:
- ≤200 total records: Client-side filtering (load all data)
- >200 total records: Server-side filtering (database filtering + pagination)
Features:
- Progressive loading for large datasets
- 5-minute intelligent caching
- Comprehensive filter metadata
- Optimized queries with prefetch_related
"""
# Configuration constants
INITIAL_LOAD_SIZE = 50
PROGRESSIVE_LOAD_SIZE = 25
MAX_CLIENT_SIDE_RECORDS = 200
CACHE_TIMEOUT = 300 # 5 minutes
def __init__(self):
self.cache_prefix = "rides_hybrid_"
def get_initial_load(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
def get_initial_load(self, filters: dict[str, Any] | None = None) -> dict[str, Any]:
"""
Get initial data load with automatic strategy selection.
Args:
filters: Optional filter parameters
Returns:
Dict containing:
- strategy: 'client_side' or 'server_side'
@@ -65,63 +66,63 @@ class SmartRideLoader:
- has_more: Whether more data is available
- filter_metadata: Available filter options
"""
# Get total count for strategy decision
total_count = self._get_total_count(filters)
# Choose strategy based on total count
if total_count <= self.MAX_CLIENT_SIDE_RECORDS:
return self._get_client_side_data(filters, total_count)
else:
return self._get_server_side_data(filters, total_count)
def get_progressive_load(self, offset: int, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
def get_progressive_load(self, offset: int, filters: dict[str, Any] | None = None) -> dict[str, Any]:
"""
Get additional data for progressive loading (server-side strategy only).
Args:
offset: Number of records to skip
filters: Filter parameters
Returns:
Dict containing additional ride records
"""
# Build queryset with filters
queryset = self._build_filtered_queryset(filters)
# Get total count for this filtered set
total_count = queryset.count()
# Get progressive batch
rides = list(queryset[offset:offset + self.PROGRESSIVE_LOAD_SIZE])
return {
'rides': self._serialize_rides(rides),
'total_count': total_count,
'has_more': len(rides) == self.PROGRESSIVE_LOAD_SIZE,
'next_offset': offset + len(rides) if len(rides) == self.PROGRESSIVE_LOAD_SIZE else None
}
def get_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
def get_filter_metadata(self, filters: dict[str, Any] | None = None) -> dict[str, Any]:
"""
Get comprehensive filter metadata for dynamic filter generation.
Args:
filters: Optional filters to scope the metadata
Returns:
Dict containing all available filter options and ranges
"""
cache_key = f"{self.cache_prefix}filter_metadata_{hash(str(filters))}"
metadata = cache.get(cache_key)
if metadata is None:
metadata = self._generate_filter_metadata(filters)
cache.set(cache_key, metadata, self.CACHE_TIMEOUT)
return metadata
def invalidate_cache(self) -> None:
"""Invalidate all cached data for rides."""
# Note: In production, you might want to use cache versioning
@@ -131,31 +132,31 @@ class SmartRideLoader:
f"{self.cache_prefix}filter_metadata",
f"{self.cache_prefix}total_count",
]
for key in cache_keys:
cache.delete(key)
def _get_total_count(self, filters: Optional[Dict[str, Any]] = None) -> int:
def _get_total_count(self, filters: dict[str, Any] | None = None) -> int:
"""Get total count of rides matching filters."""
cache_key = f"{self.cache_prefix}total_count_{hash(str(filters))}"
count = cache.get(cache_key)
if count is None:
queryset = self._build_filtered_queryset(filters)
count = queryset.count()
cache.set(cache_key, count, self.CACHE_TIMEOUT)
return count
def _get_client_side_data(self, filters: Optional[Dict[str, Any]],
total_count: int) -> Dict[str, Any]:
def _get_client_side_data(self, filters: dict[str, Any] | None,
total_count: int) -> dict[str, Any]:
"""Get all data for client-side filtering."""
cache_key = f"{self.cache_prefix}client_side_all"
cached_data = cache.get(cache_key)
if cached_data is None:
from apps.rides.models import Ride
# Load all rides with optimized query
queryset = Ride.objects.select_related(
'park',
@@ -168,11 +169,11 @@ class SmartRideLoader:
).prefetch_related(
'coaster_stats'
).order_by('name')
rides = list(queryset)
cached_data = self._serialize_rides(rides)
cache.set(cache_key, cached_data, self.CACHE_TIMEOUT)
return {
'strategy': 'client_side',
'rides': cached_data,
@@ -180,16 +181,16 @@ class SmartRideLoader:
'has_more': False,
'filter_metadata': self.get_filter_metadata(filters)
}
def _get_server_side_data(self, filters: Optional[Dict[str, Any]],
total_count: int) -> Dict[str, Any]:
def _get_server_side_data(self, filters: dict[str, Any] | None,
total_count: int) -> dict[str, Any]:
"""Get initial batch for server-side filtering."""
# Build filtered queryset
queryset = self._build_filtered_queryset(filters)
# Get initial batch
rides = list(queryset[:self.INITIAL_LOAD_SIZE])
return {
'strategy': 'server_side',
'rides': self._serialize_rides(rides),
@@ -197,11 +198,11 @@ class SmartRideLoader:
'has_more': len(rides) == self.INITIAL_LOAD_SIZE,
'next_offset': len(rides) if len(rides) == self.INITIAL_LOAD_SIZE else None
}
def _build_filtered_queryset(self, filters: Optional[Dict[str, Any]]):
def _build_filtered_queryset(self, filters: dict[str, Any] | None):
"""Build Django queryset with applied filters."""
from apps.rides.models import Ride
# Start with optimized base queryset
queryset = Ride.objects.select_related(
'park',
@@ -214,115 +215,115 @@ class SmartRideLoader:
).prefetch_related(
'coaster_stats'
)
if not filters:
return queryset.order_by('name')
# Apply filters
q_objects = Q()
# Text search using computed search_text field
if 'search' in filters and filters['search']:
search_term = filters['search'].lower()
q_objects &= Q(search_text__icontains=search_term)
# Park filters
if 'park_slug' in filters and filters['park_slug']:
q_objects &= Q(park__slug=filters['park_slug'])
if 'park_id' in filters and filters['park_id']:
q_objects &= Q(park_id=filters['park_id'])
# Category filters
if 'category' in filters and filters['category']:
q_objects &= Q(category__in=filters['category'])
# Status filters
if 'status' in filters and filters['status']:
q_objects &= Q(status__in=filters['status'])
# Company filters
if 'manufacturer_ids' in filters and filters['manufacturer_ids']:
q_objects &= Q(manufacturer_id__in=filters['manufacturer_ids'])
if 'designer_ids' in filters and filters['designer_ids']:
q_objects &= Q(designer_id__in=filters['designer_ids'])
# Ride model filters
if 'ride_model_ids' in filters and filters['ride_model_ids']:
q_objects &= Q(ride_model_id__in=filters['ride_model_ids'])
# Opening year filters using computed opening_year field
if 'opening_year' in filters and filters['opening_year']:
q_objects &= Q(opening_year=filters['opening_year'])
if 'min_opening_year' in filters and filters['min_opening_year']:
q_objects &= Q(opening_year__gte=filters['min_opening_year'])
if 'max_opening_year' in filters and filters['max_opening_year']:
q_objects &= Q(opening_year__lte=filters['max_opening_year'])
# Rating filters
if 'min_rating' in filters and filters['min_rating']:
q_objects &= Q(average_rating__gte=filters['min_rating'])
if 'max_rating' in filters and filters['max_rating']:
q_objects &= Q(average_rating__lte=filters['max_rating'])
# Height requirement filters
if 'min_height_requirement' in filters and filters['min_height_requirement']:
q_objects &= Q(min_height_in__gte=filters['min_height_requirement'])
if 'max_height_requirement' in filters and filters['max_height_requirement']:
q_objects &= Q(max_height_in__lte=filters['max_height_requirement'])
# Capacity filters
if 'min_capacity' in filters and filters['min_capacity']:
q_objects &= Q(capacity_per_hour__gte=filters['min_capacity'])
if 'max_capacity' in filters and filters['max_capacity']:
q_objects &= Q(capacity_per_hour__lte=filters['max_capacity'])
# Roller coaster specific filters
if 'roller_coaster_type' in filters and filters['roller_coaster_type']:
q_objects &= Q(coaster_stats__roller_coaster_type__in=filters['roller_coaster_type'])
if 'track_material' in filters and filters['track_material']:
q_objects &= Q(coaster_stats__track_material__in=filters['track_material'])
if 'propulsion_system' in filters and filters['propulsion_system']:
q_objects &= Q(coaster_stats__propulsion_system__in=filters['propulsion_system'])
# Roller coaster height filters
if 'min_height_ft' in filters and filters['min_height_ft']:
q_objects &= Q(coaster_stats__height_ft__gte=filters['min_height_ft'])
if 'max_height_ft' in filters and filters['max_height_ft']:
q_objects &= Q(coaster_stats__height_ft__lte=filters['max_height_ft'])
# Roller coaster speed filters
if 'min_speed_mph' in filters and filters['min_speed_mph']:
q_objects &= Q(coaster_stats__speed_mph__gte=filters['min_speed_mph'])
if 'max_speed_mph' in filters and filters['max_speed_mph']:
q_objects &= Q(coaster_stats__speed_mph__lte=filters['max_speed_mph'])
# Inversion filters
if 'min_inversions' in filters and filters['min_inversions']:
q_objects &= Q(coaster_stats__inversions__gte=filters['min_inversions'])
if 'max_inversions' in filters and filters['max_inversions']:
q_objects &= Q(coaster_stats__inversions__lte=filters['max_inversions'])
if 'has_inversions' in filters and filters['has_inversions'] is not None:
if filters['has_inversions']:
q_objects &= Q(coaster_stats__inversions__gt=0)
else:
q_objects &= Q(coaster_stats__inversions=0)
# Apply filters and ordering
queryset = queryset.filter(q_objects)
# Apply ordering
ordering = filters.get('ordering', 'name')
if ordering in ['height_ft', '-height_ft', 'speed_mph', '-speed_mph']:
@@ -331,13 +332,13 @@ class SmartRideLoader:
queryset = queryset.order_by(ordering_field)
else:
queryset = queryset.order_by(ordering)
return queryset
def _serialize_rides(self, rides: List) -> List[Dict[str, Any]]:
def _serialize_rides(self, rides: list) -> list[dict[str, Any]]:
"""Serialize ride objects to dictionaries."""
serialized = []
for ride in rides:
# Basic ride data
ride_data = {
@@ -360,7 +361,7 @@ class SmartRideLoader:
'created_at': ride.created_at.isoformat(),
'updated_at': ride.updated_at.isoformat(),
}
# Park data
if ride.park:
ride_data['park'] = {
@@ -368,7 +369,7 @@ class SmartRideLoader:
'name': ride.park.name,
'slug': ride.park.slug,
}
# Park location data
if hasattr(ride.park, 'location') and ride.park.location:
ride_data['park']['location'] = {
@@ -376,7 +377,7 @@ class SmartRideLoader:
'state': ride.park.location.state,
'country': ride.park.location.country,
}
# Park area data
if ride.park_area:
ride_data['park_area'] = {
@@ -384,7 +385,7 @@ class SmartRideLoader:
'name': ride.park_area.name,
'slug': ride.park_area.slug,
}
# Company data
if ride.manufacturer:
ride_data['manufacturer'] = {
@@ -392,14 +393,14 @@ class SmartRideLoader:
'name': ride.manufacturer.name,
'slug': ride.manufacturer.slug,
}
if ride.designer:
ride_data['designer'] = {
'id': ride.designer.id,
'name': ride.designer.name,
'slug': ride.designer.slug,
}
# Ride model data
if ride.ride_model:
ride_data['ride_model'] = {
@@ -408,14 +409,14 @@ class SmartRideLoader:
'slug': ride.ride_model.slug,
'category': ride.ride_model.category,
}
if ride.ride_model.manufacturer:
ride_data['ride_model']['manufacturer'] = {
'id': ride.ride_model.manufacturer.id,
'name': ride.ride_model.manufacturer.name,
'slug': ride.ride_model.manufacturer.slug,
}
# Roller coaster stats
if hasattr(ride, 'coaster_stats') and ride.coaster_stats:
stats = ride.coaster_stats
@@ -435,70 +436,70 @@ class SmartRideLoader:
'cars_per_train': stats.cars_per_train,
'seats_per_car': stats.seats_per_car,
}
serialized.append(ride_data)
return serialized
def _generate_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
def _generate_filter_metadata(self, filters: dict[str, Any] | None = None) -> dict[str, Any]:
"""Generate comprehensive filter metadata."""
from apps.rides.models import Ride, RideModel
from apps.rides.models.company import Company
from apps.rides.models.rides import RollerCoasterStats
# Get unique values from database with counts
parks_data = list(Ride.objects.exclude(
park__isnull=True
).select_related('park').values(
'park__id', 'park__name', 'park__slug'
).annotate(count=models.Count('id')).distinct().order_by('park__name'))
park_areas_data = list(Ride.objects.exclude(
park_area__isnull=True
).select_related('park_area').values(
'park_area__id', 'park_area__name', 'park_area__slug'
).annotate(count=models.Count('id')).distinct().order_by('park_area__name'))
manufacturers_data = list(Company.objects.filter(
roles__contains=['MANUFACTURER']
).values('id', 'name', 'slug').annotate(
count=models.Count('manufactured_rides')
).order_by('name'))
designers_data = list(Company.objects.filter(
roles__contains=['DESIGNER']
).values('id', 'name', 'slug').annotate(
count=models.Count('designed_rides')
).order_by('name'))
ride_models_data = list(RideModel.objects.select_related(
'manufacturer'
).values(
'id', 'name', 'slug', 'manufacturer__name', 'manufacturer__slug', 'category'
).annotate(count=models.Count('rides')).order_by('manufacturer__name', 'name'))
# Get categories and statuses with counts
categories_data = list(Ride.objects.values('category').annotate(
count=models.Count('id')
).order_by('category'))
statuses_data = list(Ride.objects.values('status').annotate(
count=models.Count('id')
).order_by('status'))
# Get roller coaster specific data with counts
rc_types_data = list(RollerCoasterStats.objects.values('roller_coaster_type').annotate(
count=models.Count('ride')
).exclude(roller_coaster_type__isnull=True).order_by('roller_coaster_type'))
track_materials_data = list(RollerCoasterStats.objects.values('track_material').annotate(
count=models.Count('ride')
).exclude(track_material__isnull=True).order_by('track_material'))
propulsion_systems_data = list(RollerCoasterStats.objects.values('propulsion_system').annotate(
count=models.Count('ride')
).exclude(propulsion_system__isnull=True).order_by('propulsion_system'))
# Convert to frontend-expected format with value/label/count
categories = [
{
@@ -508,7 +509,7 @@ class SmartRideLoader:
}
for item in categories_data
]
statuses = [
{
'value': item['status'],
@@ -517,7 +518,7 @@ class SmartRideLoader:
}
for item in statuses_data
]
roller_coaster_types = [
{
'value': item['roller_coaster_type'],
@@ -526,7 +527,7 @@ class SmartRideLoader:
}
for item in rc_types_data
]
track_materials = [
{
'value': item['track_material'],
@@ -535,7 +536,7 @@ class SmartRideLoader:
}
for item in track_materials_data
]
propulsion_systems = [
{
'value': item['propulsion_system'],
@@ -544,7 +545,7 @@ class SmartRideLoader:
}
for item in propulsion_systems_data
]
# Convert other data to expected format
parks = [
{
@@ -554,7 +555,7 @@ class SmartRideLoader:
}
for item in parks_data
]
park_areas = [
{
'value': str(item['park_area__id']),
@@ -563,7 +564,7 @@ class SmartRideLoader:
}
for item in park_areas_data
]
manufacturers = [
{
'value': str(item['id']),
@@ -572,7 +573,7 @@ class SmartRideLoader:
}
for item in manufacturers_data
]
designers = [
{
'value': str(item['id']),
@@ -581,7 +582,7 @@ class SmartRideLoader:
}
for item in designers_data
]
ride_models = [
{
'value': str(item['id']),
@@ -590,7 +591,7 @@ class SmartRideLoader:
}
for item in ride_models_data
]
# Calculate ranges from actual data
ride_stats = Ride.objects.aggregate(
min_rating=Min('average_rating'),
@@ -604,7 +605,7 @@ class SmartRideLoader:
min_year=Min('opening_year'),
max_year=Max('opening_year'),
)
# Calculate roller coaster specific ranges
coaster_stats = RollerCoasterStats.objects.aggregate(
min_height_ft=Min('height_ft'),
@@ -626,7 +627,7 @@ class SmartRideLoader:
min_seats=Min('seats_per_car'),
max_seats=Max('seats_per_car'),
)
return {
'categorical': {
'categories': categories,
@@ -698,7 +699,7 @@ class SmartRideLoader:
},
'total_count': Ride.objects.count(),
}
def _get_category_label(self, category: str) -> str:
"""Convert category code to human-readable label."""
category_labels = {
@@ -713,7 +714,7 @@ class SmartRideLoader:
return category_labels[category]
else:
raise ValueError(f"Unknown ride category: {category}")
def _get_status_label(self, status: str) -> str:
"""Convert status code to human-readable label."""
status_labels = {
@@ -730,7 +731,7 @@ class SmartRideLoader:
return status_labels[status]
else:
raise ValueError(f"Unknown ride status: {status}")
def _get_rc_type_label(self, rc_type: str) -> str:
"""Convert roller coaster type to human-readable label."""
rc_type_labels = {
@@ -752,7 +753,7 @@ class SmartRideLoader:
return rc_type_labels[rc_type]
else:
raise ValueError(f"Unknown roller coaster type: {rc_type}")
def _get_track_material_label(self, material: str) -> str:
"""Convert track material to human-readable label."""
material_labels = {
@@ -764,7 +765,7 @@ class SmartRideLoader:
return material_labels[material]
else:
raise ValueError(f"Unknown track material: {material}")
def _get_propulsion_system_label(self, propulsion_system: str) -> str:
"""Convert propulsion system to human-readable label."""
propulsion_labels = {

View File

@@ -3,10 +3,11 @@ Rides-specific location services with OpenStreetMap integration.
Handles location management for individual rides within parks.
"""
import requests
from typing import List, Dict, Any, Optional
from django.db import transaction
import logging
from typing import Any
import requests
from django.db import transaction
from ..models import RideLocation
@@ -27,8 +28,8 @@ class RideLocationService:
cls,
*,
ride,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
latitude: float | None = None,
longitude: float | None = None,
park_area: str = "",
notes: str = "",
entrance_notes: str = "",
@@ -101,7 +102,7 @@ class RideLocationService:
return ride_location
@classmethod
def find_rides_in_area(cls, park, park_area: str) -> List[RideLocation]:
def find_rides_in_area(cls, park, park_area: str) -> list[RideLocation]:
"""
Find all rides in a specific park area.
@@ -121,7 +122,7 @@ class RideLocationService:
@classmethod
def find_nearby_rides(
cls, latitude: float, longitude: float, park=None, radius_meters: float = 500
) -> List[RideLocation]:
) -> list[RideLocation]:
"""
Find rides near given coordinates using PostGIS.
Useful for finding rides near a specific location within a park.
@@ -153,7 +154,7 @@ class RideLocationService:
)
@classmethod
def get_ride_navigation_info(cls, ride_location: RideLocation) -> Dict[str, Any]:
def get_ride_navigation_info(cls, ride_location: RideLocation) -> dict[str, Any]:
"""
Get comprehensive navigation information for a ride.
@@ -196,8 +197,8 @@ class RideLocationService:
def estimate_ride_coordinates_from_park(
cls,
ride_location: RideLocation,
area_offset_meters: Optional[Dict[str, List[float]]] = None,
) -> Optional[List[float]]:
area_offset_meters: dict[str, list[float]] | None = None,
) -> list[float] | None:
"""
Estimate ride coordinates based on park location and area.
Useful when exact ride coordinates are not available.
@@ -332,7 +333,7 @@ class RideLocationService:
return updated_count
@classmethod
def generate_park_area_map(cls, park) -> Dict[str, List[str]]:
def generate_park_area_map(cls, park) -> dict[str, list[str]]:
"""
Generate a map of park areas and the rides in each area.

View File

@@ -5,11 +5,14 @@ This module provides media management functionality specific to rides.
"""
import logging
from typing import List, Optional, Dict, Any
from typing import Any
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
from django.contrib.auth import get_user_model
from apps.core.services.media_service import MediaService
from ..models import Ride, RidePhoto
User = get_user_model()
@@ -83,8 +86,8 @@ class RideMediaService:
ride: Ride,
approved_only: bool = True,
primary_first: bool = True,
photo_type: Optional[str] = None,
) -> List[RidePhoto]:
photo_type: str | None = None,
) -> list[RidePhoto]:
"""
Get photos for a ride.
@@ -113,7 +116,7 @@ class RideMediaService:
return list(queryset)
@staticmethod
def get_primary_photo(ride: Ride) -> Optional[RidePhoto]:
def get_primary_photo(ride: Ride) -> RidePhoto | None:
"""
Get the primary photo for a ride.
@@ -129,7 +132,7 @@ class RideMediaService:
return None
@staticmethod
def get_photos_by_type(ride: Ride, photo_type: str) -> List[RidePhoto]:
def get_photos_by_type(ride: Ride, photo_type: str) -> list[RidePhoto]:
"""
Get photos of a specific type for a ride.
@@ -224,7 +227,7 @@ class RideMediaService:
return False
@staticmethod
def get_photo_stats(ride: Ride) -> Dict[str, Any]:
def get_photo_stats(ride: Ride) -> dict[str, Any]:
"""
Get photo statistics for a ride.
@@ -251,7 +254,7 @@ class RideMediaService:
}
@staticmethod
def bulk_approve_photos(photos: List[RidePhoto], approved_by: User) -> int:
def bulk_approve_photos(photos: list[RidePhoto], approved_by: User) -> int:
"""
Bulk approve multiple photos.
@@ -275,7 +278,7 @@ class RideMediaService:
return approved_count
@staticmethod
def get_construction_timeline(ride: Ride) -> List[RidePhoto]:
def get_construction_timeline(ride: Ride) -> list[RidePhoto]:
"""
Get construction photos ordered chronologically.
@@ -292,7 +295,7 @@ class RideMediaService:
)
@staticmethod
def get_onride_photos(ride: Ride) -> List[RidePhoto]:
def get_onride_photos(ride: Ride) -> list[RidePhoto]:
"""
Get on-ride photos for a ride.

View File

@@ -7,23 +7,21 @@ Rankings are determined by winning percentage in these comparisons.
"""
import logging
from typing import Dict, List, Optional
from decimal import Decimal
from datetime import date
from decimal import Decimal
from django.db import transaction
from django.db.models import Avg, Count, Q
from django.utils import timezone
from apps.rides.models import (
Ride,
RideReview,
RideRanking,
RidePairComparison,
RankingSnapshot,
Ride,
RidePairComparison,
RideRanking,
RideReview,
)
logger = logging.getLogger(__name__)
@@ -43,7 +41,7 @@ class RideRankingService:
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
self.calculation_version = "1.0"
def update_all_rankings(self, category: Optional[str] = None) -> Dict[str, any]:
def update_all_rankings(self, category: str | None = None) -> dict[str, any]:
"""
Main entry point to update all ride rankings.
@@ -105,7 +103,7 @@ class RideRankingService:
self.logger.error(f"Error updating rankings: {e}", exc_info=True)
raise
def _get_eligible_rides(self, category: Optional[str] = None) -> List[Ride]:
def _get_eligible_rides(self, category: str | None = None) -> list[Ride]:
"""
Get rides that are eligible for ranking.
@@ -127,8 +125,8 @@ class RideRankingService:
return list(queryset.distinct())
def _calculate_all_comparisons(
self, rides: List[Ride]
) -> Dict[tuple[int, int], RidePairComparison]:
self, rides: list[Ride]
) -> dict[tuple[int, int], RidePairComparison]:
"""
Calculate pairwise comparisons for all ride pairs.
@@ -156,7 +154,7 @@ class RideRankingService:
def _calculate_pairwise_comparison(
self, ride_a: Ride, ride_b: Ride
) -> Optional[RidePairComparison]:
) -> RidePairComparison | None:
"""
Calculate the pairwise comparison between two rides.
@@ -246,8 +244,8 @@ class RideRankingService:
return comparison
def _calculate_rankings_from_comparisons(
self, rides: List[Ride], comparisons: Dict[tuple[int, int], RidePairComparison]
) -> List[Dict]:
self, rides: list[Ride], comparisons: dict[tuple[int, int], RidePairComparison]
) -> list[dict]:
"""
Calculate final rankings from pairwise comparisons.
@@ -343,9 +341,9 @@ class RideRankingService:
def _apply_tiebreakers(
self,
rankings: List[Dict],
comparisons: Dict[tuple[int, int], RidePairComparison],
) -> List[Dict]:
rankings: list[dict],
comparisons: dict[tuple[int, int], RidePairComparison],
) -> list[dict]:
"""
Apply head-to-head tiebreaker for rides with identical winning percentages.
@@ -379,9 +377,9 @@ class RideRankingService:
def _sort_tied_group(
self,
tied_group: List[Dict],
comparisons: Dict[tuple[int, int], RidePairComparison],
) -> List[Dict]:
tied_group: list[dict],
comparisons: dict[tuple[int, int], RidePairComparison],
) -> list[dict]:
"""
Sort a group of tied rides using head-to-head comparisons.
"""
@@ -426,7 +424,7 @@ class RideRankingService:
return tied_group
def _save_rankings(self, rankings: List[Dict]):
def _save_rankings(self, rankings: list[dict]):
"""Save calculated rankings to the database."""
for ranking_data in rankings:
RideRanking.objects.update_or_create(
@@ -445,7 +443,7 @@ class RideRankingService:
},
)
def _save_ranking_snapshots(self, rankings: List[Dict]):
def _save_ranking_snapshots(self, rankings: list[dict]):
"""Save ranking snapshots for historical tracking."""
today = date.today()
@@ -471,7 +469,7 @@ class RideRankingService:
if deleted_snapshots[0] > 0:
self.logger.info(f"Deleted {deleted_snapshots[0]} old ranking snapshots")
def get_ride_ranking_details(self, ride: Ride) -> Optional[Dict]:
def get_ride_ranking_details(self, ride: Ride) -> dict | None:
"""
Get detailed ranking information for a specific ride.

View File

@@ -9,19 +9,20 @@ This service implements the filtering design specified in:
backend/docs/ride_filtering_design.md
"""
from typing import Any
from django.contrib.postgres.search import (
SearchVector,
SearchQuery,
SearchRank,
SearchVector,
TrigramSimilarity,
)
from django.db import models
from django.db.models import Q, F, Value
from django.db.models import F, Q, Value
from django.db.models.functions import Greatest
from typing import Dict, List, Optional, Any
from apps.rides.models import Ride
from apps.parks.models import Park
from apps.rides.models import Ride
from apps.rides.models.company import Company
@@ -104,11 +105,11 @@ class RideSearchService:
def search_and_filter(
self,
filters: Dict[str, Any],
filters: dict[str, Any],
sort_by: str = "relevance",
page: int = 1,
page_size: int = 20,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Main search and filter method that combines all capabilities.
@@ -219,7 +220,7 @@ class RideSearchService:
return queryset, final_rank
def _apply_basic_info_filters(
self, queryset, filters: Dict[str, Any]
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
"""Apply basic information filters."""
@@ -267,7 +268,7 @@ class RideSearchService:
return queryset
def _apply_date_filters(self, queryset, filters: Dict[str, Any]) -> models.QuerySet:
def _apply_date_filters(self, queryset, filters: dict[str, Any]) -> models.QuerySet:
"""Apply date range filters."""
# Opening date range
@@ -297,7 +298,7 @@ class RideSearchService:
return queryset
def _apply_height_safety_filters(
self, queryset, filters: Dict[str, Any]
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
"""Apply height and safety requirement filters."""
@@ -320,7 +321,7 @@ class RideSearchService:
return queryset
def _apply_performance_filters(
self, queryset, filters: Dict[str, Any]
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
"""Apply performance metric filters."""
@@ -355,7 +356,7 @@ class RideSearchService:
return queryset
def _apply_relationship_filters(
self, queryset, filters: Dict[str, Any]
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
"""Apply relationship filters (manufacturer, designer, ride model)."""
@@ -398,7 +399,7 @@ class RideSearchService:
return queryset
def _apply_roller_coaster_filters(
self, queryset, filters: Dict[str, Any]
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
"""Apply roller coaster specific filters."""
queryset = self._apply_numeric_range_filter(
@@ -448,7 +449,7 @@ class RideSearchService:
def _apply_numeric_range_filter(
self,
queryset,
filters: Dict[str, Any],
filters: dict[str, Any],
filter_key: str,
field_name: str,
) -> models.QuerySet:
@@ -466,7 +467,7 @@ class RideSearchService:
return queryset
def _apply_company_filters(
self, queryset, filters: Dict[str, Any]
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
"""Apply company-related filters."""
@@ -522,8 +523,8 @@ class RideSearchService:
) # Always add name as secondary sort
def _add_search_highlights(
self, results: List[Ride], search_term: str
) -> List[Ride]:
self, results: list[Ride], search_term: str
) -> list[Ride]:
"""Add search highlights to results using SearchHeadline."""
if not search_term or not results:
@@ -536,12 +537,12 @@ class RideSearchService:
# (note: highlights would need to be processed at query time)
for ride in results:
# Store highlighted versions as dynamic attributes (for template use)
setattr(ride, "highlighted_name", ride.name)
setattr(ride, "highlighted_description", ride.description)
ride.highlighted_name = ride.name
ride.highlighted_description = ride.description
return results
def _get_applied_filters_summary(self, filters: Dict[str, Any]) -> Dict[str, Any]:
def _get_applied_filters_summary(self, filters: dict[str, Any]) -> dict[str, Any]:
"""Generate a summary of applied filters for the frontend."""
applied = {}
@@ -602,7 +603,7 @@ class RideSearchService:
def get_search_suggestions(
self, query: str, limit: int = 10
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
"""
Get search suggestions for autocomplete functionality.
"""
@@ -672,8 +673,8 @@ class RideSearchService:
return suggestions[:limit]
def get_filter_options(
self, filter_type: str, context_filters: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
self, filter_type: str, context_filters: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
"""
Get available options for a specific filter type.
Optionally filter options based on current context.
@@ -716,7 +717,7 @@ class RideSearchService:
# Add more filter options as needed
return []
def _apply_all_filters(self, queryset, filters: Dict[str, Any]) -> models.QuerySet:
def _apply_all_filters(self, queryset, filters: dict[str, Any]) -> models.QuerySet:
"""Apply all filters except search ranking."""
queryset = self._apply_basic_info_filters(queryset, filters)

View File

@@ -3,9 +3,9 @@ Services for ride status transitions and management.
Following Django styleguide pattern for business logic encapsulation.
"""
from typing import Optional
from django.db import transaction
from django.contrib.auth.models import AbstractBaseUser
from django.db import transaction
from apps.rides.models import Ride
@@ -14,7 +14,7 @@ class RideStatusService:
"""Service for managing ride status transitions using FSM."""
@staticmethod
def open_ride(*, ride_id: int, user: Optional[AbstractBaseUser] = None) -> Ride:
def open_ride(*, ride_id: int, user: AbstractBaseUser | None = None) -> Ride:
"""
Open a ride for operation.
@@ -35,7 +35,7 @@ class RideStatusService:
@staticmethod
def close_ride_temporarily(
*, ride_id: int, user: Optional[AbstractBaseUser] = None
*, ride_id: int, user: AbstractBaseUser | None = None
) -> Ride:
"""
Temporarily close a ride.
@@ -57,7 +57,7 @@ class RideStatusService:
@staticmethod
def mark_ride_sbno(
*, ride_id: int, user: Optional[AbstractBaseUser] = None
*, ride_id: int, user: AbstractBaseUser | None = None
) -> Ride:
"""
Mark a ride as SBNO (Standing But Not Operating).
@@ -83,7 +83,7 @@ class RideStatusService:
ride_id: int,
closing_date,
post_closing_status: str,
user: Optional[AbstractBaseUser] = None,
user: AbstractBaseUser | None = None,
) -> Ride:
"""
Mark a ride as closing with a specific date and post-closing status.
@@ -112,7 +112,7 @@ class RideStatusService:
@staticmethod
def close_ride_permanently(
*, ride_id: int, user: Optional[AbstractBaseUser] = None
*, ride_id: int, user: AbstractBaseUser | None = None
) -> Ride:
"""
Permanently close a ride.
@@ -133,7 +133,7 @@ class RideStatusService:
return ride
@staticmethod
def demolish_ride(*, ride_id: int, user: Optional[AbstractBaseUser] = None) -> Ride:
def demolish_ride(*, ride_id: int, user: AbstractBaseUser | None = None) -> Ride:
"""
Mark a ride as demolished.
@@ -153,7 +153,7 @@ class RideStatusService:
return ride
@staticmethod
def relocate_ride(*, ride_id: int, user: Optional[AbstractBaseUser] = None) -> Ride:
def relocate_ride(*, ride_id: int, user: AbstractBaseUser | None = None) -> Ride:
"""
Mark a ride as relocated.

View File

@@ -3,10 +3,11 @@ Services for ride-related business logic.
Following Django styleguide pattern for business logic encapsulation.
"""
from typing import Optional, Dict, Any
from django.db import transaction
from typing import Any
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractBaseUser
from django.db import transaction
from apps.rides.models import Ride
@@ -26,13 +27,13 @@ class RideService:
description: str = "",
status: str = "OPERATING",
category: str = "",
manufacturer_id: Optional[int] = None,
designer_id: Optional[int] = None,
ride_model_id: Optional[int] = None,
park_area_id: Optional[int] = None,
opening_date: Optional[str] = None,
closing_date: Optional[str] = None,
created_by: Optional[UserType] = None,
manufacturer_id: int | None = None,
designer_id: int | None = None,
ride_model_id: int | None = None,
park_area_id: int | None = None,
opening_date: str | None = None,
closing_date: str | None = None,
created_by: UserType | None = None,
) -> Ride:
"""
Create a new ride with validation.
@@ -105,8 +106,8 @@ class RideService:
def update_ride(
*,
ride_id: int,
updates: Dict[str, Any],
updated_by: Optional[UserType] = None,
updates: dict[str, Any],
updated_by: UserType | None = None,
) -> Ride:
"""
Update an existing ride with validation.
@@ -139,7 +140,7 @@ class RideService:
@staticmethod
def close_ride_temporarily(
*, ride_id: int, user: Optional[UserType] = None
*, ride_id: int, user: UserType | None = None
) -> Ride:
"""
Temporarily close a ride.
@@ -161,7 +162,7 @@ class RideService:
@staticmethod
def mark_ride_sbno(
*, ride_id: int, user: Optional[UserType] = None
*, ride_id: int, user: UserType | None = None
) -> Ride:
"""
Mark a ride as SBNO (Standing But Not Operating).
@@ -187,7 +188,7 @@ class RideService:
ride_id: int,
closing_date,
post_closing_status: str,
user: Optional[UserType] = None,
user: UserType | None = None,
) -> Ride:
"""
Schedule a ride to close on a specific date with a post-closing status.
@@ -216,7 +217,7 @@ class RideService:
@staticmethod
def close_ride_permanently(
*, ride_id: int, user: Optional[UserType] = None
*, ride_id: int, user: UserType | None = None
) -> Ride:
"""
Permanently close a ride.
@@ -237,7 +238,7 @@ class RideService:
return ride
@staticmethod
def demolish_ride(*, ride_id: int, user: Optional[UserType] = None) -> Ride:
def demolish_ride(*, ride_id: int, user: UserType | None = None) -> Ride:
"""
Mark a ride as demolished.
@@ -258,7 +259,7 @@ class RideService:
@staticmethod
def relocate_ride(
*, ride_id: int, new_park_id: int, user: Optional[UserType] = None
*, ride_id: int, new_park_id: int, user: UserType | None = None
) -> Ride:
"""
Relocate a ride to a new park.
@@ -289,7 +290,7 @@ class RideService:
return ride
@staticmethod
def reopen_ride(*, ride_id: int, user: Optional[UserType] = None) -> Ride:
def reopen_ride(*, ride_id: int, user: UserType | None = None) -> Ride:
"""
Reopen a ride for operation.
@@ -311,9 +312,9 @@ class RideService:
@staticmethod
def handle_new_entity_suggestions(
*,
form_data: Dict[str, Any],
form_data: dict[str, Any],
submitter: UserType,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Handle suggestions for new manufacturers, designers, and ride models.

View File

@@ -1,12 +1,11 @@
import logging
from django.db.models.signals import pre_save, post_save
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.utils import timezone
from .models import Ride
logger = logging.getLogger(__name__)
@@ -59,16 +58,15 @@ def handle_ride_status(sender, instance, **kwargs):
# Check if transition is allowed before attempting
if hasattr(instance, 'can_proceed'):
can_proceed = getattr(instance, f'can_transition_to_{target_status.lower()}', None)
if can_proceed and callable(can_proceed):
if not can_proceed():
logger.warning(
f"FSM transition to {target_status} not allowed "
f"for ride {instance.pk}"
)
# Fall back to direct status change
instance.status = target_status
instance.status_since = instance.closing_date
return
if can_proceed and callable(can_proceed) and not can_proceed():
logger.warning(
f"FSM transition to {target_status} not allowed "
f"for ride {instance.pk}"
)
# Fall back to direct status change
instance.status = target_status
instance.status_since = instance.closing_date
return
try:
method = getattr(instance, transition_method_name)

View File

@@ -1,5 +1,6 @@
from django import template
from django.templatetags.static import static
from ..choices import RIDE_CATEGORIES
register = template.Library()

View File

@@ -9,15 +9,18 @@ This module contains tests for:
- Related model updates during transitions
"""
from django.test import TestCase
from datetime import date, timedelta
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.test import TestCase
from django_fsm import TransitionNotAllowed
from .models import Ride, RideModel, Company
from apps.parks.models import Park, Company as ParkCompany
from datetime import date, timedelta
from apps.parks.models import Company as ParkCompany
from apps.parks.models import Park
from .models import Company, Ride
User = get_user_model()
@@ -50,7 +53,7 @@ class RideTransitionTests(TestCase):
password='testpass123',
role='ADMIN'
)
# Create operator and park
self.operator = ParkCompany.objects.create(
name='Test Operator',
@@ -64,7 +67,7 @@ class RideTransitionTests(TestCase):
operator=self.operator,
timezone='America/New_York'
)
# Create manufacturer
self.manufacturer = Company.objects.create(
name='Test Manufacturer',
@@ -92,30 +95,30 @@ class RideTransitionTests(TestCase):
"""Test transition from OPERATING to CLOSED_TEMP."""
ride = self._create_ride(status='OPERATING')
self.assertEqual(ride.status, 'OPERATING')
ride.transition_to_closed_temp(user=self.user)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSED_TEMP')
def test_operating_to_sbno_transition(self):
"""Test transition from OPERATING to SBNO."""
ride = self._create_ride(status='OPERATING')
ride.transition_to_sbno(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'SBNO')
def test_operating_to_closing_transition(self):
"""Test transition from OPERATING to CLOSING."""
ride = self._create_ride(status='OPERATING')
ride.transition_to_closing(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSING')
@@ -127,10 +130,10 @@ class RideTransitionTests(TestCase):
"""Test transition from UNDER_CONSTRUCTION to OPERATING."""
ride = self._create_ride(status='UNDER_CONSTRUCTION')
self.assertEqual(ride.status, 'UNDER_CONSTRUCTION')
ride.transition_to_operating(user=self.user)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'OPERATING')
@@ -141,31 +144,31 @@ class RideTransitionTests(TestCase):
def test_closed_temp_to_operating_transition(self):
"""Test transition from CLOSED_TEMP to OPERATING (reopen)."""
ride = self._create_ride(status='CLOSED_TEMP')
ride.transition_to_operating(user=self.user)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'OPERATING')
def test_closed_temp_to_sbno_transition(self):
"""Test transition from CLOSED_TEMP to SBNO."""
ride = self._create_ride(status='CLOSED_TEMP')
ride.transition_to_sbno(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'SBNO')
def test_closed_temp_to_closed_perm_transition(self):
"""Test transition from CLOSED_TEMP to CLOSED_PERM."""
ride = self._create_ride(status='CLOSED_TEMP')
ride.transition_to_closed_perm(user=self.moderator)
ride.closing_date = date.today()
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSED_PERM')
@@ -176,20 +179,20 @@ class RideTransitionTests(TestCase):
def test_sbno_to_operating_transition(self):
"""Test transition from SBNO to OPERATING (revival)."""
ride = self._create_ride(status='SBNO')
ride.transition_to_operating(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'OPERATING')
def test_sbno_to_closed_perm_transition(self):
"""Test transition from SBNO to CLOSED_PERM."""
ride = self._create_ride(status='SBNO')
ride.transition_to_closed_perm(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSED_PERM')
@@ -200,20 +203,20 @@ class RideTransitionTests(TestCase):
def test_closing_to_closed_perm_transition(self):
"""Test transition from CLOSING to CLOSED_PERM."""
ride = self._create_ride(status='CLOSING')
ride.transition_to_closed_perm(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSED_PERM')
def test_closing_to_sbno_transition(self):
"""Test transition from CLOSING to SBNO."""
ride = self._create_ride(status='CLOSING')
ride.transition_to_sbno(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'SBNO')
@@ -224,20 +227,20 @@ class RideTransitionTests(TestCase):
def test_closed_perm_to_demolished_transition(self):
"""Test transition from CLOSED_PERM to DEMOLISHED."""
ride = self._create_ride(status='CLOSED_PERM')
ride.transition_to_demolished(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'DEMOLISHED')
def test_closed_perm_to_relocated_transition(self):
"""Test transition from CLOSED_PERM to RELOCATED."""
ride = self._create_ride(status='CLOSED_PERM')
ride.transition_to_relocated(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'RELOCATED')
@@ -248,28 +251,28 @@ class RideTransitionTests(TestCase):
def test_demolished_cannot_transition(self):
"""Test that DEMOLISHED state cannot transition further."""
ride = self._create_ride(status='DEMOLISHED')
with self.assertRaises(TransitionNotAllowed):
ride.transition_to_operating(user=self.moderator)
def test_relocated_cannot_transition(self):
"""Test that RELOCATED state cannot transition further."""
ride = self._create_ride(status='RELOCATED')
with self.assertRaises(TransitionNotAllowed):
ride.transition_to_operating(user=self.moderator)
def test_operating_cannot_directly_demolish(self):
"""Test that OPERATING cannot directly transition to DEMOLISHED."""
ride = self._create_ride(status='OPERATING')
with self.assertRaises(TransitionNotAllowed):
ride.transition_to_demolished(user=self.moderator)
def test_operating_cannot_directly_relocate(self):
"""Test that OPERATING cannot directly transition to RELOCATED."""
ride = self._create_ride(status='OPERATING')
with self.assertRaises(TransitionNotAllowed):
ride.transition_to_relocated(user=self.moderator)
@@ -280,27 +283,27 @@ class RideTransitionTests(TestCase):
def test_open_wrapper_method(self):
"""Test the open() wrapper method."""
ride = self._create_ride(status='CLOSED_TEMP')
ride.open(user=self.user)
ride.refresh_from_db()
self.assertEqual(ride.status, 'OPERATING')
def test_close_temporarily_wrapper_method(self):
"""Test the close_temporarily() wrapper method."""
ride = self._create_ride(status='OPERATING')
ride.close_temporarily(user=self.user)
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSED_TEMP')
def test_mark_sbno_wrapper_method(self):
"""Test the mark_sbno() wrapper method."""
ride = self._create_ride(status='OPERATING')
ride.mark_sbno(user=self.moderator)
ride.refresh_from_db()
self.assertEqual(ride.status, 'SBNO')
@@ -308,13 +311,13 @@ class RideTransitionTests(TestCase):
"""Test the mark_closing() wrapper method."""
ride = self._create_ride(status='OPERATING')
closing = date(2025, 12, 31)
ride.mark_closing(
closing_date=closing,
post_closing_status='DEMOLISHED',
user=self.moderator
)
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSING')
self.assertEqual(ride.closing_date, closing)
@@ -323,7 +326,7 @@ class RideTransitionTests(TestCase):
def test_mark_closing_requires_post_closing_status(self):
"""Test that mark_closing() requires post_closing_status."""
ride = self._create_ride(status='OPERATING')
with self.assertRaises(ValidationError):
ride.mark_closing(
closing_date=date(2025, 12, 31),
@@ -334,27 +337,27 @@ class RideTransitionTests(TestCase):
def test_close_permanently_wrapper_method(self):
"""Test the close_permanently() wrapper method."""
ride = self._create_ride(status='SBNO')
ride.close_permanently(user=self.moderator)
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSED_PERM')
def test_demolish_wrapper_method(self):
"""Test the demolish() wrapper method."""
ride = self._create_ride(status='CLOSED_PERM')
ride.demolish(user=self.moderator)
ride.refresh_from_db()
self.assertEqual(ride.status, 'DEMOLISHED')
def test_relocate_wrapper_method(self):
"""Test the relocate() wrapper method."""
ride = self._create_ride(status='CLOSED_PERM')
ride.relocate(user=self.moderator)
ride.refresh_from_db()
self.assertEqual(ride.status, 'RELOCATED')
@@ -413,9 +416,9 @@ class RidePostClosingTests(TestCase):
closing_date=yesterday,
post_closing_status='DEMOLISHED'
)
ride.apply_post_closing_status(user=self.moderator)
ride.refresh_from_db()
self.assertEqual(ride.status, 'DEMOLISHED')
@@ -427,9 +430,9 @@ class RidePostClosingTests(TestCase):
closing_date=yesterday,
post_closing_status='RELOCATED'
)
ride.apply_post_closing_status(user=self.moderator)
ride.refresh_from_db()
self.assertEqual(ride.status, 'RELOCATED')
@@ -441,9 +444,9 @@ class RidePostClosingTests(TestCase):
closing_date=yesterday,
post_closing_status='SBNO'
)
ride.apply_post_closing_status(user=self.moderator)
ride.refresh_from_db()
self.assertEqual(ride.status, 'SBNO')
@@ -455,9 +458,9 @@ class RidePostClosingTests(TestCase):
closing_date=yesterday,
post_closing_status='CLOSED_PERM'
)
ride.apply_post_closing_status(user=self.moderator)
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSED_PERM')
@@ -469,9 +472,9 @@ class RidePostClosingTests(TestCase):
closing_date=tomorrow,
post_closing_status='DEMOLISHED'
)
ride.apply_post_closing_status(user=self.moderator)
ride.refresh_from_db()
# Status should remain CLOSING since date hasn't been reached
self.assertEqual(ride.status, 'CLOSING')
@@ -479,10 +482,10 @@ class RidePostClosingTests(TestCase):
def test_apply_post_closing_status_requires_closing_status(self):
"""Test apply_post_closing_status requires CLOSING status."""
ride = self._create_ride(status='OPERATING')
with self.assertRaises(ValidationError) as ctx:
ride.apply_post_closing_status(user=self.moderator)
self.assertIn('CLOSING', str(ctx.exception))
def test_apply_post_closing_status_requires_closing_date(self):
@@ -493,10 +496,10 @@ class RidePostClosingTests(TestCase):
)
ride.closing_date = None
ride.save()
with self.assertRaises(ValidationError) as ctx:
ride.apply_post_closing_status(user=self.moderator)
self.assertIn('closing_date', str(ctx.exception))
def test_apply_post_closing_status_requires_post_closing_status(self):
@@ -508,10 +511,10 @@ class RidePostClosingTests(TestCase):
)
ride.post_closing_status = None
ride.save()
with self.assertRaises(ValidationError) as ctx:
ride.apply_post_closing_status(user=self.moderator)
self.assertIn('post_closing_status', str(ctx.exception))
@@ -563,18 +566,18 @@ class RideTransitionHistoryTests(TestCase):
def test_transition_creates_state_log(self):
"""Test that transitions create StateLog entries."""
from django_fsm_log.models import StateLog
ride = self._create_ride(status='OPERATING')
ride.transition_to_closed_temp(user=self.moderator)
ride.save()
ride_ct = ContentType.objects.get_for_model(ride)
log = StateLog.objects.filter(
content_type=ride_ct,
object_id=ride.id
).first()
self.assertIsNotNone(log)
self.assertEqual(log.state, 'CLOSED_TEMP')
self.assertEqual(log.by, self.moderator)
@@ -582,23 +585,23 @@ class RideTransitionHistoryTests(TestCase):
def test_multiple_transitions_create_multiple_logs(self):
"""Test that multiple transitions create multiple log entries."""
from django_fsm_log.models import StateLog
ride = self._create_ride(status='OPERATING')
ride_ct = ContentType.objects.get_for_model(ride)
# First transition
ride.transition_to_closed_temp(user=self.moderator)
ride.save()
# Second transition
ride.transition_to_operating(user=self.moderator)
ride.save()
logs = StateLog.objects.filter(
content_type=ride_ct,
object_id=ride.id
).order_by('timestamp')
self.assertEqual(logs.count(), 2)
self.assertEqual(logs[0].state, 'CLOSED_TEMP')
self.assertEqual(logs[1].state, 'OPERATING')
@@ -606,39 +609,39 @@ class RideTransitionHistoryTests(TestCase):
def test_transition_log_includes_user(self):
"""Test that transition logs include the user who made the change."""
from django_fsm_log.models import StateLog
ride = self._create_ride(status='OPERATING')
ride.transition_to_sbno(user=self.moderator)
ride.save()
ride_ct = ContentType.objects.get_for_model(ride)
log = StateLog.objects.filter(
content_type=ride_ct,
object_id=ride.id
).first()
self.assertEqual(log.by, self.moderator)
def test_post_closing_transition_logged(self):
"""Test that post_closing_status transitions are logged."""
from django_fsm_log.models import StateLog
yesterday = date.today() - timedelta(days=1)
ride = self._create_ride(status='CLOSING')
ride.closing_date = yesterday
ride.post_closing_status = 'DEMOLISHED'
ride.save()
ride.apply_post_closing_status(user=self.moderator)
ride_ct = ContentType.objects.get_for_model(ride)
log = StateLog.objects.filter(
content_type=ride_ct,
object_id=ride.id,
state='DEMOLISHED'
).first()
self.assertIsNotNone(log)
self.assertEqual(log.by, self.moderator)
@@ -680,7 +683,7 @@ class RideBusinessLogicTests(TestCase):
park=self.park,
manufacturer=self.manufacturer
)
self.assertEqual(ride.park, self.park)
def test_ride_slug_auto_generated(self):
@@ -691,7 +694,7 @@ class RideBusinessLogicTests(TestCase):
park=self.park,
manufacturer=self.manufacturer
)
self.assertEqual(ride.slug, 'my-amazing-roller-coaster')
def test_ride_url_generated(self):
@@ -703,7 +706,7 @@ class RideBusinessLogicTests(TestCase):
park=self.park,
manufacturer=self.manufacturer
)
self.assertIn('test-park', ride.url)
self.assertIn('test-ride', ride.url)
@@ -717,7 +720,7 @@ class RideBusinessLogicTests(TestCase):
manufacturer=self.manufacturer,
opening_date=date(2020, 6, 15)
)
self.assertEqual(ride.opening_year, 2020)
def test_search_text_populated(self):
@@ -729,7 +732,7 @@ class RideBusinessLogicTests(TestCase):
park=self.park,
manufacturer=self.manufacturer
)
self.assertIn('test ride', ride.search_text)
self.assertIn('thrilling roller coaster', ride.search_text)
self.assertIn('test park', ride.search_text)
@@ -744,7 +747,7 @@ class RideBusinessLogicTests(TestCase):
park=self.park,
manufacturer=self.manufacturer
)
# Creating another ride with same name should get different slug
ride2 = Ride.objects.create(
name='Test Ride',
@@ -752,7 +755,7 @@ class RideBusinessLogicTests(TestCase):
park=self.park,
manufacturer=self.manufacturer
)
self.assertNotEqual(ride2.slug, 'test-ride')
self.assertTrue(ride2.slug.startswith('test-ride'))
@@ -801,9 +804,9 @@ class RideMoveTests(TestCase):
park=self.park1,
manufacturer=self.manufacturer
)
changes = ride.move_to_park(self.park2)
ride.refresh_from_db()
self.assertEqual(ride.park, self.park2)
self.assertEqual(changes['old_park']['id'], self.park1.id)
@@ -819,9 +822,9 @@ class RideMoveTests(TestCase):
manufacturer=self.manufacturer
)
old_url = ride.url
changes = ride.move_to_park(self.park2)
ride.refresh_from_db()
self.assertNotEqual(ride.url, old_url)
self.assertIn('park-two', ride.url)
@@ -837,7 +840,7 @@ class RideMoveTests(TestCase):
park=self.park1,
manufacturer=self.manufacturer
)
# Create ride with same slug in park2
Ride.objects.create(
name='Test Ride',
@@ -846,10 +849,10 @@ class RideMoveTests(TestCase):
park=self.park2,
manufacturer=self.manufacturer
)
# Move ride1 to park2
changes = ride1.move_to_park(self.park2)
ride1.refresh_from_db()
self.assertEqual(ride1.park, self.park2)
# Slug should have been modified to avoid conflict
@@ -894,9 +897,9 @@ class RideSlugHistoryTests(TestCase):
park=self.park,
manufacturer=self.manufacturer
)
found_ride, is_historical = Ride.get_by_slug('test-ride', park=self.park)
self.assertEqual(found_ride, ride)
self.assertFalse(is_historical)
@@ -909,11 +912,11 @@ class RideSlugHistoryTests(TestCase):
park=self.park,
manufacturer=self.manufacturer
)
# Should find ride in correct park
found_ride, is_historical = Ride.get_by_slug('test-ride', park=self.park)
self.assertEqual(found_ride, ride)
# Should not find ride in different park
other_park = Park.objects.create(
name='Other Park',

View File

@@ -5,7 +5,6 @@ These tests verify the functionality of ride, model, stats, company,
review, and ranking admin classes including query optimization and custom actions.
"""
import pytest
from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase
@@ -14,7 +13,6 @@ from apps.rides.admin import (
CompanyAdmin,
RankingSnapshotAdmin,
RideAdmin,
RideLocationAdmin,
RideModelAdmin,
RidePairComparisonAdmin,
RideRankingAdmin,
@@ -22,7 +20,6 @@ from apps.rides.admin import (
RollerCoasterStatsAdmin,
)
from apps.rides.models.company import Company
from apps.rides.models.location import RideLocation
from apps.rides.models.rankings import RankingSnapshot, RidePairComparison, RideRanking
from apps.rides.models.reviews import RideReview
from apps.rides.models.rides import Ride, RideModel, RollerCoasterStats

View File

@@ -10,17 +10,18 @@ This module tests end-to-end ride lifecycle workflows including:
- Ride relocation workflow
"""
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.utils import timezone
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
User = get_user_model()
class RideOpeningWorkflowTests(TestCase):
"""Tests for ride opening workflow."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
@@ -29,18 +30,18 @@ class RideOpeningWorkflowTests(TestCase):
password='testpass123',
role='USER'
)
def _create_ride(self, status='OPERATING', **kwargs):
"""Helper to create a ride with park."""
from apps.parks.models import Company, Park
from apps.rides.models import Ride
from apps.parks.models import Park, Company
# Create manufacturer
manufacturer = Company.objects.create(
name=f'Manufacturer {timezone.now().timestamp()}',
roles=['MANUFACTURER']
)
# Create park with operator
operator = Company.objects.create(
name=f'Operator {timezone.now().timestamp()}',
@@ -53,7 +54,7 @@ class RideOpeningWorkflowTests(TestCase):
status='OPERATING',
timezone='America/New_York'
)
defaults = {
'name': f'Test Ride {timezone.now().timestamp()}',
'slug': f'test-ride-{timezone.now().timestamp()}',
@@ -63,28 +64,28 @@ class RideOpeningWorkflowTests(TestCase):
}
defaults.update(kwargs)
return Ride.objects.create(**defaults)
def test_ride_opens_from_under_construction(self):
"""
Test ride opening from under construction state.
Flow: UNDER_CONSTRUCTION → OPERATING
"""
ride = self._create_ride(status='UNDER_CONSTRUCTION')
self.assertEqual(ride.status, 'UNDER_CONSTRUCTION')
# Ride opens
ride.transition_to_operating(user=self.user)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'OPERATING')
class RideMaintenanceWorkflowTests(TestCase):
"""Tests for ride maintenance (temporary closure) workflow."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
@@ -93,11 +94,11 @@ class RideMaintenanceWorkflowTests(TestCase):
password='testpass123',
role='USER'
)
def _create_ride(self, status='OPERATING', **kwargs):
from apps.parks.models import Company, Park
from apps.rides.models import Ride
from apps.parks.models import Park, Company
manufacturer = Company.objects.create(
name=f'Mfr Maint {timezone.now().timestamp()}',
roles=['MANUFACTURER']
@@ -113,7 +114,7 @@ class RideMaintenanceWorkflowTests(TestCase):
status='OPERATING',
timezone='America/New_York'
)
defaults = {
'name': f'Ride Maint {timezone.now().timestamp()}',
'slug': f'ride-maint-{timezone.now().timestamp()}',
@@ -123,33 +124,33 @@ class RideMaintenanceWorkflowTests(TestCase):
}
defaults.update(kwargs)
return Ride.objects.create(**defaults)
def test_ride_maintenance_and_reopen(self):
"""
Test ride maintenance and reopening.
Flow: OPERATING → CLOSED_TEMP → OPERATING
"""
ride = self._create_ride(status='OPERATING')
# Close for maintenance
ride.transition_to_closed_temp(user=self.user)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSED_TEMP')
# Reopen after maintenance
ride.transition_to_operating(user=self.user)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'OPERATING')
class RideSBNOWorkflowTests(TestCase):
"""Tests for ride SBNO (Standing But Not Operating) workflow."""
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
@@ -158,11 +159,11 @@ class RideSBNOWorkflowTests(TestCase):
password='testpass123',
role='MODERATOR'
)
def _create_ride(self, status='OPERATING', **kwargs):
from apps.parks.models import Company, Park
from apps.rides.models import Ride
from apps.parks.models import Park, Company
manufacturer = Company.objects.create(
name=f'Mfr SBNO {timezone.now().timestamp()}',
roles=['MANUFACTURER']
@@ -178,7 +179,7 @@ class RideSBNOWorkflowTests(TestCase):
status='OPERATING',
timezone='America/New_York'
)
defaults = {
'name': f'Ride SBNO {timezone.now().timestamp()}',
'slug': f'ride-sbno-{timezone.now().timestamp()}',
@@ -188,71 +189,71 @@ class RideSBNOWorkflowTests(TestCase):
}
defaults.update(kwargs)
return Ride.objects.create(**defaults)
def test_ride_sbno_from_operating(self):
"""
Test ride becomes SBNO from operating.
Flow: OPERATING → SBNO
"""
ride = self._create_ride(status='OPERATING')
# Mark as SBNO
ride.transition_to_sbno(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'SBNO')
def test_ride_sbno_from_closed_temp(self):
"""
Test ride becomes SBNO from temporary closure.
Flow: OPERATING → CLOSED_TEMP → SBNO
"""
ride = self._create_ride(status='CLOSED_TEMP')
# Extended to SBNO
ride.transition_to_sbno(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'SBNO')
def test_ride_revival_from_sbno(self):
"""
Test ride revival from SBNO state.
Flow: SBNO → OPERATING
"""
ride = self._create_ride(status='SBNO')
# Revive the ride
ride.transition_to_operating(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'OPERATING')
def test_sbno_to_closed_perm(self):
"""
Test ride permanently closes from SBNO.
Flow: SBNO → CLOSED_PERM
"""
ride = self._create_ride(status='SBNO')
# Confirm permanent closure
ride.transition_to_closed_perm(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSED_PERM')
class RideScheduledClosureWorkflowTests(TestCase):
"""Tests for ride scheduled closure (CLOSING state) workflow."""
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
@@ -261,11 +262,11 @@ class RideScheduledClosureWorkflowTests(TestCase):
password='testpass123',
role='MODERATOR'
)
def _create_ride(self, status='OPERATING', **kwargs):
from apps.parks.models import Company, Park
from apps.rides.models import Ride
from apps.parks.models import Park, Company
manufacturer = Company.objects.create(
name=f'Mfr Closing {timezone.now().timestamp()}',
roles=['MANUFACTURER']
@@ -281,7 +282,7 @@ class RideScheduledClosureWorkflowTests(TestCase):
status='OPERATING',
timezone='America/New_York'
)
defaults = {
'name': f'Ride Closing {timezone.now().timestamp()}',
'slug': f'ride-closing-{timezone.now().timestamp()}',
@@ -291,67 +292,67 @@ class RideScheduledClosureWorkflowTests(TestCase):
}
defaults.update(kwargs)
return Ride.objects.create(**defaults)
def test_ride_mark_closing_with_date(self):
"""
Test ride marked as closing with scheduled date.
Flow: OPERATING → CLOSING (with closing_date and post_closing_status)
"""
ride = self._create_ride(status='OPERATING')
closing_date = (timezone.now() + timedelta(days=30)).date()
# Mark as closing
ride.transition_to_closing(user=self.moderator)
ride.closing_date = closing_date
ride.post_closing_status = 'DEMOLISHED'
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSING')
self.assertEqual(ride.closing_date, closing_date)
self.assertEqual(ride.post_closing_status, 'DEMOLISHED')
def test_closing_to_closed_perm(self):
"""
Test ride transitions from CLOSING to CLOSED_PERM when date reached.
Flow: CLOSING → CLOSED_PERM
"""
ride = self._create_ride(status='CLOSING')
ride.closing_date = timezone.now().date()
ride.post_closing_status = 'CLOSED_PERM'
ride.save()
# Transition when closing date reached
ride.transition_to_closed_perm(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSED_PERM')
def test_closing_to_sbno(self):
"""
Test ride transitions from CLOSING to SBNO.
Flow: CLOSING → SBNO
"""
ride = self._create_ride(status='CLOSING')
ride.closing_date = timezone.now().date()
ride.post_closing_status = 'SBNO'
ride.save()
# Transition to SBNO
ride.transition_to_sbno(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'SBNO')
class RideDemolitionWorkflowTests(TestCase):
"""Tests for ride demolition workflow."""
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
@@ -360,11 +361,11 @@ class RideDemolitionWorkflowTests(TestCase):
password='testpass123',
role='MODERATOR'
)
def _create_ride(self, status='CLOSED_PERM', **kwargs):
from apps.parks.models import Company, Park
from apps.rides.models import Ride
from apps.parks.models import Park, Company
manufacturer = Company.objects.create(
name=f'Mfr Demo {timezone.now().timestamp()}',
roles=['MANUFACTURER']
@@ -380,7 +381,7 @@ class RideDemolitionWorkflowTests(TestCase):
status='OPERATING',
timezone='America/New_York'
)
defaults = {
'name': f'Ride Demo {timezone.now().timestamp()}',
'slug': f'ride-demo-{timezone.now().timestamp()}',
@@ -390,28 +391,28 @@ class RideDemolitionWorkflowTests(TestCase):
}
defaults.update(kwargs)
return Ride.objects.create(**defaults)
def test_ride_demolition(self):
"""
Test ride demolition from permanently closed.
Flow: CLOSED_PERM → DEMOLISHED
"""
ride = self._create_ride(status='CLOSED_PERM')
# Demolish
ride.transition_to_demolished(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'DEMOLISHED')
def test_demolished_is_final_state(self):
"""Test that demolished rides cannot transition further."""
from django_fsm import TransitionNotAllowed
ride = self._create_ride(status='DEMOLISHED')
# Cannot transition from demolished
with self.assertRaises(TransitionNotAllowed):
ride.transition_to_operating(user=self.moderator)
@@ -419,7 +420,7 @@ class RideDemolitionWorkflowTests(TestCase):
class RideRelocationWorkflowTests(TestCase):
"""Tests for ride relocation workflow."""
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
@@ -428,11 +429,11 @@ class RideRelocationWorkflowTests(TestCase):
password='testpass123',
role='MODERATOR'
)
def _create_ride(self, status='CLOSED_PERM', **kwargs):
from apps.parks.models import Company, Park
from apps.rides.models import Ride
from apps.parks.models import Park, Company
manufacturer = Company.objects.create(
name=f'Mfr Reloc {timezone.now().timestamp()}',
roles=['MANUFACTURER']
@@ -448,7 +449,7 @@ class RideRelocationWorkflowTests(TestCase):
status='OPERATING',
timezone='America/New_York'
)
defaults = {
'name': f'Ride Reloc {timezone.now().timestamp()}',
'slug': f'ride-reloc-{timezone.now().timestamp()}',
@@ -458,28 +459,28 @@ class RideRelocationWorkflowTests(TestCase):
}
defaults.update(kwargs)
return Ride.objects.create(**defaults)
def test_ride_relocation(self):
"""
Test ride relocation from permanently closed.
Flow: CLOSED_PERM → RELOCATED
"""
ride = self._create_ride(status='CLOSED_PERM')
# Relocate
ride.transition_to_relocated(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'RELOCATED')
def test_relocated_is_final_state(self):
"""Test that relocated rides cannot transition further."""
from django_fsm import TransitionNotAllowed
ride = self._create_ride(status='RELOCATED')
# Cannot transition from relocated
with self.assertRaises(TransitionNotAllowed):
ride.transition_to_operating(user=self.moderator)
@@ -487,7 +488,7 @@ class RideRelocationWorkflowTests(TestCase):
class RideWrapperMethodTests(TestCase):
"""Tests for ride wrapper methods."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
@@ -502,11 +503,11 @@ class RideWrapperMethodTests(TestCase):
password='testpass123',
role='MODERATOR'
)
def _create_ride(self, status='OPERATING', **kwargs):
from apps.parks.models import Company, Park
from apps.rides.models import Ride
from apps.parks.models import Park, Company
manufacturer = Company.objects.create(
name=f'Mfr Wrapper {timezone.now().timestamp()}',
roles=['MANUFACTURER']
@@ -522,7 +523,7 @@ class RideWrapperMethodTests(TestCase):
status='OPERATING',
timezone='America/New_York'
)
defaults = {
'name': f'Ride Wrapper {timezone.now().timestamp()}',
'slug': f'ride-wrapper-{timezone.now().timestamp()}',
@@ -532,38 +533,38 @@ class RideWrapperMethodTests(TestCase):
}
defaults.update(kwargs)
return Ride.objects.create(**defaults)
def test_close_temporarily_wrapper(self):
"""Test close_temporarily wrapper method."""
ride = self._create_ride(status='OPERATING')
if hasattr(ride, 'close_temporarily'):
ride.close_temporarily(user=self.user)
else:
ride.transition_to_closed_temp(user=self.user)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSED_TEMP')
def test_mark_sbno_wrapper(self):
"""Test mark_sbno wrapper method."""
ride = self._create_ride(status='OPERATING')
if hasattr(ride, 'mark_sbno'):
ride.mark_sbno(user=self.moderator)
else:
ride.transition_to_sbno(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'SBNO')
def test_mark_closing_wrapper(self):
"""Test mark_closing wrapper method."""
ride = self._create_ride(status='OPERATING')
closing_date = (timezone.now() + timedelta(days=30)).date()
if hasattr(ride, 'mark_closing'):
ride.mark_closing(
closing_date=closing_date,
@@ -575,66 +576,66 @@ class RideWrapperMethodTests(TestCase):
ride.closing_date = closing_date
ride.post_closing_status = 'DEMOLISHED'
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSING')
def test_open_wrapper(self):
"""Test open wrapper method."""
ride = self._create_ride(status='CLOSED_TEMP')
if hasattr(ride, 'open'):
ride.open(user=self.user)
else:
ride.transition_to_operating(user=self.user)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'OPERATING')
def test_close_permanently_wrapper(self):
"""Test close_permanently wrapper method."""
ride = self._create_ride(status='SBNO')
if hasattr(ride, 'close_permanently'):
ride.close_permanently(user=self.moderator)
else:
ride.transition_to_closed_perm(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'CLOSED_PERM')
def test_demolish_wrapper(self):
"""Test demolish wrapper method."""
ride = self._create_ride(status='CLOSED_PERM')
if hasattr(ride, 'demolish'):
ride.demolish(user=self.moderator)
else:
ride.transition_to_demolished(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'DEMOLISHED')
def test_relocate_wrapper(self):
"""Test relocate wrapper method."""
ride = self._create_ride(status='CLOSED_PERM')
if hasattr(ride, 'relocate'):
ride.relocate(user=self.moderator)
else:
ride.transition_to_relocated(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'RELOCATED')
class RidePostClosingStatusAutomationTests(TestCase):
"""Tests for post_closing_status automation logic."""
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
@@ -643,11 +644,11 @@ class RidePostClosingStatusAutomationTests(TestCase):
password='testpass123',
role='MODERATOR'
)
def _create_ride(self, status='CLOSING', **kwargs):
from apps.parks.models import Company, Park
from apps.rides.models import Ride
from apps.parks.models import Park, Company
manufacturer = Company.objects.create(
name=f'Mfr Auto {timezone.now().timestamp()}',
roles=['MANUFACTURER']
@@ -663,7 +664,7 @@ class RidePostClosingStatusAutomationTests(TestCase):
status='OPERATING',
timezone='America/New_York'
)
defaults = {
'name': f'Ride Auto {timezone.now().timestamp()}',
'slug': f'ride-auto-{timezone.now().timestamp()}',
@@ -673,40 +674,40 @@ class RidePostClosingStatusAutomationTests(TestCase):
}
defaults.update(kwargs)
return Ride.objects.create(**defaults)
def test_apply_post_closing_status_demolished(self):
"""Test apply_post_closing_status transitions to DEMOLISHED."""
ride = self._create_ride(status='CLOSING')
ride.closing_date = timezone.now().date()
ride.post_closing_status = 'DEMOLISHED'
ride.save()
# Apply post-closing status if method exists
if hasattr(ride, 'apply_post_closing_status'):
ride.apply_post_closing_status(user=self.moderator)
else:
ride.transition_to_demolished(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'DEMOLISHED')
def test_apply_post_closing_status_relocated(self):
"""Test apply_post_closing_status transitions to RELOCATED."""
ride = self._create_ride(status='CLOSING')
ride.closing_date = timezone.now().date()
ride.post_closing_status = 'RELOCATED'
ride.save()
if hasattr(ride, 'apply_post_closing_status'):
ride.apply_post_closing_status(user=self.moderator)
else:
ride.transition_to_relocated(user=self.moderator)
ride.save()
ride.refresh_from_db()
self.assertEqual(ride.status, 'RELOCATED')
def test_apply_post_closing_status_sbno(self):
"""Test apply_post_closing_status transitions to SBNO."""
ride = self._create_ride(status='CLOSING')
@@ -743,8 +744,8 @@ class RideStateLogTests(TestCase):
)
def _create_ride(self, status='OPERATING', **kwargs):
from apps.parks.models import Company, Park
from apps.rides.models import Ride
from apps.parks.models import Park, Company
manufacturer = Company.objects.create(
name=f'Mfr Log {timezone.now().timestamp()}',
@@ -774,8 +775,8 @@ class RideStateLogTests(TestCase):
def test_transition_creates_state_log(self):
"""Test that ride transitions create StateLog entries."""
from django_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
ride = self._create_ride(status='OPERATING')
ride_ct = ContentType.objects.get_for_model(ride)
@@ -796,8 +797,8 @@ class RideStateLogTests(TestCase):
def test_multiple_transitions_logged(self):
"""Test that multiple ride transitions are all logged."""
from django_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
ride = self._create_ride(status='OPERATING')
ride_ct = ContentType.objects.get_for_model(ride)
@@ -822,8 +823,8 @@ class RideStateLogTests(TestCase):
def test_sbno_revival_workflow_logged(self):
"""Test that SBNO revival workflow is logged."""
from django_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
ride = self._create_ride(status='SBNO')
ride_ct = ContentType.objects.get_for_model(ride)
@@ -844,8 +845,8 @@ class RideStateLogTests(TestCase):
def test_full_lifecycle_logged(self):
"""Test complete ride lifecycle is logged."""
from django_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
ride = self._create_ride(status='OPERATING')
ride_ct = ContentType.objects.get_for_model(ride)
@@ -875,8 +876,8 @@ class RideStateLogTests(TestCase):
def test_scheduled_closing_workflow_logged(self):
"""Test that scheduled closing workflow creates logs."""
from django_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
ride = self._create_ride(status='OPERATING')
ride_ct = ContentType.objects.get_for_model(ride)

View File

@@ -1,7 +1,9 @@
from django.urls import path
from . import views
from apps.core.views.views import FSMTransitionView
from . import views
app_name = "rides"
urlpatterns = [

View File

@@ -38,6 +38,8 @@ Code Quality:
- Maximum complexity: 10 (McCabe)
"""
import logging
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q
from django.http import Http404, HttpRequest, HttpResponse
@@ -45,6 +47,7 @@ from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.views.generic import CreateView, DetailView, ListView, UpdateView
from apps.core.logging import log_business_event, log_exception
from apps.moderation.mixins import EditSubmissionMixin, HistoryMixin
from apps.parks.models import Park
@@ -56,10 +59,6 @@ from .models.rankings import RankingSnapshot, RideRanking
from .models.rides import Ride, RideModel
from .services.ranking_service import RideRankingService
import logging
from apps.core.logging import log_exception, log_business_event
logger = logging.getLogger(__name__)
@@ -750,6 +749,7 @@ def ranking_comparisons(request: HttpRequest, ride_slug: str) -> HttpResponse:
# Get head-to-head comparisons
from django.db.models import Q
from .models.rankings import RidePairComparison
comparisons = (