mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-30 06:27:01 -05:00
feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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='')
|
||||
|
||||
@@ -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;",
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -8,19 +8,23 @@ The Company model is aliased as Manufacturer to clarify its role as ride manufac
|
||||
while maintaining backward compatibility through the Company alias.
|
||||
"""
|
||||
|
||||
from .rides import Ride, RideModel, RollerCoasterStats
|
||||
from .company import Company
|
||||
from .location import RideLocation
|
||||
from .reviews import RideReview
|
||||
from .rankings import RideRanking, RidePairComparison, RankingSnapshot
|
||||
from .media import RidePhoto
|
||||
from .credits import RideCredit
|
||||
from .location import RideLocation
|
||||
from .media import RidePhoto
|
||||
from .rankings import RankingSnapshot, RidePairComparison, RideRanking
|
||||
from .reviews import RideReview
|
||||
from .rides import Ride, RideModel, RollerCoasterStats
|
||||
from .stats import DarkRideStats, FlatRideStats, WaterRideStats
|
||||
|
||||
__all__ = [
|
||||
# Primary models
|
||||
"Ride",
|
||||
"RideModel",
|
||||
"RollerCoasterStats",
|
||||
"WaterRideStats",
|
||||
"DarkRideStats",
|
||||
"FlatRideStats",
|
||||
"Company",
|
||||
"RideLocation",
|
||||
"RideReview",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import pghistory
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django.conf import settings
|
||||
|
||||
from apps.core.choices.fields import RichChoiceField
|
||||
from apps.core.history import HistoricalSlug
|
||||
from apps.core.models import TrackedModel
|
||||
from apps.core.choices.fields import RichChoiceField
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
import pghistory
|
||||
from django.conf import settings
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
|
||||
from apps.core.history import TrackedModel
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RideCredit(TrackedModel):
|
||||
"""
|
||||
@@ -44,12 +45,15 @@ class RideCredit(TrackedModel):
|
||||
notes = models.TextField(
|
||||
blank=True, help_text="Personal notes about the experience"
|
||||
)
|
||||
display_order = models.PositiveIntegerField(
|
||||
default=0, help_text="User-defined display order for drag-drop sorting"
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
verbose_name = "Ride Credit"
|
||||
verbose_name_plural = "Ride Credits"
|
||||
unique_together = ["user", "ride"]
|
||||
ordering = ["-last_ridden_at", "-first_ridden_at", "-created_at"]
|
||||
ordering = ["display_order", "-last_ridden_at", "-first_ridden_at", "-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} - {self.ride}"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.contrib.gis.db import models as gis_models
|
||||
from django.db import models
|
||||
from django.contrib.gis.geos import Point
|
||||
import pghistory
|
||||
from django.contrib.gis.db import models as gis_models
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.db import models
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
|
||||
@@ -4,13 +4,15 @@ Ride-specific media models for ThrillWiki.
|
||||
This module contains media models specific to rides domain.
|
||||
"""
|
||||
|
||||
from typing import Any, Optional, List, cast
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from apps.core.history import TrackedModel
|
||||
from apps.core.choices import RichChoiceField
|
||||
from apps.core.services.media_service import MediaService
|
||||
from typing import Any, cast
|
||||
|
||||
import pghistory
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from apps.core.choices import RichChoiceField
|
||||
from apps.core.history import TrackedModel
|
||||
from apps.core.services.media_service import MediaService
|
||||
|
||||
|
||||
def ride_photo_upload_path(instance: models.Model, filename: str) -> str:
|
||||
@@ -114,7 +116,7 @@ class RidePhoto(TrackedModel):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def file_size(self) -> Optional[int]:
|
||||
def file_size(self) -> int | None:
|
||||
"""Get file size in bytes."""
|
||||
try:
|
||||
return self.image.size
|
||||
@@ -122,7 +124,7 @@ class RidePhoto(TrackedModel):
|
||||
return None
|
||||
|
||||
@property
|
||||
def dimensions(self) -> Optional[List[int]]:
|
||||
def dimensions(self) -> list[int] | None:
|
||||
"""Get image dimensions as [width, height]."""
|
||||
try:
|
||||
return [self.image.width, self.image.height]
|
||||
|
||||
@@ -6,10 +6,10 @@ where each ride is compared to every other ride to determine which one
|
||||
more riders preferred.
|
||||
"""
|
||||
|
||||
import pghistory
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
import pghistory
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import pghistory
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import functions
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
|
||||
from apps.core.history import TrackedModel
|
||||
import pghistory
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import contextlib
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pghistory
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from django.core.exceptions import ValidationError
|
||||
from config.django import base as settings
|
||||
from apps.core.models import TrackedModel
|
||||
|
||||
from apps.core.choices import RichChoiceField
|
||||
from apps.core.models import TrackedModel
|
||||
from apps.core.state_machine import RichFSMField, StateMachineMixin
|
||||
from config.django import base as settings
|
||||
|
||||
from .company import Company
|
||||
import pghistory
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .rides import RollerCoasterStats
|
||||
@@ -498,7 +502,7 @@ class Ride(StateMachineMixin, TrackedModel):
|
||||
Note: The average_rating field is denormalized and refreshed by background
|
||||
jobs. Use selectors or annotations for real-time calculations if needed.
|
||||
"""
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
coaster_stats: 'RollerCoasterStats'
|
||||
|
||||
@@ -669,17 +673,17 @@ class Ride(StateMachineMixin, TrackedModel):
|
||||
return f"{self.name} at {self.park.name}"
|
||||
|
||||
# FSM Transition Wrapper Methods
|
||||
def open(self, *, user: Optional[AbstractBaseUser] = None) -> None:
|
||||
def open(self, *, user: AbstractBaseUser | None = None) -> None:
|
||||
"""Transition ride to OPERATING status."""
|
||||
self.transition_to_operating(user=user)
|
||||
self.save()
|
||||
|
||||
def close_temporarily(self, *, user: Optional[AbstractBaseUser] = None) -> None:
|
||||
def close_temporarily(self, *, user: AbstractBaseUser | None = None) -> None:
|
||||
"""Transition ride to CLOSED_TEMP status."""
|
||||
self.transition_to_closed_temp(user=user)
|
||||
self.save()
|
||||
|
||||
def mark_sbno(self, *, user: Optional[AbstractBaseUser] = None) -> None:
|
||||
def mark_sbno(self, *, user: AbstractBaseUser | None = None) -> None:
|
||||
"""Transition ride to SBNO (Standing But Not Operating) status."""
|
||||
self.transition_to_sbno(user=user)
|
||||
self.save()
|
||||
@@ -689,7 +693,7 @@ class Ride(StateMachineMixin, TrackedModel):
|
||||
*,
|
||||
closing_date,
|
||||
post_closing_status: str,
|
||||
user: Optional[AbstractBaseUser] = None,
|
||||
user: AbstractBaseUser | None = None,
|
||||
) -> None:
|
||||
"""Transition ride to CLOSING status with closing date and target status."""
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -703,25 +707,25 @@ class Ride(StateMachineMixin, TrackedModel):
|
||||
self.post_closing_status = post_closing_status
|
||||
self.save()
|
||||
|
||||
def close_permanently(self, *, user: Optional[AbstractBaseUser] = None) -> None:
|
||||
def close_permanently(self, *, user: AbstractBaseUser | None = None) -> None:
|
||||
"""Transition ride to CLOSED_PERM status."""
|
||||
self.transition_to_closed_perm(user=user)
|
||||
self.save()
|
||||
|
||||
def demolish(self, *, user: Optional[AbstractBaseUser] = None) -> None:
|
||||
def demolish(self, *, user: AbstractBaseUser | None = None) -> None:
|
||||
"""Transition ride to DEMOLISHED status."""
|
||||
self.transition_to_demolished(user=user)
|
||||
self.save()
|
||||
|
||||
def relocate(self, *, user: Optional[AbstractBaseUser] = None) -> None:
|
||||
def relocate(self, *, user: AbstractBaseUser | None = None) -> None:
|
||||
"""Transition ride to RELOCATED status."""
|
||||
self.transition_to_relocated(user=user)
|
||||
self.save()
|
||||
|
||||
def apply_post_closing_status(self, *, user: Optional[AbstractBaseUser] = None) -> None:
|
||||
def apply_post_closing_status(self, *, user: AbstractBaseUser | None = None) -> None:
|
||||
"""Apply post_closing_status if closing_date has been reached."""
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
|
||||
if self.status != "CLOSING":
|
||||
raise ValidationError("Ride must be in CLOSING status")
|
||||
@@ -757,10 +761,8 @@ class Ride(StateMachineMixin, TrackedModel):
|
||||
# Check for slug conflicts when park changes or slug is new
|
||||
original_ride = None
|
||||
if self.pk:
|
||||
try:
|
||||
with contextlib.suppress(Ride.DoesNotExist):
|
||||
original_ride = Ride.objects.get(pk=self.pk)
|
||||
except Ride.DoesNotExist:
|
||||
pass
|
||||
|
||||
# If park changed or this is a new ride, ensure slug uniqueness within the park
|
||||
park_changed = original_ride and original_ride.park.id != self.park.id
|
||||
@@ -805,13 +807,13 @@ class Ride(StateMachineMixin, TrackedModel):
|
||||
|
||||
# Build comprehensive search text
|
||||
search_parts = []
|
||||
|
||||
|
||||
# Basic ride info
|
||||
if self.name:
|
||||
search_parts.append(self.name)
|
||||
if self.description:
|
||||
search_parts.append(self.description)
|
||||
|
||||
|
||||
# Park info
|
||||
if self.park:
|
||||
search_parts.append(self.park.name)
|
||||
@@ -822,39 +824,39 @@ class Ride(StateMachineMixin, TrackedModel):
|
||||
search_parts.append(self.park.location.state)
|
||||
if self.park.location.country:
|
||||
search_parts.append(self.park.location.country)
|
||||
|
||||
|
||||
# Park area
|
||||
if self.park_area:
|
||||
search_parts.append(self.park_area.name)
|
||||
|
||||
|
||||
# Category
|
||||
if self.category:
|
||||
category_choice = self.get_category_rich_choice()
|
||||
if category_choice:
|
||||
search_parts.append(category_choice.label)
|
||||
|
||||
|
||||
# Status
|
||||
if self.status:
|
||||
status_choice = self.get_status_rich_choice()
|
||||
if status_choice:
|
||||
search_parts.append(status_choice.label)
|
||||
|
||||
|
||||
# Companies
|
||||
if self.manufacturer:
|
||||
search_parts.append(self.manufacturer.name)
|
||||
if self.designer:
|
||||
search_parts.append(self.designer.name)
|
||||
|
||||
|
||||
# Ride model
|
||||
if self.ride_model:
|
||||
search_parts.append(self.ride_model.name)
|
||||
if self.ride_model.manufacturer:
|
||||
search_parts.append(self.ride_model.manufacturer.name)
|
||||
|
||||
|
||||
# Roller coaster stats if available
|
||||
try:
|
||||
if hasattr(self, 'coaster_stats') and self.coaster_stats:
|
||||
stats = self.coaster_stats
|
||||
if hasattr(self, 'coaster_stats') and self.coaster_stats:
|
||||
stats = self.coaster_stats
|
||||
if stats.track_type:
|
||||
search_parts.append(stats.track_type)
|
||||
if stats.track_material:
|
||||
@@ -874,7 +876,7 @@ class Ride(StateMachineMixin, TrackedModel):
|
||||
except Exception:
|
||||
# Ignore if coaster_stats doesn't exist or has issues
|
||||
pass
|
||||
|
||||
|
||||
self.search_text = ' '.join(filter(None, search_parts)).lower()
|
||||
|
||||
def _ensure_unique_slug_in_park(self) -> None:
|
||||
@@ -947,6 +949,7 @@ class Ride(StateMachineMixin, TrackedModel):
|
||||
def get_by_slug(cls, slug: str, park=None) -> tuple["Ride", bool]:
|
||||
"""Get ride by current or historical slug, optionally within a specific park"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from apps.core.history import HistoricalSlug
|
||||
|
||||
# Build base query
|
||||
@@ -975,7 +978,7 @@ class Ride(StateMachineMixin, TrackedModel):
|
||||
event_model = getattr(cls, "event_model", None)
|
||||
if event_model:
|
||||
historical_events = event_model.objects.filter(slug=slug).order_by("-pgh_created_at")
|
||||
|
||||
|
||||
for historical_event in historical_events:
|
||||
try:
|
||||
ride = base_query.get(pk=historical_event.pgh_obj_id)
|
||||
|
||||
226
backend/apps/rides/models/stats.py
Normal file
226
backend/apps/rides/models/stats.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Ride-Specific Statistics Models
|
||||
|
||||
This module contains specialized statistics models for different ride categories:
|
||||
- WaterRideStats: For water rides (WR)
|
||||
- DarkRideStats: For dark rides (DR)
|
||||
- FlatRideStats: For flat rides (FR)
|
||||
|
||||
These complement the existing RollerCoasterStats model in rides.py.
|
||||
"""
|
||||
|
||||
import pghistory
|
||||
from django.db import models
|
||||
|
||||
from apps.core.history import TrackedModel
|
||||
|
||||
# Wetness Level Choices for Water Rides
|
||||
WETNESS_LEVELS = [
|
||||
("DRY", "Dry - No water contact"),
|
||||
("MILD", "Mild - Light misting"),
|
||||
("MODERATE", "Moderate - Some splashing"),
|
||||
("SOAKING", "Soaking - Prepare to get drenched"),
|
||||
]
|
||||
|
||||
# Motion Type Choices for Flat Rides
|
||||
MOTION_TYPES = [
|
||||
("SPINNING", "Spinning"),
|
||||
("SWINGING", "Swinging"),
|
||||
("BOUNCING", "Bouncing"),
|
||||
("ROTATING", "Rotating"),
|
||||
("DROPPING", "Dropping"),
|
||||
("MIXED", "Mixed Motion"),
|
||||
]
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class WaterRideStats(TrackedModel):
|
||||
"""
|
||||
Statistics specific to water rides (category=WR).
|
||||
|
||||
Tracks water-related attributes like wetness level and splash characteristics.
|
||||
"""
|
||||
|
||||
ride = models.OneToOneField(
|
||||
"rides.Ride",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="water_stats",
|
||||
help_text="Ride these water statistics belong to",
|
||||
)
|
||||
|
||||
wetness_level = models.CharField(
|
||||
max_length=10,
|
||||
choices=WETNESS_LEVELS,
|
||||
default="MODERATE",
|
||||
help_text="How wet riders typically get",
|
||||
)
|
||||
|
||||
splash_height_ft = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum splash height in feet",
|
||||
)
|
||||
|
||||
has_splash_zone = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether there is a designated splash zone for spectators",
|
||||
)
|
||||
|
||||
boat_capacity = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Number of riders per boat/raft",
|
||||
)
|
||||
|
||||
uses_flume = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the ride uses a flume/log system",
|
||||
)
|
||||
|
||||
rapids_sections = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of rapids/whitewater sections",
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
verbose_name = "Water Ride Statistics"
|
||||
verbose_name_plural = "Water Ride Statistics"
|
||||
ordering = ["ride"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Water Stats for {self.ride.name}"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class DarkRideStats(TrackedModel):
|
||||
"""
|
||||
Statistics specific to dark rides (category=DR).
|
||||
|
||||
Tracks theming elements like scenes, animatronics, and technology.
|
||||
"""
|
||||
|
||||
ride = models.OneToOneField(
|
||||
"rides.Ride",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="dark_stats",
|
||||
help_text="Ride these dark ride statistics belong to",
|
||||
)
|
||||
|
||||
scene_count = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of themed scenes",
|
||||
)
|
||||
|
||||
animatronic_count = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of animatronic figures",
|
||||
)
|
||||
|
||||
has_projection_technology = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the ride uses projection mapping or screens",
|
||||
)
|
||||
|
||||
is_interactive = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether riders can interact with elements (shooting, etc.)",
|
||||
)
|
||||
|
||||
ride_system = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Type of ride system (Omnimover, Trackless, Classic Track, etc.)",
|
||||
)
|
||||
|
||||
uses_practical_effects = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether the ride uses practical/physical effects",
|
||||
)
|
||||
|
||||
uses_motion_base = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether vehicles have motion simulation capability",
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
verbose_name = "Dark Ride Statistics"
|
||||
verbose_name_plural = "Dark Ride Statistics"
|
||||
ordering = ["ride"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Dark Ride Stats for {self.ride.name}"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class FlatRideStats(TrackedModel):
|
||||
"""
|
||||
Statistics specific to flat rides (category=FR).
|
||||
|
||||
Tracks motion characteristics like rotation, swing angles, and height.
|
||||
"""
|
||||
|
||||
ride = models.OneToOneField(
|
||||
"rides.Ride",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="flat_stats",
|
||||
help_text="Ride these flat ride statistics belong to",
|
||||
)
|
||||
|
||||
max_height_ft = models.DecimalField(
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum ride height in feet",
|
||||
)
|
||||
|
||||
rotation_speed_rpm = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum rotation speed in RPM",
|
||||
)
|
||||
|
||||
swing_angle_degrees = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum swing angle in degrees (for swinging rides)",
|
||||
)
|
||||
|
||||
motion_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=MOTION_TYPES,
|
||||
default="SPINNING",
|
||||
help_text="Primary type of motion",
|
||||
)
|
||||
|
||||
arm_count = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Number of arms/gondolas",
|
||||
)
|
||||
|
||||
seats_per_gondola = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Number of seats per gondola/arm",
|
||||
)
|
||||
|
||||
max_g_force = models.DecimalField(
|
||||
max_digits=4,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum G-force experienced",
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
verbose_name = "Flat Ride Statistics"
|
||||
verbose_name_plural = "Flat Ride Statistics"
|
||||
ordering = ["ride"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Flat Ride Stats for {self.ride.name}"
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "rides"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django import template
|
||||
from django.templatetags.static import static
|
||||
|
||||
from ..choices import RIDE_CATEGORIES
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user