mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 14:11:08 -05:00
Add comprehensive tests for Parks API and models
- Implemented extensive test cases for the Parks API, covering endpoints for listing, retrieving, creating, updating, and deleting parks. - Added tests for filtering, searching, and ordering parks in the API. - Created tests for error handling in the API, including malformed JSON and unsupported methods. - Developed model tests for Park, ParkArea, Company, and ParkReview models, ensuring validation and constraints are enforced. - Introduced utility mixins for API and model testing to streamline assertions and enhance test readability. - Included integration tests to validate complete workflows involving park creation, retrieval, updating, and deletion.
This commit is contained in:
1
rides/api/__init__.py
Normal file
1
rides/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Rides API module
|
||||
349
rides/api/serializers.py
Normal file
349
rides/api/serializers.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
Serializers for Rides API following Django styleguide patterns.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from ..models import Ride, RideModel, Company
|
||||
|
||||
|
||||
class RideModelOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride model data."""
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
manufacturer = serializers.SerializerMethodField()
|
||||
|
||||
def get_manufacturer(self, obj):
|
||||
if obj.manufacturer:
|
||||
return {
|
||||
'id': obj.manufacturer.id,
|
||||
'name': obj.manufacturer.name,
|
||||
'slug': obj.manufacturer.slug
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
class RideParkOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride's park data."""
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
|
||||
|
||||
class RideListOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride list view."""
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
|
||||
# Park info
|
||||
park = RideParkOutputSerializer()
|
||||
|
||||
# Statistics
|
||||
average_rating = serializers.DecimalField(max_digits=3, decimal_places=2, allow_null=True)
|
||||
capacity_per_hour = serializers.IntegerField(allow_null=True)
|
||||
|
||||
# Dates
|
||||
opening_date = serializers.DateField(allow_null=True)
|
||||
closing_date = serializers.DateField(allow_null=True)
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
|
||||
class RideDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride detail view."""
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
post_closing_status = serializers.CharField(allow_null=True)
|
||||
description = serializers.CharField()
|
||||
|
||||
# Park info
|
||||
park = RideParkOutputSerializer()
|
||||
park_area = serializers.SerializerMethodField()
|
||||
|
||||
# Dates
|
||||
opening_date = serializers.DateField(allow_null=True)
|
||||
closing_date = serializers.DateField(allow_null=True)
|
||||
status_since = serializers.DateField(allow_null=True)
|
||||
|
||||
# Physical specs
|
||||
min_height_in = serializers.IntegerField(allow_null=True)
|
||||
max_height_in = serializers.IntegerField(allow_null=True)
|
||||
capacity_per_hour = serializers.IntegerField(allow_null=True)
|
||||
ride_duration_seconds = serializers.IntegerField(allow_null=True)
|
||||
|
||||
# Statistics
|
||||
average_rating = serializers.DecimalField(max_digits=3, decimal_places=2, allow_null=True)
|
||||
|
||||
# Companies
|
||||
manufacturer = serializers.SerializerMethodField()
|
||||
designer = serializers.SerializerMethodField()
|
||||
|
||||
# Model
|
||||
ride_model = RideModelOutputSerializer(allow_null=True)
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
def get_park_area(self, obj):
|
||||
if obj.park_area:
|
||||
return {
|
||||
'id': obj.park_area.id,
|
||||
'name': obj.park_area.name,
|
||||
'slug': obj.park_area.slug
|
||||
}
|
||||
return None
|
||||
|
||||
def get_manufacturer(self, obj):
|
||||
if obj.manufacturer:
|
||||
return {
|
||||
'id': obj.manufacturer.id,
|
||||
'name': obj.manufacturer.name,
|
||||
'slug': obj.manufacturer.slug
|
||||
}
|
||||
return None
|
||||
|
||||
def get_designer(self, obj):
|
||||
if obj.designer:
|
||||
return {
|
||||
'id': obj.designer.id,
|
||||
'name': obj.designer.name,
|
||||
'slug': obj.designer.slug
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
class RideCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating rides."""
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
category = serializers.ChoiceField(choices=Ride.CATEGORY_CHOICES)
|
||||
status = serializers.ChoiceField(
|
||||
choices=Ride.STATUS_CHOICES,
|
||||
default="OPERATING"
|
||||
)
|
||||
|
||||
# Required park
|
||||
park_id = serializers.IntegerField()
|
||||
|
||||
# Optional area
|
||||
park_area_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
# Optional dates
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
status_since = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
# Optional specs
|
||||
min_height_in = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=30,
|
||||
max_value=90
|
||||
)
|
||||
max_height_in = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=30,
|
||||
max_value=90
|
||||
)
|
||||
capacity_per_hour = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=1
|
||||
)
|
||||
ride_duration_seconds = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=1
|
||||
)
|
||||
|
||||
# Optional companies
|
||||
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
designer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
# Optional model
|
||||
ride_model_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, data):
|
||||
"""Cross-field validation."""
|
||||
# Date validation
|
||||
opening_date = data.get('opening_date')
|
||||
closing_date = data.get('closing_date')
|
||||
|
||||
if opening_date and closing_date and closing_date < opening_date:
|
||||
raise serializers.ValidationError(
|
||||
"Closing date cannot be before opening date"
|
||||
)
|
||||
|
||||
# Height validation
|
||||
min_height = data.get('min_height_in')
|
||||
max_height = data.get('max_height_in')
|
||||
|
||||
if min_height and max_height and min_height > max_height:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum height cannot be greater than maximum height"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class RideUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating rides."""
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
category = serializers.ChoiceField(choices=Ride.CATEGORY_CHOICES, required=False)
|
||||
status = serializers.ChoiceField(choices=Ride.STATUS_CHOICES, required=False)
|
||||
post_closing_status = serializers.ChoiceField(
|
||||
choices=Ride.POST_CLOSING_STATUS_CHOICES,
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
# Park and area
|
||||
park_id = serializers.IntegerField(required=False)
|
||||
park_area_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
# Dates
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
status_since = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
# Specs
|
||||
min_height_in = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=30,
|
||||
max_value=90
|
||||
)
|
||||
max_height_in = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=30,
|
||||
max_value=90
|
||||
)
|
||||
capacity_per_hour = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=1
|
||||
)
|
||||
ride_duration_seconds = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=1
|
||||
)
|
||||
|
||||
# Companies
|
||||
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
designer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
# Model
|
||||
ride_model_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, data):
|
||||
"""Cross-field validation."""
|
||||
# Date validation
|
||||
opening_date = data.get('opening_date')
|
||||
closing_date = data.get('closing_date')
|
||||
|
||||
if opening_date and closing_date and closing_date < opening_date:
|
||||
raise serializers.ValidationError(
|
||||
"Closing date cannot be before opening date"
|
||||
)
|
||||
|
||||
# Height validation
|
||||
min_height = data.get('min_height_in')
|
||||
max_height = data.get('max_height_in')
|
||||
|
||||
if min_height and max_height and min_height > max_height:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum height cannot be greater than maximum height"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class RideFilterInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for ride filtering and search."""
|
||||
# Search
|
||||
search = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Category filter
|
||||
category = serializers.MultipleChoiceField(
|
||||
choices=Ride.CATEGORY_CHOICES,
|
||||
required=False
|
||||
)
|
||||
|
||||
# Status filter
|
||||
status = serializers.MultipleChoiceField(
|
||||
choices=Ride.STATUS_CHOICES,
|
||||
required=False
|
||||
)
|
||||
|
||||
# Park filter
|
||||
park_id = serializers.IntegerField(required=False)
|
||||
park_slug = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Company filters
|
||||
manufacturer_id = serializers.IntegerField(required=False)
|
||||
designer_id = serializers.IntegerField(required=False)
|
||||
|
||||
# Rating filter
|
||||
min_rating = serializers.DecimalField(
|
||||
max_digits=3,
|
||||
decimal_places=2,
|
||||
required=False,
|
||||
min_value=1,
|
||||
max_value=10
|
||||
)
|
||||
|
||||
# Height filters
|
||||
min_height_requirement = serializers.IntegerField(required=False)
|
||||
max_height_requirement = serializers.IntegerField(required=False)
|
||||
|
||||
# Capacity filter
|
||||
min_capacity = serializers.IntegerField(required=False)
|
||||
|
||||
# Ordering
|
||||
ordering = serializers.ChoiceField(
|
||||
choices=[
|
||||
'name', '-name',
|
||||
'opening_date', '-opening_date',
|
||||
'average_rating', '-average_rating',
|
||||
'capacity_per_hour', '-capacity_per_hour',
|
||||
'created_at', '-created_at'
|
||||
],
|
||||
required=False,
|
||||
default='name'
|
||||
)
|
||||
|
||||
|
||||
class RideStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride statistics."""
|
||||
total_rides = serializers.IntegerField()
|
||||
operating_rides = serializers.IntegerField()
|
||||
closed_rides = serializers.IntegerField()
|
||||
under_construction = serializers.IntegerField()
|
||||
|
||||
# By category
|
||||
rides_by_category = serializers.DictField()
|
||||
|
||||
# Averages
|
||||
average_rating = serializers.DecimalField(max_digits=3, decimal_places=2, allow_null=True)
|
||||
average_capacity = serializers.DecimalField(max_digits=8, decimal_places=2, allow_null=True)
|
||||
|
||||
# Top manufacturers
|
||||
top_manufacturers = serializers.ListField(child=serializers.DictField())
|
||||
|
||||
# Recently added
|
||||
recently_added_count = serializers.IntegerField()
|
||||
17
rides/api/urls.py
Normal file
17
rides/api/urls.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
URL configuration for Rides API following Django styleguide patterns.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
# Note: We'll create the views file after this
|
||||
# from .views import RideApi
|
||||
|
||||
app_name = 'rides_api'
|
||||
|
||||
# Placeholder for future implementation
|
||||
urlpatterns = [
|
||||
# Will be implemented in next phase
|
||||
# path('v1/', include(router.urls)),
|
||||
]
|
||||
@@ -284,3 +284,20 @@ class RideForm(forms.ModelForm):
|
||||
if self.instance.ride_model:
|
||||
self.fields["ride_model_search"].initial = self.instance.ride_model.name
|
||||
self.fields["ride_model"].initial = self.instance.ride_model
|
||||
|
||||
|
||||
class RideSearchForm(forms.Form):
|
||||
"""Form for searching rides with HTMX autocomplete."""
|
||||
ride = forms.ModelChoiceField(
|
||||
queryset=Ride.objects.all(),
|
||||
label="Find a ride",
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white",
|
||||
"hx-get": reverse_lazy("rides:search"),
|
||||
"hx-trigger": "change",
|
||||
"hx-target": "#ride-search-results",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
281
rides/managers.py
Normal file
281
rides/managers.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Custom managers and QuerySets for Rides models.
|
||||
Optimized queries following Django styleguide patterns.
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any, Union
|
||||
from django.db import models
|
||||
from django.db.models import Q, F, Count, Avg, Max, Min, Prefetch
|
||||
|
||||
from core.managers import (
|
||||
BaseQuerySet, BaseManager, ReviewableQuerySet, ReviewableManager,
|
||||
StatusQuerySet, StatusManager
|
||||
)
|
||||
|
||||
|
||||
class RideQuerySet(StatusQuerySet, ReviewableQuerySet):
|
||||
"""Optimized QuerySet for Ride model."""
|
||||
|
||||
def by_category(self, *, category: Union[str, List[str]]):
|
||||
"""Filter rides by category."""
|
||||
if isinstance(category, list):
|
||||
return self.filter(category__in=category)
|
||||
return self.filter(category=category)
|
||||
|
||||
def coasters(self):
|
||||
"""Filter for roller coasters."""
|
||||
return self.filter(category__in=['RC', 'WC'])
|
||||
|
||||
def thrill_rides(self):
|
||||
"""Filter for thrill rides."""
|
||||
return self.filter(category__in=['RC', 'WC', 'FR'])
|
||||
|
||||
def family_friendly(self, *, max_height_requirement: int = 42):
|
||||
"""Filter for family-friendly rides."""
|
||||
return self.filter(
|
||||
Q(min_height_in__lte=max_height_requirement) |
|
||||
Q(min_height_in__isnull=True)
|
||||
)
|
||||
|
||||
def by_park(self, *, park_id: int):
|
||||
"""Filter rides by park."""
|
||||
return self.filter(park_id=park_id)
|
||||
|
||||
def by_manufacturer(self, *, manufacturer_id: int):
|
||||
"""Filter rides by manufacturer."""
|
||||
return self.filter(manufacturer_id=manufacturer_id)
|
||||
|
||||
def by_designer(self, *, designer_id: int):
|
||||
"""Filter rides by designer."""
|
||||
return self.filter(designer_id=designer_id)
|
||||
|
||||
def with_capacity_info(self):
|
||||
"""Add capacity-related annotations."""
|
||||
return self.annotate(
|
||||
estimated_daily_capacity=F('capacity_per_hour') * 10, # Assuming 10 operating hours
|
||||
duration_minutes=F('ride_duration_seconds') / 60.0
|
||||
)
|
||||
|
||||
def high_capacity(self, *, min_capacity: int = 1000):
|
||||
"""Filter for high-capacity rides."""
|
||||
return self.filter(capacity_per_hour__gte=min_capacity)
|
||||
|
||||
def optimized_for_list(self):
|
||||
"""Optimize for ride list display."""
|
||||
return self.select_related(
|
||||
'park',
|
||||
'park_area',
|
||||
'manufacturer',
|
||||
'designer',
|
||||
'ride_model'
|
||||
).with_review_stats()
|
||||
|
||||
def optimized_for_detail(self):
|
||||
"""Optimize for ride detail display."""
|
||||
from .models import RideReview
|
||||
|
||||
return self.select_related(
|
||||
'park',
|
||||
'park_area',
|
||||
'manufacturer',
|
||||
'designer',
|
||||
'ride_model__manufacturer'
|
||||
).prefetch_related(
|
||||
'location',
|
||||
'rollercoaster_stats',
|
||||
Prefetch(
|
||||
'reviews',
|
||||
queryset=RideReview.objects.select_related('user')
|
||||
.filter(is_published=True)
|
||||
.order_by('-created_at')[:10]
|
||||
),
|
||||
'photos'
|
||||
)
|
||||
|
||||
def for_map_display(self):
|
||||
"""Optimize for map display."""
|
||||
return self.select_related('park', 'park_area').prefetch_related('location').values(
|
||||
'id', 'name', 'slug', 'category', 'status',
|
||||
'park__name', 'park__slug',
|
||||
'park_area__name',
|
||||
'location__point'
|
||||
)
|
||||
|
||||
def search_by_specs(self, *, min_height: Optional[int] = None, max_height: Optional[int] = None,
|
||||
min_speed: Optional[float] = None, inversions: Optional[bool] = None):
|
||||
"""Search rides by physical specifications."""
|
||||
queryset = self
|
||||
|
||||
if min_height:
|
||||
queryset = queryset.filter(
|
||||
Q(rollercoaster_stats__height_ft__gte=min_height) |
|
||||
Q(min_height_in__gte=min_height)
|
||||
)
|
||||
|
||||
if max_height:
|
||||
queryset = queryset.filter(
|
||||
Q(rollercoaster_stats__height_ft__lte=max_height) |
|
||||
Q(max_height_in__lte=max_height)
|
||||
)
|
||||
|
||||
if min_speed:
|
||||
queryset = queryset.filter(rollercoaster_stats__speed_mph__gte=min_speed)
|
||||
|
||||
if inversions is not None:
|
||||
if inversions:
|
||||
queryset = queryset.filter(rollercoaster_stats__inversions__gt=0)
|
||||
else:
|
||||
queryset = queryset.filter(
|
||||
Q(rollercoaster_stats__inversions=0) |
|
||||
Q(rollercoaster_stats__isnull=True)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class RideManager(StatusManager, ReviewableManager):
|
||||
"""Custom manager for Ride model."""
|
||||
|
||||
def get_queryset(self):
|
||||
return RideQuerySet(self.model, using=self._db)
|
||||
|
||||
def coasters(self):
|
||||
return self.get_queryset().coasters()
|
||||
|
||||
def thrill_rides(self):
|
||||
return self.get_queryset().thrill_rides()
|
||||
|
||||
def family_friendly(self, *, max_height_requirement: int = 42):
|
||||
return self.get_queryset().family_friendly(max_height_requirement=max_height_requirement)
|
||||
|
||||
def by_park(self, *, park_id: int):
|
||||
return self.get_queryset().by_park(park_id=park_id)
|
||||
|
||||
def high_capacity(self, *, min_capacity: int = 1000):
|
||||
return self.get_queryset().high_capacity(min_capacity=min_capacity)
|
||||
|
||||
def optimized_for_list(self):
|
||||
return self.get_queryset().optimized_for_list()
|
||||
|
||||
def optimized_for_detail(self):
|
||||
return self.get_queryset().optimized_for_detail()
|
||||
|
||||
|
||||
class RideModelQuerySet(BaseQuerySet):
|
||||
"""QuerySet for RideModel model."""
|
||||
|
||||
def by_manufacturer(self, *, manufacturer_id: int):
|
||||
"""Filter ride models by manufacturer."""
|
||||
return self.filter(manufacturer_id=manufacturer_id)
|
||||
|
||||
def by_category(self, *, category: str):
|
||||
"""Filter ride models by category."""
|
||||
return self.filter(category=category)
|
||||
|
||||
def with_ride_counts(self):
|
||||
"""Add count of rides using this model."""
|
||||
return self.annotate(
|
||||
ride_count=Count('rides', distinct=True),
|
||||
operating_rides_count=Count('rides', filter=Q(rides__status='OPERATING'), distinct=True)
|
||||
)
|
||||
|
||||
def popular_models(self, *, min_installations: int = 5):
|
||||
"""Filter for popular ride models."""
|
||||
return self.with_ride_counts().filter(ride_count__gte=min_installations)
|
||||
|
||||
def optimized_for_list(self):
|
||||
"""Optimize for model list display."""
|
||||
return self.select_related('manufacturer').with_ride_counts()
|
||||
|
||||
|
||||
class RideModelManager(BaseManager):
|
||||
"""Manager for RideModel model."""
|
||||
|
||||
def get_queryset(self):
|
||||
return RideModelQuerySet(self.model, using=self._db)
|
||||
|
||||
def by_manufacturer(self, *, manufacturer_id: int):
|
||||
return self.get_queryset().by_manufacturer(manufacturer_id=manufacturer_id)
|
||||
|
||||
def popular_models(self, *, min_installations: int = 5):
|
||||
return self.get_queryset().popular_models(min_installations=min_installations)
|
||||
|
||||
|
||||
class RideReviewQuerySet(ReviewableQuerySet):
|
||||
"""QuerySet for RideReview model."""
|
||||
|
||||
def for_ride(self, *, ride_id: int):
|
||||
"""Filter reviews for a specific ride."""
|
||||
return self.filter(ride_id=ride_id)
|
||||
|
||||
def by_user(self, *, user_id: int):
|
||||
"""Filter reviews by user."""
|
||||
return self.filter(user_id=user_id)
|
||||
|
||||
def by_rating_range(self, *, min_rating: int = 1, max_rating: int = 10):
|
||||
"""Filter reviews by rating range."""
|
||||
return self.filter(rating__gte=min_rating, rating__lte=max_rating)
|
||||
|
||||
def optimized_for_display(self):
|
||||
"""Optimize for review display."""
|
||||
return self.select_related('user', 'ride', 'moderated_by')
|
||||
|
||||
|
||||
class RideReviewManager(BaseManager):
|
||||
"""Manager for RideReview model."""
|
||||
|
||||
def get_queryset(self):
|
||||
return RideReviewQuerySet(self.model, using=self._db)
|
||||
|
||||
def for_ride(self, *, ride_id: int):
|
||||
return self.get_queryset().for_ride(ride_id=ride_id)
|
||||
|
||||
def by_rating_range(self, *, min_rating: int = 1, max_rating: int = 10):
|
||||
return self.get_queryset().by_rating_range(min_rating=min_rating, max_rating=max_rating)
|
||||
|
||||
|
||||
class RollerCoasterStatsQuerySet(BaseQuerySet):
|
||||
"""QuerySet for RollerCoasterStats model."""
|
||||
|
||||
def tall_coasters(self, *, min_height_ft: float = 200):
|
||||
"""Filter for tall roller coasters."""
|
||||
return self.filter(height_ft__gte=min_height_ft)
|
||||
|
||||
def fast_coasters(self, *, min_speed_mph: float = 60):
|
||||
"""Filter for fast roller coasters."""
|
||||
return self.filter(speed_mph__gte=min_speed_mph)
|
||||
|
||||
def with_inversions(self):
|
||||
"""Filter for coasters with inversions."""
|
||||
return self.filter(inversions__gt=0)
|
||||
|
||||
def launched_coasters(self):
|
||||
"""Filter for launched coasters."""
|
||||
return self.exclude(launch_type='NONE')
|
||||
|
||||
def by_track_type(self, *, track_type: str):
|
||||
"""Filter by track type."""
|
||||
return self.filter(track_type=track_type)
|
||||
|
||||
def optimized_for_list(self):
|
||||
"""Optimize for stats list display."""
|
||||
return self.select_related('ride', 'ride__park')
|
||||
|
||||
|
||||
class RollerCoasterStatsManager(BaseManager):
|
||||
"""Manager for RollerCoasterStats model."""
|
||||
|
||||
def get_queryset(self):
|
||||
return RollerCoasterStatsQuerySet(self.model, using=self._db)
|
||||
|
||||
def tall_coasters(self, *, min_height_ft: float = 200):
|
||||
return self.get_queryset().tall_coasters(min_height_ft=min_height_ft)
|
||||
|
||||
def fast_coasters(self, *, min_speed_mph: float = 60):
|
||||
return self.get_queryset().fast_coasters(min_speed_mph=min_speed_mph)
|
||||
|
||||
def with_inversions(self):
|
||||
return self.get_queryset().with_inversions()
|
||||
|
||||
def launched_coasters(self):
|
||||
return self.get_queryset().launched_coasters()
|
||||
137
rides/migrations/0002_add_business_constraints.py
Normal file
137
rides/migrations/0002_add_business_constraints.py
Normal file
@@ -0,0 +1,137 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-16 17:42
|
||||
|
||||
import django.db.models.functions.datetime
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0003_add_business_constraints"),
|
||||
("rides", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name="ride",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("closing_date__isnull", True),
|
||||
("opening_date__isnull", True),
|
||||
("closing_date__gte", models.F("opening_date")),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_closing_after_opening",
|
||||
violation_error_message="Closing date must be after opening date",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ride",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("min_height_in__isnull", True),
|
||||
("max_height_in__isnull", True),
|
||||
("min_height_in__lte", models.F("max_height_in")),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_height_requirements_logical",
|
||||
violation_error_message="Minimum height cannot exceed maximum height",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ride",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("min_height_in__isnull", True),
|
||||
models.Q(("min_height_in__gte", 30), ("min_height_in__lte", 90)),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_min_height_reasonable",
|
||||
violation_error_message="Minimum height must be between 30 and 90 inches",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ride",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("max_height_in__isnull", True),
|
||||
models.Q(("max_height_in__gte", 30), ("max_height_in__lte", 90)),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_max_height_reasonable",
|
||||
violation_error_message="Maximum height must be between 30 and 90 inches",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ride",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("average_rating__isnull", True),
|
||||
models.Q(("average_rating__gte", 1), ("average_rating__lte", 10)),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_rating_range",
|
||||
violation_error_message="Average rating must be between 1 and 10",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ride",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("capacity_per_hour__isnull", True),
|
||||
("capacity_per_hour__gt", 0),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_capacity_positive",
|
||||
violation_error_message="Hourly capacity must be positive",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ride",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("ride_duration_seconds__isnull", True),
|
||||
("ride_duration_seconds__gt", 0),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_duration_positive",
|
||||
violation_error_message="Ride duration must be positive",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ridereview",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(("rating__gte", 1), ("rating__lte", 10)),
|
||||
name="ride_review_rating_range",
|
||||
violation_error_message="Rating must be between 1 and 10",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ridereview",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("visit_date__lte", django.db.models.functions.datetime.Now())
|
||||
),
|
||||
name="ride_review_visit_date_not_future",
|
||||
violation_error_message="Visit date cannot be in the future",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ridereview",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
models.Q(
|
||||
("moderated_at__isnull", True), ("moderated_by__isnull", True)
|
||||
),
|
||||
models.Q(
|
||||
("moderated_at__isnull", False), ("moderated_by__isnull", False)
|
||||
),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ride_review_moderation_consistency",
|
||||
violation_error_message="Moderated reviews must have both moderator and moderation timestamp",
|
||||
),
|
||||
),
|
||||
]
|
||||
43
rides/migrations/0003_add_company_model.py
Normal file
43
rides/migrations/0003_add_company_model.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-16 23:12
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0002_add_business_constraints"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Company',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('slug', models.SlugField(max_length=255, unique=True)),
|
||||
('roles', django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[('MANUFACTURER', 'Ride Manufacturer'), ('DESIGNER', 'Ride Designer'), ('OPERATOR', 'Park Operator'), ('PROPERTY_OWNER', 'Property Owner')],
|
||||
max_length=20
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None
|
||||
)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('website', models.URLField(blank=True)),
|
||||
('founded_date', models.DateField(blank=True, null=True)),
|
||||
('rides_count', models.IntegerField(default=0)),
|
||||
('coasters_count', models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Companies',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-16 23:13
|
||||
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0003_add_company_model"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="company",
|
||||
name="created_at",
|
||||
field=models.DateTimeField(auto_now_add=True),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="company",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_e7194",
|
||||
table="rides_company",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="company",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_456a8",
|
||||
table="rides_company",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
334
rides/models.py
334
rides/models.py
@@ -1,334 +0,0 @@
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from core.history import TrackedModel, DiffMixin
|
||||
from .events import get_ride_display_changes, get_ride_model_display_changes
|
||||
import pghistory
|
||||
from .models import Company
|
||||
|
||||
# Shared choices that will be used by multiple models
|
||||
CATEGORY_CHOICES = [
|
||||
('', 'Select ride type'),
|
||||
('RC', 'Roller Coaster'),
|
||||
('DR', 'Dark Ride'),
|
||||
('FR', 'Flat Ride'),
|
||||
('WR', 'Water Ride'),
|
||||
('TR', 'Transport'),
|
||||
('OT', 'Other'),
|
||||
]
|
||||
|
||||
|
||||
class RideEvent(models.Model, DiffMixin):
|
||||
"""Event model for tracking Ride changes - uses existing pghistory table"""
|
||||
|
||||
pgh_id = models.AutoField(primary_key=True)
|
||||
pgh_created_at = models.DateTimeField(auto_now_add=True)
|
||||
pgh_label = models.TextField()
|
||||
|
||||
# Original model fields
|
||||
id = models.BigIntegerField()
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
category = models.CharField(max_length=2)
|
||||
status = models.CharField(max_length=20)
|
||||
post_closing_status = models.CharField(max_length=20, null=True)
|
||||
opening_date = models.DateField(null=True)
|
||||
closing_date = models.DateField(null=True)
|
||||
status_since = models.DateField(null=True)
|
||||
min_height_in = models.PositiveIntegerField(null=True)
|
||||
max_height_in = models.PositiveIntegerField(null=True)
|
||||
capacity_per_hour = models.PositiveIntegerField(null=True)
|
||||
ride_duration_seconds = models.PositiveIntegerField(null=True)
|
||||
average_rating = models.DecimalField(
|
||||
max_digits=3, decimal_places=2, null=True)
|
||||
created_at = models.DateTimeField()
|
||||
updated_at = models.DateTimeField()
|
||||
|
||||
# Foreign keys as IDs
|
||||
park_id = models.BigIntegerField()
|
||||
park_area_id = models.BigIntegerField(null=True)
|
||||
ride_model_id = models.BigIntegerField(null=True)
|
||||
|
||||
# Context fields
|
||||
pgh_obj = models.ForeignKey('Ride', on_delete=models.CASCADE)
|
||||
pgh_context = models.ForeignKey(
|
||||
'pghistory.Context',
|
||||
on_delete=models.DO_NOTHING,
|
||||
db_constraint=False,
|
||||
related_name='+',
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'rides_rideevent'
|
||||
managed = False
|
||||
|
||||
def get_display_changes(self) -> dict:
|
||||
"""Returns human-readable changes"""
|
||||
return get_ride_display_changes(self.diff_against_previous())
|
||||
|
||||
|
||||
class RideModelEvent(models.Model, DiffMixin):
|
||||
"""Event model for tracking RideModel changes - uses existing pghistory table"""
|
||||
|
||||
pgh_id = models.AutoField(primary_key=True)
|
||||
pgh_created_at = models.DateTimeField(auto_now_add=True)
|
||||
pgh_label = models.TextField()
|
||||
|
||||
# Original model fields
|
||||
id = models.BigIntegerField()
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
category = models.CharField(max_length=2)
|
||||
created_at = models.DateTimeField()
|
||||
updated_at = models.DateTimeField()
|
||||
|
||||
# Foreign keys as IDs
|
||||
manufacturer_id = models.BigIntegerField(null=True)
|
||||
|
||||
# Context fields
|
||||
pgh_obj = models.ForeignKey('RideModel', on_delete=models.CASCADE)
|
||||
pgh_context = models.ForeignKey(
|
||||
'pghistory.Context',
|
||||
on_delete=models.DO_NOTHING,
|
||||
db_constraint=False,
|
||||
related_name='+',
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'rides_ridemodelevent'
|
||||
managed = False
|
||||
|
||||
def get_display_changes(self) -> dict:
|
||||
"""Returns human-readable changes"""
|
||||
return get_ride_model_display_changes(self.diff_against_previous())
|
||||
|
||||
|
||||
class RideModel(TrackedModel):
|
||||
"""
|
||||
Represents a specific model/type of ride that can be manufactured by different companies.
|
||||
For example: B&M Dive Coaster, Vekoma Boomerang, etc.
|
||||
"""
|
||||
name = models.CharField(max_length=255)
|
||||
manufacturer = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='ride_models',
|
||||
null=True,
|
||||
blank=True,
|
||||
limit_choices_to={'roles__contains': [
|
||||
Company.CompanyRole.MANUFACTURER]}
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
category = models.CharField(
|
||||
max_length=2,
|
||||
choices=CATEGORY_CHOICES,
|
||||
default='',
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['manufacturer', 'name']
|
||||
unique_together = ['manufacturer', 'name']
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name if not self.manufacturer else f"{self.manufacturer.name} {self.name}"
|
||||
|
||||
|
||||
class Ride(TrackedModel):
|
||||
"""Model for individual ride installations at parks"""
|
||||
STATUS_CHOICES = [
|
||||
('', 'Select status'),
|
||||
('OPERATING', 'Operating'),
|
||||
('CLOSED_TEMP', 'Temporarily Closed'),
|
||||
('SBNO', 'Standing But Not Operating'),
|
||||
('CLOSING', 'Closing'),
|
||||
('CLOSED_PERM', 'Permanently Closed'),
|
||||
('UNDER_CONSTRUCTION', 'Under Construction'),
|
||||
('DEMOLISHED', 'Demolished'),
|
||||
('RELOCATED', 'Relocated'),
|
||||
]
|
||||
|
||||
POST_CLOSING_STATUS_CHOICES = [
|
||||
('SBNO', 'Standing But Not Operating'),
|
||||
('CLOSED_PERM', 'Permanently Closed'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
park = models.ForeignKey(
|
||||
'parks.Park',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='rides'
|
||||
)
|
||||
park_area = models.ForeignKey(
|
||||
'parks.ParkArea',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='rides',
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
category = models.CharField(
|
||||
max_length=2,
|
||||
choices=CATEGORY_CHOICES,
|
||||
default='',
|
||||
blank=True
|
||||
)
|
||||
manufacturer = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='manufactured_rides',
|
||||
limit_choices_to={'roles__contains': [
|
||||
Company.CompanyRole.MANUFACTURER]}
|
||||
)
|
||||
designer = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='designed_rides',
|
||||
null=True,
|
||||
blank=True,
|
||||
limit_choices_to={'roles__contains': [Company.CompanyRole.DESIGNER]}
|
||||
)
|
||||
ride_model = models.ForeignKey(
|
||||
'RideModel',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='rides',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="The specific model/type of this ride"
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='OPERATING'
|
||||
)
|
||||
post_closing_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=POST_CLOSING_STATUS_CHOICES,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Status to change to after closing date"
|
||||
)
|
||||
opening_date = models.DateField(null=True, blank=True)
|
||||
closing_date = models.DateField(null=True, blank=True)
|
||||
status_since = models.DateField(null=True, blank=True)
|
||||
min_height_in = models.PositiveIntegerField(null=True, blank=True)
|
||||
max_height_in = models.PositiveIntegerField(null=True, blank=True)
|
||||
capacity_per_hour = models.PositiveIntegerField(null=True, blank=True)
|
||||
ride_duration_seconds = models.PositiveIntegerField(null=True, blank=True)
|
||||
average_rating = models.DecimalField(
|
||||
max_digits=3,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
photos = GenericRelation('media.Photo')
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
unique_together = ['park', 'slug']
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} at {self.park.name}"
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class RollerCoasterStats(models.Model):
|
||||
"""Model for tracking roller coaster specific statistics"""
|
||||
TRACK_MATERIAL_CHOICES = [
|
||||
('STEEL', 'Steel'),
|
||||
('WOOD', 'Wood'),
|
||||
('HYBRID', 'Hybrid'),
|
||||
]
|
||||
|
||||
COASTER_TYPE_CHOICES = [
|
||||
('SITDOWN', 'Sit Down'),
|
||||
('INVERTED', 'Inverted'),
|
||||
('FLYING', 'Flying'),
|
||||
('STANDUP', 'Stand Up'),
|
||||
('WING', 'Wing'),
|
||||
('DIVE', 'Dive'),
|
||||
('FAMILY', 'Family'),
|
||||
('WILD_MOUSE', 'Wild Mouse'),
|
||||
('SPINNING', 'Spinning'),
|
||||
('FOURTH_DIMENSION', '4th Dimension'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
LAUNCH_CHOICES = [
|
||||
('CHAIN', 'Chain Lift'),
|
||||
('LSM', 'LSM Launch'),
|
||||
('HYDRAULIC', 'Hydraulic Launch'),
|
||||
('GRAVITY', 'Gravity'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
ride = models.OneToOneField(
|
||||
Ride,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='coaster_stats'
|
||||
)
|
||||
height_ft = models.DecimalField(
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
length_ft = models.DecimalField(
|
||||
max_digits=7,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
speed_mph = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
inversions = models.PositiveIntegerField(default=0)
|
||||
ride_time_seconds = models.PositiveIntegerField(null=True, blank=True)
|
||||
track_type = models.CharField(max_length=255, blank=True)
|
||||
track_material = models.CharField(
|
||||
max_length=20,
|
||||
choices=TRACK_MATERIAL_CHOICES,
|
||||
default='STEEL',
|
||||
blank=True
|
||||
)
|
||||
roller_coaster_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=COASTER_TYPE_CHOICES,
|
||||
default='SITDOWN',
|
||||
blank=True
|
||||
)
|
||||
max_drop_height_ft = models.DecimalField(
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
launch_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=LAUNCH_CHOICES,
|
||||
default='CHAIN'
|
||||
)
|
||||
train_style = models.CharField(max_length=255, blank=True)
|
||||
trains_count = models.PositiveIntegerField(null=True, blank=True)
|
||||
cars_per_train = models.PositiveIntegerField(null=True, blank=True)
|
||||
seats_per_car = models.PositiveIntegerField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Roller Coaster Statistics'
|
||||
verbose_name_plural = 'Roller Coaster Statistics'
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Stats for {self.ride.name}"
|
||||
@@ -1,3 +1,4 @@
|
||||
from .company import *
|
||||
from .rides import *
|
||||
from .reviews import *
|
||||
from .location import *
|
||||
from .location import *
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.db import models
|
||||
from django.db.models import functions
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from core.history import TrackedModel
|
||||
import pghistory
|
||||
@@ -44,6 +45,27 @@ class RideReview(TrackedModel):
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
unique_together = ['ride', 'user']
|
||||
constraints = [
|
||||
# Business rule: Rating must be between 1 and 10 (database level enforcement)
|
||||
models.CheckConstraint(
|
||||
name="ride_review_rating_range",
|
||||
check=models.Q(rating__gte=1) & models.Q(rating__lte=10),
|
||||
violation_error_message="Rating must be between 1 and 10"
|
||||
),
|
||||
# Business rule: Visit date cannot be in the future
|
||||
models.CheckConstraint(
|
||||
name="ride_review_visit_date_not_future",
|
||||
check=models.Q(visit_date__lte=functions.Now()),
|
||||
violation_error_message="Visit date cannot be in the future"
|
||||
),
|
||||
# Business rule: If moderated, must have moderator and timestamp
|
||||
models.CheckConstraint(
|
||||
name="ride_review_moderation_consistency",
|
||||
check=models.Q(moderated_by__isnull=True, moderated_at__isnull=True) |
|
||||
models.Q(moderated_by__isnull=False, moderated_at__isnull=False),
|
||||
violation_error_message="Moderated reviews must have both moderator and moderation timestamp"
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Review of {self.ride.name} by {self.user.username}"
|
||||
@@ -138,6 +138,48 @@ class Ride(TrackedModel):
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
unique_together = ['park', 'slug']
|
||||
constraints = [
|
||||
# Business rule: Closing date must be after opening date
|
||||
models.CheckConstraint(
|
||||
name="ride_closing_after_opening",
|
||||
check=models.Q(closing_date__isnull=True) | models.Q(opening_date__isnull=True) | models.Q(closing_date__gte=models.F("opening_date")),
|
||||
violation_error_message="Closing date must be after opening date"
|
||||
),
|
||||
# Business rule: Height requirements must be logical
|
||||
models.CheckConstraint(
|
||||
name="ride_height_requirements_logical",
|
||||
check=models.Q(min_height_in__isnull=True) | models.Q(max_height_in__isnull=True) | models.Q(min_height_in__lte=models.F("max_height_in")),
|
||||
violation_error_message="Minimum height cannot exceed maximum height"
|
||||
),
|
||||
# Business rule: Height requirements must be reasonable (between 30 and 90 inches)
|
||||
models.CheckConstraint(
|
||||
name="ride_min_height_reasonable",
|
||||
check=models.Q(min_height_in__isnull=True) | (models.Q(min_height_in__gte=30) & models.Q(min_height_in__lte=90)),
|
||||
violation_error_message="Minimum height must be between 30 and 90 inches"
|
||||
),
|
||||
models.CheckConstraint(
|
||||
name="ride_max_height_reasonable",
|
||||
check=models.Q(max_height_in__isnull=True) | (models.Q(max_height_in__gte=30) & models.Q(max_height_in__lte=90)),
|
||||
violation_error_message="Maximum height must be between 30 and 90 inches"
|
||||
),
|
||||
# Business rule: Rating must be between 1 and 10
|
||||
models.CheckConstraint(
|
||||
name="ride_rating_range",
|
||||
check=models.Q(average_rating__isnull=True) | (models.Q(average_rating__gte=1) & models.Q(average_rating__lte=10)),
|
||||
violation_error_message="Average rating must be between 1 and 10"
|
||||
),
|
||||
# Business rule: Capacity and duration must be positive
|
||||
models.CheckConstraint(
|
||||
name="ride_capacity_positive",
|
||||
check=models.Q(capacity_per_hour__isnull=True) | models.Q(capacity_per_hour__gt=0),
|
||||
violation_error_message="Hourly capacity must be positive"
|
||||
),
|
||||
models.CheckConstraint(
|
||||
name="ride_duration_positive",
|
||||
check=models.Q(ride_duration_seconds__isnull=True) | models.Q(ride_duration_seconds__gt=0),
|
||||
violation_error_message="Ride duration must be positive"
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} at {self.park.name}"
|
||||
|
||||
316
rides/selectors.py
Normal file
316
rides/selectors.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
Selectors for ride-related data retrieval.
|
||||
Following Django styleguide pattern for separating data access from business logic.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any, List
|
||||
from django.db.models import QuerySet, Q, F, Count, Avg, Prefetch
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import Distance
|
||||
|
||||
from .models import Ride, RideModel, RideReview
|
||||
from parks.models import Park
|
||||
|
||||
|
||||
def ride_list_for_display(*, filters: Optional[Dict[str, Any]] = None) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get rides optimized for list display with related data.
|
||||
|
||||
Args:
|
||||
filters: Optional dictionary of filter parameters
|
||||
|
||||
Returns:
|
||||
QuerySet of rides with optimized queries
|
||||
"""
|
||||
queryset = Ride.objects.select_related(
|
||||
'park',
|
||||
'park__operator',
|
||||
'manufacturer',
|
||||
'designer',
|
||||
'ride_model',
|
||||
'park_area'
|
||||
).prefetch_related(
|
||||
'park__location',
|
||||
'location'
|
||||
).annotate(
|
||||
average_rating_calculated=Avg('reviews__rating')
|
||||
)
|
||||
|
||||
if filters:
|
||||
if 'status' in filters:
|
||||
queryset = queryset.filter(status=filters['status'])
|
||||
if 'category' in filters:
|
||||
queryset = queryset.filter(category=filters['category'])
|
||||
if 'manufacturer' in filters:
|
||||
queryset = queryset.filter(manufacturer=filters['manufacturer'])
|
||||
if 'park' in filters:
|
||||
queryset = queryset.filter(park=filters['park'])
|
||||
if 'search' in filters:
|
||||
search_term = filters['search']
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=search_term) |
|
||||
Q(description__icontains=search_term) |
|
||||
Q(park__name__icontains=search_term)
|
||||
)
|
||||
|
||||
return queryset.order_by('park__name', 'name')
|
||||
|
||||
|
||||
def ride_detail_optimized(*, slug: str, park_slug: str) -> Ride:
|
||||
"""
|
||||
Get a single ride with all related data optimized for detail view.
|
||||
|
||||
Args:
|
||||
slug: Ride slug identifier
|
||||
park_slug: Park slug for the ride
|
||||
|
||||
Returns:
|
||||
Ride instance with optimized prefetches
|
||||
|
||||
Raises:
|
||||
Ride.DoesNotExist: If ride doesn't exist
|
||||
"""
|
||||
return Ride.objects.select_related(
|
||||
'park',
|
||||
'park__operator',
|
||||
'manufacturer',
|
||||
'designer',
|
||||
'ride_model',
|
||||
'park_area'
|
||||
).prefetch_related(
|
||||
'park__location',
|
||||
'location',
|
||||
Prefetch(
|
||||
'reviews',
|
||||
queryset=RideReview.objects.select_related('user').filter(is_published=True)
|
||||
),
|
||||
'photos'
|
||||
).get(slug=slug, park__slug=park_slug)
|
||||
|
||||
|
||||
def rides_by_category(*, category: str) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get all rides in a specific category.
|
||||
|
||||
Args:
|
||||
category: Ride category code
|
||||
|
||||
Returns:
|
||||
QuerySet of rides in the category
|
||||
"""
|
||||
return Ride.objects.filter(
|
||||
category=category
|
||||
).select_related(
|
||||
'park',
|
||||
'manufacturer',
|
||||
'designer'
|
||||
).prefetch_related(
|
||||
'park__location'
|
||||
).annotate(
|
||||
average_rating_calculated=Avg('reviews__rating')
|
||||
).order_by('park__name', 'name')
|
||||
|
||||
|
||||
def rides_by_manufacturer(*, manufacturer_id: int) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get all rides manufactured by a specific company.
|
||||
|
||||
Args:
|
||||
manufacturer_id: Company ID of the manufacturer
|
||||
|
||||
Returns:
|
||||
QuerySet of rides by the manufacturer
|
||||
"""
|
||||
return Ride.objects.filter(
|
||||
manufacturer_id=manufacturer_id
|
||||
).select_related(
|
||||
'park',
|
||||
'manufacturer',
|
||||
'ride_model'
|
||||
).prefetch_related(
|
||||
'park__location'
|
||||
).annotate(
|
||||
average_rating_calculated=Avg('reviews__rating')
|
||||
).order_by('park__name', 'name')
|
||||
|
||||
|
||||
def rides_by_designer(*, designer_id: int) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get all rides designed by a specific company.
|
||||
|
||||
Args:
|
||||
designer_id: Company ID of the designer
|
||||
|
||||
Returns:
|
||||
QuerySet of rides by the designer
|
||||
"""
|
||||
return Ride.objects.filter(
|
||||
designer_id=designer_id
|
||||
).select_related(
|
||||
'park',
|
||||
'designer',
|
||||
'ride_model'
|
||||
).prefetch_related(
|
||||
'park__location'
|
||||
).annotate(
|
||||
average_rating_calculated=Avg('reviews__rating')
|
||||
).order_by('park__name', 'name')
|
||||
|
||||
|
||||
def rides_in_park(*, park_slug: str) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get all rides in a specific park.
|
||||
|
||||
Args:
|
||||
park_slug: Slug of the park
|
||||
|
||||
Returns:
|
||||
QuerySet of rides in the park
|
||||
"""
|
||||
return Ride.objects.filter(
|
||||
park__slug=park_slug
|
||||
).select_related(
|
||||
'manufacturer',
|
||||
'designer',
|
||||
'ride_model',
|
||||
'park_area'
|
||||
).prefetch_related(
|
||||
'location'
|
||||
).annotate(
|
||||
average_rating_calculated=Avg('reviews__rating')
|
||||
).order_by('park_area__name', 'name')
|
||||
|
||||
|
||||
def rides_near_location(
|
||||
*,
|
||||
point: Point,
|
||||
distance_km: float = 50,
|
||||
limit: int = 10
|
||||
) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get rides near a specific geographic location.
|
||||
|
||||
Args:
|
||||
point: Geographic point (longitude, latitude)
|
||||
distance_km: Maximum distance in kilometers
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
QuerySet of nearby rides ordered by distance
|
||||
"""
|
||||
return Ride.objects.filter(
|
||||
park__location__coordinates__distance_lte=(point, Distance(km=distance_km))
|
||||
).select_related(
|
||||
'park',
|
||||
'manufacturer'
|
||||
).prefetch_related(
|
||||
'park__location'
|
||||
).distance(point).order_by('distance')[:limit]
|
||||
|
||||
|
||||
def ride_models_with_installations() -> QuerySet[RideModel]:
|
||||
"""
|
||||
Get ride models that have installations with counts.
|
||||
|
||||
Returns:
|
||||
QuerySet of ride models with installation counts
|
||||
"""
|
||||
return RideModel.objects.annotate(
|
||||
installation_count=Count('rides')
|
||||
).filter(
|
||||
installation_count__gt=0
|
||||
).select_related(
|
||||
'manufacturer'
|
||||
).order_by('-installation_count', 'name')
|
||||
|
||||
|
||||
def ride_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get rides matching a search query for autocomplete functionality.
|
||||
|
||||
Args:
|
||||
query: Search string
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
QuerySet of matching rides for autocomplete
|
||||
"""
|
||||
return Ride.objects.filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(park__name__icontains=query) |
|
||||
Q(manufacturer__name__icontains=query)
|
||||
).select_related(
|
||||
'park',
|
||||
'manufacturer'
|
||||
).prefetch_related(
|
||||
'park__location'
|
||||
).order_by('park__name', 'name')[:limit]
|
||||
|
||||
|
||||
def rides_with_recent_reviews(*, days: int = 30) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get rides that have received reviews in the last N days.
|
||||
|
||||
Args:
|
||||
days: Number of days to look back for reviews
|
||||
|
||||
Returns:
|
||||
QuerySet of rides with recent reviews
|
||||
"""
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
return Ride.objects.filter(
|
||||
reviews__created_at__gte=cutoff_date,
|
||||
reviews__is_published=True
|
||||
).select_related(
|
||||
'park',
|
||||
'manufacturer'
|
||||
).prefetch_related(
|
||||
'park__location'
|
||||
).annotate(
|
||||
recent_review_count=Count('reviews', filter=Q(reviews__created_at__gte=cutoff_date))
|
||||
).order_by('-recent_review_count').distinct()
|
||||
|
||||
|
||||
def ride_statistics_by_category() -> Dict[str, Any]:
|
||||
"""
|
||||
Get ride statistics grouped by category.
|
||||
|
||||
Returns:
|
||||
Dictionary containing ride statistics by category
|
||||
"""
|
||||
from .models import CATEGORY_CHOICES
|
||||
|
||||
stats = {}
|
||||
for category_code, category_name in CATEGORY_CHOICES:
|
||||
if category_code: # Skip empty choice
|
||||
count = Ride.objects.filter(category=category_code).count()
|
||||
stats[category_code] = {
|
||||
'name': category_name,
|
||||
'count': count
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def rides_by_opening_year(*, year: int) -> QuerySet[Ride]:
|
||||
"""
|
||||
Get rides that opened in a specific year.
|
||||
|
||||
Args:
|
||||
year: The opening year
|
||||
|
||||
Returns:
|
||||
QuerySet of rides that opened in the specified year
|
||||
"""
|
||||
return Ride.objects.filter(
|
||||
opening_date__year=year
|
||||
).select_related(
|
||||
'park',
|
||||
'manufacturer'
|
||||
).prefetch_related(
|
||||
'park__location'
|
||||
).order_by('opening_date', 'park__name', 'name')
|
||||
@@ -12,9 +12,9 @@ from .models import (
|
||||
Ride, RollerCoasterStats, RideModel,
|
||||
CATEGORY_CHOICES, Company
|
||||
)
|
||||
from .forms import RideForm
|
||||
from .forms import RideForm, RideSearchForm
|
||||
from parks.models import Park
|
||||
from core.views import SlugRedirectMixin
|
||||
from core.views.views import SlugRedirectMixin
|
||||
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
|
||||
from moderation.models import EditSubmission
|
||||
|
||||
@@ -418,14 +418,12 @@ class RideSearchView(ListView):
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get filtered rides based on search form."""
|
||||
from services.search import RideSearchForm
|
||||
|
||||
queryset = Ride.objects.select_related('park').order_by('name')
|
||||
|
||||
# Process search form
|
||||
form = RideSearchForm(self.request.GET)
|
||||
if form.is_valid():
|
||||
ride = form.cleaned_data.get('ride')
|
||||
ride = form.cleaned_data.get("ride")
|
||||
if ride:
|
||||
# If specific ride selected, return just that ride
|
||||
queryset = queryset.filter(id=ride.id)
|
||||
@@ -445,8 +443,6 @@ class RideSearchView(ListView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add search form to context."""
|
||||
from search.forms import RideSearchForm
|
||||
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_form'] = RideSearchForm(self.request.GET)
|
||||
return context
|
||||
|
||||
Reference in New Issue
Block a user