# 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 %}

Location

{{ park.park_location.formatted_address }}

{% if park.park_location.highway_exit %}

Highway Exit: {{ park.park_location.highway_exit }}

{% endif %} {% if park.park_location.parking_notes %}

Parking: {{ park.park_location.parking_notes }}

{% endif %}
{% 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.