mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-21 23:51:09 -05:00
major changes, including tailwind v4
This commit is contained in:
122
parks/admin.py
122
parks/admin.py
@@ -1,13 +1,67 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.gis.admin import GISModelAdmin
|
||||
from django.utils.html import format_html
|
||||
from .models import Park, ParkArea
|
||||
from .models import Park, ParkArea, ParkLocation, Company, CompanyHeadquarters
|
||||
|
||||
class ParkLocationInline(admin.StackedInline):
|
||||
"""Inline admin for ParkLocation"""
|
||||
model = ParkLocation
|
||||
extra = 0
|
||||
fields = (
|
||||
('city', 'state', 'country'),
|
||||
'street_address',
|
||||
'postal_code',
|
||||
'point',
|
||||
('highway_exit', 'best_arrival_time'),
|
||||
'parking_notes',
|
||||
'seasonal_notes',
|
||||
('osm_id', 'osm_type'),
|
||||
)
|
||||
|
||||
|
||||
class ParkLocationAdmin(GISModelAdmin):
|
||||
"""Admin for standalone ParkLocation management"""
|
||||
list_display = ('park', 'city', 'state', 'country', 'latitude', 'longitude')
|
||||
list_filter = ('country', 'state')
|
||||
search_fields = ('park__name', 'city', 'state', 'country', 'street_address')
|
||||
readonly_fields = ('latitude', 'longitude', 'coordinates')
|
||||
fieldsets = (
|
||||
('Park', {
|
||||
'fields': ('park',)
|
||||
}),
|
||||
('Address', {
|
||||
'fields': ('street_address', 'city', 'state', 'country', 'postal_code')
|
||||
}),
|
||||
('Geographic Coordinates', {
|
||||
'fields': ('point', 'latitude', 'longitude', 'coordinates'),
|
||||
'description': 'Set coordinates by clicking on the map or entering latitude/longitude'
|
||||
}),
|
||||
('Travel Information', {
|
||||
'fields': ('highway_exit', 'best_arrival_time', 'parking_notes', 'seasonal_notes'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('OpenStreetMap Integration', {
|
||||
'fields': ('osm_id', 'osm_type'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
def latitude(self, obj):
|
||||
return obj.latitude
|
||||
latitude.short_description = 'Latitude'
|
||||
|
||||
def longitude(self, obj):
|
||||
return obj.longitude
|
||||
longitude.short_description = 'Longitude'
|
||||
|
||||
|
||||
class ParkAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'formatted_location', 'status', 'operator', 'property_owner', 'created_at', 'updated_at')
|
||||
list_filter = ('status',)
|
||||
search_fields = ('name', 'description', 'location__name', 'location__city', 'location__country')
|
||||
list_filter = ('status', 'location__country', 'location__state')
|
||||
search_fields = ('name', 'description', 'location__city', 'location__state', 'location__country')
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
inlines = [ParkLocationInline]
|
||||
|
||||
def formatted_location(self, obj):
|
||||
"""Display formatted location string"""
|
||||
@@ -21,6 +75,68 @@ class ParkAreaAdmin(admin.ModelAdmin):
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
|
||||
|
||||
class CompanyHeadquartersInline(admin.StackedInline):
|
||||
"""Inline admin for CompanyHeadquarters"""
|
||||
model = CompanyHeadquarters
|
||||
extra = 0
|
||||
fields = (
|
||||
('city', 'state_province', 'country'),
|
||||
'street_address',
|
||||
'postal_code',
|
||||
'mailing_address',
|
||||
)
|
||||
|
||||
|
||||
class CompanyHeadquartersAdmin(admin.ModelAdmin):
|
||||
"""Admin for standalone CompanyHeadquarters management"""
|
||||
list_display = ('company', 'location_display', 'city', 'country', 'created_at')
|
||||
list_filter = ('country', 'state_province')
|
||||
search_fields = ('company__name', 'city', 'state_province', 'country', 'street_address')
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
fieldsets = (
|
||||
('Company', {
|
||||
'fields': ('company',)
|
||||
}),
|
||||
('Address', {
|
||||
'fields': ('street_address', 'city', 'state_province', 'country', 'postal_code')
|
||||
}),
|
||||
('Additional Information', {
|
||||
'fields': ('mailing_address',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
class CompanyAdmin(admin.ModelAdmin):
|
||||
"""Enhanced Company admin with headquarters inline"""
|
||||
list_display = ('name', 'roles_display', 'headquarters_location', 'website', 'founded_year')
|
||||
list_filter = ('roles',)
|
||||
search_fields = ('name', 'description')
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
inlines = [CompanyHeadquartersInline]
|
||||
|
||||
def roles_display(self, obj):
|
||||
"""Display roles as a formatted string"""
|
||||
return ', '.join(obj.roles) if obj.roles else 'No roles'
|
||||
roles_display.short_description = 'Roles'
|
||||
|
||||
def headquarters_location(self, obj):
|
||||
"""Display headquarters location if available"""
|
||||
if hasattr(obj, 'headquarters'):
|
||||
return obj.headquarters.location_display
|
||||
return 'No headquarters'
|
||||
headquarters_location.short_description = 'Headquarters'
|
||||
|
||||
|
||||
# Register the models with their admin classes
|
||||
admin.site.register(Park, ParkAdmin)
|
||||
admin.site.register(ParkArea, ParkAreaAdmin)
|
||||
admin.site.register(ParkLocation, ParkLocationAdmin)
|
||||
admin.site.register(Company, CompanyAdmin)
|
||||
admin.site.register(CompanyHeadquarters, CompanyHeadquartersAdmin)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db import models
|
||||
from search.filters import LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin
|
||||
from django_filters import (
|
||||
NumberFilter,
|
||||
ModelChoiceFilter,
|
||||
@@ -11,9 +10,8 @@ from django_filters import (
|
||||
CharFilter,
|
||||
BooleanFilter
|
||||
)
|
||||
from .models import Park
|
||||
from .models import Park, Company
|
||||
from .querysets import get_base_park_queryset
|
||||
from operators.models import Operator
|
||||
|
||||
def validate_positive_integer(value):
|
||||
"""Validate that a value is a positive integer"""
|
||||
@@ -25,7 +23,7 @@ def validate_positive_integer(value):
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(_('Invalid number format'))
|
||||
|
||||
class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, FilterSet):
|
||||
class ParkFilter(FilterSet):
|
||||
"""Filter set for parks with search and validation capabilities"""
|
||||
class Meta:
|
||||
model = Park
|
||||
@@ -49,8 +47,8 @@ class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, F
|
||||
|
||||
# Operator filters with helpful descriptions
|
||||
operator = ModelChoiceFilter(
|
||||
field_name='operator',
|
||||
queryset=Operator.objects.all(),
|
||||
field_name='operating_company',
|
||||
queryset=Company.objects.filter(roles__contains=['OPERATOR']),
|
||||
empty_label=_('Any operator'),
|
||||
label=_("Operating Company"),
|
||||
help_text=_("Filter parks by their operating company")
|
||||
@@ -115,7 +113,7 @@ class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, F
|
||||
|
||||
def filter_has_operator(self, queryset, name, value):
|
||||
"""Filter parks based on whether they have an operator"""
|
||||
return queryset.filter(operator__isnull=not value)
|
||||
return queryset.filter(operating_company__isnull=not value)
|
||||
|
||||
@property
|
||||
def qs(self):
|
||||
|
||||
@@ -4,7 +4,7 @@ from autocomplete import AutocompleteWidget
|
||||
|
||||
from core.forms import BaseAutocomplete
|
||||
from .models import Park
|
||||
from location.models import Location
|
||||
from .models.location import ParkLocation
|
||||
from .querysets import get_base_park_queryset
|
||||
|
||||
|
||||
@@ -262,13 +262,49 @@ class ParkForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
# Handle location: update if exists, create if not
|
||||
if park.location.exists():
|
||||
location = park.location.first()
|
||||
try:
|
||||
park_location = park.location
|
||||
# Update existing location
|
||||
for key, value in location_data.items():
|
||||
setattr(location, key, value)
|
||||
location.save()
|
||||
else:
|
||||
Location.objects.create(content_object=park, **location_data)
|
||||
if key in ['latitude', 'longitude'] and value:
|
||||
continue # Handle coordinates separately
|
||||
if hasattr(park_location, key):
|
||||
setattr(park_location, key, value)
|
||||
|
||||
# Handle coordinates if provided
|
||||
if 'latitude' in location_data and 'longitude' in location_data:
|
||||
if location_data['latitude'] and location_data['longitude']:
|
||||
park_location.set_coordinates(
|
||||
float(location_data['latitude']),
|
||||
float(location_data['longitude'])
|
||||
)
|
||||
park_location.save()
|
||||
except ParkLocation.DoesNotExist:
|
||||
# Create new ParkLocation
|
||||
coordinates_data = {}
|
||||
if 'latitude' in location_data and 'longitude' in location_data:
|
||||
if location_data['latitude'] and location_data['longitude']:
|
||||
coordinates_data = {
|
||||
'latitude': float(location_data['latitude']),
|
||||
'longitude': float(location_data['longitude'])
|
||||
}
|
||||
|
||||
# Remove coordinate fields from location_data for creation
|
||||
creation_data = {k: v for k, v in location_data.items()
|
||||
if k not in ['latitude', 'longitude']}
|
||||
creation_data.setdefault('country', 'USA')
|
||||
|
||||
park_location = ParkLocation.objects.create(
|
||||
park=park,
|
||||
**creation_data
|
||||
)
|
||||
|
||||
if coordinates_data:
|
||||
park_location.set_coordinates(
|
||||
coordinates_data['latitude'],
|
||||
coordinates_data['longitude']
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
if commit:
|
||||
park.save()
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.temp import NamedTemporaryFile
|
||||
from django.core.files import File
|
||||
import requests
|
||||
from parks.models import Park
|
||||
from rides.models import Ride, RollerCoasterStats
|
||||
from operators.models import Operator
|
||||
from manufacturers.models import Manufacturer
|
||||
from reviews.models import Review
|
||||
from media.models import Photo
|
||||
from django.contrib.auth.models import Permission
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Park coordinates mapping
|
||||
PARK_COORDINATES = {
|
||||
"Walt Disney World Magic Kingdom": {
|
||||
"latitude": "28.418778",
|
||||
"longitude": "-81.581212",
|
||||
"street_address": "1180 Seven Seas Dr",
|
||||
"city": "Orlando",
|
||||
"state": "Florida",
|
||||
"postal_code": "32836"
|
||||
},
|
||||
"Cedar Point": {
|
||||
"latitude": "41.482207",
|
||||
"longitude": "-82.683523",
|
||||
"street_address": "1 Cedar Point Dr",
|
||||
"city": "Sandusky",
|
||||
"state": "Ohio",
|
||||
"postal_code": "44870"
|
||||
},
|
||||
"Universal's Islands of Adventure": {
|
||||
"latitude": "28.470891",
|
||||
"longitude": "-81.471756",
|
||||
"street_address": "6000 Universal Blvd",
|
||||
"city": "Orlando",
|
||||
"state": "Florida",
|
||||
"postal_code": "32819"
|
||||
},
|
||||
"Alton Towers": {
|
||||
"latitude": "52.988889",
|
||||
"longitude": "-1.892778",
|
||||
"street_address": "Farley Ln",
|
||||
"city": "Alton",
|
||||
"state": "Staffordshire",
|
||||
"postal_code": "ST10 4DB"
|
||||
},
|
||||
"Europa-Park": {
|
||||
"latitude": "48.266031",
|
||||
"longitude": "7.722044",
|
||||
"street_address": "Europa-Park-Straße 2",
|
||||
"city": "Rust",
|
||||
"state": "Baden-Württemberg",
|
||||
"postal_code": "77977"
|
||||
}
|
||||
}
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seeds the database with initial data"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
self.stdout.write("Starting database seed...")
|
||||
|
||||
# Clean up media directory
|
||||
self.stdout.write("Cleaning up media directory...")
|
||||
media_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'media')
|
||||
if os.path.exists(media_dir):
|
||||
for item in os.listdir(media_dir):
|
||||
if item != '__init__.py': # Preserve __init__.py
|
||||
item_path = os.path.join(media_dir, item)
|
||||
if os.path.isdir(item_path):
|
||||
shutil.rmtree(item_path)
|
||||
else:
|
||||
os.remove(item_path)
|
||||
|
||||
# Delete all existing data
|
||||
self.stdout.write("Deleting existing data...")
|
||||
User.objects.exclude(username='admin').delete() # Delete all users except admin
|
||||
Park.objects.all().delete()
|
||||
Ride.objects.all().delete()
|
||||
Operator.objects.all().delete()
|
||||
Manufacturer.objects.all().delete()
|
||||
Review.objects.all().delete()
|
||||
Photo.objects.all().delete()
|
||||
|
||||
# Create users and set permissions
|
||||
self.create_users()
|
||||
self.setup_permissions()
|
||||
|
||||
# Create parks and rides
|
||||
self.stdout.write("Creating parks and rides from seed data...")
|
||||
self.create_companies()
|
||||
self.create_manufacturers()
|
||||
self.create_parks_and_rides()
|
||||
|
||||
# Create reviews
|
||||
self.stdout.write("Creating reviews...")
|
||||
self.create_reviews()
|
||||
|
||||
self.stdout.write("Successfully seeded database")
|
||||
|
||||
def setup_permissions(self):
|
||||
"""Set up photo permissions for all users"""
|
||||
self.stdout.write("Setting up photo permissions...")
|
||||
|
||||
# Get photo permissions
|
||||
photo_content_type = ContentType.objects.get_for_model(Photo)
|
||||
photo_permissions = Permission.objects.filter(content_type=photo_content_type)
|
||||
|
||||
# Update all users
|
||||
users = User.objects.all()
|
||||
for user in users:
|
||||
for perm in photo_permissions:
|
||||
user.user_permissions.add(perm)
|
||||
user.save()
|
||||
self.stdout.write(f"Updated permissions for user: {user.username}")
|
||||
|
||||
def create_users(self):
|
||||
self.stdout.write("Creating users...")
|
||||
|
||||
# Try to get admin user
|
||||
try:
|
||||
admin = User.objects.get(username="admin")
|
||||
self.stdout.write("Admin user exists, updating permissions...")
|
||||
except User.DoesNotExist:
|
||||
admin = User.objects.create_superuser("admin", "admin@example.com", "admin")
|
||||
self.stdout.write("Created admin user")
|
||||
|
||||
# Create 10 regular users
|
||||
usernames = [
|
||||
"thrillseeker1",
|
||||
"coasterrider2",
|
||||
"parkfan3",
|
||||
"adventurer4",
|
||||
"funseeker5",
|
||||
"parkexplorer6",
|
||||
"ridetester7",
|
||||
"themepark8",
|
||||
"coaster9",
|
||||
"parkvisitor10"
|
||||
]
|
||||
|
||||
for username in usernames:
|
||||
User.objects.create_user(
|
||||
username=username,
|
||||
email=f"{username}@example.com",
|
||||
[PASSWORD-REMOVED]",
|
||||
)
|
||||
self.stdout.write(f"Created user: {username}")
|
||||
|
||||
def create_companies(self):
|
||||
self.stdout.write("Creating companies...")
|
||||
|
||||
companies = [
|
||||
"The Walt Disney Company",
|
||||
"Cedar Fair",
|
||||
"NBCUniversal",
|
||||
"Merlin Entertainments",
|
||||
"Mack Rides",
|
||||
]
|
||||
|
||||
for name in companies:
|
||||
Operator.objects.create(name=name)
|
||||
self.stdout.write(f"Created company: {name}")
|
||||
|
||||
def create_manufacturers(self):
|
||||
self.stdout.write("Creating manufacturers...")
|
||||
|
||||
manufacturers = [
|
||||
"Walt Disney Imagineering",
|
||||
"Bolliger & Mabillard",
|
||||
"Intamin",
|
||||
"Rocky Mountain Construction",
|
||||
"Vekoma",
|
||||
"Mack Rides",
|
||||
"Oceaneering International",
|
||||
]
|
||||
|
||||
for name in manufacturers:
|
||||
Manufacturer.objects.create(name=name)
|
||||
self.stdout.write(f"Created manufacturer: {name}")
|
||||
|
||||
def download_image(self, url):
|
||||
"""Download image from URL and return as Django File object"""
|
||||
response = requests.get(url, timeout=60)
|
||||
if response.status_code == 200:
|
||||
img_temp = NamedTemporaryFile(delete=True)
|
||||
img_temp.write(response.content)
|
||||
img_temp.flush()
|
||||
return File(img_temp)
|
||||
return None
|
||||
|
||||
def create_parks_and_rides(self):
|
||||
# Load seed data
|
||||
with open(os.path.join(os.path.dirname(__file__), "seed_data.json")) as f:
|
||||
data = json.load(f)
|
||||
|
||||
for park_data in data["parks"]:
|
||||
try:
|
||||
# Create park with location data
|
||||
park_coords = PARK_COORDINATES[park_data["name"]]
|
||||
park = Park.objects.create(
|
||||
name=park_data["name"],
|
||||
country=park_data["country"],
|
||||
opening_date=park_data["opening_date"],
|
||||
status=park_data["status"],
|
||||
description=park_data["description"],
|
||||
website=park_data["website"],
|
||||
operator=Operator.objects.get(name=park_data["owner"]),
|
||||
size_acres=park_data["size_acres"],
|
||||
# Add location fields
|
||||
latitude=park_coords["latitude"],
|
||||
longitude=park_coords["longitude"],
|
||||
street_address=park_coords["street_address"],
|
||||
city=park_coords["city"],
|
||||
state=park_coords["state"],
|
||||
postal_code=park_coords["postal_code"]
|
||||
)
|
||||
|
||||
# Add park photos
|
||||
for photo_url in park_data["photos"]:
|
||||
img_file = self.download_image(photo_url)
|
||||
if img_file:
|
||||
Photo.objects.create(
|
||||
image=img_file,
|
||||
content_type=ContentType.objects.get_for_model(park),
|
||||
object_id=park.id,
|
||||
is_primary=True, # First photo is primary
|
||||
)
|
||||
|
||||
# Create rides
|
||||
for ride_data in park_data["rides"]:
|
||||
ride = Ride.objects.create(
|
||||
name=ride_data["name"],
|
||||
park=park,
|
||||
category=ride_data["category"],
|
||||
opening_date=ride_data["opening_date"],
|
||||
status=ride_data["status"],
|
||||
manufacturer=Manufacturer.objects.get(
|
||||
name=ride_data["manufacturer"]
|
||||
),
|
||||
description=ride_data["description"],
|
||||
)
|
||||
|
||||
# Add ride photos
|
||||
for photo_url in ride_data["photos"]:
|
||||
img_file = self.download_image(photo_url)
|
||||
if img_file:
|
||||
Photo.objects.create(
|
||||
image=img_file,
|
||||
content_type=ContentType.objects.get_for_model(ride),
|
||||
object_id=ride.id,
|
||||
is_primary=True, # First photo is primary
|
||||
)
|
||||
|
||||
# Add coaster stats if present
|
||||
if "stats" in ride_data:
|
||||
RollerCoasterStats.objects.create(
|
||||
ride=ride,
|
||||
height_ft=ride_data["stats"]["height_ft"],
|
||||
length_ft=ride_data["stats"]["length_ft"],
|
||||
speed_mph=ride_data["stats"]["speed_mph"],
|
||||
inversions=ride_data["stats"]["inversions"],
|
||||
ride_time_seconds=ride_data["stats"]["ride_time_seconds"],
|
||||
)
|
||||
|
||||
self.stdout.write(f"Created park and rides: {park.name}")
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"Error creating park {park_data['name']}: {str(e)}"))
|
||||
continue
|
||||
|
||||
def create_reviews(self):
|
||||
users = list(User.objects.exclude(username="admin"))
|
||||
parks = list(Park.objects.all())
|
||||
|
||||
# Generate random dates within the last year
|
||||
today = datetime.now().date()
|
||||
one_year_ago = today - timedelta(days=365)
|
||||
|
||||
for park in parks:
|
||||
# Create 3-5 reviews per park
|
||||
num_reviews = secrets.SystemRandom().randint(3, 5)
|
||||
for _ in range(num_reviews):
|
||||
# Generate random visit date
|
||||
days_offset = secrets.SystemRandom().randint(0, 365)
|
||||
visit_date = one_year_ago + timedelta(days=days_offset)
|
||||
|
||||
Review.objects.create(
|
||||
user=secrets.choice(users),
|
||||
content_type=ContentType.objects.get_for_model(park),
|
||||
object_id=park.id,
|
||||
title=f"Great experience at {park.name}",
|
||||
content="Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
rating=secrets.SystemRandom().randint(7, 10),
|
||||
visit_date=visit_date,
|
||||
)
|
||||
self.stdout.write(f"Created reviews for {park.name}")
|
||||
@@ -1,9 +1,8 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from operators.models import Operator
|
||||
from parks.models.companies import Operator
|
||||
from parks.models import Park, ParkArea
|
||||
from location.models import Location
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from parks.models.location import ParkLocation
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Seeds initial park data with major theme parks worldwide'
|
||||
@@ -218,18 +217,20 @@ class Command(BaseCommand):
|
||||
# Create location for park
|
||||
if created:
|
||||
loc_data = park_data['location']
|
||||
park_content_type = ContentType.objects.get_for_model(Park)
|
||||
Location.objects.create(
|
||||
content_type=park_content_type,
|
||||
object_id=park.id,
|
||||
park_location = ParkLocation.objects.create(
|
||||
park=park,
|
||||
street_address=loc_data['street_address'],
|
||||
city=loc_data['city'],
|
||||
state=loc_data['state'],
|
||||
country=loc_data['country'],
|
||||
postal_code=loc_data['postal_code'],
|
||||
latitude=loc_data['latitude'],
|
||||
longitude=loc_data['longitude']
|
||||
postal_code=loc_data['postal_code']
|
||||
)
|
||||
# Set coordinates using the helper method
|
||||
park_location.set_coordinates(
|
||||
loc_data['latitude'],
|
||||
loc_data['longitude']
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
# Create areas for park
|
||||
for area_data in park_data['areas']:
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from manufacturers.models import Manufacturer
|
||||
from parks.models import Park
|
||||
from rides.models import Ride, RollerCoasterStats
|
||||
from decimal import Decimal
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Seeds ride data for parks'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Create major ride manufacturers
|
||||
manufacturers_data = [
|
||||
{
|
||||
'name': 'Bolliger & Mabillard',
|
||||
'website': 'https://www.bolligermabillard.com/',
|
||||
'headquarters': 'Monthey, Switzerland',
|
||||
'description': 'Known for their smooth steel roller coasters.'
|
||||
},
|
||||
{
|
||||
'name': 'Rocky Mountain Construction',
|
||||
'website': 'https://www.rockymtnconstruction.com/',
|
||||
'headquarters': 'Hayden, Idaho, USA',
|
||||
'description': 'Specialists in hybrid and steel roller coasters.'
|
||||
},
|
||||
{
|
||||
'name': 'Intamin',
|
||||
'website': 'https://www.intamin.com/',
|
||||
'headquarters': 'Schaan, Liechtenstein',
|
||||
'description': 'Creators of record-breaking roller coasters and rides.'
|
||||
},
|
||||
{
|
||||
'name': 'Vekoma',
|
||||
'website': 'https://www.vekoma.com/',
|
||||
'headquarters': 'Vlodrop, Netherlands',
|
||||
'description': 'Manufacturers of various roller coaster types.'
|
||||
},
|
||||
{
|
||||
'name': 'Mack Rides',
|
||||
'website': 'https://mack-rides.com/',
|
||||
'headquarters': 'Waldkirch, Germany',
|
||||
'description': 'Family-owned manufacturer of roller coasters and attractions.'
|
||||
},
|
||||
{
|
||||
'name': 'Sally Dark Rides',
|
||||
'website': 'https://sallydarkrides.com/',
|
||||
'headquarters': 'Jacksonville, Florida, USA',
|
||||
'description': 'Specialists in dark rides and interactive attractions.'
|
||||
},
|
||||
{
|
||||
'name': 'Zamperla',
|
||||
'website': 'https://www.zamperla.com/',
|
||||
'headquarters': 'Vicenza, Italy',
|
||||
'description': 'Manufacturer of family rides and thrill attractions.'
|
||||
}
|
||||
]
|
||||
|
||||
manufacturers = {}
|
||||
for mfg_data in manufacturers_data:
|
||||
manufacturer, created = Manufacturer.objects.get_or_create(
|
||||
name=mfg_data['name'],
|
||||
defaults=mfg_data
|
||||
)
|
||||
manufacturers[manufacturer.name] = manufacturer
|
||||
self.stdout.write(f'{"Created" if created else "Found"} manufacturer: {manufacturer.name}')
|
||||
|
||||
# Create rides for each park
|
||||
rides_data = [
|
||||
# Silver Dollar City Rides
|
||||
{
|
||||
'park_name': 'Silver Dollar City',
|
||||
'rides': [
|
||||
{
|
||||
'name': 'Time Traveler',
|
||||
'manufacturer': 'Mack Rides',
|
||||
'description': 'The world\'s fastest, steepest, and tallest spinning roller coaster.',
|
||||
'category': 'RC',
|
||||
'opening_date': '2018-03-14',
|
||||
'stats': {
|
||||
'height_ft': 100,
|
||||
'length_ft': 3020,
|
||||
'speed_mph': 50.3,
|
||||
'inversions': 3,
|
||||
'track_material': 'STEEL',
|
||||
'roller_coaster_type': 'SPINNING',
|
||||
'launch_type': 'LSM'
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': 'Wildfire',
|
||||
'manufacturer': 'Bolliger & Mabillard',
|
||||
'description': 'A multi-looping roller coaster with a 155-foot drop.',
|
||||
'category': 'RC',
|
||||
'opening_date': '2001-04-01',
|
||||
'stats': {
|
||||
'height_ft': 155,
|
||||
'length_ft': 3073,
|
||||
'speed_mph': 66,
|
||||
'inversions': 5,
|
||||
'track_material': 'STEEL',
|
||||
'roller_coaster_type': 'SITDOWN',
|
||||
'launch_type': 'CHAIN'
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': 'Fire In The Hole',
|
||||
'manufacturer': 'Sally Dark Rides',
|
||||
'description': 'Indoor coaster featuring special effects and storytelling.',
|
||||
'category': 'DR',
|
||||
'opening_date': '1972-01-01'
|
||||
},
|
||||
{
|
||||
'name': 'American Plunge',
|
||||
'manufacturer': 'Intamin',
|
||||
'description': 'Log flume ride with a 50-foot splashdown.',
|
||||
'category': 'WR',
|
||||
'opening_date': '1981-01-01'
|
||||
}
|
||||
]
|
||||
},
|
||||
# Magic Kingdom Rides
|
||||
{
|
||||
'park_name': 'Magic Kingdom',
|
||||
'rides': [
|
||||
{
|
||||
'name': 'Space Mountain',
|
||||
'manufacturer': 'Vekoma',
|
||||
'description': 'An indoor roller coaster through space.',
|
||||
'category': 'RC',
|
||||
'opening_date': '1975-01-15',
|
||||
'stats': {
|
||||
'height_ft': 180,
|
||||
'length_ft': 3196,
|
||||
'speed_mph': 27,
|
||||
'inversions': 0,
|
||||
'track_material': 'STEEL',
|
||||
'roller_coaster_type': 'SITDOWN',
|
||||
'launch_type': 'CHAIN'
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': 'Haunted Mansion',
|
||||
'manufacturer': 'Sally Dark Rides',
|
||||
'description': 'Classic dark ride through a haunted estate.',
|
||||
'category': 'DR',
|
||||
'opening_date': '1971-10-01'
|
||||
},
|
||||
{
|
||||
'name': 'Mad Tea Party',
|
||||
'manufacturer': 'Zamperla',
|
||||
'description': 'Spinning teacup ride based on Alice in Wonderland.',
|
||||
'category': 'FR',
|
||||
'opening_date': '1971-10-01'
|
||||
},
|
||||
{
|
||||
'name': 'Splash Mountain',
|
||||
'manufacturer': 'Intamin',
|
||||
'description': 'Log flume ride with multiple drops and animatronics.',
|
||||
'category': 'WR',
|
||||
'opening_date': '1992-10-02'
|
||||
}
|
||||
]
|
||||
},
|
||||
# Cedar Point Rides
|
||||
{
|
||||
'park_name': 'Cedar Point',
|
||||
'rides': [
|
||||
{
|
||||
'name': 'Millennium Force',
|
||||
'manufacturer': 'Intamin',
|
||||
'description': 'Former world\'s tallest and fastest complete-circuit roller coaster.',
|
||||
'category': 'RC',
|
||||
'opening_date': '2000-05-13',
|
||||
'stats': {
|
||||
'height_ft': 310,
|
||||
'length_ft': 6595,
|
||||
'speed_mph': 93,
|
||||
'inversions': 0,
|
||||
'track_material': 'STEEL',
|
||||
'roller_coaster_type': 'SITDOWN',
|
||||
'launch_type': 'CABLE'
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': 'Cedar Downs Racing Derby',
|
||||
'manufacturer': 'Zamperla',
|
||||
'description': 'High-speed carousel with racing horses.',
|
||||
'category': 'FR',
|
||||
'opening_date': '1967-01-01'
|
||||
},
|
||||
{
|
||||
'name': 'Snake River Falls',
|
||||
'manufacturer': 'Intamin',
|
||||
'description': 'Shoot-the-Chutes water ride with an 82-foot drop.',
|
||||
'category': 'WR',
|
||||
'opening_date': '1993-05-01'
|
||||
}
|
||||
]
|
||||
},
|
||||
# Universal Studios Florida Rides
|
||||
{
|
||||
'park_name': 'Universal Studios Florida',
|
||||
'rides': [
|
||||
{
|
||||
'name': 'Harry Potter and the Escape from Gringotts',
|
||||
'manufacturer': 'Intamin',
|
||||
'description': 'Indoor steel roller coaster with 3D effects.',
|
||||
'category': 'RC',
|
||||
'opening_date': '2014-07-08',
|
||||
'stats': {
|
||||
'height_ft': 65,
|
||||
'length_ft': 2000,
|
||||
'speed_mph': 50,
|
||||
'inversions': 0,
|
||||
'track_material': 'STEEL',
|
||||
'roller_coaster_type': 'SITDOWN',
|
||||
'launch_type': 'LSM'
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': 'The Amazing Adventures of Spider-Man',
|
||||
'manufacturer': 'Sally Dark Rides',
|
||||
'description': 'groundbreaking 3D dark ride.',
|
||||
'category': 'DR',
|
||||
'opening_date': '1999-05-28'
|
||||
},
|
||||
{
|
||||
'name': 'Jurassic World VelociCoaster',
|
||||
'manufacturer': 'Intamin',
|
||||
'description': 'Florida\'s fastest and tallest launch coaster.',
|
||||
'category': 'RC',
|
||||
'opening_date': '2021-06-10',
|
||||
'stats': {
|
||||
'height_ft': 155,
|
||||
'length_ft': 4700,
|
||||
'speed_mph': 70,
|
||||
'inversions': 4,
|
||||
'track_material': 'STEEL',
|
||||
'roller_coaster_type': 'SITDOWN',
|
||||
'launch_type': 'LSM'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
# SeaWorld Orlando Rides
|
||||
{
|
||||
'park_name': 'SeaWorld Orlando',
|
||||
'rides': [
|
||||
{
|
||||
'name': 'Mako',
|
||||
'manufacturer': 'Bolliger & Mabillard',
|
||||
'description': 'Orlando\'s tallest, fastest and longest roller coaster.',
|
||||
'category': 'RC',
|
||||
'opening_date': '2016-06-10',
|
||||
'stats': {
|
||||
'height_ft': 200,
|
||||
'length_ft': 4760,
|
||||
'speed_mph': 73,
|
||||
'inversions': 0,
|
||||
'track_material': 'STEEL',
|
||||
'roller_coaster_type': 'SITDOWN',
|
||||
'launch_type': 'CHAIN'
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': 'Journey to Atlantis',
|
||||
'manufacturer': 'Mack Rides',
|
||||
'description': 'Water coaster combining dark ride elements with splashes.',
|
||||
'category': 'WR',
|
||||
'opening_date': '1998-03-01'
|
||||
},
|
||||
{
|
||||
'name': 'Sky Tower',
|
||||
'manufacturer': 'Intamin',
|
||||
'description': 'Rotating observation tower providing views of Orlando.',
|
||||
'category': 'TR',
|
||||
'opening_date': '1973-12-15'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
# Create rides and their stats
|
||||
for park_data in rides_data:
|
||||
try:
|
||||
park = Park.objects.get(name=park_data['park_name'])
|
||||
|
||||
for ride_data in park_data['rides']:
|
||||
manufacturer = manufacturers[ride_data['manufacturer']]
|
||||
|
||||
ride, created = Ride.objects.get_or_create(
|
||||
name=ride_data['name'],
|
||||
park=park,
|
||||
defaults={
|
||||
'description': ride_data['description'],
|
||||
'category': ride_data['category'],
|
||||
'manufacturer': manufacturer,
|
||||
'opening_date': ride_data['opening_date'],
|
||||
'status': 'OPERATING'
|
||||
}
|
||||
)
|
||||
self.stdout.write(f'{"Created" if created else "Found"} ride: {ride.name}')
|
||||
|
||||
if created and ride_data.get('stats'):
|
||||
stats = ride_data['stats']
|
||||
RollerCoasterStats.objects.create(
|
||||
ride=ride,
|
||||
height_ft=stats['height_ft'],
|
||||
length_ft=stats['length_ft'],
|
||||
speed_mph=stats['speed_mph'],
|
||||
inversions=stats['inversions'],
|
||||
track_material=stats['track_material'],
|
||||
roller_coaster_type=stats['roller_coaster_type'],
|
||||
launch_type=stats['launch_type']
|
||||
)
|
||||
self.stdout.write(f'Created stats for: {ride.name}')
|
||||
|
||||
except Park.DoesNotExist:
|
||||
self.stdout.write(self.style.WARNING(f'Park not found: {park_data["park_name"]}'))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully seeded ride data'))
|
||||
119
parks/management/commands/test_location.py
Normal file
119
parks/management/commands/test_location.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from parks.models import Park, ParkLocation
|
||||
from parks.models.companies import Company
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Test ParkLocation model functionality'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write("🧪 Testing ParkLocation Model Functionality")
|
||||
self.stdout.write("=" * 50)
|
||||
|
||||
# Create a test company (operator)
|
||||
operator, created = Company.objects.get_or_create(
|
||||
name="Test Theme Parks Inc",
|
||||
defaults={
|
||||
'slug': 'test-theme-parks-inc',
|
||||
'roles': ['OPERATOR']
|
||||
}
|
||||
)
|
||||
self.stdout.write(f"✅ Created operator: {operator.name}")
|
||||
|
||||
# Create a test park
|
||||
park, created = Park.objects.get_or_create(
|
||||
name="Test Magic Kingdom",
|
||||
defaults={
|
||||
'slug': 'test-magic-kingdom',
|
||||
'description': 'A test theme park for location testing',
|
||||
'operator': operator
|
||||
}
|
||||
)
|
||||
self.stdout.write(f"✅ Created park: {park.name}")
|
||||
|
||||
# Create a park location
|
||||
location, created = ParkLocation.objects.get_or_create(
|
||||
park=park,
|
||||
defaults={
|
||||
'street_address': '1313 Disneyland Dr',
|
||||
'city': 'Anaheim',
|
||||
'state': 'California',
|
||||
'country': 'USA',
|
||||
'postal_code': '92802',
|
||||
'highway_exit': 'I-5 Exit 110B',
|
||||
'parking_notes': 'Large parking structure available',
|
||||
'seasonal_notes': 'Open year-round'
|
||||
}
|
||||
)
|
||||
self.stdout.write(f"✅ Created location: {location}")
|
||||
|
||||
# Test coordinate setting
|
||||
self.stdout.write("\n🔍 Testing coordinate functionality:")
|
||||
location.set_coordinates(33.8121, -117.9190) # Disneyland coordinates
|
||||
location.save()
|
||||
|
||||
self.stdout.write(f" Latitude: {location.latitude}")
|
||||
self.stdout.write(f" Longitude: {location.longitude}")
|
||||
self.stdout.write(f" Coordinates: {location.coordinates}")
|
||||
self.stdout.write(f" Formatted Address: {location.formatted_address}")
|
||||
|
||||
# Test Park model integration
|
||||
self.stdout.write("\n🔍 Testing Park model integration:")
|
||||
self.stdout.write(f" Park formatted location: {park.formatted_location}")
|
||||
self.stdout.write(f" Park coordinates: {park.coordinates}")
|
||||
|
||||
# Create another location for distance testing
|
||||
operator2, created = Company.objects.get_or_create(
|
||||
name="Six Flags Entertainment",
|
||||
defaults={
|
||||
'slug': 'six-flags-entertainment',
|
||||
'roles': ['OPERATOR']
|
||||
}
|
||||
)
|
||||
|
||||
park2, created = Park.objects.get_or_create(
|
||||
name="Six Flags Magic Mountain",
|
||||
defaults={
|
||||
'slug': 'six-flags-magic-mountain',
|
||||
'description': 'Another test theme park',
|
||||
'operator': operator2
|
||||
}
|
||||
)
|
||||
|
||||
location2, created = ParkLocation.objects.get_or_create(
|
||||
park=park2,
|
||||
defaults={
|
||||
'city': 'Valencia',
|
||||
'state': 'California',
|
||||
'country': 'USA'
|
||||
}
|
||||
)
|
||||
location2.set_coordinates(34.4244, -118.5971) # Six Flags Magic Mountain coordinates
|
||||
location2.save()
|
||||
|
||||
# Test distance calculation
|
||||
self.stdout.write("\n🔍 Testing distance calculation:")
|
||||
distance = location.distance_to(location2)
|
||||
if distance:
|
||||
self.stdout.write(f" Distance between parks: {distance:.2f} km")
|
||||
else:
|
||||
self.stdout.write(" ❌ Distance calculation failed")
|
||||
|
||||
# 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
|
||||
|
||||
# Find parks within 100km of a point
|
||||
search_point = Point(-117.9190, 33.8121, srid=4326) # Same as Disneyland
|
||||
nearby_locations = ParkLocation.objects.filter(
|
||||
point__distance_lte=(search_point, D(km=100))
|
||||
)
|
||||
self.stdout.write(f" Found {nearby_locations.count()} parks within 100km")
|
||||
for loc in nearby_locations:
|
||||
self.stdout.write(f" - {loc.park.name} in {loc.city}, {loc.state}")
|
||||
except Exception as e:
|
||||
self.stdout.write(f" ⚠️ Spatial queries not fully functional: {e}")
|
||||
|
||||
self.stdout.write("\n✅ ParkLocation model tests completed successfully!")
|
||||
@@ -1,27 +1,75 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
||||
# Generated by Django 5.1.4 on 2025-08-13 21:35
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
PARKS_APP_MODEL = "parks.park"
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("operators", "0001_initial"),
|
||||
("pghistory", "0006_delete_aggregateevent"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Company",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(max_length=255, unique=True)),
|
||||
(
|
||||
"roles",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("OPERATOR", "Park Operator"),
|
||||
("PROPERTY_OWNER", "Property Owner"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
("description", models.TextField(blank=True)),
|
||||
("website", models.URLField(blank=True)),
|
||||
("founded_year", models.PositiveIntegerField(blank=True, null=True)),
|
||||
("headquarters", models.CharField(blank=True, max_length=255)),
|
||||
("parks_count", models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
"verbose_name_plural": "Companies",
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Park",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(max_length=255, unique=True)),
|
||||
("description", models.TextField(blank=True)),
|
||||
@@ -61,13 +109,25 @@ class Migration(migrations.Migration):
|
||||
("created_at", models.DateTimeField(auto_now_add=True, null=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"owner",
|
||||
"operator",
|
||||
models.ForeignKey(
|
||||
help_text="Company that operates this park",
|
||||
limit_choices_to={"roles__contains": ["OPERATOR"]},
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="operated_parks",
|
||||
to="parks.company",
|
||||
),
|
||||
),
|
||||
(
|
||||
"property_owner",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Company that owns the property (if different from operator)",
|
||||
limit_choices_to={"roles__contains": ["PROPERTY_OWNER"]},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="parks",
|
||||
to="operators.operator",
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="owned_parks",
|
||||
to="parks.company",
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -78,25 +138,32 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name="ParkArea",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(max_length=255)),
|
||||
("description", models.TextField(blank=True)),
|
||||
("opening_date", models.DateField(blank=True, null=True)),
|
||||
("closing_date", models.DateField(blank=True, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, null=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"park",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="areas",
|
||||
to=PARKS_APP_MODEL,
|
||||
to="parks.park",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@@ -106,20 +173,20 @@ class Migration(migrations.Migration):
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(db_index=False, max_length=255)),
|
||||
("description", models.TextField(blank=True)),
|
||||
("opening_date", models.DateField(blank=True, null=True)),
|
||||
("closing_date", models.DateField(blank=True, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, null=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"park",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to=PARKS_APP_MODEL,
|
||||
related_query_name="+",
|
||||
to="parks.park",
|
||||
),
|
||||
),
|
||||
(
|
||||
@@ -192,15 +259,15 @@ class Migration(migrations.Migration):
|
||||
("created_at", models.DateTimeField(auto_now_add=True, null=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"owner",
|
||||
"operator",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
help_text="Company that operates this park",
|
||||
limit_choices_to={"roles__contains": ["OPERATOR"]},
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="operators.operator",
|
||||
to="parks.company",
|
||||
),
|
||||
),
|
||||
(
|
||||
@@ -219,7 +286,21 @@ class Migration(migrations.Migration):
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to=PARKS_APP_MODEL,
|
||||
to="parks.park",
|
||||
),
|
||||
),
|
||||
(
|
||||
"property_owner",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="Company that owns the property (if different from operator)",
|
||||
limit_choices_to={"roles__contains": ["PROPERTY_OWNER"]},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="parks.company",
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -232,7 +313,7 @@ class Migration(migrations.Migration):
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "parks_parkevent" ("average_rating", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "owner_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_count", "size_acres", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."owner_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
func='INSERT INTO "parks_parkevent" ("average_rating", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_66883",
|
||||
@@ -247,7 +328,7 @@ class Migration(migrations.Migration):
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "parks_parkevent" ("average_rating", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "owner_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_count", "size_acres", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."owner_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
func='INSERT INTO "parks_parkevent" ("average_rating", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_19f56",
|
||||
@@ -256,16 +337,12 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="parkarea",
|
||||
unique_together={("park", "slug")},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="parkarea",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "parks_parkareaevent" ("closing_date", "created_at", "description", "id", "name", "opening_date", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at") VALUES (NEW."closing_date", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."updated_at"); RETURN NULL;',
|
||||
func='INSERT INTO "parks_parkareaevent" ("created_at", "description", "id", "name", "opening_date", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at") VALUES (NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."updated_at"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_13457",
|
||||
@@ -280,7 +357,7 @@ class Migration(migrations.Migration):
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "parks_parkareaevent" ("closing_date", "created_at", "description", "id", "name", "opening_date", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at") VALUES (NEW."closing_date", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."updated_at"); RETURN NULL;',
|
||||
func='INSERT INTO "parks_parkareaevent" ("created_at", "description", "id", "name", "opening_date", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at") VALUES (NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."updated_at"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_6e5aa",
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-10 09:30
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pghistory', '0006_delete_aggregateevent'),
|
||||
('parks', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='parkevent',
|
||||
name='pgh_context',
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name='+',
|
||||
to='pghistory.context',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parkareaevent',
|
||||
name='pgh_context',
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name='+',
|
||||
to='pghistory.context',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,190 @@
|
||||
# Generated by Django 5.1.4 on 2025-08-14 14:50
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0001_initial"),
|
||||
("pghistory", "0006_delete_aggregateevent"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ParkReview",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"rating",
|
||||
models.PositiveSmallIntegerField(
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(10),
|
||||
]
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("content", models.TextField()),
|
||||
("visit_date", models.DateField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("is_published", models.BooleanField(default=True)),
|
||||
("moderation_notes", models.TextField(blank=True)),
|
||||
("moderated_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"moderated_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="moderated_park_reviews",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"park",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="reviews",
|
||||
to="parks.park",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="park_reviews",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"unique_together": {("park", "user")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ParkReviewEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
(
|
||||
"rating",
|
||||
models.PositiveSmallIntegerField(
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(10),
|
||||
]
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("content", models.TextField()),
|
||||
("visit_date", models.DateField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("is_published", models.BooleanField(default=True)),
|
||||
("moderation_notes", models.TextField(blank=True)),
|
||||
("moderated_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"moderated_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"park",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="parks.park",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="parks.parkreview",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="parkreview",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "parks_parkreviewevent" ("content", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_a99bc",
|
||||
table="parks_parkreview",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="parkreview",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "parks_parkreviewevent" ("content", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_0e40d",
|
||||
table="parks_parkreview",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,39 +0,0 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-21 17:55
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0002_fix_pghistory_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="park",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="parkarea",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="parkareaevent",
|
||||
name="park",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="parks.park",
|
||||
),
|
||||
),
|
||||
]
|
||||
61
parks/migrations/0003_parklocation.py
Normal file
61
parks/migrations/0003_parklocation.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# Generated by Django 5.1.4 on 2025-08-15 01:16
|
||||
|
||||
import django.contrib.gis.db.models.fields
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0002_parkreview_parkreviewevent_parkreview_insert_insert_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ParkLocation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"point",
|
||||
django.contrib.gis.db.models.fields.PointField(
|
||||
db_index=True, srid=4326
|
||||
),
|
||||
),
|
||||
("street_address", models.CharField(blank=True, max_length=255)),
|
||||
("city", models.CharField(db_index=True, max_length=100)),
|
||||
("state", models.CharField(db_index=True, max_length=100)),
|
||||
("country", models.CharField(default="USA", max_length=100)),
|
||||
("postal_code", models.CharField(blank=True, max_length=20)),
|
||||
("highway_exit", models.CharField(blank=True, max_length=100)),
|
||||
("parking_notes", models.TextField(blank=True)),
|
||||
("best_arrival_time", models.TimeField(blank=True, null=True)),
|
||||
("osm_id", models.BigIntegerField(blank=True, null=True)),
|
||||
(
|
||||
"park",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="location",
|
||||
to="parks.park",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Park Location",
|
||||
"verbose_name_plural": "Park Locations",
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["city", "state"], name="parks_parkl_city_7cc873_idx"
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.1.4 on 2025-08-15 01:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0003_parklocation"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="company",
|
||||
name="headquarters",
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CompanyHeadquarters",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("city", models.CharField(db_index=True, max_length=100)),
|
||||
("state", models.CharField(db_index=True, max_length=100)),
|
||||
("country", models.CharField(default="USA", max_length=100)),
|
||||
(
|
||||
"company",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="headquarters",
|
||||
to="parks.company",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Company Headquarters",
|
||||
"verbose_name_plural": "Company Headquarters",
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,111 +0,0 @@
|
||||
# Generated by Django 5.1.4 on 2025-07-04 15:26
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("operators", "0001_initial"),
|
||||
("parks", "0003_alter_park_id_alter_parkarea_id_and_more"),
|
||||
("property_owners", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="park",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="park",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="park",
|
||||
name="owner",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="parkevent",
|
||||
name="owner",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="park",
|
||||
name="operator",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="parks",
|
||||
to="operators.operator",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="park",
|
||||
name="property_owner",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="owned_parks",
|
||||
to="property_owners.propertyowner",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="parkevent",
|
||||
name="operator",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="operators.operator",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="parkevent",
|
||||
name="property_owner",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="property_owners.propertyowner",
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="park",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "parks_parkevent" ("average_rating", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
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", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_19f56",
|
||||
table="parks_park",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 5.1.4 on 2025-08-15 14:11
|
||||
|
||||
import django.contrib.gis.db.models.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0004_remove_company_headquarters_companyheadquarters"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="parklocation",
|
||||
options={
|
||||
"ordering": ["park__name"],
|
||||
"verbose_name": "Park Location",
|
||||
"verbose_name_plural": "Park Locations",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="parklocation",
|
||||
name="osm_type",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Type of OpenStreetMap object (node, way, or relation)",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="parklocation",
|
||||
name="seasonal_notes",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="parklocation",
|
||||
name="point",
|
||||
field=django.contrib.gis.db.models.fields.PointField(
|
||||
blank=True,
|
||||
help_text="Geographic coordinates (longitude, latitude)",
|
||||
null=True,
|
||||
srid=4326,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,96 @@
|
||||
# Generated by Django 5.1.4 on 2025-08-15 14:16
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0005_alter_parklocation_options_parklocation_osm_type_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="companyheadquarters",
|
||||
options={
|
||||
"ordering": ["company__name"],
|
||||
"verbose_name": "Company Headquarters",
|
||||
"verbose_name_plural": "Company Headquarters",
|
||||
},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="companyheadquarters",
|
||||
name="state",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="companyheadquarters",
|
||||
name="created_at",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True, default=django.utils.timezone.now
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="companyheadquarters",
|
||||
name="mailing_address",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
help_text="Complete mailing address if different from basic address",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="companyheadquarters",
|
||||
name="postal_code",
|
||||
field=models.CharField(
|
||||
blank=True, help_text="ZIP or postal code", max_length=20
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="companyheadquarters",
|
||||
name="state_province",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="State/Province/Region",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="companyheadquarters",
|
||||
name="street_address",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Mailing address if publicly available",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="companyheadquarters",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="companyheadquarters",
|
||||
name="city",
|
||||
field=models.CharField(
|
||||
db_index=True, help_text="Headquarters city", max_length=100
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="companyheadquarters",
|
||||
name="country",
|
||||
field=models.CharField(
|
||||
db_index=True,
|
||||
default="USA",
|
||||
help_text="Country where headquarters is located",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="companyheadquarters",
|
||||
index=models.Index(
|
||||
fields=["city", "country"], name="parks_compa_city_cf9a4e_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,210 @@
|
||||
# Generated by Django migration for location system consolidation
|
||||
|
||||
from django.db import migrations, transaction
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
|
||||
def migrate_generic_locations_to_domain_specific(apps, schema_editor):
|
||||
"""
|
||||
Migrate data from generic Location model to domain-specific location models.
|
||||
|
||||
This migration:
|
||||
1. Migrates park locations from Location to ParkLocation
|
||||
2. Logs the migration process for verification
|
||||
3. Preserves all coordinate and address data
|
||||
"""
|
||||
# Get model references
|
||||
Location = apps.get_model('location', 'Location')
|
||||
Park = apps.get_model('parks', 'Park')
|
||||
ParkLocation = apps.get_model('parks', 'ParkLocation')
|
||||
|
||||
print("\n=== Starting Location Migration ===")
|
||||
|
||||
# Track migration statistics
|
||||
stats = {
|
||||
'parks_migrated': 0,
|
||||
'parks_skipped': 0,
|
||||
'errors': 0
|
||||
}
|
||||
|
||||
# Get content type for Park model using the migration apps registry
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
try:
|
||||
park_content_type = ContentType.objects.get(app_label='parks', model='park')
|
||||
except Exception as e:
|
||||
print(f"ERROR: Could not get ContentType for Park: {e}")
|
||||
return
|
||||
|
||||
# Find all generic locations that reference parks
|
||||
park_locations = Location.objects.filter(content_type=park_content_type)
|
||||
|
||||
print(f"Found {park_locations.count()} generic location objects for parks")
|
||||
|
||||
with transaction.atomic():
|
||||
for generic_location in park_locations:
|
||||
try:
|
||||
# Get the associated park
|
||||
try:
|
||||
park = Park.objects.get(id=generic_location.object_id)
|
||||
except Park.DoesNotExist:
|
||||
print(f"WARNING: Park with ID {generic_location.object_id} not found, skipping location")
|
||||
stats['parks_skipped'] += 1
|
||||
continue
|
||||
|
||||
# Check if ParkLocation already exists
|
||||
if hasattr(park, 'location') and park.location:
|
||||
print(f"INFO: Park '{park.name}' already has ParkLocation, skipping")
|
||||
stats['parks_skipped'] += 1
|
||||
continue
|
||||
|
||||
print(f"Migrating location for park: {park.name}")
|
||||
|
||||
# Create ParkLocation from generic Location data
|
||||
park_location_data = {
|
||||
'park': park,
|
||||
'street_address': generic_location.street_address or '',
|
||||
'city': generic_location.city or '',
|
||||
'state': generic_location.state or '',
|
||||
'country': generic_location.country or 'USA',
|
||||
'postal_code': generic_location.postal_code or '',
|
||||
}
|
||||
|
||||
# Handle coordinates - prefer point field, fall back to lat/lon
|
||||
if generic_location.point:
|
||||
park_location_data['point'] = generic_location.point
|
||||
print(f" Coordinates from point: {generic_location.point}")
|
||||
elif generic_location.latitude and generic_location.longitude:
|
||||
# Create Point from lat/lon
|
||||
park_location_data['point'] = Point(
|
||||
float(generic_location.longitude),
|
||||
float(generic_location.latitude),
|
||||
srid=4326
|
||||
)
|
||||
print(f" Coordinates from lat/lon: {generic_location.latitude}, {generic_location.longitude}")
|
||||
else:
|
||||
print(f" No coordinates available")
|
||||
|
||||
# Create the ParkLocation
|
||||
park_location = ParkLocation.objects.create(**park_location_data)
|
||||
|
||||
print(f" Created ParkLocation for {park.name}")
|
||||
stats['parks_migrated'] += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR migrating location for park {generic_location.object_id}: {e}")
|
||||
stats['errors'] += 1
|
||||
# Continue with other migrations rather than failing completely
|
||||
continue
|
||||
|
||||
# Print migration summary
|
||||
print(f"\n=== Migration Summary ===")
|
||||
print(f"Parks migrated: {stats['parks_migrated']}")
|
||||
print(f"Parks skipped: {stats['parks_skipped']}")
|
||||
print(f"Errors: {stats['errors']}")
|
||||
|
||||
# Verify migration
|
||||
print(f"\n=== Verification ===")
|
||||
total_parks = Park.objects.count()
|
||||
parks_with_location = Park.objects.filter(location__isnull=False).count()
|
||||
print(f"Total parks: {total_parks}")
|
||||
print(f"Parks with ParkLocation: {parks_with_location}")
|
||||
|
||||
if stats['errors'] == 0:
|
||||
print("✓ Migration completed successfully!")
|
||||
else:
|
||||
print(f"⚠ Migration completed with {stats['errors']} errors - check output above")
|
||||
|
||||
|
||||
def reverse_migrate_domain_specific_to_generic(apps, schema_editor):
|
||||
"""
|
||||
Reverse migration: Convert ParkLocation back to generic Location objects.
|
||||
|
||||
This is primarily for development/testing purposes.
|
||||
"""
|
||||
# Get model references
|
||||
Location = apps.get_model('location', 'Location')
|
||||
Park = apps.get_model('parks', 'Park')
|
||||
ParkLocation = apps.get_model('parks', 'ParkLocation')
|
||||
|
||||
print("\n=== Starting Reverse Migration ===")
|
||||
|
||||
stats = {
|
||||
'parks_migrated': 0,
|
||||
'errors': 0
|
||||
}
|
||||
|
||||
# Get content type for Park model using the migration apps registry
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
try:
|
||||
park_content_type = ContentType.objects.get(app_label='parks', model='park')
|
||||
except Exception as e:
|
||||
print(f"ERROR: Could not get ContentType for Park: {e}")
|
||||
return
|
||||
|
||||
park_locations = ParkLocation.objects.all()
|
||||
print(f"Found {park_locations.count()} ParkLocation objects to reverse migrate")
|
||||
|
||||
with transaction.atomic():
|
||||
for park_location in park_locations:
|
||||
try:
|
||||
park = park_location.park
|
||||
print(f"Reverse migrating location for park: {park.name}")
|
||||
|
||||
# Create generic Location from ParkLocation data
|
||||
location_data = {
|
||||
'content_type': park_content_type,
|
||||
'object_id': park.id,
|
||||
'name': park.name,
|
||||
'location_type': 'business',
|
||||
'street_address': park_location.street_address,
|
||||
'city': park_location.city,
|
||||
'state': park_location.state,
|
||||
'country': park_location.country,
|
||||
'postal_code': park_location.postal_code,
|
||||
}
|
||||
|
||||
# Handle coordinates
|
||||
if park_location.point:
|
||||
location_data['point'] = park_location.point
|
||||
location_data['latitude'] = park_location.point.y
|
||||
location_data['longitude'] = park_location.point.x
|
||||
|
||||
# Create the generic Location
|
||||
generic_location = Location.objects.create(**location_data)
|
||||
|
||||
print(f" Created generic Location: {generic_location}")
|
||||
stats['parks_migrated'] += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR reverse migrating location for park {park_location.park.name}: {e}")
|
||||
stats['errors'] += 1
|
||||
continue
|
||||
|
||||
print(f"\n=== Reverse Migration Summary ===")
|
||||
print(f"Parks reverse migrated: {stats['parks_migrated']}")
|
||||
print(f"Errors: {stats['errors']}")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""
|
||||
Data migration to transition from generic Location model to domain-specific location models.
|
||||
|
||||
This migration moves location data from the generic location.Location model
|
||||
to the new domain-specific models like parks.ParkLocation, while preserving
|
||||
all coordinate and address information.
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
('parks', '0006_alter_companyheadquarters_options_and_more'),
|
||||
('location', '0001_initial'), # Ensure location app is available
|
||||
('contenttypes', '0002_remove_content_type_name'), # Need ContentType
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
migrate_generic_locations_to_domain_specific,
|
||||
reverse_migrate_domain_specific_to_generic,
|
||||
elidable=True,
|
||||
),
|
||||
]
|
||||
5
parks/models/__init__.py
Normal file
5
parks/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .location import *
|
||||
from .areas import *
|
||||
from .parks import *
|
||||
from .reviews import *
|
||||
from .companies import *
|
||||
18
parks/models/areas.py
Normal file
18
parks/models/areas.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from typing import Tuple, Any
|
||||
import pghistory
|
||||
|
||||
from core.history import TrackedModel
|
||||
from .parks import Park
|
||||
|
||||
@pghistory.track()
|
||||
class ParkArea(TrackedModel):
|
||||
id: int # Type hint for Django's automatic id field
|
||||
park = models.ForeignKey(Park, on_delete=models.CASCADE, related_name="areas")
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
opening_date = models.DateField(null=True, blank=True)
|
||||
closing_date = models
|
||||
118
parks/models/companies.py
Normal file
118
parks/models/companies.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
from core.models import TrackedModel
|
||||
|
||||
class Company(TrackedModel):
|
||||
class CompanyRole(models.TextChoices):
|
||||
OPERATOR = 'OPERATOR', 'Park Operator'
|
||||
PROPERTY_OWNER = 'PROPERTY_OWNER', 'Property Owner'
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
roles = ArrayField(
|
||||
models.CharField(max_length=20, choices=CompanyRole.choices),
|
||||
default=list,
|
||||
blank=True
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
website = models.URLField(blank=True)
|
||||
|
||||
# Operator-specific fields
|
||||
founded_year = models.PositiveIntegerField(blank=True, null=True)
|
||||
parks_count = models.IntegerField(default=0)
|
||||
rides_count = models.IntegerField(default=0)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name_plural = 'Companies'
|
||||
|
||||
class CompanyHeadquarters(models.Model):
|
||||
"""
|
||||
Simple address storage for company headquarters without coordinate tracking.
|
||||
Focus on human-readable location information for display purposes.
|
||||
"""
|
||||
# Relationships
|
||||
company = models.OneToOneField(
|
||||
'Company',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='headquarters'
|
||||
)
|
||||
|
||||
# Address Fields (No coordinates needed)
|
||||
street_address = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="Mailing address if publicly available"
|
||||
)
|
||||
city = models.CharField(
|
||||
max_length=100,
|
||||
db_index=True,
|
||||
help_text="Headquarters city"
|
||||
)
|
||||
state_province = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="State/Province/Region"
|
||||
)
|
||||
country = models.CharField(
|
||||
max_length=100,
|
||||
default='USA',
|
||||
db_index=True,
|
||||
help_text="Country where headquarters is located"
|
||||
)
|
||||
postal_code = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
help_text="ZIP or postal code"
|
||||
)
|
||||
|
||||
# Optional mailing address if different or more complete
|
||||
mailing_address = models.TextField(
|
||||
blank=True,
|
||||
help_text="Complete mailing address if different from basic address"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def formatted_location(self):
|
||||
"""Returns a formatted address string for display."""
|
||||
components = []
|
||||
if self.street_address:
|
||||
components.append(self.street_address)
|
||||
if self.city:
|
||||
components.append(self.city)
|
||||
if self.state_province:
|
||||
components.append(self.state_province)
|
||||
if self.postal_code:
|
||||
components.append(self.postal_code)
|
||||
if self.country and self.country != 'USA':
|
||||
components.append(self.country)
|
||||
return ", ".join(components) if components else f"{self.city}, {self.country}"
|
||||
|
||||
@property
|
||||
def location_display(self):
|
||||
"""Simple city, state/country display for compact views."""
|
||||
parts = [self.city]
|
||||
if self.state_province:
|
||||
parts.append(self.state_province)
|
||||
elif self.country != 'USA':
|
||||
parts.append(self.country)
|
||||
return ", ".join(parts) if parts else "Unknown Location"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.company.name} Headquarters - {self.location_display}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Company Headquarters"
|
||||
verbose_name_plural = "Company Headquarters"
|
||||
ordering = ['company__name']
|
||||
indexes = [
|
||||
models.Index(fields=['city', 'country']),
|
||||
]
|
||||
115
parks/models/location.py
Normal file
115
parks/models/location.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from django.contrib.gis.db import models
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import D
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
|
||||
|
||||
class ParkLocation(models.Model):
|
||||
"""
|
||||
Represents the geographic location and address of a park, with PostGIS support.
|
||||
"""
|
||||
park = models.OneToOneField(
|
||||
'parks.Park',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='location'
|
||||
)
|
||||
|
||||
# Spatial Data
|
||||
point = models.PointField(
|
||||
srid=4326,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Geographic coordinates (longitude, latitude)"
|
||||
)
|
||||
|
||||
# Address Fields
|
||||
street_address = models.CharField(max_length=255, blank=True)
|
||||
city = models.CharField(max_length=100, db_index=True)
|
||||
state = models.CharField(max_length=100, db_index=True)
|
||||
country = models.CharField(max_length=100, default='USA')
|
||||
postal_code = models.CharField(max_length=20, blank=True)
|
||||
|
||||
# Road Trip Metadata
|
||||
highway_exit = models.CharField(max_length=100, blank=True)
|
||||
parking_notes = models.TextField(blank=True)
|
||||
best_arrival_time = models.TimeField(null=True, blank=True)
|
||||
seasonal_notes = models.TextField(blank=True)
|
||||
|
||||
# OSM Integration
|
||||
osm_id = models.BigIntegerField(null=True, blank=True)
|
||||
osm_type = models.CharField(
|
||||
max_length=10,
|
||||
blank=True,
|
||||
help_text="Type of OpenStreetMap object (node, way, or relation)"
|
||||
)
|
||||
|
||||
@property
|
||||
def latitude(self):
|
||||
"""Return latitude from point field."""
|
||||
if self.point:
|
||||
return self.point.y
|
||||
return None
|
||||
|
||||
@property
|
||||
def longitude(self):
|
||||
"""Return longitude from point field."""
|
||||
if self.point:
|
||||
return self.point.x
|
||||
return None
|
||||
|
||||
@property
|
||||
def coordinates(self):
|
||||
"""Return (latitude, longitude) tuple."""
|
||||
if self.point:
|
||||
return (self.latitude, self.longitude)
|
||||
return (None, None)
|
||||
|
||||
@property
|
||||
def formatted_address(self):
|
||||
"""Return a nicely formatted address string."""
|
||||
address_parts = [
|
||||
self.street_address,
|
||||
self.city,
|
||||
self.state,
|
||||
self.postal_code,
|
||||
self.country
|
||||
]
|
||||
return ", ".join(part for part in address_parts if part)
|
||||
|
||||
def set_coordinates(self, latitude, longitude):
|
||||
"""
|
||||
Set the location's point from latitude and longitude coordinates.
|
||||
Validates coordinate ranges.
|
||||
"""
|
||||
if latitude is None or longitude is None:
|
||||
self.point = None
|
||||
return
|
||||
|
||||
if not -90 <= latitude <= 90:
|
||||
raise ValueError("Latitude must be between -90 and 90.")
|
||||
if not -180 <= longitude <= 180:
|
||||
raise ValueError("Longitude must be between -180 and 180.")
|
||||
|
||||
self.point = Point(longitude, latitude, srid=4326)
|
||||
|
||||
def distance_to(self, other_location):
|
||||
"""
|
||||
Calculate the distance to another ParkLocation instance.
|
||||
Returns distance in kilometers.
|
||||
"""
|
||||
if not self.point or not other_location.point:
|
||||
return None
|
||||
# Use geodetic distance calculation which returns meters, convert to km
|
||||
distance_m = self.point.distance(other_location.point)
|
||||
return distance_m / 1000.0
|
||||
|
||||
def __str__(self):
|
||||
return f"Location for {self.park.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Park Location"
|
||||
verbose_name_plural = "Park Locations"
|
||||
ordering = ['park__name']
|
||||
indexes = [
|
||||
models.Index(fields=['city', 'state']),
|
||||
]
|
||||
@@ -7,11 +7,9 @@ from decimal import Decimal, ROUND_DOWN, InvalidOperation
|
||||
from typing import Tuple, Optional, Any, TYPE_CHECKING
|
||||
import pghistory
|
||||
|
||||
from operators.models import Operator
|
||||
from property_owners.models import PropertyOwner
|
||||
from .companies import Company
|
||||
from media.models import Photo
|
||||
from history_tracking.models import TrackedModel
|
||||
from location.models import Location
|
||||
from core.history import TrackedModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rides.models import Ride
|
||||
@@ -35,8 +33,8 @@ class Park(TrackedModel):
|
||||
max_length=20, choices=STATUS_CHOICES, default="OPERATING"
|
||||
)
|
||||
|
||||
# Location fields using GenericRelation
|
||||
location = GenericRelation(Location, related_query_name='park')
|
||||
# Location relationship - reverse relation from ParkLocation
|
||||
# location will be available via the 'location' related_name on ParkLocation
|
||||
|
||||
# Details
|
||||
opening_date = models.DateField(null=True, blank=True)
|
||||
@@ -56,10 +54,20 @@ class Park(TrackedModel):
|
||||
|
||||
# Relationships
|
||||
operator = models.ForeignKey(
|
||||
Operator, on_delete=models.SET_NULL, null=True, blank=True, related_name="parks"
|
||||
'Company',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='operated_parks',
|
||||
help_text='Company that operates this park',
|
||||
limit_choices_to={'roles__contains': ['OPERATOR']},
|
||||
)
|
||||
property_owner = models.ForeignKey(
|
||||
PropertyOwner, on_delete=models.SET_NULL, null=True, blank=True, related_name="owned_parks"
|
||||
'Company',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='owned_parks',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text='Company that owns the property (if different from operator)',
|
||||
limit_choices_to={'roles__contains': ['PROPERTY_OWNER']},
|
||||
)
|
||||
photos = GenericRelation(Photo, related_query_name="park")
|
||||
areas: models.Manager['ParkArea'] # Type hint for reverse relation
|
||||
@@ -77,7 +85,7 @@ class Park(TrackedModel):
|
||||
|
||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from history_tracking.models import HistoricalSlug
|
||||
from core.history import HistoricalSlug
|
||||
|
||||
# Get old instance if it exists
|
||||
if self.pk:
|
||||
@@ -107,6 +115,13 @@ class Park(TrackedModel):
|
||||
slug=old_slug
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.operator and 'OPERATOR' not in self.operator.roles:
|
||||
raise ValidationError({'operator': 'Company must have the OPERATOR role.'})
|
||||
if self.property_owner and 'PROPERTY_OWNER' not in self.property_owner.roles:
|
||||
raise ValidationError({'property_owner': 'Company must have the PROPERTY_OWNER role.'})
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("parks:park_detail", kwargs={"slug": self.slug})
|
||||
|
||||
@@ -124,26 +139,23 @@ class Park(TrackedModel):
|
||||
|
||||
@property
|
||||
def formatted_location(self) -> str:
|
||||
if self.location.exists():
|
||||
location = self.location.first()
|
||||
if location:
|
||||
return location.get_formatted_address()
|
||||
"""Get formatted address from ParkLocation if it exists"""
|
||||
if hasattr(self, 'location') and self.location:
|
||||
return self.location.formatted_address
|
||||
return ""
|
||||
|
||||
@property
|
||||
def coordinates(self) -> Optional[Tuple[float, float]]:
|
||||
"""Returns coordinates as a tuple (latitude, longitude)"""
|
||||
if self.location.exists():
|
||||
location = self.location.first()
|
||||
if location:
|
||||
return location.coordinates
|
||||
if hasattr(self, 'location') and self.location:
|
||||
return self.location.coordinates
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
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 history_tracking.models import HistoricalSlug
|
||||
from core.history import HistoricalSlug
|
||||
|
||||
print(f"\nLooking up slug: {slug}")
|
||||
|
||||
@@ -194,57 +206,4 @@ class Park(TrackedModel):
|
||||
else:
|
||||
print("No pghistory event found")
|
||||
|
||||
raise cls.DoesNotExist("No park found with this slug")
|
||||
|
||||
@pghistory.track()
|
||||
class ParkArea(TrackedModel):
|
||||
id: int # Type hint for Django's automatic id field
|
||||
park = models.ForeignKey(Park, on_delete=models.CASCADE, related_name="areas")
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
opening_date = models.DateField(null=True, blank=True)
|
||||
closing_date = models.DateField(null=True, blank=True)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
unique_together = ["park", "slug"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} at {self.park.name}"
|
||||
|
||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||
# Always update slug when name changes
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse(
|
||||
"parks:area_detail",
|
||||
kwargs={"park_slug": self.park.slug, "area_slug": self.slug},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_by_slug(cls, slug: str) -> Tuple['ParkArea', bool]:
|
||||
"""Get area by current or historical slug"""
|
||||
try:
|
||||
return cls.objects.get(slug=slug), False
|
||||
except cls.DoesNotExist:
|
||||
# Check pghistory events
|
||||
event_model = getattr(cls, 'event_model', None)
|
||||
if event_model:
|
||||
historical_event = event_model.objects.filter(
|
||||
slug=slug
|
||||
).order_by('-pgh_created_at').first()
|
||||
|
||||
if historical_event:
|
||||
try:
|
||||
return cls.objects.get(pk=historical_event.pgh_obj_id), True
|
||||
except cls.DoesNotExist:
|
||||
pass
|
||||
|
||||
raise cls.DoesNotExist("No park area found with this slug")
|
||||
raise cls.DoesNotExist("No park found with this slug")
|
||||
49
parks/models/reviews.py
Normal file
49
parks/models/reviews.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from core.history import TrackedModel
|
||||
import pghistory
|
||||
|
||||
@pghistory.track()
|
||||
class ParkReview(TrackedModel):
|
||||
"""
|
||||
A review of a park.
|
||||
"""
|
||||
park = models.ForeignKey(
|
||||
'parks.Park',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='reviews'
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
'accounts.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='park_reviews'
|
||||
)
|
||||
rating = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1), MaxValueValidator(10)]
|
||||
)
|
||||
title = models.CharField(max_length=200)
|
||||
content = models.TextField()
|
||||
visit_date = models.DateField()
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Moderation
|
||||
is_published = models.BooleanField(default=True)
|
||||
moderation_notes = models.TextField(blank=True)
|
||||
moderated_by = models.ForeignKey(
|
||||
'accounts.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='moderated_park_reviews'
|
||||
)
|
||||
moderated_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
unique_together = ['park', 'user']
|
||||
|
||||
def __str__(self):
|
||||
return f"Review of {self.park.name} by {self.user.username}"
|
||||
@@ -3,16 +3,11 @@ from .models import Park
|
||||
|
||||
def get_base_park_queryset() -> QuerySet[Park]:
|
||||
"""Get base queryset with all needed annotations and prefetches"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
park_type = ContentType.objects.get_for_model(Park)
|
||||
return (
|
||||
Park.objects.select_related('operator', 'property_owner')
|
||||
Park.objects.select_related('operator', 'property_owner', 'location')
|
||||
.prefetch_related(
|
||||
'photos',
|
||||
'rides',
|
||||
'location',
|
||||
'location__content_type'
|
||||
'rides'
|
||||
)
|
||||
.annotate(
|
||||
current_ride_count=Count('rides', distinct=True),
|
||||
|
||||
3
parks/services/__init__.py
Normal file
3
parks/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .roadtrip import RoadTripService
|
||||
|
||||
__all__ = ['RoadTripService']
|
||||
639
parks/services/roadtrip.py
Normal file
639
parks/services/roadtrip.py
Normal file
@@ -0,0 +1,639 @@
|
||||
"""
|
||||
Road Trip Service for theme park planning using OpenStreetMap APIs.
|
||||
|
||||
This service provides functionality for:
|
||||
- Geocoding addresses using Nominatim
|
||||
- Route calculation using OSRM
|
||||
- Park discovery along routes
|
||||
- Multi-park trip planning
|
||||
- Proper rate limiting and caching
|
||||
"""
|
||||
|
||||
import time
|
||||
import math
|
||||
import logging
|
||||
import requests
|
||||
from typing import Dict, List, Tuple, Optional, Any, Union
|
||||
from dataclasses import dataclass
|
||||
from itertools import permutations
|
||||
|
||||
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.db.models import Q
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Coordinates:
|
||||
"""Represents latitude and longitude coordinates."""
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
def to_tuple(self) -> Tuple[float, float]:
|
||||
"""Return as (lat, lon) tuple."""
|
||||
return (self.latitude, self.longitude)
|
||||
|
||||
def to_point(self) -> Point:
|
||||
"""Convert to Django Point object."""
|
||||
return Point(self.longitude, self.latitude, srid=4326)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteInfo:
|
||||
"""Information about a calculated route."""
|
||||
distance_km: float
|
||||
duration_minutes: int
|
||||
geometry: Optional[str] = None # Encoded polyline
|
||||
|
||||
@property
|
||||
def formatted_distance(self) -> str:
|
||||
"""Return formatted distance string."""
|
||||
if self.distance_km < 1:
|
||||
return f"{self.distance_km * 1000:.0f}m"
|
||||
return f"{self.distance_km:.1f}km"
|
||||
|
||||
@property
|
||||
def formatted_duration(self) -> str:
|
||||
"""Return formatted duration string."""
|
||||
hours = self.duration_minutes // 60
|
||||
minutes = self.duration_minutes % 60
|
||||
if hours == 0:
|
||||
return f"{minutes}min"
|
||||
elif minutes == 0:
|
||||
return f"{hours}h"
|
||||
else:
|
||||
return f"{hours}h {minutes}min"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TripLeg:
|
||||
"""Represents one leg of a multi-park trip."""
|
||||
from_park: 'Park'
|
||||
to_park: 'Park'
|
||||
route: RouteInfo
|
||||
|
||||
@property
|
||||
def parks_along_route(self) -> List['Park']:
|
||||
"""Get parks along this route segment."""
|
||||
# This would be populated by find_parks_along_route
|
||||
return []
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoadTrip:
|
||||
"""Complete road trip with multiple parks."""
|
||||
parks: List['Park']
|
||||
legs: List[TripLeg]
|
||||
total_distance_km: float
|
||||
total_duration_minutes: int
|
||||
|
||||
@property
|
||||
def formatted_total_distance(self) -> str:
|
||||
"""Return formatted total distance."""
|
||||
return f"{self.total_distance_km:.1f}km"
|
||||
|
||||
@property
|
||||
def formatted_total_duration(self) -> str:
|
||||
"""Return formatted total duration."""
|
||||
hours = self.total_duration_minutes // 60
|
||||
minutes = self.total_duration_minutes % 60
|
||||
if hours == 0:
|
||||
return f"{minutes}min"
|
||||
elif minutes == 0:
|
||||
return f"{hours}h"
|
||||
else:
|
||||
return f"{hours}h {minutes}min"
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Simple rate limiter for API requests."""
|
||||
|
||||
def __init__(self, max_requests_per_second: float = 1.0):
|
||||
self.max_requests_per_second = max_requests_per_second
|
||||
self.min_interval = 1.0 / max_requests_per_second
|
||||
self.last_request_time = 0.0
|
||||
|
||||
def wait_if_needed(self):
|
||||
"""Wait if necessary to respect rate limits."""
|
||||
current_time = time.time()
|
||||
time_since_last = current_time - self.last_request_time
|
||||
|
||||
if time_since_last < self.min_interval:
|
||||
wait_time = self.min_interval - time_since_last
|
||||
time.sleep(wait_time)
|
||||
|
||||
self.last_request_time = time.time()
|
||||
|
||||
|
||||
class OSMAPIException(Exception):
|
||||
"""Exception for OSM API related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class RoadTripService:
|
||||
"""
|
||||
Service for planning road trips between theme parks using OpenStreetMap APIs.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.nominatim_base_url = "https://nominatim.openstreetmap.org"
|
||||
self.osrm_base_url = "http://router.project-osrm.org/route/v1/driving"
|
||||
|
||||
# Configuration from Django settings
|
||||
self.cache_timeout = getattr(settings, 'ROADTRIP_CACHE_TIMEOUT', 3600 * 24)
|
||||
self.route_cache_timeout = getattr(settings, 'ROADTRIP_ROUTE_CACHE_TIMEOUT', 3600 * 6)
|
||||
self.user_agent = getattr(settings, 'ROADTRIP_USER_AGENT', 'ThrillWiki Road Trip Planner')
|
||||
self.request_timeout = getattr(settings, 'ROADTRIP_REQUEST_TIMEOUT', 10)
|
||||
self.max_retries = getattr(settings, 'ROADTRIP_MAX_RETRIES', 3)
|
||||
self.backoff_factor = getattr(settings, 'ROADTRIP_BACKOFF_FACTOR', 2)
|
||||
|
||||
# Rate limiter
|
||||
max_rps = getattr(settings, 'ROADTRIP_MAX_REQUESTS_PER_SECOND', 1)
|
||||
self.rate_limiter = RateLimiter(max_rps)
|
||||
|
||||
# Request session with proper headers
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': self.user_agent,
|
||||
'Accept': 'application/json',
|
||||
})
|
||||
|
||||
def _make_request(self, url: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Make HTTP request with rate limiting, retries, and error handling.
|
||||
"""
|
||||
self.rate_limiter.wait_if_needed()
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
response = self.session.get(
|
||||
url,
|
||||
params=params,
|
||||
timeout=self.request_timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"Request attempt {attempt + 1} failed: {e}")
|
||||
|
||||
if attempt < self.max_retries - 1:
|
||||
wait_time = self.backoff_factor ** attempt
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
raise OSMAPIException(f"Failed to make request after {self.max_retries} attempts: {e}")
|
||||
|
||||
def geocode_address(self, address: str) -> Optional[Coordinates]:
|
||||
"""
|
||||
Convert address to coordinates using Nominatim geocoding service.
|
||||
|
||||
Args:
|
||||
address: Address string to geocode
|
||||
|
||||
Returns:
|
||||
Coordinates object or None if geocoding fails
|
||||
"""
|
||||
if not address or not address.strip():
|
||||
return None
|
||||
|
||||
# Check cache first
|
||||
cache_key = f"roadtrip:geocode:{hash(address.lower().strip())}"
|
||||
cached_result = cache.get(cache_key)
|
||||
if cached_result:
|
||||
return Coordinates(**cached_result)
|
||||
|
||||
try:
|
||||
params = {
|
||||
'q': address.strip(),
|
||||
'format': 'json',
|
||||
'limit': 1,
|
||||
'addressdetails': 1,
|
||||
}
|
||||
|
||||
url = f"{self.nominatim_base_url}/search"
|
||||
response = self._make_request(url, params)
|
||||
|
||||
if response and len(response) > 0:
|
||||
result = response[0]
|
||||
coords = Coordinates(
|
||||
latitude=float(result['lat']),
|
||||
longitude=float(result['lon'])
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
cache.set(cache_key, {
|
||||
'latitude': coords.latitude,
|
||||
'longitude': coords.longitude
|
||||
}, self.cache_timeout)
|
||||
|
||||
logger.info(f"Geocoded '{address}' to {coords.latitude}, {coords.longitude}")
|
||||
return coords
|
||||
else:
|
||||
logger.warning(f"No geocoding results for address: {address}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Geocoding failed for '{address}': {e}")
|
||||
return None
|
||||
|
||||
def calculate_route(self, start_coords: Coordinates, end_coords: Coordinates) -> Optional[RouteInfo]:
|
||||
"""
|
||||
Calculate route between two coordinate points using OSRM.
|
||||
|
||||
Args:
|
||||
start_coords: Starting coordinates
|
||||
end_coords: Ending coordinates
|
||||
|
||||
Returns:
|
||||
RouteInfo object or None if routing fails
|
||||
"""
|
||||
if not start_coords or not end_coords:
|
||||
return None
|
||||
|
||||
# Check cache first
|
||||
cache_key = f"roadtrip:route:{start_coords.latitude},{start_coords.longitude}:{end_coords.latitude},{end_coords.longitude}"
|
||||
cached_result = cache.get(cache_key)
|
||||
if cached_result:
|
||||
return RouteInfo(**cached_result)
|
||||
|
||||
try:
|
||||
# Format coordinates for OSRM (lon,lat format)
|
||||
coords_string = f"{start_coords.longitude},{start_coords.latitude};{end_coords.longitude},{end_coords.latitude}"
|
||||
url = f"{self.osrm_base_url}/{coords_string}"
|
||||
|
||||
params = {
|
||||
'overview': 'full',
|
||||
'geometries': 'polyline',
|
||||
'steps': 'false',
|
||||
}
|
||||
|
||||
response = self._make_request(url, params)
|
||||
|
||||
if response.get('code') == 'Ok' and response.get('routes'):
|
||||
route_data = response['routes'][0]
|
||||
|
||||
# Distance is in meters, convert to km
|
||||
distance_km = route_data['distance'] / 1000.0
|
||||
# Duration is in seconds, convert to minutes
|
||||
duration_minutes = int(route_data['duration'] / 60)
|
||||
|
||||
route_info = RouteInfo(
|
||||
distance_km=distance_km,
|
||||
duration_minutes=duration_minutes,
|
||||
geometry=route_data.get('geometry')
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
cache.set(cache_key, {
|
||||
'distance_km': route_info.distance_km,
|
||||
'duration_minutes': route_info.duration_minutes,
|
||||
'geometry': route_info.geometry
|
||||
}, self.route_cache_timeout)
|
||||
|
||||
logger.info(f"Route calculated: {route_info.formatted_distance}, {route_info.formatted_duration}")
|
||||
return route_info
|
||||
else:
|
||||
# Fallback to straight-line distance calculation
|
||||
logger.warning(f"OSRM routing failed, falling back to straight-line distance")
|
||||
return self._calculate_straight_line_route(start_coords, end_coords)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Route calculation failed: {e}")
|
||||
# Fallback to straight-line distance
|
||||
return self._calculate_straight_line_route(start_coords, end_coords)
|
||||
|
||||
def _calculate_straight_line_route(self, start_coords: Coordinates, end_coords: Coordinates) -> RouteInfo:
|
||||
"""
|
||||
Calculate straight-line distance as fallback when routing fails.
|
||||
"""
|
||||
# Haversine formula for great-circle distance
|
||||
lat1, lon1 = math.radians(start_coords.latitude), math.radians(start_coords.longitude)
|
||||
lat2, lon2 = math.radians(end_coords.latitude), math.radians(end_coords.longitude)
|
||||
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
|
||||
# Earth's radius in kilometers
|
||||
earth_radius_km = 6371.0
|
||||
distance_km = earth_radius_km * c
|
||||
|
||||
# Estimate driving time (assume average 80 km/h with 25% extra for roads)
|
||||
estimated_duration_minutes = int((distance_km * 1.25 / 80.0) * 60)
|
||||
|
||||
return RouteInfo(
|
||||
distance_km=distance_km,
|
||||
duration_minutes=estimated_duration_minutes,
|
||||
geometry=None
|
||||
)
|
||||
|
||||
def find_parks_along_route(self, start_park: 'Park', end_park: 'Park', max_detour_km: float = 50) -> List['Park']:
|
||||
"""
|
||||
Find parks along a route within specified detour distance.
|
||||
|
||||
Args:
|
||||
start_park: Starting park
|
||||
end_park: Ending park
|
||||
max_detour_km: Maximum detour distance in kilometers
|
||||
|
||||
Returns:
|
||||
List of parks along the route
|
||||
"""
|
||||
from parks.models import Park
|
||||
|
||||
if not hasattr(start_park, 'location') or not hasattr(end_park, 'location'):
|
||||
return []
|
||||
|
||||
if not start_park.location or not end_park.location:
|
||||
return []
|
||||
|
||||
start_coords = start_park.coordinates
|
||||
end_coords = end_park.coordinates
|
||||
|
||||
if not start_coords or not end_coords:
|
||||
return []
|
||||
|
||||
start_point = Point(start_coords[1], start_coords[0], srid=4326) # lon, lat
|
||||
end_point = Point(end_coords[1], end_coords[0], srid=4326)
|
||||
|
||||
# Find all parks within a reasonable distance from both start and end
|
||||
max_search_distance = Distance(km=max_detour_km * 2)
|
||||
|
||||
candidate_parks = Park.objects.filter(
|
||||
location__point__distance_lte=(start_point, max_search_distance)
|
||||
).exclude(
|
||||
id__in=[start_park.id, end_park.id]
|
||||
).select_related('location')
|
||||
|
||||
parks_along_route = []
|
||||
|
||||
for park in candidate_parks:
|
||||
if not park.location or not park.location.point:
|
||||
continue
|
||||
|
||||
park_coords = park.coordinates
|
||||
if not park_coords:
|
||||
continue
|
||||
|
||||
# Calculate detour distance
|
||||
detour_distance = self._calculate_detour_distance(
|
||||
Coordinates(*start_coords),
|
||||
Coordinates(*end_coords),
|
||||
Coordinates(*park_coords)
|
||||
)
|
||||
|
||||
if detour_distance and detour_distance <= max_detour_km:
|
||||
parks_along_route.append(park)
|
||||
|
||||
return parks_along_route
|
||||
|
||||
def _calculate_detour_distance(self, start: Coordinates, end: Coordinates, waypoint: Coordinates) -> Optional[float]:
|
||||
"""
|
||||
Calculate the detour distance when visiting a waypoint.
|
||||
"""
|
||||
try:
|
||||
# Direct route distance
|
||||
direct_route = self.calculate_route(start, end)
|
||||
if not direct_route:
|
||||
return None
|
||||
|
||||
# Route via waypoint
|
||||
route_to_waypoint = self.calculate_route(start, waypoint)
|
||||
route_from_waypoint = self.calculate_route(waypoint, end)
|
||||
|
||||
if not route_to_waypoint or not route_from_waypoint:
|
||||
return None
|
||||
|
||||
detour_distance = (route_to_waypoint.distance_km + route_from_waypoint.distance_km) - direct_route.distance_km
|
||||
return max(0, detour_distance) # Don't return negative detours
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to calculate detour distance: {e}")
|
||||
return None
|
||||
|
||||
def create_multi_park_trip(self, park_list: List['Park']) -> Optional[RoadTrip]:
|
||||
"""
|
||||
Create optimized multi-park road trip using simple nearest neighbor heuristic.
|
||||
|
||||
Args:
|
||||
park_list: List of parks to visit
|
||||
|
||||
Returns:
|
||||
RoadTrip object with optimized route
|
||||
"""
|
||||
if len(park_list) < 2:
|
||||
return None
|
||||
|
||||
# For small numbers of parks, try all permutations
|
||||
if len(park_list) <= 6:
|
||||
return self._optimize_trip_exhaustive(park_list)
|
||||
else:
|
||||
return self._optimize_trip_nearest_neighbor(park_list)
|
||||
|
||||
def _optimize_trip_exhaustive(self, park_list: List['Park']) -> Optional[RoadTrip]:
|
||||
"""
|
||||
Find optimal route by testing all permutations (for small lists).
|
||||
"""
|
||||
best_trip = None
|
||||
best_distance = float('inf')
|
||||
|
||||
# Try all possible orders (excluding the first park as starting point)
|
||||
for perm in permutations(park_list[1:]):
|
||||
ordered_parks = [park_list[0]] + list(perm)
|
||||
trip = self._create_trip_from_order(ordered_parks)
|
||||
|
||||
if trip and trip.total_distance_km < best_distance:
|
||||
best_distance = trip.total_distance_km
|
||||
best_trip = trip
|
||||
|
||||
return best_trip
|
||||
|
||||
def _optimize_trip_nearest_neighbor(self, park_list: List['Park']) -> Optional[RoadTrip]:
|
||||
"""
|
||||
Optimize trip using nearest neighbor heuristic (for larger lists).
|
||||
"""
|
||||
if not park_list:
|
||||
return None
|
||||
|
||||
# Start with the first park
|
||||
current_park = park_list[0]
|
||||
ordered_parks = [current_park]
|
||||
remaining_parks = park_list[1:]
|
||||
|
||||
while remaining_parks:
|
||||
# Find nearest unvisited park
|
||||
nearest_park = None
|
||||
min_distance = float('inf')
|
||||
|
||||
current_coords = current_park.coordinates
|
||||
if not current_coords:
|
||||
break
|
||||
|
||||
for park in remaining_parks:
|
||||
park_coords = park.coordinates
|
||||
if not park_coords:
|
||||
continue
|
||||
|
||||
route = self.calculate_route(
|
||||
Coordinates(*current_coords),
|
||||
Coordinates(*park_coords)
|
||||
)
|
||||
|
||||
if route and route.distance_km < min_distance:
|
||||
min_distance = route.distance_km
|
||||
nearest_park = park
|
||||
|
||||
if nearest_park:
|
||||
ordered_parks.append(nearest_park)
|
||||
remaining_parks.remove(nearest_park)
|
||||
current_park = nearest_park
|
||||
else:
|
||||
break
|
||||
|
||||
return self._create_trip_from_order(ordered_parks)
|
||||
|
||||
def _create_trip_from_order(self, ordered_parks: List['Park']) -> Optional[RoadTrip]:
|
||||
"""
|
||||
Create a RoadTrip object from an ordered list of parks.
|
||||
"""
|
||||
if len(ordered_parks) < 2:
|
||||
return None
|
||||
|
||||
legs = []
|
||||
total_distance = 0
|
||||
total_duration = 0
|
||||
|
||||
for i in range(len(ordered_parks) - 1):
|
||||
from_park = ordered_parks[i]
|
||||
to_park = ordered_parks[i + 1]
|
||||
|
||||
from_coords = from_park.coordinates
|
||||
to_coords = to_park.coordinates
|
||||
|
||||
if not from_coords or not to_coords:
|
||||
continue
|
||||
|
||||
route = self.calculate_route(
|
||||
Coordinates(*from_coords),
|
||||
Coordinates(*to_coords)
|
||||
)
|
||||
|
||||
if route:
|
||||
legs.append(TripLeg(
|
||||
from_park=from_park,
|
||||
to_park=to_park,
|
||||
route=route
|
||||
))
|
||||
total_distance += route.distance_km
|
||||
total_duration += route.duration_minutes
|
||||
|
||||
if not legs:
|
||||
return None
|
||||
|
||||
return RoadTrip(
|
||||
parks=ordered_parks,
|
||||
legs=legs,
|
||||
total_distance_km=total_distance,
|
||||
total_duration_minutes=total_duration
|
||||
)
|
||||
|
||||
def get_park_distances(self, center_park: 'Park', radius_km: float = 100) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all parks within radius of a center park with distances.
|
||||
|
||||
Args:
|
||||
center_park: Center park for search
|
||||
radius_km: Search radius in kilometers
|
||||
|
||||
Returns:
|
||||
List of dictionaries with park and distance information
|
||||
"""
|
||||
from parks.models import Park
|
||||
|
||||
if not hasattr(center_park, 'location') or not center_park.location:
|
||||
return []
|
||||
|
||||
center_coords = center_park.coordinates
|
||||
if not center_coords:
|
||||
return []
|
||||
|
||||
center_point = Point(center_coords[1], center_coords[0], srid=4326) # lon, lat
|
||||
search_distance = Distance(km=radius_km)
|
||||
|
||||
nearby_parks = Park.objects.filter(
|
||||
location__point__distance_lte=(center_point, search_distance)
|
||||
).exclude(
|
||||
id=center_park.id
|
||||
).select_related('location')
|
||||
|
||||
results = []
|
||||
|
||||
for park in nearby_parks:
|
||||
park_coords = park.coordinates
|
||||
if not park_coords:
|
||||
continue
|
||||
|
||||
route = self.calculate_route(
|
||||
Coordinates(*center_coords),
|
||||
Coordinates(*park_coords)
|
||||
)
|
||||
|
||||
if route:
|
||||
results.append({
|
||||
'park': park,
|
||||
'distance_km': route.distance_km,
|
||||
'duration_minutes': route.duration_minutes,
|
||||
'formatted_distance': route.formatted_distance,
|
||||
'formatted_duration': route.formatted_duration,
|
||||
})
|
||||
|
||||
# Sort by distance
|
||||
results.sort(key=lambda x: x['distance_km'])
|
||||
|
||||
return results
|
||||
|
||||
def geocode_park_if_needed(self, park: 'Park') -> bool:
|
||||
"""
|
||||
Geocode park location if coordinates are missing.
|
||||
|
||||
Args:
|
||||
park: Park to geocode
|
||||
|
||||
Returns:
|
||||
True if geocoding succeeded or wasn't needed, False otherwise
|
||||
"""
|
||||
if not hasattr(park, 'location') or not park.location:
|
||||
return False
|
||||
|
||||
location = park.location
|
||||
|
||||
# If we already have coordinates, no need to geocode
|
||||
if location.point:
|
||||
return True
|
||||
|
||||
# Build address string for geocoding
|
||||
address_parts = [
|
||||
park.name,
|
||||
location.street_address,
|
||||
location.city,
|
||||
location.state,
|
||||
location.country
|
||||
]
|
||||
address = ", ".join(part for part in address_parts if part)
|
||||
|
||||
if not address:
|
||||
return False
|
||||
|
||||
coords = self.geocode_address(address)
|
||||
if coords:
|
||||
location.set_coordinates(coords.latitude, coords.longitude)
|
||||
location.save()
|
||||
logger.info(f"Geocoded park '{park.name}' to {coords.latitude}, {coords.longitude}")
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -2,30 +2,29 @@ from django.test import TestCase, Client
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.http import HttpResponse
|
||||
from typing import cast, Optional, Tuple
|
||||
from .models import Park, ParkArea
|
||||
from operators.models import Operator
|
||||
from location.models import Location
|
||||
from parks.models.companies import Operator
|
||||
from parks.models.location import ParkLocation
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
def create_test_location(park: Park) -> Location:
|
||||
def create_test_location(park: Park) -> ParkLocation:
|
||||
"""Helper function to create a test location"""
|
||||
return Location.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=park.id,
|
||||
name='Test Park Location',
|
||||
location_type='park',
|
||||
park_location = ParkLocation.objects.create(
|
||||
park=park,
|
||||
street_address='123 Test St',
|
||||
city='Test City',
|
||||
state='TS',
|
||||
country='Test Country',
|
||||
postal_code='12345',
|
||||
point=Point(-118.2437, 34.0522)
|
||||
postal_code='12345'
|
||||
)
|
||||
# Set coordinates using the helper method
|
||||
park_location.set_coordinates(34.0522, -118.2437) # latitude, longitude
|
||||
park_location.save()
|
||||
return park_location
|
||||
|
||||
class ParkModelTests(TestCase):
|
||||
@classmethod
|
||||
|
||||
@@ -7,10 +7,11 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import timezone
|
||||
from datetime import date, timedelta
|
||||
|
||||
from parks.models import Park
|
||||
from parks.models import Park, ParkLocation
|
||||
from parks.filters import ParkFilter
|
||||
from operators.models import Operator
|
||||
from location.models import Location
|
||||
from parks.models.companies import Operator
|
||||
# NOTE: These tests need to be updated to work with the new ParkLocation model
|
||||
# instead of the generic Location model
|
||||
|
||||
class ParkFilterTests(TestCase):
|
||||
@classmethod
|
||||
@@ -8,9 +8,10 @@ from django.db import IntegrityError
|
||||
from django.utils import timezone
|
||||
from datetime import date
|
||||
|
||||
from parks.models import Park, ParkArea
|
||||
from operators.models import Operator
|
||||
from location.models import Location
|
||||
from parks.models import Park, ParkArea, ParkLocation
|
||||
from parks.models.companies import Operator
|
||||
# NOTE: These tests need to be updated to work with the new ParkLocation model
|
||||
# instead of the generic Location model
|
||||
|
||||
class ParkModelTests(TestCase):
|
||||
def setUp(self):
|
||||
@@ -61,7 +62,7 @@ class ParkModelTests(TestCase):
|
||||
"""Test finding park by historical slug"""
|
||||
from django.db import transaction
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from history_tracking.models import HistoricalSlug
|
||||
from core.history import HistoricalSlug
|
||||
|
||||
with transaction.atomic():
|
||||
# Create initial park with a specific name/slug
|
||||
@@ -1,7 +1,6 @@
|
||||
from .querysets import get_base_park_queryset
|
||||
from search.mixins import HTMXFilterableMixin
|
||||
from reviews.models import Review
|
||||
from location.models import Location
|
||||
from core.mixins import HTMXFilterableMixin
|
||||
from .models.location import ParkLocation
|
||||
from media.models import Photo
|
||||
from moderation.models import EditSubmission
|
||||
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
|
||||
@@ -375,20 +374,22 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
if form.cleaned_data.get("latitude") and form.cleaned_data.get(
|
||||
"longitude"
|
||||
):
|
||||
Location.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=self.object.id,
|
||||
name=self.object.name,
|
||||
location_type="park",
|
||||
latitude=form.cleaned_data["latitude"],
|
||||
longitude=form.cleaned_data["longitude"],
|
||||
street_address=form.cleaned_data.get(
|
||||
"street_address", ""),
|
||||
city=form.cleaned_data.get("city", ""),
|
||||
state=form.cleaned_data.get("state", ""),
|
||||
country=form.cleaned_data.get("country", ""),
|
||||
postal_code=form.cleaned_data.get("postal_code", ""),
|
||||
# Create or update ParkLocation
|
||||
park_location, created = ParkLocation.objects.get_or_create(
|
||||
park=self.object,
|
||||
defaults={
|
||||
'street_address': form.cleaned_data.get("street_address", ""),
|
||||
'city': form.cleaned_data.get("city", ""),
|
||||
'state': form.cleaned_data.get("state", ""),
|
||||
'country': form.cleaned_data.get("country", "USA"),
|
||||
'postal_code': form.cleaned_data.get("postal_code", ""),
|
||||
}
|
||||
)
|
||||
park_location.set_coordinates(
|
||||
form.cleaned_data["latitude"],
|
||||
form.cleaned_data["longitude"]
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
@@ -507,17 +508,50 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
||||
"postal_code": form.cleaned_data.get("postal_code", ""),
|
||||
}
|
||||
|
||||
if self.object.location.exists():
|
||||
location = self.object.location.first()
|
||||
# Create or update ParkLocation
|
||||
try:
|
||||
park_location = self.object.location
|
||||
# Update existing location
|
||||
for key, value in location_data.items():
|
||||
setattr(location, key, value)
|
||||
location.save()
|
||||
else:
|
||||
Location.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=self.object.id,
|
||||
**location_data,
|
||||
if key in ['latitude', 'longitude'] and value:
|
||||
continue # Handle coordinates separately
|
||||
if hasattr(park_location, key):
|
||||
setattr(park_location, key, value)
|
||||
|
||||
# Handle coordinates if provided
|
||||
if 'latitude' in location_data and 'longitude' in location_data:
|
||||
if location_data['latitude'] and location_data['longitude']:
|
||||
park_location.set_coordinates(
|
||||
float(location_data['latitude']),
|
||||
float(location_data['longitude'])
|
||||
)
|
||||
park_location.save()
|
||||
except ParkLocation.DoesNotExist:
|
||||
# Create new ParkLocation
|
||||
coordinates_data = {}
|
||||
if 'latitude' in location_data and 'longitude' in location_data:
|
||||
if location_data['latitude'] and location_data['longitude']:
|
||||
coordinates_data = {
|
||||
'latitude': float(location_data['latitude']),
|
||||
'longitude': float(location_data['longitude'])
|
||||
}
|
||||
|
||||
# Remove coordinate fields from location_data for creation
|
||||
creation_data = {k: v for k, v in location_data.items()
|
||||
if k not in ['latitude', 'longitude']}
|
||||
creation_data.setdefault('country', 'USA')
|
||||
|
||||
park_location = ParkLocation.objects.create(
|
||||
park=self.object,
|
||||
**creation_data
|
||||
)
|
||||
|
||||
if coordinates_data:
|
||||
park_location.set_coordinates(
|
||||
coordinates_data['latitude'],
|
||||
coordinates_data['longitude']
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
|
||||
Reference in New Issue
Block a user