feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.

This commit is contained in:
pacnpal
2025-12-28 17:32:53 -05:00
parent aa56c46c27
commit c95f99ca10
452 changed files with 7948 additions and 6073 deletions

View File

@@ -325,10 +325,7 @@ class ParkAdmin(
@admin.display(description="Avg Rating")
def average_rating(self, obj):
"""Display average park review rating."""
if hasattr(obj, "_avg_rating"):
rating = obj._avg_rating
else:
rating = obj.reviews.aggregate(avg=Avg("rating"))["avg"]
rating = obj._avg_rating if hasattr(obj, "_avg_rating") else obj.reviews.aggregate(avg=Avg("rating"))["avg"]
if rating:
stars = "" * int(rating) + "" * (5 - int(rating))
return format_html('<span style="color: gold;">{}</span> {:.1f}', stars, rating)

View File

@@ -2,7 +2,6 @@ import logging
from django.apps import AppConfig
logger = logging.getLogger(__name__)
@@ -11,8 +10,8 @@ class ParksConfig(AppConfig):
name = "apps.parks"
def ready(self):
import apps.parks.signals # noqa: F401 - Register signals
import apps.parks.choices # noqa: F401 - Register choices
import apps.parks.signals # noqa: F401 - Register signals
self._apply_state_machines()
self._register_callbacks()
@@ -29,10 +28,9 @@ class ParksConfig(AppConfig):
def _register_callbacks(self):
"""Register FSM transition callbacks for park models."""
from apps.core.state_machine.registry import register_callback
from apps.core.state_machine.callbacks.cache import (
ParkCacheInvalidation,
APICacheInvalidation,
ParkCacheInvalidation,
)
from apps.core.state_machine.callbacks.notifications import (
StatusChangeNotification,
@@ -40,6 +38,7 @@ class ParksConfig(AppConfig):
from apps.core.state_machine.callbacks.related_updates import (
SearchTextUpdateCallback,
)
from apps.core.state_machine.registry import register_callback
from apps.parks.models import Park
# Cache invalidation for all park status changes

View File

@@ -5,10 +5,9 @@ This module defines all choice objects for the parks domain, replacing
the legacy tuple-based choices with rich choice objects.
"""
from apps.core.choices import RichChoice, ChoiceCategory
from apps.core.choices import ChoiceCategory, RichChoice
from apps.core.choices.registry import register_choices
# Park Status Choices
PARK_STATUSES = [
RichChoice(
@@ -287,7 +286,7 @@ PARKS_COMPANY_ROLES = [
def register_parks_choices():
"""Register all parks domain choices with the global registry"""
register_choices(
name="statuses",
choices=PARK_STATUSES,
@@ -295,7 +294,7 @@ def register_parks_choices():
description="Park operational status options",
metadata={'domain': 'parks', 'type': 'status'}
)
register_choices(
name="types",
choices=PARK_TYPES,
@@ -303,7 +302,7 @@ def register_parks_choices():
description="Park type and category classifications",
metadata={'domain': 'parks', 'type': 'park_type'}
)
register_choices(
name="company_roles",
choices=PARKS_COMPANY_ROLES,

View File

@@ -1,22 +1,24 @@
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from django.db import models
import requests
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import Distance
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from django_filters import (
NumberFilter,
ModelChoiceFilter,
DateFromToRangeFilter,
ChoiceFilter,
FilterSet,
CharFilter,
BooleanFilter,
CharFilter,
ChoiceFilter,
DateFromToRangeFilter,
FilterSet,
ModelChoiceFilter,
NumberFilter,
OrderingFilter,
)
from .models import Park, Company
from .querysets import get_base_park_queryset
from apps.core.choices.registry import get_choices
import requests
from .models import Company, Park
from .querysets import get_base_park_queryset
def validate_positive_integer(value):

View File

@@ -1,8 +1,10 @@
from django import forms
from decimal import Decimal, InvalidOperation, ROUND_DOWN
from decimal import ROUND_DOWN, Decimal, InvalidOperation
from autocomplete.core import register
from autocomplete.shortcuts import ModelAutocomplete
from autocomplete.widgets import AutocompleteWidget
from django import forms
from .models import Park
from .models.location import ParkLocation

View File

@@ -1,4 +1,4 @@
from decimal import Decimal, ROUND_DOWN, InvalidOperation
from decimal import ROUND_DOWN, Decimal, InvalidOperation
def normalize_coordinate(value, max_digits, decimal_places):

View File

@@ -208,110 +208,3 @@ class Command(BaseCommand):
# Park creation data - will be used to create parks in the database
# See FUTURE_WORK.md - THRILLWIKI-111 for implementation plan
parks_data = [
{
"name": "Magic Kingdom",
"slug": "magic-kingdom",
"operator_slug": "walt-disney-company",
"property_owner_slug": "walt-disney-company",
"description": "The first theme park at Walt Disney World Resort in Florida, opened in 1971.",
"opening_date": "1971-10-01",
"size_acres": 142,
"website": "https://disneyworld.disney.go.com/destinations/magic-kingdom/",
"location": {
"street_address": "1180 Seven Seas Dr",
"city": "Lake Buena Vista",
"state_province": "Florida",
"country": "USA",
"postal_code": "32830",
"latitude": 28.4177,
"longitude": -81.5812,
},
},
{
"name": "Universal Studios Florida",
"slug": "universal-studios-florida",
"operator_slug": "universal-parks-resorts",
"property_owner_slug": "universal-parks-resorts",
"description": "Movie and television-based theme park in Orlando, Florida.",
"opening_date": "1990-06-07",
"size_acres": 108,
"website": "https://www.universalorlando.com/web/en/us/theme-parks/universal-studios-florida",
"location": {
"street_address": "6000 Universal Blvd",
"city": "Orlando",
"state_province": "Florida",
"country": "USA",
"postal_code": "32819",
"latitude": 28.4749,
"longitude": -81.4687,
},
},
{
"name": "Cedar Point",
"slug": "cedar-point",
"operator_slug": "cedar-fair-entertainment",
"property_owner_slug": "cedar-fair-entertainment",
"description": 'Known as the "Roller Coaster Capital of the World".',
"opening_date": "1870-06-01",
"size_acres": 364,
"website": "https://www.cedarpoint.com/",
"location": {
"street_address": "1 Cedar Point Dr",
"city": "Sandusky",
"state_province": "Ohio",
"country": "USA",
"postal_code": "44870",
"latitude": 41.4822,
"longitude": -82.6835,
},
},
{
"name": "Six Flags Magic Mountain",
"slug": "six-flags-magic-mountain",
"operator_slug": "six-flags-entertainment",
"property_owner_slug": "six-flags-entertainment",
"description": "Known for its world-record 19 roller coasters.",
"opening_date": "1971-05-29",
"size_acres": 262,
"website": "https://www.sixflags.com/magicmountain",
"location": {
"street_address": "26101 Magic Mountain Pkwy",
"city": "Valencia",
"state_province": "California",
"country": "USA",
"postal_code": "91355",
"latitude": 34.4253,
"longitude": -118.5971,
},
},
{
"name": "Europa-Park",
"slug": "europa-park",
"operator_slug": "merlin-entertainments",
"property_owner_slug": "merlin-entertainments",
"description": "One of the most popular theme parks in Europe, located in Germany.",
"opening_date": "1975-07-12",
"size_acres": 234,
"website": "https://www.europapark.de/",
"location": {
"street_address": "Europa-Park-Straße 2",
"city": "Rust",
"state_province": "Baden-Württemberg",
"country": "Germany",
"postal_code": "77977",
"latitude": 48.2667,
"longitude": 7.7167,
},
},
{
"name": "Alton Towers",
"slug": "alton-towers",
"operator_slug": "merlin-entertainments",
"property_owner_slug": "merlin-entertainments",
"description": "Major theme park and former country estate in Staffordshire, England.",
"opening_date": "1980-04-23",
"size_acres": 500,
# Add other fields as needed
},
]

View File

@@ -1,5 +1,7 @@
from django.core.management.base import BaseCommand
from apps.parks.models import Park, ParkArea, ParkLocation, Company as Operator
from apps.parks.models import Company as Operator
from apps.parks.models import Park, ParkArea, ParkLocation
class Command(BaseCommand):

View File

@@ -1,16 +1,17 @@
from django.core.management.base import BaseCommand
from django.db import transaction, connection
import logging
from apps.parks.models import Company, Park, ParkArea, ParkReview, ParkLocation
from apps.rides.models.company import Company as RideCompany
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.accounts.models import User
from apps.parks.models import Company, Park, ParkArea, ParkLocation, ParkReview
from apps.rides.models import (
Ride,
RideModel,
RideReview,
RollerCoasterStats,
)
from apps.accounts.models import User
from apps.rides.models.company import Company as RideCompany
class Command(BaseCommand):

View File

@@ -1,5 +1,6 @@
from django.core.management.base import BaseCommand
from apps.parks.models import Park, ParkLocation, Company
from apps.parks.models import Company, Park, ParkLocation
class Command(BaseCommand):
@@ -100,8 +101,8 @@ class Command(BaseCommand):
# Test spatial indexing
self.stdout.write("\n🔍 Testing spatial queries:")
try:
from django.contrib.gis.measure import D
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D
# Find parks within 100km of a point
# Same as Disneyland

View File

@@ -1,5 +1,6 @@
from django.core.management.base import BaseCommand
from django.db.models import Q
from apps.parks.models import Park

View File

@@ -3,17 +3,17 @@ Custom managers and QuerySets for Parks models.
Optimized queries following Django styleguide patterns.
"""
from django.db.models import Q, Count, Avg, Max, Min, Prefetch
from django.db.models import Avg, Count, Max, Min, Prefetch, Q
from apps.core.managers import (
BaseQuerySet,
BaseManager,
LocationQuerySet,
BaseQuerySet,
LocationManager,
ReviewableQuerySet,
LocationQuerySet,
ReviewableManager,
StatusQuerySet,
ReviewableQuerySet,
StatusManager,
StatusQuerySet,
)
@@ -51,6 +51,7 @@ class ParkQuerySet(StatusQuerySet, ReviewableQuerySet, LocationQuerySet):
def optimized_for_detail(self):
"""Optimize for park detail display."""
from apps.rides.models import Ride
from .models import ParkReview
return self.select_related("operator", "property_owner").prefetch_related(

View File

@@ -1,12 +1,13 @@
# Generated by Django 5.2.5 on 2025-08-26 17:39
import apps.parks.models.media
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
import apps.parks.models.media
class Migration(migrations.Migration):
dependencies = [

View File

@@ -5,40 +5,40 @@ from django.db import migrations
def populate_computed_fields(apps, schema_editor):
"""Populate computed fields for existing parks using raw SQL with disabled triggers"""
# Temporarily disable pghistory triggers
schema_editor.execute("ALTER TABLE parks_park DISABLE TRIGGER ALL;")
try:
# Use raw SQL to update opening_year from opening_date
schema_editor.execute("""
UPDATE parks_park
UPDATE parks_park
SET opening_year = EXTRACT(YEAR FROM opening_date)
WHERE opening_date IS NOT NULL;
""")
# Use raw SQL to populate search_text
# This is a simplified version - we'll populate it with just name and description
schema_editor.execute("""
UPDATE parks_park
UPDATE parks_park
SET search_text = LOWER(
COALESCE(name, '') || ' ' ||
COALESCE(name, '') || ' ' ||
COALESCE(description, '')
);
""")
# Update search_text to include operator names using a join
schema_editor.execute("""
UPDATE parks_park
UPDATE parks_park
SET search_text = LOWER(
COALESCE(parks_park.name, '') || ' ' ||
COALESCE(parks_park.name, '') || ' ' ||
COALESCE(parks_park.description, '') || ' ' ||
COALESCE(parks_company.name, '')
)
FROM parks_company
WHERE parks_park.operator_id = parks_company.id;
""")
finally:
# Re-enable pghistory triggers
schema_editor.execute("ALTER TABLE parks_park ENABLE TRIGGER ALL;")

View File

@@ -27,13 +27,13 @@ class Migration(migrations.Migration):
"CREATE INDEX IF NOT EXISTS parks_park_ride_coaster_count_idx ON parks_park (ride_count, coaster_count) WHERE ride_count IS NOT NULL AND coaster_count IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS parks_park_ride_coaster_count_idx;"
),
# Full-text search index for search_text field
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS parks_park_search_text_gin_idx ON parks_park USING gin(to_tsvector('english', search_text));",
reverse_sql="DROP INDEX IF EXISTS parks_park_search_text_gin_idx;"
),
# Trigram index for fuzzy search on search_text
migrations.RunSQL(
"CREATE EXTENSION IF NOT EXISTS pg_trgm;",
@@ -43,40 +43,40 @@ class Migration(migrations.Migration):
"CREATE INDEX IF NOT EXISTS parks_park_search_text_trgm_idx ON parks_park USING gin(search_text gin_trgm_ops);",
reverse_sql="DROP INDEX IF EXISTS parks_park_search_text_trgm_idx;"
),
# Indexes for location-based filtering (assuming location relationship exists)
migrations.RunSQL(
"""
CREATE INDEX IF NOT EXISTS parks_parklocation_country_state_idx
ON parks_parklocation (country, state)
CREATE INDEX IF NOT EXISTS parks_parklocation_country_state_idx
ON parks_parklocation (country, state)
WHERE country IS NOT NULL AND state IS NOT NULL;
""",
reverse_sql="DROP INDEX IF EXISTS parks_parklocation_country_state_idx;"
),
# Index for operator-based filtering
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS parks_park_operator_status_idx ON parks_park (operator_id, status);",
reverse_sql="DROP INDEX IF EXISTS parks_park_operator_status_idx;"
),
# Partial indexes for common status filters
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS parks_park_operating_parks_idx ON parks_park (name, opening_year) WHERE status IN ('OPERATING', 'CLOSED_TEMP');",
reverse_sql="DROP INDEX IF EXISTS parks_park_operating_parks_idx;"
),
# Index for ordering by name (already exists but ensuring it's optimized)
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS parks_park_name_lower_idx ON parks_park (LOWER(name));",
reverse_sql="DROP INDEX IF EXISTS parks_park_name_lower_idx;"
),
# Covering index for common query patterns
migrations.RunSQL(
"""
CREATE INDEX IF NOT EXISTS parks_park_hybrid_covering_idx
ON parks_park (status, park_type, opening_year)
CREATE INDEX IF NOT EXISTS parks_park_hybrid_covering_idx
ON parks_park (status, park_type, opening_year)
INCLUDE (name, slug, size_acres, average_rating, ride_count, coaster_count, operator_id)
WHERE status IN ('OPERATING', 'CLOSED_TEMP');
""",

View File

@@ -14,17 +14,17 @@ class Migration(migrations.Migration):
sql="""
-- Drop the existing trigger function
DROP FUNCTION IF EXISTS pgtrigger_insert_insert_66883() CASCADE;
-- Recreate the trigger function with timezone field
CREATE OR REPLACE FUNCTION pgtrigger_insert_insert_66883()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO "parks_parkevent" (
"average_rating", "banner_image_id", "card_image_id", "closing_date",
"coaster_count", "created_at", "description", "id", "name", "opening_date",
"opening_year", "operating_season", "operator_id", "park_type",
"pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id",
"property_owner_id", "ride_count", "search_text", "size_acres",
"average_rating", "banner_image_id", "card_image_id", "closing_date",
"coaster_count", "created_at", "description", "id", "name", "opening_date",
"opening_year", "operating_season", "operator_id", "park_type",
"pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id",
"property_owner_id", "ride_count", "search_text", "size_acres",
"slug", "status", "updated_at", "url", "website", "timezone"
) VALUES (
NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date",
@@ -37,7 +37,7 @@ class Migration(migrations.Migration):
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Recreate the trigger
CREATE TRIGGER pgtrigger_insert_insert_66883
AFTER INSERT ON parks_park

View File

@@ -14,17 +14,17 @@ class Migration(migrations.Migration):
sql="""
-- Drop the existing UPDATE trigger function
DROP FUNCTION IF EXISTS pgtrigger_update_update_19f56() CASCADE;
-- Recreate the UPDATE trigger function with timezone field
CREATE OR REPLACE FUNCTION pgtrigger_update_update_19f56()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO "parks_parkevent" (
"average_rating", "banner_image_id", "card_image_id", "closing_date",
"coaster_count", "created_at", "description", "id", "name", "opening_date",
"opening_year", "operating_season", "operator_id", "park_type",
"pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id",
"property_owner_id", "ride_count", "search_text", "size_acres",
"average_rating", "banner_image_id", "card_image_id", "closing_date",
"coaster_count", "created_at", "description", "id", "name", "opening_date",
"opening_year", "operating_season", "operator_id", "park_type",
"pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id",
"property_owner_id", "ride_count", "search_text", "size_acres",
"slug", "status", "updated_at", "url", "website", "timezone"
) VALUES (
NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date",
@@ -37,7 +37,7 @@ class Migration(migrations.Migration):
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Recreate the UPDATE trigger
CREATE TRIGGER pgtrigger_update_update_19f56
AFTER UPDATE ON parks_park

View File

@@ -1,8 +1,9 @@
# Generated by Django 5.2.5 on 2025-09-15 17:35
import apps.core.choices.fields
from django.db import migrations
import apps.core.choices.fields
class Migration(migrations.Migration):

View File

@@ -1,9 +1,10 @@
# Generated by Django 5.2.5 on 2025-09-15 18:07
import apps.core.choices.fields
import django.contrib.postgres.fields
from django.db import migrations
import apps.core.choices.fields
class Migration(migrations.Migration):

View File

@@ -1,13 +1,14 @@
# Generated by Django 5.1.6 on 2025-12-26 14:10
import apps.core.choices.fields
import apps.core.state_machine.fields
import django.contrib.postgres.fields
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import apps.core.choices.fields
import apps.core.state_machine.fields
class Migration(migrations.Migration):

View File

@@ -0,0 +1,72 @@
# Generated by Django 5.2.9 on 2025-12-27 20:58
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0025_alter_company_options_alter_park_options_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="park",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="park",
name="update_update",
),
migrations.AddField(
model_name="park",
name="email",
field=models.EmailField(blank=True, help_text="Contact email address", max_length=254),
),
migrations.AddField(
model_name="park",
name="phone",
field=models.CharField(blank=True, help_text="Contact phone number", max_length=30),
),
migrations.AddField(
model_name="parkevent",
name="email",
field=models.EmailField(blank=True, help_text="Contact email address", max_length=254),
),
migrations.AddField(
model_name="parkevent",
name="phone",
field=models.CharField(blank=True, help_text="Contact phone number", max_length=30),
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "email", "id", "name", "opening_date", "opening_year", "operating_season", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "phone", "property_owner_id", "ride_count", "search_text", "size_acres", "slug", "status", "timezone", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."email", NEW."id", NEW."name", NEW."opening_date", NEW."opening_year", NEW."operating_season", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."phone", NEW."property_owner_id", NEW."ride_count", NEW."search_text", NEW."size_acres", NEW."slug", NEW."status", NEW."timezone", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
hash="003fcf4a2b230ed1d4bba51881c3675cf8270d0c",
operation="INSERT",
pgid="pgtrigger_insert_insert_66883",
table="parks_park",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "email", "id", "name", "opening_date", "opening_year", "operating_season", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "phone", "property_owner_id", "ride_count", "search_text", "size_acres", "slug", "status", "timezone", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."email", NEW."id", NEW."name", NEW."opening_date", NEW."opening_year", NEW."operating_season", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."phone", NEW."property_owner_id", NEW."ride_count", NEW."search_text", NEW."size_acres", NEW."slug", NEW."status", NEW."timezone", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
hash="cee05755a8a1c1de844f51927c15ed35a029253b",
operation="UPDATE",
pgid="pgtrigger_update_update_19f56",
table="parks_park",
when="AFTER",
),
),
),
]

