major changes, including tailwind v4

This commit is contained in:
pacnpal
2025-08-15 12:24:20 -04:00
parent f6c8e0e25c
commit da7c7e3381
261 changed files with 22783 additions and 10465 deletions

View File

@@ -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)

View File

@@ -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):

View File

@@ -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()

View File

@@ -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}")

View File

@@ -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']:

View File

@@ -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'))

View 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!")

View File

@@ -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",

View File

@@ -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',
),
),
]

View File

@@ -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",
),
),
),
]

View File

@@ -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",
),
),
]

View 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"
)
],
},
),
]

View File

@@ -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",
},
),
]

View File

@@ -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",
),
),
),
]

View File

@@ -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,
),
),
]

View File

@@ -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"
),
),
]

View File

@@ -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
View 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
View 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
View 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
View 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']),
]

View File

@@ -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
View 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}"

View File

@@ -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),

View File

@@ -0,0 +1,3 @@
from .roadtrip import RoadTripService
__all__ = ['RoadTripService']

639
parks/services/roadtrip.py Normal file
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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