Refactor test utilities and enhance ASGI settings

- Cleaned up and standardized assertions in ApiTestMixin for API response validation.
- Updated ASGI settings to use os.environ for setting the DJANGO_SETTINGS_MODULE.
- Removed unused imports and improved formatting in settings.py.
- Refactored URL patterns in urls.py for better readability and organization.
- Enhanced view functions in views.py for consistency and clarity.
- Added .flake8 configuration for linting and style enforcement.
- Introduced type stubs for django-environ to improve type checking with Pylance.
This commit is contained in:
pacnpal
2025-08-20 19:51:59 -04:00
parent 69c07d1381
commit 66ed4347a9
230 changed files with 15094 additions and 11578 deletions

View File

@@ -1,4 +1,32 @@
from .company import *
from .rides import *
from .reviews import *
from .location import *
"""
Rides app models with clean import interface.
This module provides a clean import interface for all rides-related models,
enabling imports like: from rides.models import Ride, Manufacturer
The Company model is aliased as Manufacturer to clarify its role as ride manufacturers,
while maintaining backward compatibility through the Company alias.
"""
from .rides import Ride, RideModel, RollerCoasterStats, CATEGORY_CHOICES
from .location import RideLocation
from .reviews import RideReview
from .company import Company
# Alias Company as Manufacturer for clarity
Manufacturer = Company
__all__ = [
# Primary models
"Ride",
"RideModel",
"RollerCoasterStats",
"RideLocation",
"RideReview",
# Shared constants
"CATEGORY_CHOICES",
# Company models with clear naming
"Manufacturer",
# Backward compatibility
"Company", # Alias to Manufacturer
]

View File

@@ -11,17 +11,17 @@ from core.models import TrackedModel
@pghistory.track()
class Company(TrackedModel):
class CompanyRole(models.TextChoices):
MANUFACTURER = 'MANUFACTURER', 'Ride Manufacturer'
DESIGNER = 'DESIGNER', 'Ride Designer'
OPERATOR = 'OPERATOR', 'Park Operator'
PROPERTY_OWNER = 'PROPERTY_OWNER', 'Property Owner'
MANUFACTURER = "MANUFACTURER", "Ride Manufacturer"
DESIGNER = "DESIGNER", "Ride Designer"
OPERATOR = "OPERATOR", "Park Operator"
PROPERTY_OWNER = "PROPERTY_OWNER", "Property Owner"
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
roles = ArrayField(
models.CharField(max_length=20, choices=CompanyRole.choices),
default=list,
blank=True
blank=True,
)
description = models.TextField(blank=True)
website = models.URLField(blank=True)
@@ -43,8 +43,8 @@ class Company(TrackedModel):
def get_absolute_url(self):
# This will need to be updated to handle different roles
return reverse('companies:detail', kwargs={'slug': self.slug})
return '#'
return reverse("companies:detail", kwargs={"slug": self.slug})
return "#"
@classmethod
def get_by_slug(cls, slug):
@@ -56,7 +56,7 @@ class Company(TrackedModel):
history_model = cls.get_history_model()
history_entry = (
history_model.objects.filter(slug=slug)
.order_by('-pgh_created_at')
.order_by("-pgh_created_at")
.first()
)
if history_entry:
@@ -65,13 +65,12 @@ class Company(TrackedModel):
# Check manual slug history as fallback
try:
historical = HistoricalSlug.objects.get(
content_type__model='company',
slug=slug
content_type__model="company", slug=slug
)
return cls.objects.get(pk=historical.object_id), True
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
raise cls.DoesNotExist("No company found with this slug")
class Meta:
ordering = ['name']
verbose_name_plural = 'Companies'
ordering = ["name"]
verbose_name_plural = "Companies"

View File