View File

@@ -8,15 +8,14 @@ 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
from .media import ParkPhoto
# Import choices to trigger registration
from ..choices import *
from .areas import ParkArea
from .companies import Company, CompanyHeadquarters
from .location import ParkLocation
from .media import ParkPhoto
from .parks import Park
from .reviews import ParkReview
# Alias Company as Operator for clarity
Operator = Company

View File

@@ -1,8 +1,9 @@
import pghistory
from django.db import models
from django.utils.text import slugify
import pghistory
from apps.core.history import TrackedModel
from .parks import Park

View File

@@ -1,9 +1,10 @@
import pghistory
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.utils.text import slugify
from apps.core.models import TrackedModel
from apps.core.choices.fields import RichChoiceField
import pghistory
from apps.core.models import TrackedModel
@pghistory.track()

View File

@@ -1,6 +1,6 @@
import pghistory
from django.contrib.gis.db import models
from django.contrib.gis.geos import Point
import pghistory
@pghistory.track()

View File

@@ -4,12 +4,14 @@ Park-specific media models for ThrillWiki.
This module contains media models specific to parks domain.
"""
from typing import Any, List, Optional, cast
from django.db import models
from typing import Any, cast
import pghistory
from django.conf import settings
from django.db import models
from apps.core.history import TrackedModel
from apps.core.services.media_service import MediaService
import pghistory
def park_photo_upload_path(instance: models.Model, filename: str) -> str:
@@ -114,7 +116,7 @@ class ParkPhoto(TrackedModel):
super().save(*args, **kwargs)
@property
def file_size(self) -> Optional[int]:
def file_size(self) -> int | None:
"""Get file size in bytes."""
try:
return self.image.size
@@ -122,7 +124,7 @@ class ParkPhoto(TrackedModel):
return None
@property
def dimensions(self) -> Optional[List[int]]:
def dimensions(self) -> list[int] | None:
"""Get image dimensions as [width, height]."""
try:
return [self.image.width, self.image.height]

View File

@@ -1,20 +1,23 @@
from typing import TYPE_CHECKING, Any, Optional
import pghistory
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.core.exceptions import ValidationError
from config.django import base as settings
from typing import Optional, Any, TYPE_CHECKING, List
import pghistory
from apps.core.history import TrackedModel
from apps.core.choices import RichChoiceField
from apps.core.state_machine import RichFSMField, StateMachineMixin
from apps.core.choices import RichChoiceField
from apps.core.history import TrackedModel
from apps.core.state_machine import RichFSMField, StateMachineMixin
from config.django import base as settings
if TYPE_CHECKING:
from apps.rides.models import Ride
from . import ParkArea
from django.contrib.auth.models import AbstractBaseUser
from apps.rides.models import Ride
from . import ParkArea
@pghistory.track()
class Park(StateMachineMixin, TrackedModel):
@@ -57,6 +60,8 @@ class Park(StateMachineMixin, TrackedModel):
max_digits=10, decimal_places=2, null=True, blank=True, help_text="Park size in acres"
)
website = models.URLField(blank=True, help_text="Official website URL")
phone = models.CharField(max_length=30, blank=True, help_text="Contact phone number")
email = models.EmailField(blank=True, help_text="Contact email address")
# Statistics
average_rating = models.DecimalField(
@@ -113,17 +118,17 @@ class Park(StateMachineMixin, TrackedModel):
# Computed fields for hybrid filtering
opening_year = models.IntegerField(
null=True,
blank=True,
null=True,
blank=True,
db_index=True,
help_text="Year the park opened (computed from opening_date)"
)
search_text = models.TextField(
blank=True,
blank=True,
db_index=True,
help_text="Searchable text combining name, description, location, and operator"
)
# Timezone for park operations
timezone = models.CharField(
max_length=50,
@@ -220,6 +225,7 @@ class Park(StateMachineMixin, TrackedModel):
def save(self, *args: Any, **kwargs: Any) -> None:
from django.contrib.contenttypes.models import ContentType
from apps.core.history import HistoricalSlug
# Get old instance if it exists
@@ -264,13 +270,13 @@ class Park(StateMachineMixin, TrackedModel):
self.opening_year = self.opening_date.year
else:
self.opening_year = None
# Populate search_text for client-side filtering
search_parts = [self.name]
if self.description:
search_parts.append(self.description)
# Add location information if available
try:
if hasattr(self, 'location') and self.location:
@@ -283,15 +289,15 @@ class Park(StateMachineMixin, TrackedModel):
except Exception:
# Handle case where location relationship doesn't exist yet
pass
# Add operator information
if self.operator:
search_parts.append(self.operator.name)
# Add property owner information if different
if self.property_owner and self.property_owner != self.operator:
search_parts.append(self.property_owner.name)
# Combine all parts into searchable text
self.search_text = ' '.join(filter(None, search_parts)).lower()
@@ -315,7 +321,7 @@ class Park(StateMachineMixin, TrackedModel):
return ""
@property
def coordinates(self) -> Optional[List[float]]:
def coordinates(self) -> list[float] | None:
"""Returns coordinates as a list [latitude, longitude]"""
if hasattr(self, "location") and self.location:
coords = self.location.coordinates
@@ -327,6 +333,7 @@ class Park(StateMachineMixin, TrackedModel):
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 apps.core.history import HistoricalSlug
print(f"\nLooking up slug: {slug}")

View File

@@ -1,8 +1,9 @@
import pghistory
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import functions
from django.core.validators import MinValueValidator, MaxValueValidator
from apps.core.history import TrackedModel
import pghistory
@pghistory.track()

View File

@@ -1,4 +1,5 @@
from django.db.models import QuerySet, Count, Q
from django.db.models import Count, Q, QuerySet
from .models import Park

View File

@@ -3,16 +3,18 @@ Selectors for park-related data retrieval.
Following Django styleguide pattern for separating data access from business logic.
"""
from typing import Optional, Dict, Any
from django.db.models import QuerySet, Q, Count, Avg, Prefetch
from typing import Any
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import Distance
from django.db.models import Avg, Count, Prefetch, Q, QuerySet
from .models import Park, ParkArea, ParkReview
from apps.rides.models import Ride
from .models import Park, ParkArea, ParkReview
def park_list_with_stats(*, filters: Optional[Dict[str, Any]] = None) -> QuerySet[Park]:
def park_list_with_stats(*, filters: dict[str, Any] | None = None) -> QuerySet[Park]:
"""
Get parks optimized for list display with basic stats.
@@ -116,7 +118,7 @@ def parks_near_location(
)
def park_statistics() -> Dict[str, Any]:
def park_statistics() -> dict[str, Any]:
"""
Get overall park statistics for dashboard/analytics.
@@ -167,9 +169,10 @@ def parks_with_recent_reviews(*, days: int = 30) -> QuerySet[Park]:
Returns:
QuerySet of parks with recent reviews
"""
from django.utils import timezone
from datetime import timedelta
from django.utils import timezone
cutoff_date = timezone.now() - timedelta(days=days)
return (

View File

@@ -3,11 +3,12 @@ Services for park-related business logic.
Following Django styleguide pattern for business logic encapsulation.
"""
from typing import Optional, Dict, Any
from django.db import transaction
from django.db.models import Q
from typing import Any
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractBaseUser
from django.db import transaction
from django.db.models import Q
from .models import Park, ParkArea
from .services.location_service import ParkLocationService
@@ -26,15 +27,15 @@ class ParkService:
name: str,
description: str = "",
status: str = "OPERATING",
operator_id: Optional[int] = None,
property_owner_id: Optional[int] = None,
opening_date: Optional[str] = None,
closing_date: Optional[str] = None,
operator_id: int | None = None,
property_owner_id: int | None = None,
opening_date: str | None = None,
closing_date: str | None = None,
operating_season: str = "",
size_acres: Optional[float] = None,
size_acres: float | None = None,
website: str = "",
location_data: Optional[Dict[str, Any]] = None,
created_by: Optional[UserType] = None,
location_data: dict[str, Any] | None = None,
created_by: UserType | None = None,
) -> Park:
"""
Create a new park with validation and location handling.
@@ -97,8 +98,8 @@ class ParkService:
def update_park(
*,
park_id: int,
updates: Dict[str, Any],
updated_by: Optional[UserType] = None,
updates: dict[str, Any],
updated_by: UserType | None = None,
) -> Park:
"""
Update an existing park with validation.
@@ -130,7 +131,7 @@ class ParkService:
return park
@staticmethod
def delete_park(*, park_id: int, deleted_by: Optional[UserType] = None) -> bool:
def delete_park(*, park_id: int, deleted_by: UserType | None = None) -> bool:
"""
Soft delete a park by setting status to DEMOLISHED.
@@ -156,7 +157,7 @@ class ParkService:
park_id: int,
name: str,
description: str = "",
created_by: Optional[UserType] = None,
created_by: UserType | None = None,
) -> ParkArea:
"""
Create a new area within a park.
@@ -195,9 +196,11 @@ class ParkService:
Returns:
Updated Park instance with fresh statistics
"""
from django.db.models import Avg, Count
from apps.rides.models import Ride
from .models import ParkReview
from django.db.models import Count, Avg
with transaction.atomic():
park = Park.objects.select_for_update().get(id=park_id)

View File

@@ -1,8 +1,8 @@
from .roadtrip import RoadTripService
from .park_management import ParkService
from .location_service import ParkLocationService
from .filter_service import ParkFilterService
from .location_service import ParkLocationService
from .media_service import ParkMediaService
from .park_management import ParkService
from .roadtrip import RoadTripService
__all__ = [
"RoadTripService",

View File

@@ -5,11 +5,13 @@ Provides filtering functionality, aggregations, and caching for park filters.
This service handles complex filter logic and provides useful filter statistics.
"""
from typing import Dict, List, Any, Optional
from django.db.models import QuerySet, Count, Q
from django.core.cache import cache
from typing import Any
from django.conf import settings
from ..models import Park, Company
from django.core.cache import cache
from django.db.models import Count, Q, QuerySet
from ..models import Company, Park
from ..querysets import get_base_park_queryset
@@ -25,8 +27,8 @@ class ParkFilterService:
self.cache_prefix = "park_filter"
def get_filter_counts(
self, base_queryset: Optional[QuerySet] = None
) -> Dict[str, Any]:
self, base_queryset: QuerySet | None = None
) -> dict[str, Any]:
"""
Get counts for various filter options to show users what's available.
@@ -61,7 +63,7 @@ class ParkFilterService:
cache.set(cache_key, filter_counts, self.CACHE_TIMEOUT)
return filter_counts
def _get_park_type_counts(self, queryset: QuerySet) -> Dict[str, int]:
def _get_park_type_counts(self, queryset: QuerySet) -> dict[str, int]:
"""Get counts for different park types based on operator names."""
return {
"disney": queryset.filter(operator__name__icontains="Disney").count(),
@@ -76,7 +78,7 @@ class ParkFilterService:
def _get_top_operators(
self, queryset: QuerySet, limit: int = 10
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
"""Get the top operators by number of parks."""
return list(
queryset.values("operator__name", "operator__id")
@@ -87,7 +89,7 @@ class ParkFilterService:
def _get_country_counts(
self, queryset: QuerySet, limit: int = 10
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
"""Get countries with the most parks."""
return list(
queryset.filter(location__country__isnull=False)
@@ -97,7 +99,7 @@ class ParkFilterService:
.order_by("-park_count")[:limit]
)
def get_filter_suggestions(self, query: str) -> Dict[str, List[str]]:
def get_filter_suggestions(self, query: str) -> dict[str, list[str]]:
"""
Get filter suggestions based on a search query.
@@ -151,7 +153,7 @@ class ParkFilterService:
cache.set(cache_key, suggestions, 60) # 1 minute cache
return suggestions
def get_popular_filters(self) -> Dict[str, Any]:
def get_popular_filters(self) -> dict[str, Any]:
"""
Get commonly used filter combinations and popular filter values.
@@ -210,7 +212,7 @@ class ParkFilterService:
for key in cache_keys:
cache.delete(key)
def get_filtered_queryset(self, filters: Dict[str, Any]) -> QuerySet: # noqa: C901
def get_filtered_queryset(self, filters: dict[str, Any]) -> QuerySet: # noqa: C901
"""
Apply filters to get a filtered queryset with optimizations.

