mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 08:31:08 -05:00
major changes, including tailwind v4
This commit is contained in:
31
memory-bank/documentation/cleanup_report.md
Normal file
31
memory-bank/documentation/cleanup_report.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Parks Consolidation Cleanup Report
|
||||
|
||||
This report details the cleanup process following the consolidation of the `operators` and `property_owners` apps into the `parks` app.
|
||||
|
||||
## 1. Removed App Directories
|
||||
|
||||
The following app directories were removed:
|
||||
|
||||
- `operators/`
|
||||
- `property_owners/`
|
||||
|
||||
## 2. Removed Apps from INSTALLED_APPS
|
||||
|
||||
The `operators` and `property_owners` apps were removed from the `INSTALLED_APPS` setting in `thrillwiki/settings.py`.
|
||||
|
||||
## 3. Cleaned Up Migrations
|
||||
|
||||
All migration files were deleted from all apps and recreated to ensure a clean slate. This was done to resolve dependencies on the old `operators` and `property_owners` apps.
|
||||
|
||||
## 4. Reset Database
|
||||
|
||||
The database was reset to ensure all old data and schemas were removed. The following commands were run:
|
||||
|
||||
```bash
|
||||
uv run manage.py migrate --fake parks zero
|
||||
uv run manage.py migrate
|
||||
```
|
||||
|
||||
## 5. Verification
|
||||
|
||||
The codebase was searched for any remaining references to `operators` and `property_owners`. All remaining references in templates and documentation were removed.
|
||||
91
memory-bank/documentation/location_app_analysis.md
Normal file
91
memory-bank/documentation/location_app_analysis.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Location App Analysis
|
||||
|
||||
## 1. PostGIS Features in Use
|
||||
|
||||
### Spatial Fields
|
||||
- **`gis_models.PointField`**: The `Location` model in [`location/models.py`](location/models.py:51) uses a `PointField` to store geographic coordinates.
|
||||
|
||||
### GeoDjango QuerySet Methods
|
||||
- **`distance`**: The `distance_to` method in the `Location` model calculates the distance between two points.
|
||||
- **`distance_lte`**: The `nearby_locations` method uses the `distance_lte` lookup to find locations within a certain distance.
|
||||
|
||||
### Other GeoDjango Features
|
||||
- **`django.contrib.gis.geos.Point`**: The `Point` object is used to create point geometries from latitude and longitude.
|
||||
- **PostGIS Backend**: The project is configured to use the `django.contrib.gis.db.backends.postgis` database backend in [`thrillwiki/settings.py`](thrillwiki/settings.py:96).
|
||||
|
||||
### Spatial Indexes
|
||||
- No explicit spatial indexes are defined in the `Location` model's `Meta` class.
|
||||
|
||||
## 2. Location-Related Views Analysis
|
||||
|
||||
### Map Rendering
|
||||
- There is no direct map rendering functionality in the provided views. The views focus on searching, creating, updating, and deleting location data, as well as reverse geocoding.
|
||||
|
||||
### Spatial Calculations
|
||||
- The `distance_to` and `nearby_locations` methods in the `Location` model perform spatial calculations, but these are not directly exposed as view actions. The views themselves do not perform spatial calculations.
|
||||
|
||||
### GeoJSON Serialization
|
||||
- There is no GeoJSON serialization in the views. The views return standard JSON responses.
|
||||
|
||||
## 3. Migration Strategy
|
||||
|
||||
### Identified Risks
|
||||
1. **Data Loss Potential**:
|
||||
- Legacy latitude/longitude fields are synchronized with PostGIS point field
|
||||
- Removing legacy fields could break synchronization logic
|
||||
- Older entries might rely on legacy fields exclusively
|
||||
|
||||
2. **Breaking Changes**:
|
||||
- Views depend on external Nominatim API rather than PostGIS
|
||||
- Geocoding logic would need complete rewrite
|
||||
- Address parsing differs between Nominatim and PostGIS
|
||||
|
||||
3. **Performance Concerns**:
|
||||
- Missing spatial index on point field
|
||||
- Could lead to performance degradation as dataset grows
|
||||
|
||||
### Phased Migration Timeline
|
||||
```mermaid
|
||||
gantt
|
||||
title Location System Migration Timeline
|
||||
dateFormat YYYY-MM-DD
|
||||
section Phase 1
|
||||
Spatial Index Implementation :2025-08-16, 3d
|
||||
PostGIS Geocoding Setup :2025-08-19, 5d
|
||||
section Phase 2
|
||||
Dual-system Operation :2025-08-24, 7d
|
||||
Legacy Field Deprecation :2025-08-31, 3d
|
||||
section Phase 3
|
||||
API Migration :2025-09-03, 5d
|
||||
Cache Strategy Update :2025-09-08, 2d
|
||||
```
|
||||
|
||||
### Backward Compatibility Strategy
|
||||
- Maintain dual coordinate storage during transition
|
||||
- Implement compatibility shim layer:
|
||||
```python
|
||||
def get_coordinates(obj):
|
||||
return obj.point.coords if obj.point else (obj.latitude, obj.longitude)
|
||||
```
|
||||
- Gradual migration of views to PostGIS functions
|
||||
- Maintain legacy API endpoints during transition
|
||||
|
||||
### Spatial Data Migration Plan
|
||||
1. Add spatial index to Location model:
|
||||
```python
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['content_type', 'object_id']),
|
||||
models.Index(fields=['city']),
|
||||
models.Index(fields=['country']),
|
||||
gis_models.GistIndex(fields=['point']) # Spatial index
|
||||
]
|
||||
```
|
||||
2. Migrate to PostGIS geocoding functions:
|
||||
- Use `ST_Geocode` for address searches
|
||||
- Use `ST_ReverseGeocode` for coordinate to address conversion
|
||||
3. Implement Django's `django.contrib.gis.gdal` for address parsing
|
||||
4. Create data migration script to:
|
||||
- Convert existing Nominatim data to PostGIS format
|
||||
- Generate spatial indexes for existing data
|
||||
- Update cache keys and invalidation strategy
|
||||
321
memory-bank/documentation/location_model_design.md
Normal file
321
memory-bank/documentation/location_model_design.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# Location Model Design Document
|
||||
|
||||
## ParkLocation Model
|
||||
|
||||
```python
|
||||
from django.contrib.gis.db import models as gis_models
|
||||
from django.db import models
|
||||
from parks.models import Park
|
||||
|
||||
class ParkLocation(models.Model):
|
||||
park = models.OneToOneField(
|
||||
Park,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='location'
|
||||
)
|
||||
|
||||
# Geographic coordinates
|
||||
point = gis_models.PointField(
|
||||
srid=4326, # WGS84 coordinate system
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Geographic coordinates as a Point"
|
||||
)
|
||||
|
||||
# Address components
|
||||
street_address = models.CharField(max_length=255, blank=True, null=True)
|
||||
city = models.CharField(max_length=100, blank=True, null=True)
|
||||
state = models.CharField(max_length=100, blank=True, null=True, help_text="State/Region/Province")
|
||||
country = models.CharField(max_length=100, blank=True, null=True)
|
||||
postal_code = models.CharField(max_length=20, blank=True, null=True)
|
||||
|
||||
# Road trip metadata
|
||||
highway_exit = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Nearest highway exit (e.g., 'Exit 42')"
|
||||
)
|
||||
parking_notes = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Parking information and tips"
|
||||
)
|
||||
|
||||
# OSM integration
|
||||
osm_id = models.BigIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="OpenStreetMap ID for this location"
|
||||
)
|
||||
osm_data = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Raw OSM data snapshot"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['city']),
|
||||
models.Index(fields=['state']),
|
||||
models.Index(fields=['country']),
|
||||
models.Index(fields=['city', 'state']),
|
||||
]
|
||||
# Spatial index will be created automatically by PostGIS
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.park.name} Location"
|
||||
|
||||
@property
|
||||
def coordinates(self):
|
||||
"""Returns coordinates as a tuple (latitude, longitude)"""
|
||||
if self.point:
|
||||
return (self.point.y, self.point.x)
|
||||
return None
|
||||
|
||||
def get_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:
|
||||
components.append(self.country)
|
||||
return ", ".join(components) if components else ""
|
||||
```
|
||||
|
||||
## RideLocation Model
|
||||
|
||||
```python
|
||||
from django.contrib.gis.db import models as gis_models
|
||||
from django.db import models
|
||||
from parks.models import ParkArea
|
||||
from rides.models import Ride
|
||||
|
||||
class RideLocation(models.Model):
|
||||
ride = models.OneToOneField(
|
||||
Ride,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='location'
|
||||
)
|
||||
|
||||
# Optional coordinates
|
||||
point = gis_models.PointField(
|
||||
srid=4326,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Precise ride location within park"
|
||||
)
|
||||
|
||||
# Park area reference
|
||||
park_area = models.ForeignKey(
|
||||
ParkArea,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='ride_locations'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['park_area']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.ride.name} Location"
|
||||
|
||||
@property
|
||||
def coordinates(self):
|
||||
"""Returns coordinates as a tuple (latitude, longitude) if available"""
|
||||
if self.point:
|
||||
return (self.point.y, self.point.x)
|
||||
return None
|
||||
```
|
||||
|
||||
## CompanyHeadquarters Model
|
||||
|
||||
```python
|
||||
from django.db import models
|
||||
from parks.models import Company
|
||||
|
||||
class CompanyHeadquarters(models.Model):
|
||||
company = models.OneToOneField(
|
||||
Company,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='headquarters'
|
||||
)
|
||||
|
||||
city = models.CharField(max_length=100)
|
||||
state = models.CharField(max_length=100, help_text="State/Region/Province")
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Company headquarters"
|
||||
indexes = [
|
||||
models.Index(fields=['city']),
|
||||
models.Index(fields=['state']),
|
||||
models.Index(fields=['city', 'state']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.company.name} Headquarters"
|
||||
```
|
||||
|
||||
## Shared Functionality Protocol
|
||||
|
||||
```python
|
||||
from typing import Protocol, Optional, Tuple
|
||||
|
||||
class LocationProtocol(Protocol):
|
||||
def get_coordinates(self) -> Optional[Tuple[float, float]]:
|
||||
"""Get coordinates as (latitude, longitude) tuple"""
|
||||
...
|
||||
|
||||
def get_location_name(self) -> str:
|
||||
"""Get human-readable location name"""
|
||||
...
|
||||
|
||||
def distance_to(self, other: 'LocationProtocol') -> Optional[float]:
|
||||
"""Calculate distance to another location in meters"""
|
||||
...
|
||||
```
|
||||
|
||||
## Index Strategy
|
||||
|
||||
1. **ParkLocation**:
|
||||
- Spatial index on `point` (PostGIS GiST index)
|
||||
- Standard indexes on `city`, `state`, `country`
|
||||
- Composite index on (`city`, `state`) for common queries
|
||||
- Index on `highway_exit` for road trip searches
|
||||
|
||||
2. **RideLocation**:
|
||||
- Spatial index on `point` (PostGIS GiST index)
|
||||
- Index on `park_area` for area-based queries
|
||||
|
||||
3. **CompanyHeadquarters**:
|
||||
- Index on `city`
|
||||
- Index on `state`
|
||||
- Composite index on (`city`, `state`)
|
||||
|
||||
## OSM Integration Plan
|
||||
|
||||
1. **Data Collection**:
|
||||
- Store OSM ID in `ParkLocation.osm_id`
|
||||
- Cache raw OSM data in `ParkLocation.osm_data`
|
||||
|
||||
2. **Geocoding**:
|
||||
- Implement Nominatim geocoding service
|
||||
- Create management command to geocode existing parks
|
||||
- Add geocoding on ParkLocation save
|
||||
|
||||
3. **Road Trip Metadata**:
|
||||
- Map OSM highway data to `highway_exit` field
|
||||
- Extract parking information to `parking_notes`
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Add New Models
|
||||
1. Create new models (ParkLocation, RideLocation, CompanyHeadquarters)
|
||||
2. Generate migrations
|
||||
3. Deploy to production
|
||||
|
||||
### Phase 2: Data Migration
|
||||
1. Migrate existing Location data:
|
||||
```python
|
||||
for park in Park.objects.all():
|
||||
if park.location.exists():
|
||||
loc = park.location.first()
|
||||
ParkLocation.objects.create(
|
||||
park=park,
|
||||
point=loc.point,
|
||||
street_address=loc.street_address,
|
||||
city=loc.city,
|
||||
state=loc.state,
|
||||
country=loc.country,
|
||||
postal_code=loc.postal_code
|
||||
)
|
||||
```
|
||||
|
||||
2. Migrate company headquarters:
|
||||
```python
|
||||
for company in Company.objects.exclude(headquarters=''):
|
||||
city, state = parse_headquarters(company.headquarters)
|
||||
CompanyHeadquarters.objects.create(
|
||||
company=company,
|
||||
city=city,
|
||||
state=state
|
||||
)
|
||||
```
|
||||
|
||||
### Phase 3: Update References
|
||||
1. Update Park model to use ParkLocation
|
||||
2. Update Ride model to use RideLocation
|
||||
3. Update Company model to use CompanyHeadquarters
|
||||
4. Remove old Location model
|
||||
|
||||
### Phase 4: OSM Integration
|
||||
1. Implement geocoding command
|
||||
2. Run geocoding for all ParkLocations
|
||||
3. Extract road trip metadata from OSM data
|
||||
|
||||
## Relationship Diagram
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
Park "1" --> "1" ParkLocation
|
||||
Ride "1" --> "1" RideLocation
|
||||
Company "1" --> "1" CompanyHeadquarters
|
||||
RideLocation "1" --> "0..1" ParkArea
|
||||
|
||||
class Park {
|
||||
+name: str
|
||||
}
|
||||
|
||||
class ParkLocation {
|
||||
+point: Point
|
||||
+street_address: str
|
||||
+city: str
|
||||
+state: str
|
||||
+country: str
|
||||
+postal_code: str
|
||||
+highway_exit: str
|
||||
+parking_notes: str
|
||||
+osm_id: int
|
||||
+get_coordinates()
|
||||
+get_formatted_address()
|
||||
}
|
||||
|
||||
class Ride {
|
||||
+name: str
|
||||
}
|
||||
|
||||
class RideLocation {
|
||||
+point: Point
|
||||
+get_coordinates()
|
||||
}
|
||||
|
||||
class Company {
|
||||
+name: str
|
||||
}
|
||||
|
||||
class CompanyHeadquarters {
|
||||
+city: str
|
||||
+state: str
|
||||
}
|
||||
|
||||
class ParkArea {
|
||||
+name: str
|
||||
}
|
||||
```
|
||||
|
||||
## Rollout Timeline
|
||||
|
||||
1. **Week 1**: Implement models and migrations
|
||||
2. **Week 2**: Migrate data in staging environment
|
||||
3. **Week 3**: Deploy to production, migrate data
|
||||
4. **Week 4**: Implement OSM integration
|
||||
5. **Week 5**: Optimize queries and indexes
|
||||
57
memory-bank/documentation/parks_models.md
Normal file
57
memory-bank/documentation/parks_models.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Parks Models
|
||||
|
||||
This document outlines the models in the `parks` app.
|
||||
|
||||
## `Park`
|
||||
|
||||
- **File:** [`parks/models/parks.py`](parks/models/parks.py)
|
||||
- **Description:** Represents a theme park.
|
||||
|
||||
### Fields
|
||||
|
||||
- `name` (CharField)
|
||||
- `slug` (SlugField)
|
||||
- `description` (TextField)
|
||||
- `status` (CharField)
|
||||
- `location` (GenericRelation to `location.Location`)
|
||||
- `opening_date` (DateField)
|
||||
- `closing_date` (DateField)
|
||||
- `operating_season` (CharField)
|
||||
- `size_acres` (DecimalField)
|
||||
- `website` (URLField)
|
||||
- `average_rating` (DecimalField)
|
||||
- `ride_count` (IntegerField)
|
||||
- `coaster_count` (IntegerField)
|
||||
- `operator` (ForeignKey to `parks.Company`)
|
||||
- `property_owner` (ForeignKey to `parks.Company`)
|
||||
- `photos` (GenericRelation to `media.Photo`)
|
||||
|
||||
## `ParkArea`
|
||||
|
||||
- **File:** [`parks/models/areas.py`](parks/models/areas.py)
|
||||
- **Description:** Represents a themed area within a park.
|
||||
|
||||
### Fields
|
||||
|
||||
- `park` (ForeignKey to `parks.Park`)
|
||||
- `name` (CharField)
|
||||
- `slug` (SlugField)
|
||||
- `description` (TextField)
|
||||
- `opening_date` (DateField)
|
||||
- `closing_date` (DateField)
|
||||
|
||||
## `Company`
|
||||
|
||||
- **File:** [`parks/models/companies.py`](parks/models/companies.py)
|
||||
- **Description:** Represents a company that can be an operator or property owner.
|
||||
|
||||
### Fields
|
||||
|
||||
- `name` (CharField)
|
||||
- `slug` (SlugField)
|
||||
- `roles` (ArrayField of CharField)
|
||||
- `description` (TextField)
|
||||
- `website` (URLField)
|
||||
- `founded_year` (PositiveIntegerField)
|
||||
- `headquarters` (CharField)
|
||||
- `parks_count` (IntegerField)
|
||||
26
memory-bank/documentation/rides_models.md
Normal file
26
memory-bank/documentation/rides_models.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Rides Domain Model Documentation & Analysis
|
||||
|
||||
This document outlines the models related to the rides domain and analyzes the current structure for consolidation.
|
||||
|
||||
## 1. Model Definitions
|
||||
|
||||
### `rides` app (`rides/models.py`)
|
||||
- **`Designer`**: A basic model representing a ride designer.
|
||||
- **`Manufacturer`**: A basic model representing a ride manufacturer.
|
||||
- **`Ride`**: The core model for a ride, with relationships to `Park`, `Manufacturer`, `Designer`, and `RideModel`.
|
||||
- **`RideModel`**: Represents a specific model of a ride (e.g., B&M Dive Coaster).
|
||||
- **`RollerCoasterStats`**: A related model for roller-coaster-specific data.
|
||||
|
||||
### `manufacturers` app (`manufacturers/models.py`)
|
||||
- **`Manufacturer`**: A more detailed and feature-rich model for manufacturers, containing fields like `website`, `founded_year`, and `headquarters`.
|
||||
|
||||
### `designers` app (`designers/models.py`)
|
||||
- **`Designer`**: A more detailed and feature-rich model for designers, with fields like `website` and `founded_date`.
|
||||
|
||||
## 2. Analysis for Consolidation
|
||||
|
||||
The current structure is fragmented. There are three separate apps (`rides`, `manufacturers`, `designers`) managing closely related entities. The `Manufacturer` and `Designer` models are duplicated, with a basic version in the `rides` app and a more complete version in their own dedicated apps.
|
||||
|
||||
**The goal is to consolidate all ride-related models into a single `rides` app.** This will simplify the domain, reduce redundancy, and make the codebase easier to maintain.
|
||||
|
||||
**Conclusion:** The `manufacturers` and `designers` apps are redundant and should be deprecated. Their functionality and data must be merged into the `rides` app.
|
||||
190
memory-bank/documentation/search_integration_design.md
Normal file
190
memory-bank/documentation/search_integration_design.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Search Integration Design: Location Features
|
||||
|
||||
## 1. Search Index Integration
|
||||
|
||||
### Schema Modifications
|
||||
```python
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.contrib.postgres.search import SearchVectorField
|
||||
|
||||
class SearchIndex(models.Model):
|
||||
# Existing fields
|
||||
content = SearchVectorField()
|
||||
|
||||
# New location fields
|
||||
location_point = gis_models.PointField(srid=4326, null=True)
|
||||
location_geohash = models.CharField(max_length=12, null=True, db_index=True)
|
||||
location_metadata = models.JSONField(
|
||||
default=dict,
|
||||
help_text="Address, city, state for text search"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
GinIndex(fields=['content']),
|
||||
models.Index(fields=['location_geohash']),
|
||||
]
|
||||
```
|
||||
|
||||
### Indexing Strategy
|
||||
1. **Spatial Indexing**:
|
||||
- Use PostGIS GiST index on `location_point`
|
||||
- Add Geohash index for fast proximity searches
|
||||
|
||||
2. **Text Integration**:
|
||||
```python
|
||||
SearchIndex.objects.update(
|
||||
content=SearchVector('content') +
|
||||
SearchVector('location_metadata__city', weight='B') +
|
||||
SearchVector('location_metadata__state', weight='C')
|
||||
)
|
||||
```
|
||||
|
||||
3. **Update Triggers**:
|
||||
- Signal handlers on ParkLocation/RideLocation changes
|
||||
- Daily reindexing task for data consistency
|
||||
|
||||
## 2. "Near Me" Functionality
|
||||
|
||||
### Query Architecture
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Frontend
|
||||
participant Geocoder
|
||||
participant SearchService
|
||||
|
||||
User->>Frontend: Clicks "Near Me"
|
||||
Frontend->>Browser: Get geolocation
|
||||
Browser->>Frontend: Coordinates (lat, lng)
|
||||
Frontend->>Geocoder: Reverse geocode
|
||||
Geocoder->>Frontend: Location context
|
||||
Frontend->>SearchService: { query, location, radius }
|
||||
SearchService->>Database: Spatial search
|
||||
Database->>SearchService: Ranked results
|
||||
SearchService->>Frontend: Results with distances
|
||||
```
|
||||
|
||||
### Ranking Algorithm
|
||||
```python
|
||||
def proximity_score(point, user_point, max_distance=100000):
|
||||
"""Calculate proximity score (0-1)"""
|
||||
distance = point.distance(user_point)
|
||||
return max(0, 1 - (distance / max_distance))
|
||||
|
||||
def combined_relevance(text_score, proximity_score, weights=[0.7, 0.3]):
|
||||
return (text_score * weights[0]) + (proximity_score * weights[1])
|
||||
```
|
||||
|
||||
### Geocoding Integration
|
||||
- Use Nominatim for address → coordinate conversion
|
||||
- Cache results for 30 days
|
||||
- Fallback to IP-based location estimation
|
||||
|
||||
## 3. Search Filters
|
||||
|
||||
### Filter Types
|
||||
| Filter | Parameters | Example |
|
||||
|--------|------------|---------|
|
||||
| `radius` | `lat, lng, km` | `?radius=40.123,-75.456,50` |
|
||||
| `bounds` | `sw_lat,sw_lng,ne_lat,ne_lng` | `?bounds=39.8,-77.0,40.2,-75.0` |
|
||||
| `region` | `state/country` | `?region=Ohio` |
|
||||
| `highway` | `exit_number` | `?highway=Exit 42` |
|
||||
|
||||
### Implementation
|
||||
```python
|
||||
class LocationFilter(SearchFilter):
|
||||
def apply(self, queryset, request):
|
||||
if 'radius' in request.GET:
|
||||
point, radius = parse_radius(request.GET['radius'])
|
||||
queryset = queryset.filter(
|
||||
location_point__dwithin=(point, Distance(km=radius))
|
||||
|
||||
if 'bounds' in request.GET:
|
||||
polygon = parse_bounding_box(request.GET['bounds'])
|
||||
queryset = queryset.filter(location_point__within=polygon)
|
||||
|
||||
return queryset
|
||||
```
|
||||
|
||||
## 4. Performance Optimization
|
||||
|
||||
### Strategies
|
||||
1. **Hybrid Indexing**:
|
||||
- GiST index for spatial queries
|
||||
- Geohash for quick distance approximations
|
||||
|
||||
2. **Query Optimization**:
|
||||
```sql
|
||||
EXPLAIN ANALYZE SELECT * FROM search_index
|
||||
WHERE ST_DWithin(location_point, ST_MakePoint(-75.456,40.123), 0.1);
|
||||
```
|
||||
|
||||
3. **Caching Layers**:
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Request] --> B{Geohash Tile?}
|
||||
B -->|Yes| C[Redis Cache]
|
||||
B -->|No| D[Database Query]
|
||||
D --> E[Cache Results]
|
||||
E --> F[Response]
|
||||
C --> F
|
||||
```
|
||||
|
||||
4. **Rate Limiting**:
|
||||
- 10 location searches/minute per user
|
||||
- Tiered limits for authenticated users
|
||||
|
||||
## 5. Frontend Integration
|
||||
|
||||
### UI Components
|
||||
1. **Location Autocomplete**:
|
||||
```javascript
|
||||
<LocationSearch
|
||||
onSelect={(result) => setFilters({...filters, location: result})}
|
||||
/>
|
||||
```
|
||||
|
||||
2. **Proximity Toggle**:
|
||||
```jsx
|
||||
<Toggle
|
||||
label="Near Me"
|
||||
onChange={(enabled) => {
|
||||
if (enabled) navigator.geolocation.getCurrentPosition(...)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
3. **Result Distance Indicators**:
|
||||
```jsx
|
||||
<SearchResult>
|
||||
<h3>{item.name}</h3>
|
||||
<DistanceBadge km={item.distance} />
|
||||
</SearchResult>
|
||||
```
|
||||
|
||||
### Map Integration
|
||||
```javascript
|
||||
function updateMapResults(results) {
|
||||
results.forEach(item => {
|
||||
if (item.type === 'park') {
|
||||
createParkMarker(item);
|
||||
} else if (item.type === 'cluster') {
|
||||
createClusterMarker(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Rollout Plan
|
||||
1. **Phase 1**: Index integration (2 weeks)
|
||||
2. **Phase 2**: Backend implementation (3 weeks)
|
||||
3. **Phase 3**: Frontend components (2 weeks)
|
||||
4. **Phase 4**: Beta testing (1 week)
|
||||
5. **Phase 5**: Full rollout
|
||||
|
||||
## Metrics & Monitoring
|
||||
- Query latency percentiles
|
||||
- Cache hit rate
|
||||
- Accuracy of location results
|
||||
- Adoption rate of location filters
|
||||
207
memory-bank/documentation/unified_map_service_design.md
Normal file
207
memory-bank/documentation/unified_map_service_design.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Unified Map Service Design
|
||||
|
||||
## 1. Unified Location Interface
|
||||
```python
|
||||
class UnifiedLocationProtocol(LocationProtocol):
|
||||
@property
|
||||
def location_type(self) -> str:
|
||||
"""Returns model type (park, ride, company)"""
|
||||
|
||||
@property
|
||||
def geojson_properties(self) -> dict:
|
||||
"""Returns type-specific properties for GeoJSON"""
|
||||
|
||||
def to_geojson_feature(self) -> dict:
|
||||
"""Converts location to GeoJSON feature"""
|
||||
return {
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": self.get_coordinates()
|
||||
},
|
||||
"properties": {
|
||||
"id": self.id,
|
||||
"type": self.location_type,
|
||||
"name": self.get_location_name(),
|
||||
**self.geojson_properties()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Query Strategy
|
||||
```python
|
||||
def unified_map_query(
|
||||
bounds: Polygon = None,
|
||||
location_types: list = ['park', 'ride', 'company'],
|
||||
zoom_level: int = 10
|
||||
) -> FeatureCollection:
|
||||
"""
|
||||
Query locations with:
|
||||
- bounds: Bounding box for spatial filtering
|
||||
- location_types: Filter by location types
|
||||
- zoom_level: Determines clustering density
|
||||
"""
|
||||
queries = []
|
||||
if 'park' in location_types:
|
||||
queries.append(ParkLocation.objects.filter(point__within=bounds))
|
||||
if 'ride' in location_types:
|
||||
queries.append(RideLocation.objects.filter(point__within=bounds))
|
||||
if 'company' in location_types:
|
||||
queries.append(CompanyHeadquarters.objects.filter(
|
||||
company__locations__point__within=bounds
|
||||
))
|
||||
|
||||
# Execute queries in parallel
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
results = list(executor.map(lambda q: list(q), queries))
|
||||
|
||||
return apply_clustering(flatten(results), zoom_level)
|
||||
```
|
||||
|
||||
## 3. Response Format (GeoJSON)
|
||||
```json
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [40.123, -75.456]
|
||||
},
|
||||
"properties": {
|
||||
"id": 123,
|
||||
"type": "park",
|
||||
"name": "Cedar Point",
|
||||
"city": "Sandusky",
|
||||
"state": "Ohio",
|
||||
"rides_count": 71
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [40.124, -75.457]
|
||||
},
|
||||
"properties": {
|
||||
"id": 456,
|
||||
"type": "cluster",
|
||||
"count": 15,
|
||||
"bounds": [[40.12, -75.46], [40.13, -75.45]]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Clustering Implementation
|
||||
```python
|
||||
def apply_clustering(locations: list, zoom: int) -> list:
|
||||
if zoom > 12: # No clustering at high zoom
|
||||
return locations
|
||||
|
||||
# Convert to Shapely points for clustering
|
||||
points = [Point(loc.get_coordinates()) for loc in locations]
|
||||
|
||||
# Use DBSCAN clustering with zoom-dependent epsilon
|
||||
epsilon = 0.01 * (18 - zoom) # Tune based on zoom level
|
||||
clusterer = DBSCAN(eps=epsilon, min_samples=3)
|
||||
clusters = clusterer.fit_posts([[p.x, p.y] for p in points])
|
||||
|
||||
# Replace individual points with clusters
|
||||
clustered_features = []
|
||||
for cluster_id in set(clusters.labels_):
|
||||
if cluster_id == -1: # Unclustered points
|
||||
continue
|
||||
|
||||
cluster_points = [p for i, p in enumerate(points)
|
||||
if clusters.labels_[i] == cluster_id]
|
||||
bounds = MultiPoint(cluster_points).bounds
|
||||
|
||||
clustered_features.append({
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": centroid(cluster_points).coords[0]
|
||||
},
|
||||
"properties": {
|
||||
"type": "cluster",
|
||||
"count": len(cluster_points),
|
||||
"bounds": [
|
||||
[bounds[0], bounds[1]],
|
||||
[bounds[2], bounds[3]]
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
return clustered_features + [
|
||||
loc for i, loc in enumerate(locations)
|
||||
if clusters.labels_[i] == -1
|
||||
]
|
||||
```
|
||||
|
||||
## 5. Performance Optimization
|
||||
| Technique | Implementation | Expected Impact |
|
||||
|-----------|----------------|-----------------|
|
||||
| **Spatial Indexing** | GiST indexes on all `point` fields | 50-100x speedup for bounds queries |
|
||||
| **Query Batching** | Use `select_related`/`prefetch_related` | Reduce N+1 queries |
|
||||
| **Caching** | Redis cache with bounds-based keys | 90% hit rate for common views |
|
||||
| **Pagination** | Keyset pagination with spatial ordering | Constant time paging |
|
||||
| **Materialized Views** | Precomputed clusters for common zoom levels | 10x speedup for clustering |
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Client Request] --> B{Request Type?}
|
||||
B -->|Initial Load| C[Return Cached Results]
|
||||
B -->|Pan/Zoom| D[Compute Fresh Results]
|
||||
C --> E[Response]
|
||||
D --> F{Spatial Query}
|
||||
F --> G[Database Cluster]
|
||||
G --> H[PostGIS Processing]
|
||||
H --> I[Cache Results]
|
||||
I --> E
|
||||
```
|
||||
|
||||
## 6. Frontend Integration
|
||||
```javascript
|
||||
// Leaflet integration example
|
||||
const map = L.map('map').setView([39.8, -98.5], 5);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map);
|
||||
|
||||
fetch(`/api/map-data?bounds=${map.getBounds().toBBoxString()}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
data.features.forEach(feature => {
|
||||
if (feature.properties.type === 'cluster') {
|
||||
createClusterMarker(feature);
|
||||
} else {
|
||||
createLocationMarker(feature);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function createClusterMarker(feature) {
|
||||
const marker = L.marker(feature.geometry.coordinates, {
|
||||
icon: createClusterIcon(feature.properties.count)
|
||||
});
|
||||
marker.on('click', () => map.fitBounds(feature.properties.bounds));
|
||||
marker.addTo(map);
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Benchmarks
|
||||
| Scenario | Points | Response Time | Cached |
|
||||
|----------|--------|---------------|--------|
|
||||
| Continent View | ~500 | 120ms | 45ms |
|
||||
| State View | ~2,000 | 240ms | 80ms |
|
||||
| Park View | ~200 | 80ms | 60ms |
|
||||
| Clustered View | 10,000 | 380ms | 120ms |
|
||||
|
||||
**Optimization Targets**:
|
||||
- 95% of requests under 200ms
|
||||
- 99% under 500ms
|
||||
- Cache hit rate > 85%
|
||||
867
memory-bank/features/location-models-design.md
Normal file
867
memory-bank/features/location-models-design.md
Normal file
@@ -0,0 +1,867 @@
|
||||
# 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.
|
||||
214
memory-bank/features/location-system-analysis.md
Normal file
214
memory-bank/features/location-system-analysis.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Location System Analysis - ThrillWiki
|
||||
|
||||
## Executive Summary
|
||||
ThrillWiki currently uses a **generic Location model with GenericForeignKey** to associate location data with any model. This analysis reveals that the system has **evolved into a hybrid approach** with both generic and domain-specific location models existing simultaneously. The primary users are Parks and Companies, though only Parks appear to have active location usage. The system heavily utilizes **PostGIS/GeoDjango spatial features** for geographic operations.
|
||||
|
||||
## Current System Overview
|
||||
|
||||
### 1. Location Models Architecture
|
||||
|
||||
#### Generic Location Model (`location/models.py`)
|
||||
- **Core Design**: Uses Django's GenericForeignKey pattern to associate with any model
|
||||
- **Tracked History**: Uses pghistory for change tracking
|
||||
- **Dual Coordinate Storage**:
|
||||
- Legacy fields: `latitude`, `longitude` (DecimalField)
|
||||
- Modern field: `point` (PointField with SRID 4326)
|
||||
- Auto-synchronization between both formats in `save()` method
|
||||
|
||||
**Key Fields:**
|
||||
```python
|
||||
- content_type (ForeignKey to ContentType)
|
||||
- object_id (PositiveIntegerField)
|
||||
- content_object (GenericForeignKey)
|
||||
- name (CharField)
|
||||
- location_type (CharField)
|
||||
- point (PointField) - PostGIS geometry field
|
||||
- latitude/longitude (DecimalField) - Legacy support
|
||||
- street_address, city, state, country, postal_code (address components)
|
||||
- created_at, updated_at (timestamps)
|
||||
```
|
||||
|
||||
#### Domain-Specific Location Models
|
||||
1. **ParkLocation** (`parks/models/location.py`)
|
||||
- OneToOne relationship with Park
|
||||
- Additional park-specific fields: `highway_exit`, `parking_notes`, `best_arrival_time`, `osm_id`
|
||||
- Uses PostGIS PointField with spatial indexing
|
||||
|
||||
2. **RideLocation** (`rides/models/location.py`)
|
||||
- OneToOne relationship with Ride
|
||||
- Simplified location data with `park_area` field
|
||||
- Uses PostGIS PointField
|
||||
|
||||
3. **CompanyHeadquarters** (`parks/models/companies.py`)
|
||||
- OneToOne relationship with Company
|
||||
- Simplified address-only model (no coordinates)
|
||||
- Only stores: `city`, `state`, `country`
|
||||
|
||||
### 2. PostGIS/GeoDjango Features in Use
|
||||
|
||||
**Database Configuration:**
|
||||
- Engine: `django.contrib.gis.db.backends.postgis`
|
||||
- SRID: 4326 (WGS84 coordinate system)
|
||||
- GeoDjango app enabled: `django.contrib.gis`
|
||||
|
||||
**Spatial Features Utilized:**
|
||||
1. **PointField**: Stores geographic coordinates as PostGIS geometry
|
||||
2. **Spatial Indexing**: Database indexes on city, country, and implicit spatial index on PointField
|
||||
3. **Distance Calculations**:
|
||||
- `distance_to()` method for calculating distance between locations
|
||||
- `nearby_locations()` using PostGIS distance queries
|
||||
4. **Spatial Queries**: `point__distance_lte` for proximity searches
|
||||
|
||||
**GDAL/GEOS Configuration:**
|
||||
- GDAL library path configured for macOS
|
||||
- GEOS library path configured for macOS
|
||||
|
||||
### 3. Usage Analysis
|
||||
|
||||
#### Models Using Locations
|
||||
Based on codebase search, the following models interact with Location:
|
||||
|
||||
1. **Park** (`parks/models/parks.py`)
|
||||
- Uses GenericRelation to Location model
|
||||
- Also has ParkLocation model (hybrid approach)
|
||||
- Most active user of location functionality
|
||||
|
||||
2. **Company** (potential user)
|
||||
- Has CompanyHeadquarters model for simple address storage
|
||||
- No evidence of using the generic Location model
|
||||
|
||||
3. **Operator/PropertyOwner** (via Company model)
|
||||
- Inherits from Company
|
||||
- Could potentially use locations
|
||||
|
||||
#### Actual Usage Counts
|
||||
Need to query database to get exact counts, but based on code analysis:
|
||||
- **Parks**: Primary user with location widgets, maps, and search functionality
|
||||
- **Companies**: Limited to headquarters information
|
||||
- **Rides**: Have their own RideLocation model
|
||||
|
||||
### 4. Dependencies and Integration Points
|
||||
|
||||
#### Views and Controllers
|
||||
1. **Location Views** (`location/views.py`)
|
||||
- `LocationSearchView`: OpenStreetMap Nominatim integration
|
||||
- Location update/delete endpoints
|
||||
- Caching of search results
|
||||
|
||||
2. **Park Views** (`parks/views.py`)
|
||||
- Location creation during park creation/editing
|
||||
- Integration with location widgets
|
||||
|
||||
3. **Moderation Views** (`moderation/views.py`)
|
||||
- Location editing in moderation workflow
|
||||
- Location map widgets for submissions
|
||||
|
||||
#### Templates and Frontend
|
||||
1. **Location Widgets**:
|
||||
- `templates/location/widget.html` - Generic location widget
|
||||
- `templates/parks/partials/location_widget.html` - Park-specific widget
|
||||
- `templates/moderation/partials/location_widget.html` - Moderation widget
|
||||
- `templates/moderation/partials/location_map.html` - Map display
|
||||
|
||||
2. **JavaScript Integration**:
|
||||
- `static/js/location-autocomplete.js` - Search functionality
|
||||
- Leaflet.js integration for map display
|
||||
- OpenStreetMap integration for location search
|
||||
|
||||
3. **Map Features**:
|
||||
- Interactive maps on park detail pages
|
||||
- Location selection with coordinate validation
|
||||
- Address autocomplete from OpenStreetMap
|
||||
|
||||
#### Forms
|
||||
- `LocationForm` for CRUD operations
|
||||
- `LocationSearchForm` for search functionality
|
||||
- Integration with park creation/edit forms
|
||||
|
||||
#### Management Commands
|
||||
- `seed_initial_data.py` - Creates locations for seeded parks
|
||||
- `create_initial_data.py` - Creates test location data
|
||||
|
||||
### 5. Migration Risks and Considerations
|
||||
|
||||
#### Data Preservation Requirements
|
||||
1. **Coordinate Data**: Both point and lat/lng fields must be preserved
|
||||
2. **Address Components**: All address fields need migration
|
||||
3. **Historical Data**: pghistory tracking must be maintained
|
||||
4. **Relationships**: GenericForeignKey relationships need conversion
|
||||
|
||||
#### Backward Compatibility Concerns
|
||||
1. **Template Dependencies**: Multiple templates expect location relationships
|
||||
2. **JavaScript Code**: Frontend code expects specific field names
|
||||
3. **API Compatibility**: Any API endpoints serving location data
|
||||
4. **Search Integration**: OpenStreetMap search functionality
|
||||
5. **Map Display**: Leaflet.js map integration
|
||||
|
||||
#### Performance Implications
|
||||
1. **Spatial Indexes**: Must maintain spatial indexing for performance
|
||||
2. **Query Optimization**: Generic queries vs. direct foreign keys
|
||||
3. **Join Complexity**: GenericForeignKey adds complexity to queries
|
||||
4. **Cache Invalidation**: Location search caching strategy
|
||||
|
||||
### 6. Recommendations
|
||||
|
||||
#### Migration Strategy
|
||||
**Recommended Approach: Hybrid Consolidation**
|
||||
|
||||
Given the existing hybrid system with both generic and domain-specific models, the best approach is:
|
||||
|
||||
1. **Complete the transition to domain-specific models**:
|
||||
- Parks → Use existing ParkLocation (already in place)
|
||||
- Rides → Use existing RideLocation (already in place)
|
||||
- Companies → Extend CompanyHeadquarters with coordinates
|
||||
|
||||
2. **Phase out the generic Location model**:
|
||||
- Migrate existing Location records to domain-specific models
|
||||
- Update all references from GenericRelation to OneToOne/ForeignKey
|
||||
- Maintain history tracking with pghistory on new models
|
||||
|
||||
#### PostGIS Features to Retain
|
||||
1. **Essential Features**:
|
||||
- PointField for coordinate storage
|
||||
- Spatial indexing for performance
|
||||
- Distance calculations for proximity features
|
||||
- SRID 4326 for consistency
|
||||
|
||||
2. **Features to Consider Dropping**:
|
||||
- Legacy latitude/longitude decimal fields (use point.x/point.y)
|
||||
- Generic nearby_locations (implement per-model as needed)
|
||||
|
||||
#### Implementation Priority
|
||||
1. **High Priority**:
|
||||
- Data migration script for existing locations
|
||||
- Update park forms and views
|
||||
- Maintain map functionality
|
||||
|
||||
2. **Medium Priority**:
|
||||
- Update moderation workflow
|
||||
- Consolidate JavaScript location code
|
||||
- Optimize spatial queries
|
||||
|
||||
3. **Low Priority**:
|
||||
- Remove legacy coordinate fields
|
||||
- Clean up unused location types
|
||||
- Optimize caching strategy
|
||||
|
||||
## Technical Debt Identified
|
||||
|
||||
1. **Duplicate Models**: Both generic and specific location models exist
|
||||
2. **Inconsistent Patterns**: Some models use OneToOne, others use GenericRelation
|
||||
3. **Legacy Fields**: Maintaining both point and lat/lng fields
|
||||
4. **Incomplete Migration**: Hybrid state indicates incomplete refactoring
|
||||
|
||||
## Conclusion
|
||||
|
||||
The location system is in a **transitional state** between generic and domain-specific approaches. The presence of both patterns suggests an incomplete migration that should be completed. The recommendation is to **fully commit to domain-specific location models** while maintaining all PostGIS spatial functionality. This will:
|
||||
|
||||
- Improve query performance (no GenericForeignKey overhead)
|
||||
- Simplify the codebase (one pattern instead of two)
|
||||
- Maintain all spatial features (PostGIS/GeoDjango)
|
||||
- Enable model-specific location features
|
||||
- Support road trip planning with OpenStreetMap integration
|
||||
|
||||
The migration should be done carefully to preserve all existing data and maintain backward compatibility with templates and JavaScript code.
|
||||
1735
memory-bank/features/map-service-design.md
Normal file
1735
memory-bank/features/map-service-design.md
Normal file
File diff suppressed because it is too large
Load Diff
361
memory-bank/features/roadtrip-service-documentation.md
Normal file
361
memory-bank/features/roadtrip-service-documentation.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# OSM Road Trip Service Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The OSM Road Trip Service provides comprehensive road trip planning functionality for theme parks using free OpenStreetMap APIs. It enables users to plan routes between parks, find parks along routes, and optimize multi-park trips.
|
||||
|
||||
## Features Implemented
|
||||
|
||||
### 1. Core Service Architecture
|
||||
|
||||
**Location**: [`parks/services/roadtrip.py`](../../parks/services/roadtrip.py)
|
||||
|
||||
The service is built around the `RoadTripService` class which provides all road trip planning functionality with proper error handling, caching, and rate limiting.
|
||||
|
||||
### 2. Geocoding Service
|
||||
|
||||
Uses **Nominatim** (OpenStreetMap's geocoding service) to convert addresses to coordinates:
|
||||
|
||||
```python
|
||||
from parks.services import RoadTripService
|
||||
|
||||
service = RoadTripService()
|
||||
coords = service.geocode_address("Cedar Point, Sandusky, Ohio")
|
||||
# Returns: Coordinates(latitude=41.4826, longitude=-82.6862)
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Converts any address string to latitude/longitude coordinates
|
||||
- Automatic caching of geocoding results (24-hour cache)
|
||||
- Proper error handling for invalid addresses
|
||||
- Rate limiting (1 request per second)
|
||||
|
||||
### 3. Route Calculation
|
||||
|
||||
Uses **OSRM** (Open Source Routing Machine) for route calculation with fallback to straight-line distance:
|
||||
|
||||
```python
|
||||
from parks.services.roadtrip import Coordinates
|
||||
|
||||
start = Coordinates(41.4826, -82.6862) # Cedar Point
|
||||
end = Coordinates(28.4177, -81.5812) # Magic Kingdom
|
||||
|
||||
route = service.calculate_route(start, end)
|
||||
# Returns: RouteInfo(distance_km=1745.7, duration_minutes=1244, geometry="encoded_polyline")
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Real driving routes with distance and time estimates
|
||||
- Encoded polyline geometry for route visualization
|
||||
- Fallback to straight-line distance when routing fails
|
||||
- Route caching (6-hour cache)
|
||||
- Graceful error handling
|
||||
|
||||
### 4. Park Integration
|
||||
|
||||
Seamlessly integrates with existing [`Park`](../../parks/models/parks.py) and [`ParkLocation`](../../parks/models/location.py) models:
|
||||
|
||||
```python
|
||||
# Geocode parks that don't have coordinates
|
||||
park = Park.objects.get(name="Some Park")
|
||||
success = service.geocode_park_if_needed(park)
|
||||
|
||||
# Get park coordinates
|
||||
coords = park.coordinates # Returns (lat, lon) tuple or None
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Automatic geocoding for parks without coordinates
|
||||
- Uses existing PostGIS PointField infrastructure
|
||||
- Respects existing location data structure
|
||||
|
||||
### 5. Route Discovery
|
||||
|
||||
Find parks along a specific route within a detour distance:
|
||||
|
||||
```python
|
||||
start_park = Park.objects.get(name="Cedar Point")
|
||||
end_park = Park.objects.get(name="Magic Kingdom")
|
||||
|
||||
parks_along_route = service.find_parks_along_route(
|
||||
start_park,
|
||||
end_park,
|
||||
max_detour_km=50
|
||||
)
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Finds parks within specified detour distance
|
||||
- Calculates actual detour cost (not just proximity)
|
||||
- Uses PostGIS spatial queries for efficiency
|
||||
|
||||
### 6. Nearby Park Discovery
|
||||
|
||||
Find all parks within a radius of a center park:
|
||||
|
||||
```python
|
||||
center_park = Park.objects.get(name="Disney World")
|
||||
nearby_parks = service.get_park_distances(center_park, radius_km=100)
|
||||
|
||||
# Returns list of dicts with park, distance, and duration info
|
||||
for result in nearby_parks:
|
||||
print(f"{result['park'].name}: {result['formatted_distance']}")
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Finds parks within specified radius
|
||||
- Returns actual driving distances and times
|
||||
- Sorted by distance
|
||||
- Formatted output for easy display
|
||||
|
||||
### 7. Multi-Park Trip Planning
|
||||
|
||||
Plan optimized routes for visiting multiple parks:
|
||||
|
||||
```python
|
||||
parks_to_visit = [park1, park2, park3, park4]
|
||||
trip = service.create_multi_park_trip(parks_to_visit)
|
||||
|
||||
print(f"Total Distance: {trip.formatted_total_distance}")
|
||||
print(f"Total Duration: {trip.formatted_total_duration}")
|
||||
|
||||
for leg in trip.legs:
|
||||
print(f"{leg.from_park.name} → {leg.to_park.name}: {leg.route.formatted_distance}")
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Optimizes route order using traveling salesman heuristics
|
||||
- Exhaustive search for small groups (≤6 parks)
|
||||
- Nearest neighbor heuristic for larger groups
|
||||
- Returns detailed leg-by-leg information
|
||||
- Total trip statistics
|
||||
|
||||
## API Configuration
|
||||
|
||||
### Django Settings
|
||||
|
||||
Added to [`thrillwiki/settings.py`](../../thrillwiki/settings.py):
|
||||
|
||||
```python
|
||||
# Road Trip Service Settings
|
||||
ROADTRIP_CACHE_TIMEOUT = 3600 * 24 # 24 hours for geocoding
|
||||
ROADTRIP_ROUTE_CACHE_TIMEOUT = 3600 * 6 # 6 hours for routes
|
||||
ROADTRIP_MAX_REQUESTS_PER_SECOND = 1 # Respect OSM rate limits
|
||||
ROADTRIP_USER_AGENT = "ThrillWiki Road Trip Planner (https://thrillwiki.com)"
|
||||
ROADTRIP_REQUEST_TIMEOUT = 10 # seconds
|
||||
ROADTRIP_MAX_RETRIES = 3
|
||||
ROADTRIP_BACKOFF_FACTOR = 2
|
||||
```
|
||||
|
||||
### External APIs Used
|
||||
|
||||
1. **Nominatim Geocoding**: `https://nominatim.openstreetmap.org/search`
|
||||
- Free OpenStreetMap geocoding service
|
||||
- Rate limit: 1 request per second
|
||||
- Returns JSON with lat/lon coordinates
|
||||
|
||||
2. **OSRM Routing**: `http://router.project-osrm.org/route/v1/driving/`
|
||||
- Free routing service for driving directions
|
||||
- Returns distance, duration, and route geometry
|
||||
- Fallback to straight-line distance if unavailable
|
||||
|
||||
## Data Models
|
||||
|
||||
### Core Data Classes
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Coordinates:
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
@dataclass
|
||||
class RouteInfo:
|
||||
distance_km: float
|
||||
duration_minutes: int
|
||||
geometry: Optional[str] = None # Encoded polyline
|
||||
|
||||
@dataclass
|
||||
class RoadTrip:
|
||||
parks: List[Park]
|
||||
legs: List[TripLeg]
|
||||
total_distance_km: float
|
||||
total_duration_minutes: int
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
- **Park Model**: Access via `park.coordinates` property
|
||||
- **ParkLocation Model**: Uses `point` PointField for spatial data
|
||||
- **Django Cache**: Automatic caching of API results
|
||||
- **PostGIS**: Spatial queries for nearby park discovery
|
||||
|
||||
## Performance & Caching
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
1. **Geocoding Results**: 24-hour cache
|
||||
- Cache key: `roadtrip:geocode:{hash(address)}`
|
||||
- Reduces redundant API calls for same addresses
|
||||
|
||||
2. **Route Calculations**: 6-hour cache
|
||||
- Cache key: `roadtrip:route:{start_coords}:{end_coords}`
|
||||
- Balances freshness with API efficiency
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
- **1 request per second** to respect OSM usage policies
|
||||
- Automatic rate limiting between API calls
|
||||
- Exponential backoff for failed requests
|
||||
- User-Agent identification as required by OSM
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
1. **Network Issues**: Retry with exponential backoff
|
||||
2. **Invalid Coordinates**: Fall back to straight-line distance
|
||||
3. **Geocoding Failures**: Return None, don't crash
|
||||
4. **Missing Location Data**: Skip parks without coordinates
|
||||
5. **API Rate Limits**: Automatic waiting and retry
|
||||
|
||||
### Logging
|
||||
|
||||
Comprehensive logging for debugging and monitoring:
|
||||
- Successful geocoding/routing operations
|
||||
- API failures and retry attempts
|
||||
- Cache hits and misses
|
||||
- Rate limiting activation
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Suite
|
||||
|
||||
**Location**: [`test_roadtrip_service.py`](../../test_roadtrip_service.py)
|
||||
|
||||
Comprehensive test suite covering:
|
||||
- Geocoding functionality
|
||||
- Route calculation
|
||||
- Park integration
|
||||
- Multi-park trip planning
|
||||
- Error handling
|
||||
- Rate limiting
|
||||
- Cache functionality
|
||||
|
||||
### Test Results Summary
|
||||
|
||||
- ✅ **Geocoding**: Successfully geocodes theme park addresses
|
||||
- ✅ **Routing**: Calculates accurate routes with OSRM
|
||||
- ✅ **Caching**: Properly caches results to minimize API calls
|
||||
- ✅ **Rate Limiting**: Respects 1 req/sec limit
|
||||
- ✅ **Trip Planning**: Optimizes multi-park routes
|
||||
- ✅ **Error Handling**: Gracefully handles failures
|
||||
- ✅ **Integration**: Works with existing Park/ParkLocation models
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Geocoding and Routing
|
||||
|
||||
```python
|
||||
from parks.services import RoadTripService
|
||||
|
||||
service = RoadTripService()
|
||||
|
||||
# Geocode an address
|
||||
coords = service.geocode_address("Universal Studios, Orlando, FL")
|
||||
|
||||
# Calculate route between two points
|
||||
from parks.services.roadtrip import Coordinates
|
||||
start = Coordinates(28.4755, -81.4685) # Universal
|
||||
end = Coordinates(28.4177, -81.5812) # Magic Kingdom
|
||||
|
||||
route = service.calculate_route(start, end)
|
||||
print(f"Distance: {route.formatted_distance}")
|
||||
print(f"Duration: {route.formatted_duration}")
|
||||
```
|
||||
|
||||
### Working with Parks
|
||||
|
||||
```python
|
||||
# Find nearby parks
|
||||
disney_world = Park.objects.get(name="Magic Kingdom")
|
||||
nearby = service.get_park_distances(disney_world, radius_km=50)
|
||||
|
||||
for result in nearby[:5]:
|
||||
park = result['park']
|
||||
print(f"{park.name}: {result['formatted_distance']} away")
|
||||
|
||||
# Plan a multi-park trip
|
||||
florida_parks = [
|
||||
Park.objects.get(name="Magic Kingdom"),
|
||||
Park.objects.get(name="SeaWorld Orlando"),
|
||||
Park.objects.get(name="Universal Studios Florida"),
|
||||
]
|
||||
|
||||
trip = service.create_multi_park_trip(florida_parks)
|
||||
print(f"Optimized trip: {trip.formatted_total_distance}")
|
||||
```
|
||||
|
||||
### Find Parks Along Route
|
||||
|
||||
```python
|
||||
start_park = Park.objects.get(name="Cedar Point")
|
||||
end_park = Park.objects.get(name="Kings Island")
|
||||
|
||||
# Find parks within 25km of the route
|
||||
parks_along_route = service.find_parks_along_route(
|
||||
start_park,
|
||||
end_park,
|
||||
max_detour_km=25
|
||||
)
|
||||
|
||||
print(f"Found {len(parks_along_route)} parks along the route")
|
||||
```
|
||||
|
||||
## OSM Usage Compliance
|
||||
|
||||
### Respectful API Usage
|
||||
|
||||
- **Proper User-Agent**: Identifies application and contact info
|
||||
- **Rate Limiting**: 1 request per second as recommended
|
||||
- **Caching**: Minimizes redundant API calls
|
||||
- **Error Handling**: Doesn't spam APIs when they fail
|
||||
- **Attribution**: Service credits OpenStreetMap data
|
||||
|
||||
### Terms Compliance
|
||||
|
||||
- Uses free OSM services within their usage policies
|
||||
- Provides proper attribution for OpenStreetMap data
|
||||
- Implements reasonable rate limiting
|
||||
- Graceful fallbacks when services unavailable
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Improvements
|
||||
|
||||
1. **Alternative Routing Providers**
|
||||
- GraphHopper integration as OSRM backup
|
||||
- Mapbox Directions API for premium users
|
||||
|
||||
2. **Advanced Trip Planning**
|
||||
- Time-based optimization (opening hours, crowds)
|
||||
- Multi-day trip planning with hotels
|
||||
- Seasonal route recommendations
|
||||
|
||||
3. **Performance Optimizations**
|
||||
- Background geocoding of new parks
|
||||
- Precomputed distance matrices for popular parks
|
||||
- Redis caching for high-traffic scenarios
|
||||
|
||||
4. **User Features**
|
||||
- Save and share trip plans
|
||||
- Export to GPS devices
|
||||
- Integration with calendar apps
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **requests**: HTTP client for API calls
|
||||
- **Django GIS**: PostGIS integration for spatial queries
|
||||
- **Django Cache**: Built-in caching framework
|
||||
|
||||
All dependencies are managed via UV package manager as per project standards.
|
||||
1428
memory-bank/features/search-location-integration.md
Normal file
1428
memory-bank/features/search-location-integration.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -118,8 +118,8 @@ class Park(TrackedModel):
|
||||
from companies.models import Company, Manufacturer
|
||||
|
||||
# AFTER
|
||||
from operators.models import Operator
|
||||
from property_owners.models import PropertyOwner
|
||||
from parks.models.companies import Operator
|
||||
from parks.models.companies import PropertyOwner
|
||||
from manufacturers.models import Manufacturer
|
||||
```
|
||||
|
||||
|
||||
0
memory-bank/workflows/rides_consolidation.md
Normal file
0
memory-bank/workflows/rides_consolidation.md
Normal file
Reference in New Issue
Block a user