@@ -8,47 +8,45 @@ class RideLocation(models.Model):
Lightweight location tracking for individual rides within parks.
Optional coordinates with focus on practical navigation information.
"""
# Relationships
ride = models.OneToOneField(
'rides.Ride',
on_delete=models.CASCADE,
related_name='ride_location'
"rides.Ride", on_delete=models.CASCADE, related_name="ride_location"
)
# Optional Spatial Data - keep it simple with single point
point = gis_models.PointField(
srid=4326,
null=True,
null=True,
blank=True,
help_text="Geographic coordinates for ride location (longitude, latitude)"
help_text="Geographic coordinates for ride location (longitude, latitude)",
)
# Park Area Information
park_area = models.CharField(
max_length=100,
blank=True,
db_index=True,
help_text="Themed area or land within the park (e.g., 'Frontierland', 'Tomorrowland')"
help_text=(
"Themed area or land within the park (e.g., 'Frontierland', 'Tomorrowland')"
),
)
# General notes field to match database schema
notes = models.TextField(
blank=True,
help_text="General location notes"
)
notes = models.TextField(blank=True, help_text="General location notes")
# Navigation and Entrance Information
entrance_notes = models.TextField(
blank=True,
help_text="Directions to ride entrance, queue location, or navigation tips"
help_text="Directions to ride entrance, queue location, or navigation tips",
)
# Accessibility Information
accessibility_notes = models.TextField(
blank=True,
help_text="Information about accessible entrances, wheelchair access, etc."
help_text="Information about accessible entrances, wheelchair access, etc.",
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -102,11 +100,11 @@ class RideLocation(models.Model):
"""
if not self.point:
return None
park_location = getattr(self.ride.park, 'location', None)
park_location = getattr(self.ride.park, "location", None)
if not park_location or not park_location.point:
return None
# Use geodetic distance calculation which returns meters, convert to km
distance_m = self.point.distance(park_location.point)
return distance_m / 1000.0
@@ -118,8 +116,9 @@ class RideLocation(models.Model):
class Meta:
verbose_name = "Ride Location"
verbose_name_plural = "Ride Locations"
ordering = ['ride__name']
ordering = ["ride__name"]
indexes = [
models.Index(fields=['park_area']),
# Spatial index will be created automatically for PostGIS PointField
]
models.Index(fields=["park_area"]),
# Spatial index will be created automatically for PostGIS
# PointField
]

View File

@@ -4,20 +4,18 @@ from django.core.validators import MinValueValidator, MaxValueValidator
from core.history import TrackedModel
import pghistory
@pghistory.track()
class RideReview(TrackedModel):
"""
A review of a ride.
"""
ride = models.ForeignKey(
'rides.Ride',
on_delete=models.CASCADE,
related_name='reviews'
"rides.Ride", on_delete=models.CASCADE, related_name="reviews"
)
user = models.ForeignKey(
'accounts.User',
on_delete=models.CASCADE,
related_name='ride_reviews'
"accounts.User", on_delete=models.CASCADE, related_name="ride_reviews"
)
rating = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(10)]
@@ -25,47 +23,53 @@ class RideReview(TrackedModel):
title = models.CharField(max_length=200)
content = models.TextField()
visit_date = models.DateField()
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Moderation
is_published = models.BooleanField(default=True)
moderation_notes = models.TextField(blank=True)
moderated_by = models.ForeignKey(
'accounts.User',
"accounts.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='moderated_ride_reviews'
related_name="moderated_ride_reviews",
)
moderated_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
unique_together = ['ride', 'user']
ordering = ["-created_at"]
unique_together = ["ride", "user"]
constraints = [
# Business rule: Rating must be between 1 and 10 (database level enforcement)
# 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"
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"
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"
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}"
return f"Review of {self.ride.name} by {self.user.username}"

View File