View File

@@ -5,10 +5,12 @@ This module provides intelligent data loading capabilities for the hybrid filter
optimizing database queries and implementing progressive loading strategies.
"""
from typing import Dict, Optional, Any
from django.db import models
from django.core.cache import cache
from typing import Any
from django.conf import settings
from django.core.cache import cache
from django.db import models
from apps.parks.models import Park
@@ -17,19 +19,19 @@ class SmartParkLoader:
Intelligent park data loader that optimizes queries based on filtering requirements.
Implements progressive loading and smart caching strategies.
"""
# Cache configuration
CACHE_TIMEOUT = getattr(settings, 'HYBRID_FILTER_CACHE_TIMEOUT', 300) # 5 minutes
CACHE_KEY_PREFIX = 'hybrid_parks'
# Progressive loading thresholds
INITIAL_LOAD_SIZE = 50
PROGRESSIVE_LOAD_SIZE = 25
MAX_CLIENT_SIDE_RECORDS = 200
def __init__(self):
self.base_queryset = self._get_optimized_queryset()
def _get_optimized_queryset(self) -> models.QuerySet:
"""Get optimized base queryset with all necessary prefetches."""
return Park.objects.select_related(
@@ -43,31 +45,31 @@ class SmartParkLoader:
# Only include operating and temporarily closed parks by default
status__in=['OPERATING', 'CLOSED_TEMP']
).order_by('name')
def get_initial_load(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
def get_initial_load(self, filters: dict[str, Any] | None = None) -> dict[str, Any]:
"""
Get initial park data load with smart filtering decisions.
Args:
filters: Optional filters to apply
Returns:
Dictionary containing parks data and metadata
"""
cache_key = self._generate_cache_key('initial', filters)
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
# Apply filters if provided
queryset = self.base_queryset
if filters:
queryset = self._apply_filters(queryset, filters)
# Get total count for pagination decisions
total_count = queryset.count()
# Determine loading strategy
if total_count <= self.MAX_CLIENT_SIDE_RECORDS:
# Load all data for client-side filtering
@@ -79,7 +81,7 @@ class SmartParkLoader:
parks = list(queryset[:self.INITIAL_LOAD_SIZE])
strategy = 'server_side'
has_more = total_count > self.INITIAL_LOAD_SIZE
result = {
'parks': parks,
'total_count': total_count,
@@ -88,163 +90,163 @@ class SmartParkLoader:
'next_offset': len(parks) if has_more else None,
'filter_metadata': self._get_filter_metadata(queryset),
}
# Cache the result
cache.set(cache_key, result, self.CACHE_TIMEOUT)
return result
def get_progressive_load(
self,
offset: int,
filters: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
self,
offset: int,
filters: dict[str, Any] | None = None
) -> dict[str, Any]:
"""
Get next batch of parks for progressive loading.
Args:
offset: Starting offset for the batch
filters: Optional filters to apply
Returns:
Dictionary containing parks data and metadata
"""
cache_key = self._generate_cache_key(f'progressive_{offset}', filters)
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
# Apply filters if provided
queryset = self.base_queryset
if filters:
queryset = self._apply_filters(queryset, filters)
# Get the batch
end_offset = offset + self.PROGRESSIVE_LOAD_SIZE
parks = list(queryset[offset:end_offset])
# Check if there are more records
total_count = queryset.count()
has_more = end_offset < total_count
result = {
'parks': parks,
'total_count': total_count,
'has_more': has_more,
'next_offset': end_offset if has_more else None,
}
# Cache the result
cache.set(cache_key, result, self.CACHE_TIMEOUT)
return result
def get_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
def get_filter_metadata(self, filters: dict[str, Any] | None = None) -> dict[str, Any]:
"""
Get metadata about available filter options.
Args:
filters: Current filters to scope the metadata
Returns:
Dictionary containing filter metadata
"""
cache_key = self._generate_cache_key('metadata', filters)
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
# Apply filters if provided
queryset = self.base_queryset
if filters:
queryset = self._apply_filters(queryset, filters)
result = self._get_filter_metadata(queryset)
# Cache the result
cache.set(cache_key, result, self.CACHE_TIMEOUT)
return result
def _apply_filters(self, queryset: models.QuerySet, filters: Dict[str, Any]) -> models.QuerySet:
def _apply_filters(self, queryset: models.QuerySet, filters: dict[str, Any]) -> models.QuerySet:
"""Apply filters to the queryset."""
# Status filter
if 'status' in filters and filters['status']:
if isinstance(filters['status'], list):
queryset = queryset.filter(status__in=filters['status'])
else:
queryset = queryset.filter(status=filters['status'])
# Park type filter
if 'park_type' in filters and filters['park_type']:
if isinstance(filters['park_type'], list):
queryset = queryset.filter(park_type__in=filters['park_type'])
else:
queryset = queryset.filter(park_type=filters['park_type'])
# Country filter
if 'country' in filters and filters['country']:
queryset = queryset.filter(location__country__in=filters['country'])
# State filter
if 'state' in filters and filters['state']:
queryset = queryset.filter(location__state__in=filters['state'])
# Opening year range
if 'opening_year_min' in filters and filters['opening_year_min']:
queryset = queryset.filter(opening_year__gte=filters['opening_year_min'])
if 'opening_year_max' in filters and filters['opening_year_max']:
queryset = queryset.filter(opening_year__lte=filters['opening_year_max'])
# Size range
if 'size_min' in filters and filters['size_min']:
queryset = queryset.filter(size_acres__gte=filters['size_min'])
if 'size_max' in filters and filters['size_max']:
queryset = queryset.filter(size_acres__lte=filters['size_max'])
# Rating range
if 'rating_min' in filters and filters['rating_min']:
queryset = queryset.filter(average_rating__gte=filters['rating_min'])
if 'rating_max' in filters and filters['rating_max']:
queryset = queryset.filter(average_rating__lte=filters['rating_max'])
# Ride count range
if 'ride_count_min' in filters and filters['ride_count_min']:
queryset = queryset.filter(ride_count__gte=filters['ride_count_min'])
if 'ride_count_max' in filters and filters['ride_count_max']:
queryset = queryset.filter(ride_count__lte=filters['ride_count_max'])
# Coaster count range
if 'coaster_count_min' in filters and filters['coaster_count_min']:
queryset = queryset.filter(coaster_count__gte=filters['coaster_count_min'])
if 'coaster_count_max' in filters and filters['coaster_count_max']:
queryset = queryset.filter(coaster_count__lte=filters['coaster_count_max'])
# Operator filter
if 'operator' in filters and filters['operator']:
if isinstance(filters['operator'], list):
queryset = queryset.filter(operator__slug__in=filters['operator'])
else:
queryset = queryset.filter(operator__slug=filters['operator'])
# Search query
if 'search' in filters and filters['search']:
search_term = filters['search'].lower()
queryset = queryset.filter(search_text__icontains=search_term)
return queryset
def _get_filter_metadata(self, queryset: models.QuerySet) -> Dict[str, Any]:
def _get_filter_metadata(self, queryset: models.QuerySet) -> dict[str, Any]:
"""Generate filter metadata from the current queryset."""
# Get distinct values for categorical filters with counts
countries_data = list(
queryset.values('location__country')
@@ -252,27 +254,27 @@ class SmartParkLoader:
.annotate(count=models.Count('id'))
.order_by('location__country')
)
states_data = list(
queryset.values('location__state')
.exclude(location__state__isnull=True)
.annotate(count=models.Count('id'))
.order_by('location__state')
)
park_types_data = list(
queryset.values('park_type')
.exclude(park_type__isnull=True)
.annotate(count=models.Count('id'))
.order_by('park_type')
)
statuses_data = list(
queryset.values('status')
.annotate(count=models.Count('id'))
.order_by('status')
)
operators_data = list(
queryset.select_related('operator')
.values('operator__id', 'operator__name', 'operator__slug')
@@ -280,7 +282,7 @@ class SmartParkLoader:
.annotate(count=models.Count('id'))
.order_by('operator__name')
)
# Convert to frontend-expected format with value/label/count
countries = [
{
@@ -290,7 +292,7 @@ class SmartParkLoader:
}
for item in countries_data
]
states = [
{
'value': item['location__state'],
@@ -299,7 +301,7 @@ class SmartParkLoader:
}
for item in states_data
]
park_types = [
{
'value': item['park_type'],
@@ -308,7 +310,7 @@ class SmartParkLoader:
}
for item in park_types_data
]
statuses = [
{
'value': item['status'],
@@ -317,7 +319,7 @@ class SmartParkLoader:
}
for item in statuses_data
]
operators = [
{
'value': item['operator__slug'],
@@ -326,7 +328,7 @@ class SmartParkLoader:
}
for item in operators_data
]
# Get ranges for numerical filters
aggregates = queryset.aggregate(
opening_year_min=models.Min('opening_year'),
@@ -340,7 +342,7 @@ class SmartParkLoader:
coaster_count_min=models.Min('coaster_count'),
coaster_count_max=models.Max('coaster_count'),
)
return {
'categorical': {
'countries': countries,
@@ -383,7 +385,7 @@ class SmartParkLoader:
},
'total_count': queryset.count(),
}
def _get_status_label(self, status: str) -> str:
"""Convert status code to human-readable label."""
status_labels = {
@@ -396,19 +398,19 @@ class SmartParkLoader:
return status_labels[status]
else:
raise ValueError(f"Unknown park status: {status}")
def _generate_cache_key(self, operation: str, filters: Optional[Dict[str, Any]] = None) -> str:
def _generate_cache_key(self, operation: str, filters: dict[str, Any] | None = None) -> str:
"""Generate cache key for the given operation and filters."""
key_parts = [self.CACHE_KEY_PREFIX, operation]
if filters:
# Create a consistent string representation of filters
filter_str = '_'.join(f"{k}:{v}" for k, v in sorted(filters.items()) if v)
key_parts.append(filter_str)
return '_'.join(key_parts)
def invalidate_cache(self, filters: Optional[Dict[str, Any]] = None) -> None:
def invalidate_cache(self, filters: dict[str, Any] | None = None) -> None:
"""Invalidate cached data for the given filters."""
# This is a simplified implementation
# In production, you might want to use cache versioning or tags
@@ -416,11 +418,11 @@ class SmartParkLoader:
self._generate_cache_key('initial', filters),
self._generate_cache_key('metadata', filters),
]
# Also invalidate progressive load caches
for offset in range(0, 1000, self.PROGRESSIVE_LOAD_SIZE):
cache_keys.append(self._generate_cache_key(f'progressive_{offset}', filters))
cache.delete_many(cache_keys)

View File

@@ -3,11 +3,12 @@ Parks-specific location services with OpenStreetMap integration.
Handles geocoding, reverse geocoding, and location search for parks.
"""
import logging
from typing import Any
import requests
from typing import List, Dict, Any, Optional
from django.core.cache import cache
from django.db import transaction
import logging
from ..models import ParkLocation
@@ -23,7 +24,7 @@ class ParkLocationService:
USER_AGENT = "ThrillWiki/1.0 (https://thrillwiki.com)"
@classmethod
def search_locations(cls, query: str, limit: int = 10) -> Dict[str, Any]:
def search_locations(cls, query: str, limit: int = 10) -> dict[str, Any]:
"""
Search for locations using OpenStreetMap Nominatim API.
Optimized for finding theme parks and amusement parks.
@@ -98,7 +99,7 @@ class ParkLocationService:
}
@classmethod
def reverse_geocode(cls, latitude: float, longitude: float) -> Dict[str, Any]:
def reverse_geocode(cls, latitude: float, longitude: float) -> dict[str, Any]:
"""
Reverse geocode coordinates to get location information using OSM.
@@ -159,7 +160,7 @@ class ParkLocationService:
return {"error": "Reverse geocoding service temporarily unavailable"}
@classmethod
def geocode_address(cls, address: str) -> Dict[str, Any]:
def geocode_address(cls, address: str) -> dict[str, Any]:
"""
Geocode an address to get coordinates using OSM.
@@ -185,8 +186,8 @@ class ParkLocationService:
cls,
*,
park,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
latitude: float | None = None,
longitude: float | None = None,
street_address: str = "",
city: str = "",
state: str = "",
@@ -195,7 +196,7 @@ class ParkLocationService:
highway_exit: str = "",
parking_notes: str = "",
seasonal_notes: str = "",
osm_id: Optional[int] = None,
osm_id: int | None = None,
osm_type: str = "",
) -> ParkLocation:
"""
@@ -279,7 +280,7 @@ class ParkLocationService:
@classmethod
def find_nearby_parks(
cls, latitude: float, longitude: float, radius_km: float = 50
) -> List[ParkLocation]:
) -> list[ParkLocation]:
"""
Find parks near given coordinates using PostGIS.
@@ -349,8 +350,8 @@ class ParkLocationService:
@classmethod
def _transform_osm_result(
cls, osm_item: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
cls, osm_item: dict[str, Any]
) -> dict[str, Any] | None:
"""Transform OSM search result to our standard format."""
try:
address = osm_item.get("address", {})
@@ -432,8 +433,8 @@ class ParkLocationService:
@classmethod
def _transform_osm_reverse_result(
cls, osm_result: Dict[str, Any]
) -> Dict[str, Any]:
cls, osm_result: dict[str, Any]
) -> dict[str, Any]:
"""Transform OSM reverse geocoding result to our standard format."""
address = osm_result.get("address", {})

