mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-29 23:27:03 -05:00
feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.parks.models import Park
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;")
|
||||
|
||||
@@ -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');
|
||||
""",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pghistory
|
||||
from django.contrib.gis.db import models
|
||||
from django.contrib.gis.geos import Point
|
||||
import pghistory
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.db.models import QuerySet, Count, Q
|
||||
from django.db.models import Count, Q, QuerySet
|
||||
|
||||
from .models import Park
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user