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,5 +1,31 @@
from .location import *
from .areas import *
from .parks import *
from .reviews import *
from .companies import *
"""
Parks app models with clean import interface.
This module provides a clean import interface for all parks-related models,
enabling imports like: from parks.models import Park, Operator
The Company model is aliased as Operator to clarify its role as park operators,
while maintaining backward compatibility through the Company alias.
"""
from .parks import Park
from .areas import ParkArea
from .location import ParkLocation
from .reviews import ParkReview
from .companies import Company, CompanyHeadquarters
# Alias Company as Operator for clarity
Operator = Company
__all__ = [
# Primary models
"Park",
"ParkArea",
"ParkLocation",
"ParkReview",
# Company models with clear naming
"Operator",
"CompanyHeadquarters",
# Backward compatibility
"Company", # Alias to Operator
]

View File

@@ -1,18 +1,17 @@
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from typing import Tuple, Any
import pghistory
from core.history import TrackedModel
from .parks import Park
@pghistory.track()
class ParkArea(TrackedModel):
# Import managers
from ..managers import ParkAreaManager
objects = ParkAreaManager()
id: int # Type hint for Django's automatic id field
park = models.ForeignKey(Park, on_delete=models.CASCADE, related_name="areas")
@@ -31,4 +30,4 @@ class ParkArea(TrackedModel):
return self.name
class Meta:
unique_together = ('park', 'slug')
unique_together = ("park", "slug")

View File

