Files
thrillwiki_django_no_react/memory-bank/features/location-models-design.md
2025-08-15 12:24:20 -04:00

867 lines
24 KiB
Markdown

# Domain-Specific Location Models Design - ThrillWiki
## Executive Summary
This design document outlines the complete transition from ThrillWiki's generic location system to domain-specific location models. The design builds upon existing partial implementations (ParkLocation, RideLocation, CompanyHeadquarters) and addresses the requirements for road trip planning, spatial queries, and clean domain boundaries.
## 1. Model Specifications
### 1.1 ParkLocation Model
#### Purpose
Primary location model for theme parks, optimized for road trip planning and visitor navigation.
#### Field Specifications
```python
class ParkLocation(models.Model):
# Relationships
park = models.OneToOneField(
'parks.Park',
on_delete=models.CASCADE,
related_name='park_location' # Changed from 'location' to avoid conflicts
)
# Spatial Data (PostGIS)
point = gis_models.PointField(
srid=4326, # WGS84 coordinate system
db_index=True,
help_text="Geographic coordinates for mapping and distance calculations"
)
# Core Address Fields
street_address = models.CharField(
max_length=255,
blank=True,
help_text="Street number and name for the main entrance"
)
city = models.CharField(
max_length=100,
db_index=True,
help_text="City where the park is located"
)
state = models.CharField(
max_length=100,
db_index=True,
help_text="State/Province/Region"
)
country = models.CharField(
max_length=100,
default='USA',
db_index=True,
help_text="Country code or full name"
)
postal_code = models.CharField(
max_length=20,
blank=True,
help_text="ZIP or postal code"
)
# Road Trip Metadata
highway_exit = models.CharField(
max_length=100,
blank=True,
help_text="Nearest highway exit information (e.g., 'I-75 Exit 234')"
)
parking_notes = models.TextField(
blank=True,
help_text="Parking tips, costs, and preferred lots"
)
best_arrival_time = models.TimeField(
null=True,
blank=True,
help_text="Recommended arrival time to minimize crowds"
)
seasonal_notes = models.TextField(
blank=True,
help_text="Seasonal considerations for visiting (weather, crowds, events)"
)
# Navigation Helpers
main_entrance_notes = models.TextField(
blank=True,
help_text="Specific directions to main entrance from parking"
)
gps_accuracy_notes = models.CharField(
max_length=255,
blank=True,
help_text="Notes about GPS accuracy or common navigation issues"
)
# OpenStreetMap Integration
osm_id = models.BigIntegerField(
null=True,
blank=True,
db_index=True,
help_text="OpenStreetMap ID for data synchronization"
)
osm_last_sync = models.DateTimeField(
null=True,
blank=True,
help_text="Last time data was synchronized with OSM"
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
verified_date = models.DateField(
null=True,
blank=True,
help_text="Date location was last verified as accurate"
)
verified_by = models.ForeignKey(
'accounts.User',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='verified_park_locations'
)
```
#### Properties and Methods
```python
@property
def latitude(self):
"""Returns latitude for backward compatibility"""
return self.point.y if self.point else None
@property
def longitude(self):
"""Returns longitude for backward compatibility"""
return self.point.x if self.point else None
@property
def formatted_address(self):
"""Returns a formatted address string"""
components = []
if self.street_address:
components.append(self.street_address)
if self.city:
components.append(self.city)
if self.state:
components.append(self.state)
if self.postal_code:
components.append(self.postal_code)
if self.country and self.country != 'USA':
components.append(self.country)
return ", ".join(components)
@property
def short_address(self):
"""Returns city, state for compact display"""
parts = []
if self.city:
parts.append(self.city)
if self.state:
parts.append(self.state)
return ", ".join(parts) if parts else "Location Unknown"
def distance_to(self, other_location):
"""Calculate distance to another ParkLocation in miles"""
if not self.point or not hasattr(other_location, 'point') or not other_location.point:
return None
# Use PostGIS distance calculation and convert to miles
from django.contrib.gis.measure import D
return self.point.distance(other_location.point) * 69.0 # Rough conversion
def nearby_parks(self, distance_miles=50):
"""Find other parks within specified distance"""
if not self.point:
return ParkLocation.objects.none()
from django.contrib.gis.measure import D
return ParkLocation.objects.filter(
point__distance_lte=(self.point, D(mi=distance_miles))
).exclude(pk=self.pk).select_related('park')
def get_directions_url(self):
"""Generate Google Maps directions URL"""
if self.point:
return f"https://www.google.com/maps/dir/?api=1&destination={self.latitude},{self.longitude}"
return None
```
#### Meta Options
```python
class Meta:
verbose_name = "Park Location"
verbose_name_plural = "Park Locations"
indexes = [
models.Index(fields=['city', 'state']),
models.Index(fields=['country']),
models.Index(fields=['osm_id']),
GistIndex(fields=['point']), # Spatial index for PostGIS
]
constraints = [
models.UniqueConstraint(
fields=['park'],
name='unique_park_location'
)
]
```
### 1.2 RideLocation Model
#### Purpose
Optional lightweight location tracking for individual rides within parks.
#### Field Specifications
```python
class RideLocation(models.Model):
# Relationships
ride = models.OneToOneField(
'rides.Ride',
on_delete=models.CASCADE,
related_name='ride_location'
)
# Optional Spatial Data
entrance_point = gis_models.PointField(
srid=4326,
null=True,
blank=True,
help_text="Specific coordinates for ride entrance"
)
exit_point = gis_models.PointField(
srid=4326,
null=True,
blank=True,
help_text="Specific coordinates for ride exit (if different)"
)
# Park Area Information
park_area = models.CharField(
max_length=100,
blank=True,
db_index=True,
help_text="Themed area or land within the park"
)
level = models.CharField(
max_length=50,
blank=True,
help_text="Floor or level if in multi-story area"
)
# Accessibility
accessible_entrance_point = gis_models.PointField(
srid=4326,
null=True,
blank=True,
help_text="Coordinates for accessible entrance if different"
)
accessible_entrance_notes = models.TextField(
blank=True,
help_text="Directions to accessible entrance"
)
# Queue and Navigation
queue_entrance_notes = models.TextField(
blank=True,
help_text="How to find the queue entrance"
)
fastpass_entrance_notes = models.TextField(
blank=True,
help_text="Location of FastPass/Express entrance"
)
single_rider_entrance_notes = models.TextField(
blank=True,
help_text="Location of single rider entrance if available"
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
```
#### Properties and Methods
```python
@property
def has_coordinates(self):
"""Check if any coordinates are set"""
return bool(self.entrance_point or self.exit_point or self.accessible_entrance_point)
@property
def primary_point(self):
"""Returns the primary location point (entrance preferred)"""
return self.entrance_point or self.exit_point or self.accessible_entrance_point
def get_park_location(self):
"""Get the parent park's location"""
return self.ride.park.park_location if hasattr(self.ride.park, 'park_location') else None
```
#### Meta Options
```python
class Meta:
verbose_name = "Ride Location"
verbose_name_plural = "Ride Locations"
indexes = [
models.Index(fields=['park_area']),
GistIndex(fields=['entrance_point'], condition=Q(entrance_point__isnull=False)),
]
```
### 1.3 CompanyHeadquarters Model
#### Purpose
Simple address storage for company headquarters without coordinate tracking.
#### Field Specifications
```python
class CompanyHeadquarters(models.Model):
# Relationships
company = models.OneToOneField(
'parks.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 = 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
)
postal_code = models.CharField(
max_length=20,
blank=True
)
# Contact Information (Optional)
phone = models.CharField(
max_length=30,
blank=True,
help_text="Corporate phone number"
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
```
#### Properties and Methods
```python
@property
def formatted_address(self):
"""Returns a formatted address string"""
components = []
if self.street_address:
components.append(self.street_address)
if self.city:
components.append(self.city)
if self.state:
components.append(self.state)
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, country display"""
parts = [self.city]
if self.state:
parts.append(self.state)
if self.country != 'USA':
parts.append(self.country)
return ", ".join(parts)
```
#### Meta Options
```python
class Meta:
verbose_name = "Company Headquarters"
verbose_name_plural = "Company Headquarters"
indexes = [
models.Index(fields=['city', 'country']),
]
```
## 2. Shared Functionality Design
### 2.1 Address Formatting Utilities
Create a utility module `location/utils.py`:
```python
class AddressFormatter:
"""Utility class for consistent address formatting across models"""
@staticmethod
def format_full(street=None, city=None, state=None, postal=None, country=None):
"""Format a complete address"""
components = []
if street:
components.append(street)
if city:
components.append(city)
if state:
components.append(state)
if postal:
components.append(postal)
if country and country != 'USA':
components.append(country)
return ", ".join(components)
@staticmethod
def format_short(city=None, state=None, country=None):
"""Format a short location display"""
parts = []
if city:
parts.append(city)
if state:
parts.append(state)
elif country and country != 'USA':
parts.append(country)
return ", ".join(parts) if parts else "Unknown Location"
```
### 2.2 Geocoding Service
Create `location/services.py`:
```python
class GeocodingService:
"""Service for geocoding addresses using OpenStreetMap Nominatim"""
@staticmethod
def geocode_address(street, city, state, country='USA'):
"""Convert address to coordinates"""
# Implementation using Nominatim API
pass
@staticmethod
def reverse_geocode(latitude, longitude):
"""Convert coordinates to address"""
# Implementation using Nominatim API
pass
@staticmethod
def validate_coordinates(latitude, longitude):
"""Validate coordinate ranges"""
return (-90 <= latitude <= 90) and (-180 <= longitude <= 180)
```
### 2.3 Distance Calculation Mixin
```python
class DistanceCalculationMixin:
"""Mixin for models with point fields to calculate distances"""
def distance_to_point(self, point):
"""Calculate distance to a point in miles"""
if not self.point or not point:
return None
# Use PostGIS for calculation
return self.point.distance(point) * 69.0 # Rough miles conversion
def within_radius(self, radius_miles):
"""Get queryset of objects within radius"""
if not self.point:
return self.__class__.objects.none()
from django.contrib.gis.measure import D
return self.__class__.objects.filter(
point__distance_lte=(self.point, D(mi=radius_miles))
).exclude(pk=self.pk)
```
## 3. Data Flow Design
### 3.1 Location Data Entry Flow
```mermaid
graph TD
A[User Creates/Edits Park] --> B[Park Form]
B --> C{Has Address?}
C -->|Yes| D[Geocoding Service]
C -->|No| E[Manual Coordinate Entry]
D --> F[Validate Coordinates]
E --> F
F --> G[Create/Update ParkLocation]
G --> H[Update OSM Fields]
H --> I[Save to Database]
```
### 3.2 Location Search Flow
```mermaid
graph TD
A[User Searches Location] --> B[Search View]
B --> C[Check Cache]
C -->|Hit| D[Return Cached Results]
C -->|Miss| E[Query OSM Nominatim]
E --> F[Process Results]
F --> G[Filter by Park Existence]
G --> H[Cache Results]
H --> D
```
### 3.3 Road Trip Planning Flow
```mermaid
graph TD
A[User Plans Road Trip] --> B[Select Starting Point]
B --> C[Query Nearby Parks]
C --> D[Calculate Distances]
D --> E[Sort by Distance/Route]
E --> F[Display with Highway Exits]
F --> G[Show Parking/Arrival Info]
```
## 4. Query Patterns
### 4.1 Common Spatial Queries
```python
# Find parks within radius
ParkLocation.objects.filter(
point__distance_lte=(origin_point, D(mi=50))
).select_related('park')
# Find nearest park
ParkLocation.objects.annotate(
distance=Distance('point', origin_point)
).order_by('distance').first()
# Parks along a route (bounding box)
from django.contrib.gis.geos import Polygon
bbox = Polygon.from_bbox((min_lng, min_lat, max_lng, max_lat))
ParkLocation.objects.filter(point__within=bbox)
# Group parks by state
ParkLocation.objects.values('state').annotate(
count=Count('id'),
parks=ArrayAgg('park__name')
)
```
### 4.2 Performance Optimizations
```python
# Prefetch related data for park listings
Park.objects.select_related(
'park_location',
'operator',
'property_owner'
).prefetch_related('rides')
# Use database functions for formatting
from django.db.models import Value, F
from django.db.models.functions import Concat
ParkLocation.objects.annotate(
display_address=Concat(
F('city'), Value(', '),
F('state')
)
)
```
### 4.3 Caching Strategy
```python
# Cache frequently accessed location data
CACHE_KEYS = {
'park_location': 'park_location_{park_id}',
'nearby_parks': 'nearby_parks_{park_id}_{radius}',
'state_parks': 'state_parks_{state}',
}
# Cache timeout in seconds
CACHE_TIMEOUTS = {
'park_location': 3600, # 1 hour
'nearby_parks': 1800, # 30 minutes
'state_parks': 7200, # 2 hours
}
```
## 5. Integration Points
### 5.1 Model Integration
```python
# Park model integration
class Park(models.Model):
# Remove GenericRelation to Location
# location = GenericRelation(Location) # REMOVE THIS
@property
def location(self):
"""Backward compatibility property"""
return self.park_location if hasattr(self, 'park_location') else None
@property
def coordinates(self):
"""Quick access to coordinates"""
if hasattr(self, 'park_location') and self.park_location:
return (self.park_location.latitude, self.park_location.longitude)
return None
```
### 5.2 Form Integration
```python
# Park forms will need location inline
class ParkLocationForm(forms.ModelForm):
class Meta:
model = ParkLocation
fields = [
'street_address', 'city', 'state', 'country', 'postal_code',
'highway_exit', 'parking_notes', 'best_arrival_time',
'seasonal_notes', 'point'
]
widgets = {
'point': LeafletWidget(), # Map widget for coordinate selection
}
class ParkForm(forms.ModelForm):
# Include location fields as nested form
location = ParkLocationForm()
```
### 5.3 API Serialization
```python
# Django REST Framework serializers
class ParkLocationSerializer(serializers.ModelSerializer):
latitude = serializers.ReadOnlyField()
longitude = serializers.ReadOnlyField()
formatted_address = serializers.ReadOnlyField()
class Meta:
model = ParkLocation
fields = [
'latitude', 'longitude', 'formatted_address',
'city', 'state', 'country', 'highway_exit',
'parking_notes', 'best_arrival_time'
]
class ParkSerializer(serializers.ModelSerializer):
location = ParkLocationSerializer(source='park_location', read_only=True)
```
### 5.4 Template Integration
```django
{# Park detail template #}
{% if park.park_location %}
<div class="park-location">
<h3>Location</h3>
<p>{{ park.park_location.formatted_address }}</p>
{% if park.park_location.highway_exit %}
<p><strong>Highway Exit:</strong> {{ park.park_location.highway_exit }}</p>
{% endif %}
{% if park.park_location.parking_notes %}
<p><strong>Parking:</strong> {{ park.park_location.parking_notes }}</p>
{% endif %}
<div id="park-map"
data-lat="{{ park.park_location.latitude }}"
data-lng="{{ park.park_location.longitude }}">
</div>
</div>
{% endif %}
```
## 6. Migration Plan
### 6.1 Migration Phases
#### Phase 1: Prepare New Models (No Downtime)
1. Create new models alongside existing ones
2. Add backward compatibility properties
3. Deploy without activating
#### Phase 2: Data Migration (Minimal Downtime)
1. Create migration script to copy data
2. Run in batches to avoid locks
3. Verify data integrity
#### Phase 3: Switch References (No Downtime)
1. Update views to use new models
2. Update forms and templates
3. Deploy with feature flags
#### Phase 4: Cleanup (No Downtime)
1. Remove GenericRelation from Park
2. Archive old Location model
3. Remove backward compatibility code
### 6.2 Migration Script
```python
from django.db import migrations
from django.contrib.contenttypes.models import ContentType
def migrate_park_locations(apps, schema_editor):
Location = apps.get_model('location', 'Location')
Park = apps.get_model('parks', 'Park')
ParkLocation = apps.get_model('parks', 'ParkLocation')
park_ct = ContentType.objects.get_for_model(Park)
for location in Location.objects.filter(content_type=park_ct):
try:
park = Park.objects.get(id=location.object_id)
# Create or update ParkLocation
park_location, created = ParkLocation.objects.update_or_create(
park=park,
defaults={
'point': location.point,
'street_address': location.street_address or '',
'city': location.city or '',
'state': location.state or '',
'country': location.country or 'USA',
'postal_code': location.postal_code or '',
# Map any additional fields
}
)
print(f"Migrated location for park: {park.name}")
except Park.DoesNotExist:
print(f"Park not found for location: {location.id}")
continue
def reverse_migration(apps, schema_editor):
# Reverse migration if needed
pass
class Migration(migrations.Migration):
dependencies = [
('parks', 'XXXX_create_park_location'),
('location', 'XXXX_previous'),
]
operations = [
migrations.RunPython(migrate_park_locations, reverse_migration),
]
```
### 6.3 Data Validation
```python
# Validation script to ensure migration success
def validate_migration():
from location.models import Location
from parks.models import Park, ParkLocation
from django.contrib.contenttypes.models import ContentType
park_ct = ContentType.objects.get_for_model(Park)
old_count = Location.objects.filter(content_type=park_ct).count()
new_count = ParkLocation.objects.count()
assert old_count == new_count, f"Count mismatch: {old_count} vs {new_count}"
# Verify data integrity
for park_location in ParkLocation.objects.all():
assert park_location.point is not None, f"Missing point for {park_location.park}"
assert park_location.city, f"Missing city for {park_location.park}"
print("Migration validation successful!")
```
### 6.4 Rollback Strategy
1. **Feature Flags**: Use flags to switch between old and new systems
2. **Database Backups**: Take snapshots before migration
3. **Parallel Running**: Keep both systems running initially
4. **Gradual Rollout**: Migrate parks in batches
5. **Monitoring**: Track errors and performance
## 7. Testing Strategy
### 7.1 Unit Tests
```python
# Test ParkLocation model
class ParkLocationTestCase(TestCase):
def test_formatted_address(self):
location = ParkLocation(
city="Orlando",
state="Florida",
country="USA"
)
self.assertEqual(location.formatted_address, "Orlando, Florida")
def test_distance_calculation(self):
location1 = ParkLocation(point=Point(-81.5639, 28.3852))
location2 = ParkLocation(point=Point(-81.4678, 28.4736))
distance = location1.distance_to(location2)
self.assertAlmostEqual(distance, 8.5, delta=0.5)
```
### 7.2 Integration Tests
```python
# Test location creation with park
class ParkLocationIntegrationTest(TestCase):
def test_create_park_with_location(self):
park = Park.objects.create(name="Test Park", ...)
location = ParkLocation.objects.create(
park=park,
point=Point(-81.5639, 28.3852),
city="Orlando",
state="Florida"
)
self.assertEqual(park.park_location, location)
self.assertEqual(park.coordinates, (28.3852, -81.5639))
```
## 8. Documentation Requirements
### 8.1 Developer Documentation
- Model field descriptions
- Query examples
- Migration guide
- API endpoint changes
### 8.2 Admin Documentation
- Location data entry guide
- Geocoding workflow
- Verification process
### 8.3 User Documentation
- How locations are displayed
- Road trip planning features
- Map interactions
## Conclusion
This design provides a comprehensive transition from generic to domain-specific location models while:
- Maintaining all existing functionality
- Improving query performance
- Enabling better road trip planning features
- Keeping clean domain boundaries
- Supporting zero-downtime migration
The design prioritizes parks as the primary location entities while keeping ride locations optional and company headquarters simple. All PostGIS spatial features are retained and optimized for the specific needs of each domain model.