View File

@@ -5,11 +5,14 @@ This module provides media management functionality specific to parks.
"""
import logging
from typing import List, Optional, Dict, Any
from typing import Any
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
from django.contrib.auth import get_user_model
from apps.core.services.media_service import MediaService
from ..models import Park, ParkPhoto
User = get_user_model()
@@ -78,7 +81,7 @@ class ParkMediaService:
@staticmethod
def get_park_photos(
park: Park, approved_only: bool = True, primary_first: bool = True
) -> List[ParkPhoto]:
) -> list[ParkPhoto]:
"""
Get photos for a park.
@@ -103,7 +106,7 @@ class ParkMediaService:
return list(queryset)
@staticmethod
def get_primary_photo(park: Park) -> Optional[ParkPhoto]:
def get_primary_photo(park: Park) -> ParkPhoto | None:
"""
Get the primary photo for a park.
@@ -196,7 +199,7 @@ class ParkMediaService:
return False
@staticmethod
def get_photo_stats(park: Park) -> Dict[str, Any]:
def get_photo_stats(park: Park) -> dict[str, Any]:
"""
Get photo statistics for a park.
@@ -217,7 +220,7 @@ class ParkMediaService:
}
@staticmethod
def bulk_approve_photos(photos: List[ParkPhoto], approved_by: User) -> int:
def bulk_approve_photos(photos: list[ParkPhoto], approved_by: User) -> int:
"""
Bulk approve multiple photos.

View File

@@ -4,10 +4,11 @@ Following Django styleguide pattern for business logic encapsulation.
"""
import logging
from typing import Optional, Dict, Any, List, TYPE_CHECKING
from typing import TYPE_CHECKING, Any, Optional
from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
from django.db.models import Q
from django.core.files.uploadedfile import UploadedFile
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractUser
@@ -28,14 +29,14 @@ class ParkService:
name: str,
description: str = "",
status: str = "OPERATING",
operator_id: Optional[int] = None,
property_owner_id: Optional[int] = None,
opening_date: Optional[str] = None,
closing_date: Optional[str] = None,
operator_id: int | None = None,
property_owner_id: int | None = None,
opening_date: str | None = None,
closing_date: str | None = None,
operating_season: str = "",
size_acres: Optional[float] = None,
size_acres: float | None = None,
website: str = "",
location_data: Optional[Dict[str, Any]] = None,
location_data: dict[str, Any] | None = None,
created_by: Optional["AbstractUser"] = None,
) -> Park:
"""
@@ -99,7 +100,7 @@ class ParkService:
def update_park(
*,
park_id: int,
updates: Dict[str, Any],
updates: dict[str, Any],
updated_by: Optional["AbstractUser"] = None,
) -> Park:
"""
@@ -203,9 +204,10 @@ class ParkService:
Returns:
Updated Park instance with fresh statistics
"""
from apps.rides.models import Ride
from django.db.models import Avg, Count
from apps.parks.models import ParkReview
from django.db.models import Count, Avg
from apps.rides.models import Ride
with transaction.atomic():
park = Park.objects.select_for_update().get(id=park_id)
@@ -235,11 +237,11 @@ class ParkService:
@staticmethod
def create_park_with_moderation(
*,
changes: Dict[str, Any],
changes: dict[str, Any],
submitter: "AbstractUser",
reason: str = "",
source: str = "",
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Create a park through the moderation system.
@@ -267,11 +269,11 @@ class ParkService:
def update_park_with_moderation(
*,
park: Park,
changes: Dict[str, Any],
changes: dict[str, Any],
submitter: "AbstractUser",
reason: str = "",
source: str = "",
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Update a park through the moderation system.
@@ -300,14 +302,14 @@ class ParkService:
def create_or_update_location(
*,
park: Park,
latitude: Optional[float],
longitude: Optional[float],
latitude: float | None,
longitude: float | None,
street_address: str = "",
city: str = "",
state: str = "",
country: str = "USA",
postal_code: str = "",
) -> Optional[ParkLocation]:
) -> ParkLocation | None:
"""
Create or update a park's location.
@@ -356,9 +358,9 @@ class ParkService:
def upload_photos(
*,
park: Park,
photos: List[UploadedFile],
photos: list[UploadedFile],
uploaded_by: "AbstractUser",
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Upload multiple photos for a park.
@@ -370,10 +372,9 @@ class ParkService:
Returns:
Dictionary with uploaded_count and errors list
"""
from django.contrib.contenttypes.models import ContentType
uploaded_count = 0
errors: List[str] = []
errors: list[str] = []
for photo_file in photos:
try:
@@ -396,11 +397,11 @@ class ParkService:
@staticmethod
def handle_park_creation_result(
*,
result: Dict[str, Any],
form_data: Dict[str, Any],
photos: List[UploadedFile],
result: dict[str, Any],
form_data: dict[str, Any],
photos: list[UploadedFile],
user: "AbstractUser",
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Handle the result of park creation through moderation.
@@ -413,7 +414,7 @@ class ParkService:
Returns:
Dictionary with status, park (if created), uploaded_count, and errors
"""
response: Dict[str, Any] = {
response: dict[str, Any] = {
"status": result["status"],
"park": None,
"uploaded_count": 0,
@@ -454,12 +455,12 @@ class ParkService:
@staticmethod
def handle_park_update_result(
*,
result: Dict[str, Any],
result: dict[str, Any],
park: Park,
form_data: Dict[str, Any],
photos: List[UploadedFile],
form_data: dict[str, Any],
photos: list[UploadedFile],
user: "AbstractUser",
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Handle the result of park update through moderation.
@@ -473,7 +474,7 @@ class ParkService:
Returns:
Dictionary with status, park, uploaded_count, and errors
"""
response: Dict[str, Any] = {
response: dict[str, Any] = {
"status": result["status"],
"park": park,
"uploaded_count": 0,

View File

@@ -9,18 +9,19 @@ This service provides functionality for:
- Proper rate limiting and caching
"""
import time
import math
import logging
import requests
from typing import Dict, List, Optional, Any
import math
import time
from dataclasses import dataclass
from itertools import permutations
from typing import Any
import requests
from django.conf import settings
from django.core.cache import cache
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import Distance
from django.core.cache import cache
from apps.parks.models import Park
logger = logging.getLogger(__name__)
@@ -33,7 +34,7 @@ class Coordinates:
latitude: float
longitude: float
def to_list(self) -> List[float]:
def to_list(self) -> list[float]:
"""Return as [lat, lon] list."""
return [self.latitude, self.longitude]
@@ -48,7 +49,7 @@ class RouteInfo:
distance_km: float
duration_minutes: int
geometry: Optional[str] = None # Encoded polyline
geometry: str | None = None # Encoded polyline
@property
def formatted_distance(self) -> str:
@@ -79,7 +80,7 @@ class TripLeg:
route: RouteInfo
@property
def parks_along_route(self) -> List["Park"]:
def parks_along_route(self) -> list["Park"]:
"""Get parks along this route segment."""
# This would be populated by find_parks_along_route
return []
@@ -89,8 +90,8 @@ class TripLeg:
class RoadTrip:
"""Complete road trip with multiple parks."""
parks: List["Park"]
legs: List[TripLeg]
parks: list["Park"]
legs: list[TripLeg]
total_distance_km: float
total_duration_minutes: int
@@ -170,7 +171,7 @@ class RoadTripService:
}
)
def _make_request(self, url: str, params: Dict[str, Any]) -> Dict[str, Any]:
def _make_request(self, url: str, params: dict[str, Any]) -> dict[str, Any]:
"""
Make HTTP request with rate limiting, retries, and error handling.
"""
@@ -195,7 +196,7 @@ class RoadTripService:
f"Failed to make request after {self.max_retries} attempts: {e}"
)
def geocode_address(self, address: str) -> Optional[Coordinates]:
def geocode_address(self, address: str) -> Coordinates | None:
"""
Convert address to coordinates using Nominatim geocoding service.
@@ -256,7 +257,7 @@ class RoadTripService:
def calculate_route(
self, start_coords: Coordinates, end_coords: Coordinates
) -> Optional[RouteInfo]:
) -> RouteInfo | None:
"""
Calculate route between two coordinate points using OSRM.
@@ -377,7 +378,7 @@ class RoadTripService:
def find_parks_along_route(
self, start_park: "Park", end_park: "Park", max_detour_km: float = 50
) -> List["Park"]:
) -> list["Park"]:
"""
Find parks along a route within specified detour distance.
@@ -444,7 +445,7 @@ class RoadTripService:
def _calculate_detour_distance(
self, start: Coordinates, end: Coordinates, waypoint: Coordinates
) -> Optional[float]:
) -> float | None:
"""
Calculate the detour distance when visiting a waypoint.
"""
@@ -470,7 +471,7 @@ class RoadTripService:
logger.error(f"Failed to calculate detour distance: {e}")
return None
def create_multi_park_trip(self, park_list: List["Park"]) -> Optional[RoadTrip]:
def create_multi_park_trip(self, park_list: list["Park"]) -> RoadTrip | None:
"""
Create optimized multi-park road trip using simple nearest neighbor heuristic.
@@ -489,7 +490,7 @@ class RoadTripService:
else:
return self._optimize_trip_nearest_neighbor(park_list)
def _optimize_trip_exhaustive(self, park_list: List["Park"]) -> Optional[RoadTrip]:
def _optimize_trip_exhaustive(self, park_list: list["Park"]) -> RoadTrip | None:
"""
Find optimal route by testing all permutations (for small lists).
"""
@@ -508,8 +509,8 @@ class RoadTripService:
return best_trip
def _optimize_trip_nearest_neighbor(
self, park_list: List["Park"]
) -> Optional[RoadTrip]:
self, park_list: list["Park"]
) -> RoadTrip | None:
"""
Optimize trip using nearest neighbor heuristic (for larger lists).
"""
@@ -553,8 +554,8 @@ class RoadTripService:
return self._create_trip_from_order(ordered_parks)
def _create_trip_from_order(
self, ordered_parks: List["Park"]
) -> Optional[RoadTrip]:
self, ordered_parks: list["Park"]
) -> RoadTrip | None:
"""
Create a RoadTrip object from an ordered list of parks.
"""
@@ -596,7 +597,7 @@ class RoadTripService:
def get_park_distances(
self, center_park: "Park", radius_km: float = 100
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
"""
Get all parks within radius of a center park with distances.

View File

@@ -1,12 +1,12 @@
import logging
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.db.models import Q
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from apps.rides.models import Ride
from .models import Park
from .models import Park
logger = logging.getLogger(__name__)

View File

@@ -8,13 +8,14 @@ This module contains tests for:
- Related model updates during transitions
"""
from django.test import TestCase
from datetime import date
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django.test import TestCase
from django_fsm import TransitionNotAllowed
from .models import Park, Company
from datetime import date
from .models import Company, Park
User = get_user_model()
@@ -47,7 +48,7 @@ class ParkTransitionTests(TestCase):
password='testpass123',
role='ADMIN'
)
# Create operator company
self.operator = Company.objects.create(
name='Test Operator',
@@ -75,21 +76,21 @@ class ParkTransitionTests(TestCase):
"""Test transition from OPERATING to CLOSED_TEMP."""
park = self._create_park(status='OPERATING')
self.assertEqual(park.status, 'OPERATING')
park.transition_to_closed_temp(user=self.user)
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'CLOSED_TEMP')
def test_operating_to_closed_perm_transition(self):
"""Test transition from OPERATING to CLOSED_PERM."""
park = self._create_park(status='OPERATING')
park.transition_to_closed_perm(user=self.moderator)
park.closing_date = date.today()
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'CLOSED_PERM')
self.assertIsNotNone(park.closing_date)
@@ -102,10 +103,10 @@ class ParkTransitionTests(TestCase):
"""Test transition from UNDER_CONSTRUCTION to OPERATING."""
park = self._create_park(status='UNDER_CONSTRUCTION')
self.assertEqual(park.status, 'UNDER_CONSTRUCTION')
park.transition_to_operating(user=self.user)
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'OPERATING')
@@ -116,21 +117,21 @@ class ParkTransitionTests(TestCase):
def test_closed_temp_to_operating_transition(self):
"""Test transition from CLOSED_TEMP to OPERATING (reopen)."""
park = self._create_park(status='CLOSED_TEMP')
park.transition_to_operating(user=self.user)
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'OPERATING')
def test_closed_temp_to_closed_perm_transition(self):
"""Test transition from CLOSED_TEMP to CLOSED_PERM."""
park = self._create_park(status='CLOSED_TEMP')
park.transition_to_closed_perm(user=self.moderator)
park.closing_date = date.today()
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'CLOSED_PERM')
@@ -141,20 +142,20 @@ class ParkTransitionTests(TestCase):
def test_closed_perm_to_demolished_transition(self):
"""Test transition from CLOSED_PERM to DEMOLISHED."""
park = self._create_park(status='CLOSED_PERM')
park.transition_to_demolished(user=self.moderator)
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'DEMOLISHED')
def test_closed_perm_to_relocated_transition(self):
"""Test transition from CLOSED_PERM to RELOCATED."""
park = self._create_park(status='CLOSED_PERM')
park.transition_to_relocated(user=self.moderator)
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'RELOCATED')
@@ -165,28 +166,28 @@ class ParkTransitionTests(TestCase):
def test_demolished_cannot_transition(self):
"""Test that DEMOLISHED state cannot transition further."""
park = self._create_park(status='DEMOLISHED')
with self.assertRaises(TransitionNotAllowed):
park.transition_to_operating(user=self.moderator)
def test_relocated_cannot_transition(self):
"""Test that RELOCATED state cannot transition further."""
park = self._create_park(status='RELOCATED')
with self.assertRaises(TransitionNotAllowed):
park.transition_to_operating(user=self.moderator)
def test_operating_cannot_directly_demolish(self):
"""Test that OPERATING cannot directly transition to DEMOLISHED."""
park = self._create_park(status='OPERATING')
with self.assertRaises(TransitionNotAllowed):
park.transition_to_demolished(user=self.moderator)
def test_operating_cannot_directly_relocate(self):
"""Test that OPERATING cannot directly transition to RELOCATED."""
park = self._create_park(status='OPERATING')
with self.assertRaises(TransitionNotAllowed):
park.transition_to_relocated(user=self.moderator)
@@ -197,18 +198,18 @@ class ParkTransitionTests(TestCase):
def test_reopen_wrapper_method(self):
"""Test the reopen() wrapper method."""
park = self._create_park(status='CLOSED_TEMP')
park.reopen(user=self.user)
park.refresh_from_db()
self.assertEqual(park.status, 'OPERATING')
def test_close_temporarily_wrapper_method(self):
"""Test the close_temporarily() wrapper method."""
park = self._create_park(status='OPERATING')
park.close_temporarily(user=self.user)
park.refresh_from_db()
self.assertEqual(park.status, 'CLOSED_TEMP')
@@ -216,9 +217,9 @@ class ParkTransitionTests(TestCase):
"""Test the close_permanently() wrapper method."""
park = self._create_park(status='OPERATING')
closing = date(2025, 12, 31)
park.close_permanently(closing_date=closing, user=self.moderator)
park.refresh_from_db()
self.assertEqual(park.status, 'CLOSED_PERM')
self.assertEqual(park.closing_date, closing)
@@ -226,9 +227,9 @@ class ParkTransitionTests(TestCase):
def test_close_permanently_without_date(self):
"""Test close_permanently() without closing_date."""
park = self._create_park(status='OPERATING')
park.close_permanently(user=self.moderator)
park.refresh_from_db()
self.assertEqual(park.status, 'CLOSED_PERM')
self.assertIsNone(park.closing_date)
@@ -236,18 +237,18 @@ class ParkTransitionTests(TestCase):
def test_demolish_wrapper_method(self):
"""Test the demolish() wrapper method."""
park = self._create_park(status='CLOSED_PERM')
park.demolish(user=self.moderator)
park.refresh_from_db()
self.assertEqual(park.status, 'DEMOLISHED')
def test_relocate_wrapper_method(self):
"""Test the relocate() wrapper method."""
park = self._create_park(status='CLOSED_PERM')
park.relocate(user=self.moderator)
park.refresh_from_db()
self.assertEqual(park.status, 'RELOCATED')
@@ -300,18 +301,18 @@ class ParkTransitionHistoryTests(TestCase):
def test_transition_creates_state_log(self):
"""Test that transitions create StateLog entries."""
from django_fsm_log.models import StateLog
park = self._create_park(status='OPERATING')
park.transition_to_closed_temp(user=self.moderator)
park.save()
park_ct = ContentType.objects.get_for_model(park)
log = StateLog.objects.filter(
content_type=park_ct,
object_id=park.id
).first()
self.assertIsNotNone(log)
self.assertEqual(log.state, 'CLOSED_TEMP')
self.assertEqual(log.by, self.moderator)
@@ -319,23 +320,23 @@ class ParkTransitionHistoryTests(TestCase):
def test_multiple_transitions_create_multiple_logs(self):
"""Test that multiple transitions create multiple log entries."""
from django_fsm_log.models import StateLog
park = self._create_park(status='OPERATING')
park_ct = ContentType.objects.get_for_model(park)
# First transition
park.transition_to_closed_temp(user=self.moderator)
park.save()
# Second transition
park.transition_to_operating(user=self.moderator)
park.save()
logs = StateLog.objects.filter(
content_type=park_ct,
object_id=park.id
).order_by('timestamp')
self.assertEqual(logs.count(), 2)
self.assertEqual(logs[0].state, 'CLOSED_TEMP')
self.assertEqual(logs[1].state, 'OPERATING')
@@ -343,18 +344,18 @@ class ParkTransitionHistoryTests(TestCase):
def test_transition_log_includes_user(self):
"""Test that transition logs include the user who made the change."""
from django_fsm_log.models import StateLog
park = self._create_park(status='OPERATING')
park.transition_to_closed_perm(user=self.moderator)
park.save()
park_ct = ContentType.objects.get_for_model(park)
log = StateLog.objects.filter(
content_type=park_ct,
object_id=park.id
).first()
self.assertEqual(log.by, self.moderator)
@@ -388,7 +389,7 @@ class ParkBusinessLogicTests(TestCase):
operator=self.operator,
timezone='America/New_York'
)
self.assertEqual(park.operator, self.operator)
def test_park_slug_auto_generated(self):
@@ -399,7 +400,7 @@ class ParkBusinessLogicTests(TestCase):
operator=self.operator,
timezone='America/New_York'
)
self.assertEqual(park.slug, 'my-amazing-theme-park')
def test_park_url_generated(self):
@@ -411,7 +412,7 @@ class ParkBusinessLogicTests(TestCase):
operator=self.operator,
timezone='America/New_York'
)
self.assertIn('test-park', park.url)
def test_opening_year_computed_from_opening_date(self):
@@ -424,7 +425,7 @@ class ParkBusinessLogicTests(TestCase):
opening_date=date(2020, 6, 15),
timezone='America/New_York'
)
self.assertEqual(park.opening_year, 2020)
def test_search_text_populated(self):
@@ -436,7 +437,7 @@ class ParkBusinessLogicTests(TestCase):
operator=self.operator,
timezone='America/New_York'
)
self.assertIn('test park', park.search_text)
self.assertIn('wonderful theme park', park.search_text)
self.assertIn('test operator', park.search_text)
@@ -451,7 +452,7 @@ class ParkBusinessLogicTests(TestCase):
property_owner=self.property_owner,
timezone='America/New_York'
)
self.assertEqual(park.operator, self.operator)
self.assertEqual(park.property_owner, self.property_owner)
@@ -475,21 +476,22 @@ class ParkSlugHistoryTests(TestCase):
def test_historical_slug_created_on_name_change(self):
"""Test that historical slug is created when name changes."""
from django.contrib.contenttypes.models import ContentType
from apps.core.history import HistoricalSlug
park = Park.objects.create(
name='Original Name',
description='A test park',
operator=self.operator,
timezone='America/New_York'
)
original_slug = park.slug
# Change name
park.name = 'New Name'
park.save()
# Check historical slug was created
park_ct = ContentType.objects.get_for_model(park)
historical = HistoricalSlug.objects.filter(
@@ -497,7 +499,7 @@ class ParkSlugHistoryTests(TestCase):
object_id=park.id,
slug=original_slug
).first()
self.assertIsNotNone(historical)
self.assertEqual(historical.slug, original_slug)
@@ -510,32 +512,30 @@ class ParkSlugHistoryTests(TestCase):
operator=self.operator,
timezone='America/New_York'
)
found_park, is_historical = Park.get_by_slug('test-park')
self.assertEqual(found_park, park)
self.assertFalse(is_historical)
def test_get_by_slug_finds_historical_slug(self):
"""Test get_by_slug finds park by historical slug."""
from django.contrib.contenttypes.models import ContentType
from apps.core.history import HistoricalSlug
park = Park.objects.create(
name='Original Name',
description='A test park',
operator=self.operator,
timezone='America/New_York'
)
original_slug = park.slug
# Change name to create historical slug
park.name = 'New Name'
park.save()
# Find by historical slug
found_park, is_historical = Park.get_by_slug(original_slug)
self.assertEqual(found_park, park)
self.assertTrue(is_historical)

