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:
pacnpal
2025-08-17 19:36:20 -04:00
parent 17228e9935
commit c26414ff74
210 changed files with 24155 additions and 833 deletions

1
rides/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Rides API module

349
rides/api/serializers.py Normal file
View 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
View 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)),
]

View File

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

View 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",
),
),
]

View 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'],
},
),
]

View File

@@ -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",
),
),
),
]

View File

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

View File

@@ -1,3 +1,4 @@
from .company import *
from .rides import *
from .reviews import *
from .location import *
from .location import *

View File

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

View File

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

View File

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