@@ -14,15 +14,15 @@ class Company(TrackedModel):
objects = CompanyManager()
class CompanyRole(models.TextChoices):
OPERATOR = 'OPERATOR', 'Park Operator'
PROPERTY_OWNER = 'PROPERTY_OWNER', 'Property Owner'
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)
@@ -41,8 +41,8 @@ class Company(TrackedModel):
return self.name
class Meta:
ordering = ['name']
verbose_name_plural = 'Companies'
ordering = ["name"]
verbose_name_plural = "Companies"
class CompanyHeadquarters(models.Model):
@@ -50,46 +50,41 @@ class CompanyHeadquarters(models.Model):
Simple address storage for company headquarters without coordinate tracking.
Focus on human-readable location information for display purposes.
"""
# Relationships
company = models.OneToOneField(
'Company',
on_delete=models.CASCADE,
related_name='headquarters'
"Company", on_delete=models.CASCADE, related_name="headquarters"
)
# Address Fields (No coordinates needed)
street_address = models.CharField(
max_length=255,
blank=True,
help_text="Mailing address if publicly available"
help_text="Mailing address if publicly available",
)
city = models.CharField(
max_length=100,
db_index=True,
help_text="Headquarters city"
max_length=100, db_index=True, help_text="Headquarters city"
)
state_province = models.CharField(
max_length=100,
blank=True,
db_index=True,
help_text="State/Province/Region"
help_text="State/Province/Region",
)
country = models.CharField(
max_length=100,
default='USA',
default="USA",
db_index=True,
help_text="Country where headquarters is located"
help_text="Country where headquarters is located",
)
postal_code = models.CharField(
max_length=20,
blank=True,
help_text="ZIP or postal code"
max_length=20, blank=True, help_text="ZIP or postal code"
)
# Optional mailing address if different or more complete
mailing_address = models.TextField(
blank=True,
help_text="Complete mailing address if different from basic address"
help_text="Complete mailing address if different from basic address",
)
# Metadata
@@ -108,9 +103,15 @@ class CompanyHeadquarters(models.Model):
components.append(self.state_province)
if self.postal_code:
components.append(self.postal_code)
if self.country and self.country != 'USA':
if self.country and self.country != "USA":
components.append(self.country)
return ", ".join(components) if components else f"{self.city}, {self.country}"
return (
", ".join(components)
if components
else f"{
self.city}, {
self.country}"
)
@property
def location_display(self):
@@ -118,7 +119,7 @@ class CompanyHeadquarters(models.Model):
parts = [self.city]
if self.state_province:
parts.append(self.state_province)
elif self.country != 'USA':
elif self.country != "USA":
parts.append(self.country)
return ", ".join(parts) if parts else "Unknown Location"
@@ -128,7 +129,7 @@ class CompanyHeadquarters(models.Model):
class Meta:
verbose_name = "Company Headquarters"
verbose_name_plural = "Company Headquarters"
ordering = ['company__name']
ordering = ["company__name"]
indexes = [
models.Index(fields=['city', 'country']),
models.Index(fields=["city", "country"]),
]

View File

@@ -1,17 +1,14 @@
from django.contrib.gis.db import models
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D
from django.core.validators import MinValueValidator, MaxValueValidator
class ParkLocation(models.Model):
"""
Represents the geographic location and address of a park, with PostGIS support.
"""
park = models.OneToOneField(
'parks.Park',
on_delete=models.CASCADE,
related_name='location'
"parks.Park", on_delete=models.CASCADE, related_name="location"
)
# Spatial Data
@@ -19,14 +16,14 @@ class ParkLocation(models.Model):
srid=4326,
null=True,
blank=True,
help_text="Geographic coordinates (longitude, latitude)"
help_text="Geographic coordinates (longitude, latitude)",
)
# Address Fields
street_address = models.CharField(max_length=255, blank=True)
city = models.CharField(max_length=100, db_index=True)
state = models.CharField(max_length=100, db_index=True)
country = models.CharField(max_length=100, default='USA')
country = models.CharField(max_length=100, default="USA")
postal_code = models.CharField(max_length=20, blank=True)
# Road Trip Metadata
@@ -40,7 +37,7 @@ class ParkLocation(models.Model):
osm_type = models.CharField(
max_length=10,
blank=True,
help_text="Type of OpenStreetMap object (node, way, or relation)"
help_text="Type of OpenStreetMap object (node, way, or relation)",
)
@property
@@ -72,7 +69,7 @@ class ParkLocation(models.Model):
self.city,
self.state,
self.postal_code,
self.country
self.country,
]
return ", ".join(part for part in address_parts if part)
@@ -109,7 +106,7 @@ class ParkLocation(models.Model):
class Meta:
verbose_name = "Park Location"
verbose_name_plural = "Park Locations"
ordering = ['park__name']
ordering = ["park__name"]
indexes = [
models.Index(fields=['city', 'state']),
]
models.Index(fields=["city", "state"]),
]

View File

@@ -3,10 +3,8 @@ from django.urls import reverse
from django.utils.text import slugify
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from decimal import Decimal, ROUND_DOWN, InvalidOperation
from typing import Tuple, Optional, Any, TYPE_CHECKING
import pghistory
from .companies import Company
from media.models import Photo
from core.history import TrackedModel
@@ -17,10 +15,10 @@ if TYPE_CHECKING:
@pghistory.track()
class Park(TrackedModel):
# Import managers
from ..managers import ParkManager
objects = ParkManager()
id: int # Type hint for Django's automatic id field
STATUS_CHOICES = [
@@ -40,7 +38,8 @@ class Park(TrackedModel):
)
# Location relationship - reverse relation from ParkLocation
# location will be available via the 'location' related_name on ParkLocation
# location will be available via the 'location' related_name on
# ParkLocation
# Details
opening_date = models.DateField(null=True, blank=True)
@@ -60,25 +59,25 @@ class Park(TrackedModel):
# Relationships
operator = models.ForeignKey(
'Company',
"Company",
on_delete=models.PROTECT,
related_name='operated_parks',
help_text='Company that operates this park',
limit_choices_to={'roles__contains': ['OPERATOR']},
related_name="operated_parks",
help_text="Company that operates this park",
limit_choices_to={"roles__contains": ["OPERATOR"]},
)
property_owner = models.ForeignKey(
'Company',
"Company",
on_delete=models.PROTECT,
related_name='owned_parks',
related_name="owned_parks",
null=True,
blank=True,
help_text='Company that owns the property (if different from operator)',
limit_choices_to={'roles__contains': ['PROPERTY_OWNER']},
help_text="Company that owns the property (if different from operator)",
limit_choices_to={"roles__contains": ["PROPERTY_OWNER"]},
)
photos = GenericRelation(Photo, related_query_name="park")
areas: models.Manager['ParkArea'] # Type hint for reverse relation
areas: models.Manager["ParkArea"] # Type hint for reverse relation
# Type hint for reverse relation from rides app
rides: models.Manager['Ride']
rides: models.Manager["Ride"]
# Metadata
created_at = models.DateTimeField(auto_now_add=True, null=True)
@@ -90,37 +89,43 @@ class Park(TrackedModel):
# Business rule: Closing date must be after opening date
models.CheckConstraint(
name="park_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: Size must be positive
models.CheckConstraint(
name="park_size_positive",
check=models.Q(size_acres__isnull=True) | models.Q(size_acres__gt=0),
violation_error_message="Park size must be positive"
violation_error_message="Park size must be positive",
),
# Business rule: Rating must be between 1 and 10
models.CheckConstraint(
name="park_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: Counts must be non-negative
models.CheckConstraint(
name="park_ride_count_non_negative",
check=models.Q(ride_count__isnull=True) | models.Q(ride_count__gte=0),
violation_error_message="Ride count must be non-negative"
violation_error_message="Ride count must be non-negative",
),
models.CheckConstraint(
name="park_coaster_count_non_negative",
check=models.Q(coaster_count__isnull=True) | models.Q(coaster_count__gte=0),
violation_error_message="Coaster count must be non-negative"
check=models.Q(coaster_count__isnull=True)
| models.Q(coaster_count__gte=0),
violation_error_message="Coaster count must be non-negative",
),
# Business rule: Coaster count cannot exceed ride count
models.CheckConstraint(
name="park_coaster_count_lte_ride_count",
check=models.Q(coaster_count__isnull=True) | models.Q(ride_count__isnull=True) | models.Q(coaster_count__lte=models.F("ride_count")),
violation_error_message="Coaster count cannot exceed total ride count"
check=models.Q(coaster_count__isnull=True)
| models.Q(ride_count__isnull=True)
| models.Q(coaster_count__lte=models.F("ride_count")),
violation_error_message="Coaster count cannot exceed total ride count",
),
]
@@ -156,17 +161,17 @@ class Park(TrackedModel):
HistoricalSlug.objects.create(
content_type=ContentType.objects.get_for_model(self),
object_id=self.pk,
slug=old_slug
slug=old_slug,
)
def clean(self):
super().clean()
if self.operator and 'OPERATOR' not in self.operator.roles:
if self.operator and "OPERATOR" not in self.operator.roles:
raise ValidationError({"operator": "Company must have the OPERATOR role."})
if self.property_owner and "PROPERTY_OWNER" not in self.property_owner.roles:
raise ValidationError(
{'operator': 'Company must have the OPERATOR role.'})
if self.property_owner and 'PROPERTY_OWNER' not in self.property_owner.roles:
raise ValidationError(
{'property_owner': 'Company must have the PROPERTY_OWNER role.'})
{"property_owner": "Company must have the PROPERTY_OWNER role."}
)
def get_absolute_url(self) -> str:
return reverse("parks:park_detail", kwargs={"slug": self.slug})
@@ -174,31 +179,31 @@ class Park(TrackedModel):
def get_status_color(self) -> str:
"""Get Tailwind color classes for park status"""
status_colors = {
'OPERATING': 'bg-green-100 text-green-800',
'CLOSED_TEMP': 'bg-yellow-100 text-yellow-800',
'CLOSED_PERM': 'bg-red-100 text-red-800',
'UNDER_CONSTRUCTION': 'bg-blue-100 text-blue-800',
'DEMOLISHED': 'bg-gray-100 text-gray-800',
'RELOCATED': 'bg-purple-100 text-purple-800',
"OPERATING": "bg-green-100 text-green-800",
"CLOSED_TEMP": "bg-yellow-100 text-yellow-800",
"CLOSED_PERM": "bg-red-100 text-red-800",
"UNDER_CONSTRUCTION": "bg-blue-100 text-blue-800",
"DEMOLISHED": "bg-gray-100 text-gray-800",
"RELOCATED": "bg-purple-100 text-purple-800",
}
return status_colors.get(self.status, 'bg-gray-100 text-gray-500')
return status_colors.get(self.status, "bg-gray-100 text-gray-500")
@property
def formatted_location(self) -> str:
"""Get formatted address from ParkLocation if it exists"""
if hasattr(self, 'location') and self.location:
if hasattr(self, "location") and self.location:
return self.location.formatted_address
return ""
@property
def coordinates(self) -> Optional[Tuple[float, float]]:
"""Returns coordinates as a tuple (latitude, longitude)"""
if hasattr(self, 'location') and self.location:
if hasattr(self, "location") and self.location:
return self.location.coordinates
return None
@classmethod
def get_by_slug(cls, slug: str) -> Tuple['Park', bool]:
def get_by_slug(cls, slug: str) -> Tuple["Park", bool]:
"""Get park by current or historical slug"""
from django.contrib.contenttypes.models import ContentType
from core.history import HistoricalSlug
@@ -214,16 +219,18 @@ class Park(TrackedModel):
# Try historical slugs in HistoricalSlug model
content_type = ContentType.objects.get_for_model(cls)
print(
f"Searching HistoricalSlug with content_type: {content_type}")
historical = HistoricalSlug.objects.filter(
content_type=content_type,
slug=slug
).order_by('-created_at').first()
print(f"Searching HistoricalSlug with content_type: {content_type}")
historical = (
HistoricalSlug.objects.filter(content_type=content_type, slug=slug)
.order_by("-created_at")
.first()
)
if historical:
print(
f"Found historical slug record for object_id: {historical.object_id}")
f"Found historical slug record for object_id: {
historical.object_id}"
)
try:
park = cls.objects.get(pk=historical.object_id)
print(f"Found park from historical slug: {park.name}")
@@ -235,15 +242,19 @@ class Park(TrackedModel):
# Try pghistory events
print("Searching pghistory events")
event_model = getattr(cls, 'event_model', None)
event_model = getattr(cls, "event_model", None)
if event_model:
historical_event = event_model.objects.filter(
slug=slug
).order_by('-pgh_created_at').first()
historical_event = (
event_model.objects.filter(slug=slug)
.order_by("-pgh_created_at")
.first()
)
if historical_event:
print(
f"Found pghistory event for pgh_obj_id: {historical_event.pgh_obj_id}")
f"Found pghistory event for pgh_obj_id: {
historical_event.pgh_obj_id}"
)
try:
park = cls.objects.get(pk=historical_event.pgh_obj_id)
print(f"Found park from pghistory: {park.name}")

View File

@@ -4,25 +4,22 @@ from django.core.validators import MinValueValidator, MaxValueValidator
from core.history import TrackedModel
import pghistory
@pghistory.track()
class ParkReview(TrackedModel):
# Import managers
# Import managers
from ..managers import ParkReviewManager
objects = ParkReviewManager()
"""
A review of a park.
"""
park = models.ForeignKey(
'parks.Park',
on_delete=models.CASCADE,
related_name='reviews'
"parks.Park", on_delete=models.CASCADE, related_name="reviews"
)
user = models.ForeignKey(
'accounts.User',
on_delete=models.CASCADE,
related_name='park_reviews'
"accounts.User", on_delete=models.CASCADE, related_name="park_reviews"
)
rating = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(10)]
@@ -30,47 +27,53 @@ class ParkReview(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_park_reviews'
related_name="moderated_park_reviews",
)
moderated_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
unique_together = ['park', 'user']
ordering = ["-created_at"]
unique_together = ["park", "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="park_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="park_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="park_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.park.name} by {self.user.username}"
return f"Review of {self.park.name} by {self.user.username}"