View File

@@ -5,20 +5,18 @@ These tests verify the functionality of park, area, company, location,
and review admin classes including query optimization and custom actions.
"""
import pytest
from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase
from apps.parks.admin import (
CompanyAdmin,
CompanyHeadquartersAdmin,
ParkAdmin,
ParkAreaAdmin,
ParkLocationAdmin,
ParkReviewAdmin,
)
from apps.parks.models import Company, CompanyHeadquarters, Park, ParkArea, ParkLocation, ParkReview
from apps.parks.models import Company, Park, ParkArea, ParkLocation, ParkReview
User = get_user_model()

View File

@@ -9,8 +9,8 @@ This module tests end-to-end park lifecycle workflows including:
- Related ride status updates
"""
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
User = get_user_model()
@@ -18,7 +18,7 @@ User = get_user_model()
class ParkOpeningWorkflowTests(TestCase):
"""Tests for park opening workflow."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
@@ -33,16 +33,16 @@ class ParkOpeningWorkflowTests(TestCase):
password='testpass123',
role='MODERATOR'
)
def _create_park(self, status='OPERATING', **kwargs):
"""Helper to create a park."""
from apps.parks.models import Park, Company
from apps.parks.models import Company, Park
operator = Company.objects.create(
name=f'Operator {status}',
roles=['OPERATOR']
)
defaults = {
'name': f'Test Park {status}',
'slug': f'test-park-{status.lower()}-{timezone.now().timestamp()}',
@@ -52,28 +52,28 @@ class ParkOpeningWorkflowTests(TestCase):
}
defaults.update(kwargs)
return Park.objects.create(**defaults)
def test_park_opens_from_under_construction(self):
"""
Test park opening from under construction state.
Flow: UNDER_CONSTRUCTION → OPERATING
"""
park = self._create_park(status='UNDER_CONSTRUCTION')
self.assertEqual(park.status, 'UNDER_CONSTRUCTION')
# Park opens
park.transition_to_operating(user=self.user)
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'OPERATING')
class ParkTemporaryClosureWorkflowTests(TestCase):
"""Tests for park temporary closure workflow."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
@@ -82,15 +82,15 @@ class ParkTemporaryClosureWorkflowTests(TestCase):
password='testpass123',
role='USER'
)
def _create_park(self, status='OPERATING', **kwargs):
from apps.parks.models import Park, Company
from apps.parks.models import Company, Park
operator = Company.objects.create(
name=f'Operator Temp {timezone.now().timestamp()}',
roles=['OPERATOR']
)
defaults = {
'name': f'Test Park Temp {timezone.now().timestamp()}',
'slug': f'test-park-temp-{timezone.now().timestamp()}',
@@ -100,35 +100,35 @@ class ParkTemporaryClosureWorkflowTests(TestCase):
}
defaults.update(kwargs)
return Park.objects.create(**defaults)
def test_park_temporary_closure_and_reopen(self):
"""
Test park temporary closure and reopening.
Flow: OPERATING → CLOSED_TEMP → OPERATING
"""
park = self._create_park(status='OPERATING')
self.assertEqual(park.status, 'OPERATING')
# Close temporarily (e.g., off-season)
park.transition_to_closed_temp(user=self.user)
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'CLOSED_TEMP')
# Reopen
park.transition_to_operating(user=self.user)
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'OPERATING')
class ParkPermanentClosureWorkflowTests(TestCase):
"""Tests for park permanent closure workflow."""
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
@@ -137,15 +137,15 @@ class ParkPermanentClosureWorkflowTests(TestCase):
password='testpass123',
role='MODERATOR'
)
def _create_park(self, status='OPERATING', **kwargs):
from apps.parks.models import Park, Company
from apps.parks.models import Company, Park
operator = Company.objects.create(
name=f'Operator Perm {timezone.now().timestamp()}',
roles=['OPERATOR']
)
defaults = {
'name': f'Test Park Perm {timezone.now().timestamp()}',
'slug': f'test-park-perm-{timezone.now().timestamp()}',
@@ -155,48 +155,48 @@ class ParkPermanentClosureWorkflowTests(TestCase):
}
defaults.update(kwargs)
return Park.objects.create(**defaults)
def test_park_permanent_closure(self):
"""
Test park permanent closure from operating state.
Flow: OPERATING → CLOSED_PERM
"""
park = self._create_park(status='OPERATING')
# Close permanently
park.transition_to_closed_perm(user=self.moderator)
park.closing_date = timezone.now().date()
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'CLOSED_PERM')
self.assertIsNotNone(park.closing_date)
def test_park_permanent_closure_from_temp(self):
"""
Test park permanent closure from temporary closure.
Flow: OPERATING → CLOSED_TEMP → CLOSED_PERM
"""
park = self._create_park(status='OPERATING')
# Temporary closure
park.transition_to_closed_temp(user=self.moderator)
park.save()
# Becomes permanent
park.transition_to_closed_perm(user=self.moderator)
park.closing_date = timezone.now().date()
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'CLOSED_PERM')
class ParkDemolitionWorkflowTests(TestCase):
"""Tests for park demolition workflow."""
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
@@ -205,15 +205,15 @@ class ParkDemolitionWorkflowTests(TestCase):
password='testpass123',
role='MODERATOR'
)
def _create_park(self, status='CLOSED_PERM', **kwargs):
from apps.parks.models import Park, Company
from apps.parks.models import Company, Park
operator = Company.objects.create(
name=f'Operator Demo {timezone.now().timestamp()}',
roles=['OPERATOR']
)
defaults = {
'name': f'Test Park Demo {timezone.now().timestamp()}',
'slug': f'test-park-demo-{timezone.now().timestamp()}',
@@ -223,28 +223,28 @@ class ParkDemolitionWorkflowTests(TestCase):
}
defaults.update(kwargs)
return Park.objects.create(**defaults)
def test_park_demolition_workflow(self):
"""
Test complete park demolition workflow.
Flow: OPERATING → CLOSED_PERM → DEMOLISHED
"""
park = self._create_park(status='CLOSED_PERM')
# Demolish
park.transition_to_demolished(user=self.moderator)
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'DEMOLISHED')
def test_demolished_is_final_state(self):
"""Test that demolished parks cannot transition further."""
from django_fsm import TransitionNotAllowed
park = self._create_park(status='DEMOLISHED')
# Cannot transition from demolished
with self.assertRaises(TransitionNotAllowed):
park.transition_to_operating(user=self.moderator)
@@ -252,7 +252,7 @@ class ParkDemolitionWorkflowTests(TestCase):
class ParkRelocationWorkflowTests(TestCase):
"""Tests for park relocation workflow."""
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
@@ -261,15 +261,15 @@ class ParkRelocationWorkflowTests(TestCase):
password='testpass123',
role='MODERATOR'
)
def _create_park(self, status='CLOSED_PERM', **kwargs):
from apps.parks.models import Park, Company
from apps.parks.models import Company, Park
operator = Company.objects.create(
name=f'Operator Reloc {timezone.now().timestamp()}',
roles=['OPERATOR']
)
defaults = {
'name': f'Test Park Reloc {timezone.now().timestamp()}',
'slug': f'test-park-reloc-{timezone.now().timestamp()}',
@@ -279,28 +279,28 @@ class ParkRelocationWorkflowTests(TestCase):
}
defaults.update(kwargs)
return Park.objects.create(**defaults)
def test_park_relocation_workflow(self):
"""
Test park relocation workflow.
Flow: OPERATING → CLOSED_PERM → RELOCATED
"""
park = self._create_park(status='CLOSED_PERM')
# Relocate
park.transition_to_relocated(user=self.moderator)
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'RELOCATED')
def test_relocated_is_final_state(self):
"""Test that relocated parks cannot transition further."""
from django_fsm import TransitionNotAllowed
park = self._create_park(status='RELOCATED')
# Cannot transition from relocated
with self.assertRaises(TransitionNotAllowed):
park.transition_to_operating(user=self.moderator)
@@ -308,7 +308,7 @@ class ParkRelocationWorkflowTests(TestCase):
class ParkWrapperMethodTests(TestCase):
"""Tests for park wrapper methods."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
@@ -323,15 +323,15 @@ class ParkWrapperMethodTests(TestCase):
password='testpass123',
role='MODERATOR'
)
def _create_park(self, status='OPERATING', **kwargs):
from apps.parks.models import Park, Company
from apps.parks.models import Company, Park
operator = Company.objects.create(
name=f'Operator Wrapper {timezone.now().timestamp()}',
roles=['OPERATOR']
)
defaults = {
'name': f'Test Park Wrapper {timezone.now().timestamp()}',
'slug': f'test-park-wrapper-{timezone.now().timestamp()}',
@@ -341,40 +341,40 @@ class ParkWrapperMethodTests(TestCase):
}
defaults.update(kwargs)
return Park.objects.create(**defaults)
def test_close_temporarily_wrapper(self):
"""Test close_temporarily wrapper method."""
park = self._create_park(status='OPERATING')
# Use wrapper method if it exists
if hasattr(park, 'close_temporarily'):
park.close_temporarily(user=self.user)
else:
park.transition_to_closed_temp(user=self.user)
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'CLOSED_TEMP')
def test_reopen_wrapper(self):
"""Test reopen wrapper method."""
park = self._create_park(status='CLOSED_TEMP')
# Use wrapper method if it exists
if hasattr(park, 'reopen'):
park.reopen(user=self.user)
else:
park.transition_to_operating(user=self.user)
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'OPERATING')
def test_close_permanently_wrapper(self):
"""Test close_permanently wrapper method."""
park = self._create_park(status='OPERATING')
closing_date = timezone.now().date()
# Use wrapper method if it exists
if hasattr(park, 'close_permanently'):
park.close_permanently(closing_date=closing_date, user=self.moderator)
@@ -382,24 +382,24 @@ class ParkWrapperMethodTests(TestCase):
park.transition_to_closed_perm(user=self.moderator)
park.closing_date = closing_date
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'CLOSED_PERM')
def test_demolish_wrapper(self):
"""Test demolish wrapper method."""
park = self._create_park(status='CLOSED_PERM')
# Use wrapper method if it exists
if hasattr(park, 'demolish'):
park.demolish(user=self.moderator)
else:
park.transition_to_demolished(user=self.moderator)
park.save()
park.refresh_from_db()
self.assertEqual(park.status, 'DEMOLISHED')
def test_relocate_wrapper(self):
"""Test relocate wrapper method."""
park = self._create_park(status='CLOSED_PERM')
@@ -434,7 +434,7 @@ class ParkStateLogTests(TestCase):
)
def _create_park(self, status='OPERATING', **kwargs):
from apps.parks.models import Park, Company
from apps.parks.models import Company, Park
operator = Company.objects.create(
name=f'Operator Log {timezone.now().timestamp()}',
@@ -453,8 +453,8 @@ class ParkStateLogTests(TestCase):
def test_transition_creates_state_log(self):
"""Test that park transitions create StateLog entries."""
from django_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
park = self._create_park(status='OPERATING')
park_ct = ContentType.objects.get_for_model(park)
@@ -475,8 +475,8 @@ class ParkStateLogTests(TestCase):
def test_multiple_transitions_logged(self):
"""Test that multiple park transitions are all logged."""
from django_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
park = self._create_park(status='OPERATING')
park_ct = ContentType.objects.get_for_model(park)
@@ -503,8 +503,8 @@ class ParkStateLogTests(TestCase):
def test_full_lifecycle_logged(self):
"""Test complete park lifecycle is logged."""
from django_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
park = self._create_park(status='OPERATING')
park_ct = ContentType.objects.get_for_model(park)

View File

@@ -7,11 +7,11 @@ These tests verify that:
3. Computed fields are updated correctly
"""
from django.test import TestCase
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from apps.parks.models import Park, ParkLocation, Company
from apps.parks.models import Company, Park, ParkLocation
class ParkQueryOptimizationTests(TestCase):
@@ -225,10 +225,9 @@ class ComputedFieldMaintenanceTests(TestCase):
)
# Initially no location in search_text
original_search_text = park.search_text
# Add location
location = ParkLocation.objects.create(
ParkLocation.objects.create(
park=park,
city="Orlando",
state="Florida",

View File

@@ -3,11 +3,12 @@ Tests for park filtering functionality including search, status filtering,
date ranges, and numeric validations.
"""
from django.test import TestCase
from datetime import date
from apps.parks.models import Park, ParkLocation, Company
from django.test import TestCase
from apps.parks.filters import ParkFilter
from apps.parks.models import Company, Park, ParkLocation
# NOTE: These tests need to be updated to work with the new ParkLocation model
# instead of the generic Location model

View File

@@ -3,10 +3,10 @@ Tests for park models functionality including CRUD operations,
slug handling, status management, and location integration.
"""
from django.test import TestCase
from django.db import IntegrityError
from django.test import TestCase
from apps.parks.models import Park, ParkArea, ParkLocation, Company
from apps.parks.models import Company, Park, ParkArea, ParkLocation
# NOTE: These tests need to be updated to work with the new ParkLocation model
# instead of the generic Location model
@@ -55,9 +55,9 @@ class ParkModelTests(TestCase):
def test_historical_slug_lookup(self):
"""Test finding park by historical slug"""
from django.db import transaction
from django.contrib.contenttypes.models import ContentType
from core.history import HistoricalSlug
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
with transaction.atomic():
# Create initial park with a specific name/slug
@@ -162,12 +162,11 @@ class ParkAreaModelTests(TestCase):
from django.db import transaction
# Try to create area with same slug in same park
with transaction.atomic():
with self.assertRaises(IntegrityError):
ParkArea.objects.create(
park=self.park,
name="Test Area", # Will generate same slug
)
with transaction.atomic(), self.assertRaises(IntegrityError):
ParkArea.objects.create(
park=self.park,
name="Test Area", # Will generate same slug
)
# Should be able to use same name in different park
other_park = Park.objects.create(name="Other Park", operator=self.operator)

View File

@@ -1,9 +1,9 @@
import pytest
from django.urls import reverse
from django.test import Client
from django.urls import reverse
from apps.parks.models import Park
from apps.parks.forms import ParkAutocomplete, ParkSearchForm
from apps.parks.models import Park
@pytest.mark.django_db

View File

@@ -1,14 +1,16 @@
from django.urls import path, include
from . import views, views_search
from apps.rides.views import ParkSingleCategoryListView
from django.urls import include, path
from apps.core.views.views import FSMTransitionView
from apps.rides.views import ParkSingleCategoryListView
from . import views, views_search
from .views_roadtrip import (
RoadTripPlannerView,
CreateTripView,
TripDetailView,
FindParksAlongRouteView,
GeocodeAddressView,
ParkDistanceCalculatorView,
RoadTripPlannerView,
TripDetailView,
)
app_name = "parks"

View File

@@ -1,41 +1,41 @@
from .querysets import get_base_park_queryset
from apps.core.mixins import HTMXFilterableMixin
from .models.location import ParkLocation
from .models.media import ParkPhoto
from apps.moderation.mixins import (
EditSubmissionMixin,
PhotoSubmissionMixin,
HistoryMixin,
)
from apps.core.views.views import SlugRedirectMixin
from .filters import ParkFilter
from .forms import ParkForm
from .models import Park, ParkArea, ParkReview as Review
from .services import ParkFilterService, ParkService
from django.http import (
HttpResponseRedirect,
HttpResponse,
HttpRequest,
JsonResponse,
)
from django.core.exceptions import ObjectDoesNotExist
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import QuerySet
from django.urls import reverse
from django.shortcuts import get_object_or_404, render
from decimal import InvalidOperation
from django.views.generic import DetailView, ListView, CreateView, UpdateView
import requests
from decimal import Decimal, ROUND_DOWN
from typing import Any, Optional, cast, Literal, Dict
from django.views.decorators.http import require_POST
from django.template.loader import render_to_string
import contextlib
import json
import logging
from decimal import ROUND_DOWN, Decimal, InvalidOperation
from typing import Any, Literal, cast
from apps.core.logging import log_exception, log_business_event
import requests
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import QuerySet
from django.http import (
HttpRequest,
HttpResponse,
HttpResponseRedirect,
JsonResponse,
)
from django.shortcuts import get_object_or_404, render
from django.template.loader import render_to_string
from django.urls import reverse
from django.views.decorators.http import require_POST
from django.views.generic import CreateView, DetailView, ListView, UpdateView
from apps.core.logging import log_business_event, log_exception
from apps.core.mixins import HTMXFilterableMixin
from apps.core.views.views import SlugRedirectMixin
from apps.moderation.mixins import (
EditSubmissionMixin,
HistoryMixin,
PhotoSubmissionMixin,
)
from .filters import ParkFilter
from .forms import ParkForm
from .models import Park, ParkArea
from .models import ParkReview as Review
from .querysets import get_base_park_queryset
from .services import ParkFilterService, ParkService
logger = logging.getLogger(__name__)
@@ -364,7 +364,7 @@ class ParkListView(HTMXFilterableMixin, ListView):
"search_query": self.request.GET.get("search", ""),
}
def _get_clean_filter_params(self) -> Dict[str, Any]:
def _get_clean_filter_params(self) -> dict[str, Any]:
"""Extract and clean filter parameters from request."""
filter_params = {}
@@ -391,7 +391,7 @@ class ParkListView(HTMXFilterableMixin, ListView):
return {k: v for k, v in filter_params.items() if v is not None}
def _clean_filter_value(self, param: str, value: str) -> Optional[Any]:
def _clean_filter_value(self, param: str, value: str) -> Any | None:
"""Clean and validate a single filter value."""
if param in ("has_coasters", "big_parks_only"):
# Boolean filters
@@ -413,7 +413,7 @@ class ParkListView(HTMXFilterableMixin, ListView):
# String filters
return value.strip()
def _build_filter_query_string(self, filter_params: Dict[str, Any]) -> str:
def _build_filter_query_string(self, filter_params: dict[str, Any]) -> str:
"""Build query string from filter parameters."""
from urllib.parse import urlencode
@@ -428,8 +428,8 @@ class ParkListView(HTMXFilterableMixin, ListView):
return urlencode(url_params)
def _get_pagination_urls(
self, page_obj, filter_params: Dict[str, Any]
) -> Dict[str, str]:
self, page_obj, filter_params: dict[str, Any]
) -> dict[str, str]:
"""Generate pagination URLs that preserve filter state."""
base_query = self._build_filter_query_string(filter_params)
@@ -841,10 +841,8 @@ def htmx_save_trip(request: HttpRequest) -> HttpResponse:
trip = Trip.objects.create(owner=request.user, name=name)
# attempt to associate parks if the Trip model supports it
try:
with contextlib.suppress(Exception):
trip.parks.set([p.id for p in parks])
except Exception:
pass
trips = list(
Trip.objects.filter(owner=request.user).order_by("-created_at")[:10]
)
@@ -1133,7 +1131,7 @@ class ParkDetailView(
template_name = "parks/park_detail.html"
context_object_name = "park"
def get_object(self, queryset: Optional[QuerySet[Park]] = None) -> Park:
def get_object(self, queryset: QuerySet[Park] | None = None) -> Park:
if queryset is None:
queryset = self.get_queryset()
slug = self.kwargs.get(self.slug_url_kwarg)
@@ -1184,7 +1182,7 @@ class ParkAreaDetailView(
context_object_name = "area"
slug_url_kwarg = "area_slug"
def get_object(self, queryset: Optional[QuerySet[ParkArea]] = None) -> ParkArea:
def get_object(self, queryset: QuerySet[ParkArea] | None = None) -> ParkArea:
if queryset is None:
queryset = self.get_queryset()
park_slug = self.kwargs.get("park_slug")
@@ -1217,9 +1215,10 @@ class OperatorListView(ListView):
def get_queryset(self):
"""Get companies that are operators with optimized query"""
from .models.companies import Company
from django.db.models import Count
from .models.companies import Company
return (
Company.objects.filter(roles__contains=["OPERATOR"])
.annotate(park_count=Count("operated_parks"))

View File

@@ -4,16 +4,18 @@ Provides interfaces for creating and managing multi-park road trips.
"""
import json
from typing import Dict, Any, List
from typing import Any
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import render
from django.http import JsonResponse, HttpRequest, HttpResponse
from django.views.generic import TemplateView, View
from django.urls import reverse
from django.views.generic import TemplateView, View
from apps.core.services.data_structures import LocationType
from apps.core.services.map_service import unified_map_service
from .models import Park
from .services.roadtrip import RoadTripService
from apps.core.services.map_service import unified_map_service
from apps.core.services.data_structures import LocationType
JSON_DECODE_ERROR_MSG = "Invalid JSON data"
PARKS_ALONG_ROUTE_HTML = "parks/partials/parks_along_route.html"
@@ -26,7 +28,7 @@ class RoadTripViewMixin:
super().__init__()
self.roadtrip_service = RoadTripService()
def get_roadtrip_context(self) -> Dict[str, Any]:
def get_roadtrip_context(self) -> dict[str, Any]:
"""Get common context data for road trip views."""
return {
"roadtrip_api_urls": {
@@ -72,7 +74,7 @@ class RoadTripPlannerView(RoadTripViewMixin, TemplateView):
return context
def _get_countries_with_parks(self) -> List[str]:
def _get_countries_with_parks(self) -> list[str]:
"""Get list of countries that have theme parks."""
countries = (
Park.objects.filter(status="OPERATING", location__country__isnull=False)
@@ -177,7 +179,7 @@ class CreateTripView(RoadTripViewMixin, View):
status=500,
)
def _park_to_dict(self, park: Park) -> Dict[str, Any]:
def _park_to_dict(self, park: Park) -> dict[str, Any]:
"""Convert park instance to dictionary."""
return {
"id": park.id,
@@ -190,7 +192,7 @@ class CreateTripView(RoadTripViewMixin, View):
"url": reverse("parks:park_detail", kwargs={"slug": park.slug}),
}
def _leg_to_dict(self, leg) -> Dict[str, Any]:
def _leg_to_dict(self, leg) -> dict[str, Any]:
"""Convert trip leg to dictionary."""
return {
"from_park": self._park_to_dict(leg.from_park),

View File

@@ -1,6 +1,6 @@
from django.http import HttpRequest, JsonResponse
from django.views.generic import TemplateView
from django.urls import reverse
from django.views.generic import TemplateView
from .filters import ParkFilter
from .forms import ParkSearchForm
@@ -23,10 +23,7 @@ class ParkSearchView(TemplateView):
# Apply search if park ID selected via autocomplete
park_id = self.request.GET.get("park")
if park_id:
queryset = filter_instance.qs.filter(id=park_id)
else:
queryset = filter_instance.qs
queryset = filter_instance.qs.filter(id=park_id) if park_id else filter_instance.qs
# Handle view mode
context["view_mode"] = self.request.GET.get("view_mode", "grid")