@@ -6,119 +6,118 @@ from .company 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'),
("", "Select ride type"),
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
]
class RideModel(TrackedModel):
"""
Represents a specific model/type of ride that can be manufactured by different companies.
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',
related_name="ride_models",
null=True,
blank=True,
limit_choices_to={'roles__contains': ['MANUFACTURER']},
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
)
description = models.TextField(blank=True)
category = models.CharField(
max_length=2,
choices=CATEGORY_CHOICES,
default='',
blank=True
max_length=2, choices=CATEGORY_CHOICES, default="", blank=True
)
class Meta:
ordering = ['manufacturer', 'name']
unique_together = ['manufacturer', 'name']
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}"
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'),
("", "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'),
("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'
"parks.Park", on_delete=models.CASCADE, related_name="rides"
)
park_area = models.ForeignKey(
'parks.ParkArea',
"parks.ParkArea",
on_delete=models.SET_NULL,
related_name='rides',
related_name="rides",
null=True,
blank=True
blank=True,
)
category = models.CharField(
max_length=2,
choices=CATEGORY_CHOICES,
default='',
blank=True
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': ['MANUFACTURER']},
related_name="manufactured_rides",
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
)
designer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
related_name='designed_rides',
related_name="designed_rides",
null=True,
blank=True,
limit_choices_to={'roles__contains': ['DESIGNER']},
limit_choices_to={"roles__contains": ["DESIGNER"]},
)
ride_model = models.ForeignKey(
'RideModel',
"RideModel",
on_delete=models.SET_NULL,
related_name='rides',
related_name="rides",
null=True,
blank=True,
help_text="The specific model/type of this ride"
help_text="The specific model/type of this ride",
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='OPERATING'
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"
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)
@@ -128,56 +127,67 @@ class Ride(TrackedModel):
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
max_digits=3, decimal_places=2, null=True, blank=True
)
photos = GenericRelation('media.Photo')
photos = GenericRelation("media.Photo")
class Meta:
ordering = ['name']
unique_together = ['park', 'slug']
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"
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"
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)
# 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"
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"
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"
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"
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"
check=models.Q(ride_duration_seconds__isnull=True)
| models.Q(ride_duration_seconds__gt=0),
violation_error_message="Ride duration must be positive",
),
]
@@ -189,58 +199,49 @@ class Ride(TrackedModel):
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'),
("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'),
("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'),
("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'
Ride, on_delete=models.CASCADE, related_name="coaster_stats"
)
height_ft = models.DecimalField(
max_digits=6,
decimal_places=2,
null=True,
blank=True
max_digits=6, decimal_places=2, null=True, blank=True
)
length_ft = models.DecimalField(
max_digits=7,
decimal_places=2,
null=True,
blank=True
max_digits=7, decimal_places=2, null=True, blank=True
)
speed_mph = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True
max_digits=5, decimal_places=2, null=True, blank=True
)
inversions = models.PositiveIntegerField(default=0)
ride_time_seconds = models.PositiveIntegerField(null=True, blank=True)
@@ -248,25 +249,20 @@ class RollerCoasterStats(models.Model):
track_material = models.CharField(
max_length=20,
choices=TRACK_MATERIAL_CHOICES,
default='STEEL',
blank=True
default="STEEL",
blank=True,
)
roller_coaster_type = models.CharField(
max_length=20,
choices=COASTER_TYPE_CHOICES,
default='SITDOWN',
blank=True
default="SITDOWN",
blank=True,
)
max_drop_height_ft = models.DecimalField(
max_digits=6,
decimal_places=2,
null=True,
blank=True
max_digits=6, decimal_places=2, null=True, blank=True
)
launch_type = models.CharField(
max_length=20,
choices=LAUNCH_CHOICES,
default='CHAIN'
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)
@@ -274,8 +270,8 @@ class RollerCoasterStats(models.Model):
seats_per_car = models.PositiveIntegerField(null=True, blank=True)
class Meta:
verbose_name = 'Roller Coaster Statistics'
verbose_name_plural = 'Roller Coaster Statistics'
verbose_name = "Roller Coaster Statistics"
verbose_name_plural = "Roller Coaster Statistics"
def __str__(self) -> str:
return f"Stats for {self.ride.name}"
return f"Stats for {self.ride.name}"