Files
thrillwiki_django_no_react/shared/docs/memory-bank/features/location-models-design.md
pacnpal d504d41de2 feat: complete monorepo structure with frontend and shared resources
- Add complete backend/ directory with full Django application
- Add frontend/ directory with Vite + TypeScript setup ready for Next.js
- Add comprehensive shared/ directory with:
  - Complete documentation and memory-bank archives
  - Media files and avatars (letters, park/ride images)
  - Deployment scripts and automation tools
  - Shared types and utilities
- Add architecture/ directory with migration guides
- Configure pnpm workspace for monorepo development
- Update .gitignore to exclude .django_tailwind_cli/ build artifacts
- Preserve all historical documentation in shared/docs/memory-bank/
- Set up proper structure for full-stack development with shared resources
2025-08-23 18:40:07 -04:00

24 KiB

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

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

    @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

    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

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

    @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

    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

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

    @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

    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:

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:

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

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

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

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

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

# 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

# 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

# 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

# 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

# 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

# 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

{# 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

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

# 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

# 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

# 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.