mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 06:11:07 -05:00
Refactor parks and rides views for improved organization and readability
- Updated imports in parks/views.py to use ParkReview as Review for clarity. - Enhanced road trip views in parks/views_roadtrip.py by removing unnecessary parameters and improving context handling. - Streamlined error handling and response messages in CreateTripView and FindParksAlongRouteView. - Improved code formatting and consistency across various methods in parks/views_roadtrip.py. - Refactored rides/models.py to import Company from models for better clarity. - Updated rides/views.py to import RideSearchForm from services for better organization. - Added a comprehensive Django best practices analysis document to memory-bank/documentation.
This commit is contained in:
@@ -1,8 +1,7 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from reviews.models import Review
|
from parks.models import Park, ParkReview as Review
|
||||||
from parks.models import Park
|
|
||||||
from rides.models import Ride
|
from rides.models import Ride
|
||||||
from media.models import Photo
|
from media.models import Photo
|
||||||
|
|
||||||
@@ -14,19 +13,22 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
# Delete test users
|
# Delete test users
|
||||||
test_users = User.objects.filter(username__in=["testuser", "moderator"])
|
test_users = User.objects.filter(
|
||||||
|
username__in=["testuser", "moderator"])
|
||||||
count = test_users.count()
|
count = test_users.count()
|
||||||
test_users.delete()
|
test_users.delete()
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
|
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
|
||||||
|
|
||||||
# Delete test reviews
|
# Delete test reviews
|
||||||
reviews = Review.objects.filter(user__username__in=["testuser", "moderator"])
|
reviews = Review.objects.filter(
|
||||||
|
user__username__in=["testuser", "moderator"])
|
||||||
count = reviews.count()
|
count = reviews.count()
|
||||||
reviews.delete()
|
reviews.delete()
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
|
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
|
||||||
|
|
||||||
# Delete test photos
|
# Delete test photos
|
||||||
photos = Photo.objects.filter(uploader__username__in=["testuser", "moderator"])
|
photos = Photo.objects.filter(uploader__username__in=[
|
||||||
|
"testuser", "moderator"])
|
||||||
count = photos.count()
|
count = photos.count()
|
||||||
photos.delete()
|
photos.delete()
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test photos"))
|
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test photos"))
|
||||||
@@ -62,6 +64,7 @@ class Command(BaseCommand):
|
|||||||
os.remove(f)
|
os.remove(f)
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {f}"))
|
self.stdout.write(self.style.SUCCESS(f"Deleted {f}"))
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
self.stdout.write(self.style.WARNING(f"Error deleting {f}: {e}"))
|
self.stdout.write(self.style.WARNING(
|
||||||
|
f"Error deleting {f}: {e}"))
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS("Test data cleanup complete"))
|
self.stdout.write(self.style.SUCCESS("Test data cleanup complete"))
|
||||||
|
|||||||
302
memory-bank/documentation/django-best-practices-analysis.md
Normal file
302
memory-bank/documentation/django-best-practices-analysis.md
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
# Django Best Practices Analysis - ThrillWiki Project
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This analysis evaluates the ThrillWiki Django project against established Django best practices as defined in the HackSoft Django Styleguide. The project demonstrates strong adherence to many best practices while having opportunities for improvement in some areas.
|
||||||
|
|
||||||
|
**Overall Assessment: ⭐⭐⭐⭐☆ (8/10)**
|
||||||
|
|
||||||
|
## Key Strengths
|
||||||
|
|
||||||
|
### ✅ Model Architecture & Base Models
|
||||||
|
- **Excellent**: Implements proper base model pattern with `TrackedModel` in `core/history.py`
|
||||||
|
- **Strong**: All major models inherit from `TrackedModel` providing consistent `created_at`/`updated_at` fields
|
||||||
|
- **Advanced**: Complex historical tracking with `pghistory` integration for full audit trails
|
||||||
|
- **Good**: Proper use of abstract base classes (`SluggedModel`) for shared functionality
|
||||||
|
|
||||||
|
```python
|
||||||
|
# core/history.py - Proper base model implementation
|
||||||
|
class TrackedModel(models.Model):
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ Service Layer Architecture
|
||||||
|
- **Excellent**: Well-structured service layer in `core/services/`
|
||||||
|
- **Strong**: Clear separation of concerns with dedicated services:
|
||||||
|
- `UnifiedMapService` - Main orchestrating service
|
||||||
|
- `ClusteringService` - Specialized clustering logic
|
||||||
|
- `LocationSearchService` - Search functionality
|
||||||
|
- `RoadTripService` - Business logic for trip planning
|
||||||
|
- **Good**: Services follow keyword-only argument patterns
|
||||||
|
- **Good**: Type annotations throughout service layer
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Example of proper service implementation
|
||||||
|
class UnifiedMapService:
|
||||||
|
def get_map_data(
|
||||||
|
self,
|
||||||
|
bounds: Optional[GeoBounds] = None,
|
||||||
|
filters: Optional[MapFilters] = None,
|
||||||
|
zoom_level: int = DEFAULT_ZOOM_LEVEL,
|
||||||
|
cluster: bool = True,
|
||||||
|
use_cache: bool = True
|
||||||
|
) -> MapResponse:
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ Template Organization & Structure
|
||||||
|
- **Excellent**: Proper template inheritance with `base/base.html`
|
||||||
|
- **Strong**: Logical template directory structure by app
|
||||||
|
- **Good**: Extensive use of partial templates for HTMX integration
|
||||||
|
- **Good**: Reusable components in `partials/` directories
|
||||||
|
- **Advanced**: HTMX integration for dynamic updates
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Proper template structure -->
|
||||||
|
{% extends "base/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{{ area.name }} - {{ area.park.name }} - ThrillWiki{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ URL Structure & Organization
|
||||||
|
- **Excellent**: Clear URL namespacing by app
|
||||||
|
- **Strong**: RESTful URL patterns with proper slug usage
|
||||||
|
- **Good**: Separation of HTML views and API endpoints
|
||||||
|
- **Good**: Logical grouping of related endpoints
|
||||||
|
|
||||||
|
```python
|
||||||
|
# parks/urls.py - Well-organized URL structure
|
||||||
|
app_name = "parks"
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views_search.ParkSearchView.as_view(), name="park_list"),
|
||||||
|
path("create/", views.ParkCreateView.as_view(), name="park_create"),
|
||||||
|
path("<slug:slug>/", views.ParkDetailView.as_view(), name="park_detail"),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ Testing Infrastructure
|
||||||
|
- **Strong**: Comprehensive testing setup with coverage reporting
|
||||||
|
- **Good**: Separate unit tests and E2E tests with Playwright
|
||||||
|
- **Good**: Custom test runner with coverage integration
|
||||||
|
- **Good**: Clear test organization by app
|
||||||
|
|
||||||
|
## Areas for Improvement
|
||||||
|
|
||||||
|
### ⚠️ Settings Organization
|
||||||
|
**Current State**: Single monolithic `settings.py` file
|
||||||
|
**Django Styleguide Recommendation**: Structured settings with separate modules
|
||||||
|
|
||||||
|
**Issues Identified**:
|
||||||
|
- All settings in one file (`thrillwiki/settings.py`)
|
||||||
|
- No environment-based configuration separation
|
||||||
|
- Hard-coded values mixed with environment-dependent settings
|
||||||
|
|
||||||
|
**Recommended Structure**:
|
||||||
|
```
|
||||||
|
config/
|
||||||
|
├── django/
|
||||||
|
│ ├── base.py # Common settings
|
||||||
|
│ ├── local.py # Development settings
|
||||||
|
│ ├── production.py # Production settings
|
||||||
|
│ └── test.py # Test settings
|
||||||
|
└── settings/
|
||||||
|
├── celery.py # Celery configuration
|
||||||
|
├── cors.py # CORS settings
|
||||||
|
└── sentry.py # Sentry configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ Selectors Pattern Implementation
|
||||||
|
**Current State**: Limited selector pattern usage
|
||||||
|
**Django Styleguide Recommendation**: Clear separation between services (push) and selectors (pull)
|
||||||
|
|
||||||
|
**Issues Identified**:
|
||||||
|
- Data retrieval logic mixed in views and services
|
||||||
|
- No dedicated `selectors.py` modules
|
||||||
|
- Query optimization scattered across multiple locations
|
||||||
|
|
||||||
|
**Recommended Pattern**:
|
||||||
|
```python
|
||||||
|
# parks/selectors.py
|
||||||
|
def park_list_with_stats(*, filters: Optional[Dict] = None) -> QuerySet[Park]:
|
||||||
|
"""Get parks with optimized queries for list display"""
|
||||||
|
queryset = Park.objects.select_related('operator', 'property_owner')
|
||||||
|
if filters:
|
||||||
|
queryset = queryset.filter(**filters)
|
||||||
|
return queryset.order_by('name')
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ API & Serializers Structure
|
||||||
|
**Current State**: Limited API implementation
|
||||||
|
**Django Styleguide Recommendation**: Structured API with proper serializers
|
||||||
|
|
||||||
|
**Issues Identified**:
|
||||||
|
- Minimal DRF usage despite having REST framework installed
|
||||||
|
- API endpoints mixed with HTML views
|
||||||
|
- No clear API versioning strategy
|
||||||
|
|
||||||
|
### ⚠️ Environment Variable Management
|
||||||
|
**Current State**: Hard-coded configuration values
|
||||||
|
**Django Styleguide Recommendation**: Environment-based configuration with `django-environ`
|
||||||
|
|
||||||
|
**Issues Identified**:
|
||||||
|
```python
|
||||||
|
# Current problematic patterns in settings.py
|
||||||
|
SECRET_KEY = "django-insecure-=0)^0#h#k$0@$8$ys=^$0#h#k$0@$8$ys=^" # Hard-coded
|
||||||
|
DEBUG = True # Hard-coded
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"NAME": "thrillwiki",
|
||||||
|
"USER": "wiki",
|
||||||
|
"PASSWORD": "thrillwiki", # Hard-coded credentials
|
||||||
|
"HOST": "192.168.86.3", # Hard-coded host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Detailed Analysis by Category
|
||||||
|
|
||||||
|
### Models (Score: 9/10)
|
||||||
|
**Strengths**:
|
||||||
|
- Excellent base model pattern with `TrackedModel`
|
||||||
|
- Complex history tracking with `pghistory`
|
||||||
|
- Proper model validation with `clean()` methods
|
||||||
|
- Type hints throughout model definitions
|
||||||
|
- Appropriate use of GenericForeignKeys
|
||||||
|
|
||||||
|
**Minor Issues**:
|
||||||
|
- Some models have redundant `created_at`/`updated_at` fields alongside `TrackedModel`
|
||||||
|
- Mixed inheritance patterns (some models don't use base classes consistently)
|
||||||
|
|
||||||
|
### Services (Score: 8/10)
|
||||||
|
**Strengths**:
|
||||||
|
- Clear service layer separation
|
||||||
|
- Type annotations and proper error handling
|
||||||
|
- Caching integration
|
||||||
|
- Business logic properly encapsulated
|
||||||
|
|
||||||
|
**Areas for Improvement**:
|
||||||
|
- Could benefit from more granular service decomposition
|
||||||
|
- Some business logic still in views
|
||||||
|
- Limited use of selectors pattern
|
||||||
|
|
||||||
|
### Templates (Score: 9/10)
|
||||||
|
**Strengths**:
|
||||||
|
- Excellent template organization
|
||||||
|
- Proper inheritance structure
|
||||||
|
- HTMX integration
|
||||||
|
- Reusable components
|
||||||
|
|
||||||
|
**Minor Issues**:
|
||||||
|
- Some templates could benefit from more granular partials
|
||||||
|
- CSS classes could be more consistently organized
|
||||||
|
|
||||||
|
### Testing (Score: 7/10)
|
||||||
|
**Strengths**:
|
||||||
|
- Comprehensive coverage reporting
|
||||||
|
- E2E tests with Playwright
|
||||||
|
- Good test organization
|
||||||
|
|
||||||
|
**Areas for Improvement**:
|
||||||
|
- Limited factory usage (recommended by styleguide)
|
||||||
|
- Some apps lack complete test coverage
|
||||||
|
- Could benefit from more integration tests
|
||||||
|
|
||||||
|
### URLs (Score: 8/10)
|
||||||
|
**Strengths**:
|
||||||
|
- Clear namespacing
|
||||||
|
- RESTful patterns
|
||||||
|
- Good organization
|
||||||
|
|
||||||
|
**Minor Issues**:
|
||||||
|
- Some URL patterns could be more consistent
|
||||||
|
- API URLs mixed with HTML view URLs
|
||||||
|
|
||||||
|
### Settings (Score: 4/10)
|
||||||
|
**Major Issues**:
|
||||||
|
- Monolithic settings file
|
||||||
|
- Hard-coded values
|
||||||
|
- No environment separation
|
||||||
|
- Security concerns with exposed secrets
|
||||||
|
|
||||||
|
## Security Assessment
|
||||||
|
|
||||||
|
### ✅ Security Strengths
|
||||||
|
- CSRF protection enabled
|
||||||
|
- Proper authentication backends
|
||||||
|
- SSL redirect configuration
|
||||||
|
- Secure headers implementation
|
||||||
|
|
||||||
|
### ⚠️ Security Concerns
|
||||||
|
- Hard-coded SECRET_KEY in settings
|
||||||
|
- Database credentials in source code
|
||||||
|
- DEBUG=True in production-destined code
|
||||||
|
- Hard-coded API keys (Turnstile keys)
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### ✅ Performance Strengths
|
||||||
|
- Query optimization with `select_related`/`prefetch_related`
|
||||||
|
- Caching implementation in services
|
||||||
|
- Efficient database queries in adapters
|
||||||
|
- HTMX for reduced page loads
|
||||||
|
|
||||||
|
### ⚠️ Performance Areas
|
||||||
|
- Could benefit from more aggressive caching
|
||||||
|
- Some N+1 query patterns in views
|
||||||
|
- Large template rendering without fragments
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
1. **Restructure Settings**: Implement environment-based settings structure
|
||||||
|
2. **Environment Variables**: Use `django-environ` for all configuration
|
||||||
|
3. **Security**: Remove hard-coded secrets and credentials
|
||||||
|
4. **Selectors**: Implement proper selectors pattern for data retrieval
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
1. **API Structure**: Implement proper DRF API with versioning
|
||||||
|
2. **Testing**: Add factory_boy for test data generation
|
||||||
|
3. **Query Optimization**: Review and optimize database queries
|
||||||
|
4. **Documentation**: Add API documentation with DRF spectacular
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
1. **Template Fragments**: Break down large templates into smaller components
|
||||||
|
2. **Service Decomposition**: Further break down large services
|
||||||
|
3. **Caching Strategy**: Implement more comprehensive caching
|
||||||
|
4. **Type Hints**: Complete type annotation coverage
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The ThrillWiki project demonstrates strong understanding and implementation of Django best practices, particularly in model architecture, service layer design, and template organization. The project's use of advanced features like `pghistory` for audit trails and HTMX for dynamic updates shows sophisticated Django development.
|
||||||
|
|
||||||
|
The main areas requiring attention are settings organization, environment configuration, and security hardening. These are common issues in Django projects and relatively straightforward to address.
|
||||||
|
|
||||||
|
The project is well-positioned for production deployment with the recommended improvements, and already exceeds many Django projects in terms of architectural decisions and code organization.
|
||||||
|
|
||||||
|
**Final Grade: B+ (85/100)**
|
||||||
|
|
||||||
|
## Implementation Timeline
|
||||||
|
|
||||||
|
### Phase 1 (Week 1): Critical Security & Settings
|
||||||
|
- [ ] Restructure settings into modular format
|
||||||
|
- [ ] Implement environment variable management
|
||||||
|
- [ ] Remove hard-coded secrets
|
||||||
|
- [ ] Add production-ready configuration
|
||||||
|
|
||||||
|
### Phase 2 (Week 2): Architecture Improvements
|
||||||
|
- [ ] Implement selectors pattern
|
||||||
|
- [ ] Optimize database queries
|
||||||
|
- [ ] Enhance API structure
|
||||||
|
- [ ] Add comprehensive error handling
|
||||||
|
|
||||||
|
### Phase 3 (Week 3): Testing & Documentation
|
||||||
|
- [ ] Add factory_boy integration
|
||||||
|
- [ ] Improve test coverage
|
||||||
|
- [ ] Add API documentation
|
||||||
|
- [ ] Performance optimization
|
||||||
|
|
||||||
|
This analysis provides a roadmap for bringing the project to full Django best practices compliance while maintaining its current strengths.
|
||||||
@@ -6,13 +6,14 @@ from django.core.exceptions import ValidationError
|
|||||||
from decimal import Decimal, ROUND_DOWN, InvalidOperation
|
from decimal import Decimal, ROUND_DOWN, InvalidOperation
|
||||||
from typing import Tuple, Optional, Any, TYPE_CHECKING
|
from typing import Tuple, Optional, Any, TYPE_CHECKING
|
||||||
import pghistory
|
import pghistory
|
||||||
|
|
||||||
from .companies import Company
|
from .companies import Company
|
||||||
from media.models import Photo
|
from media.models import Photo
|
||||||
from core.history import TrackedModel
|
from core.history import TrackedModel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from rides.models import Ride
|
from rides.models import Ride
|
||||||
|
from . import ParkArea
|
||||||
|
|
||||||
|
|
||||||
@pghistory.track()
|
@pghistory.track()
|
||||||
class Park(TrackedModel):
|
class Park(TrackedModel):
|
||||||
@@ -71,7 +72,8 @@ class Park(TrackedModel):
|
|||||||
)
|
)
|
||||||
photos = GenericRelation(Photo, related_query_name="park")
|
photos = GenericRelation(Photo, related_query_name="park")
|
||||||
areas: models.Manager['ParkArea'] # Type hint for reverse relation
|
areas: models.Manager['ParkArea'] # Type hint for reverse relation
|
||||||
rides: models.Manager['Ride'] # Type hint for reverse relation from rides app
|
# Type hint for reverse relation from rides app
|
||||||
|
rides: models.Manager['Ride']
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||||
@@ -86,7 +88,7 @@ class Park(TrackedModel):
|
|||||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from core.history import HistoricalSlug
|
from core.history import HistoricalSlug
|
||||||
|
|
||||||
# Get old instance if it exists
|
# Get old instance if it exists
|
||||||
if self.pk:
|
if self.pk:
|
||||||
try:
|
try:
|
||||||
@@ -99,14 +101,14 @@ class Park(TrackedModel):
|
|||||||
else:
|
else:
|
||||||
old_name = None
|
old_name = None
|
||||||
old_slug = None
|
old_slug = None
|
||||||
|
|
||||||
# Generate new slug if name has changed or slug is missing
|
# Generate new slug if name has changed or slug is missing
|
||||||
if not self.slug or (old_name and old_name != self.name):
|
if not self.slug or (old_name and old_name != self.name):
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
|
|
||||||
# Save the model
|
# Save the model
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
# If slug has changed, save historical record
|
# If slug has changed, save historical record
|
||||||
if old_slug and old_slug != self.slug:
|
if old_slug and old_slug != self.slug:
|
||||||
HistoricalSlug.objects.create(
|
HistoricalSlug.objects.create(
|
||||||
@@ -118,9 +120,11 @@ class Park(TrackedModel):
|
|||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
if self.operator and 'OPERATOR' not in self.operator.roles:
|
if self.operator and 'OPERATOR' not in self.operator.roles:
|
||||||
raise ValidationError({'operator': 'Company must have the OPERATOR role.'})
|
raise ValidationError(
|
||||||
|
{'operator': 'Company must have the OPERATOR role.'})
|
||||||
if self.property_owner and 'PROPERTY_OWNER' not in self.property_owner.roles:
|
if self.property_owner and 'PROPERTY_OWNER' not in self.property_owner.roles:
|
||||||
raise ValidationError({'property_owner': 'Company must have the PROPERTY_OWNER role.'})
|
raise ValidationError(
|
||||||
|
{'property_owner': 'Company must have the PROPERTY_OWNER role.'})
|
||||||
|
|
||||||
def get_absolute_url(self) -> str:
|
def get_absolute_url(self) -> str:
|
||||||
return reverse("parks:park_detail", kwargs={"slug": self.slug})
|
return reverse("parks:park_detail", kwargs={"slug": self.slug})
|
||||||
@@ -156,54 +160,55 @@ class Park(TrackedModel):
|
|||||||
"""Get park by current or historical slug"""
|
"""Get park by current or historical slug"""
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from core.history import HistoricalSlug
|
from core.history import HistoricalSlug
|
||||||
|
|
||||||
print(f"\nLooking up slug: {slug}")
|
print(f"\nLooking up slug: {slug}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
park = cls.objects.get(slug=slug)
|
park = cls.objects.get(slug=slug)
|
||||||
print(f"Found current park with slug: {slug}")
|
print(f"Found current park with slug: {slug}")
|
||||||
return park, False
|
return park, False
|
||||||
except cls.DoesNotExist:
|
except cls.DoesNotExist:
|
||||||
print(f"No current park found with slug: {slug}")
|
print(f"No current park found with slug: {slug}")
|
||||||
|
|
||||||
# Try historical slugs in HistoricalSlug model
|
# Try historical slugs in HistoricalSlug model
|
||||||
content_type = ContentType.objects.get_for_model(cls)
|
content_type = ContentType.objects.get_for_model(cls)
|
||||||
print(f"Searching HistoricalSlug with content_type: {content_type}")
|
print(
|
||||||
|
f"Searching HistoricalSlug with content_type: {content_type}")
|
||||||
historical = HistoricalSlug.objects.filter(
|
historical = HistoricalSlug.objects.filter(
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
slug=slug
|
slug=slug
|
||||||
).order_by('-created_at').first()
|
).order_by('-created_at').first()
|
||||||
|
|
||||||
if historical:
|
if historical:
|
||||||
print(f"Found historical slug record for object_id: {historical.object_id}")
|
print(
|
||||||
|
f"Found historical slug record for object_id: {historical.object_id}")
|
||||||
try:
|
try:
|
||||||
park = cls.objects.get(pk=historical.object_id)
|
park = cls.objects.get(pk=historical.object_id)
|
||||||
print(f"Found park from historical slug: {park.name}")
|
print(f"Found park from historical slug: {park.name}")
|
||||||
return park, True
|
return park, True
|
||||||
except cls.DoesNotExist:
|
except cls.DoesNotExist:
|
||||||
print(f"Park not found for historical slug record")
|
print("Park not found for historical slug record")
|
||||||
pass
|
|
||||||
else:
|
else:
|
||||||
print("No historical slug record found")
|
print("No historical slug record found")
|
||||||
|
|
||||||
# Try pghistory events
|
# Try pghistory events
|
||||||
print(f"Searching pghistory events")
|
print("Searching pghistory events")
|
||||||
event_model = getattr(cls, 'event_model', None)
|
event_model = getattr(cls, 'event_model', None)
|
||||||
if event_model:
|
if event_model:
|
||||||
historical_event = event_model.objects.filter(
|
historical_event = event_model.objects.filter(
|
||||||
slug=slug
|
slug=slug
|
||||||
).order_by('-pgh_created_at').first()
|
).order_by('-pgh_created_at').first()
|
||||||
|
|
||||||
if historical_event:
|
if historical_event:
|
||||||
print(f"Found pghistory event for pgh_obj_id: {historical_event.pgh_obj_id}")
|
print(
|
||||||
|
f"Found pghistory event for pgh_obj_id: {historical_event.pgh_obj_id}")
|
||||||
try:
|
try:
|
||||||
park = cls.objects.get(pk=historical_event.pgh_obj_id)
|
park = cls.objects.get(pk=historical_event.pgh_obj_id)
|
||||||
print(f"Found park from pghistory: {park.name}")
|
print(f"Found park from pghistory: {park.name}")
|
||||||
return park, True
|
return park, True
|
||||||
except cls.DoesNotExist:
|
except cls.DoesNotExist:
|
||||||
print(f"Park not found for pghistory event")
|
print("Park not found for pghistory event")
|
||||||
pass
|
|
||||||
else:
|
else:
|
||||||
print("No pghistory event found")
|
print("No pghistory event found")
|
||||||
|
|
||||||
raise cls.DoesNotExist("No park found with this slug")
|
raise cls.DoesNotExist("No park found with this slug")
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from django.core.cache import cache
|
|||||||
from django.contrib.gis.geos import Point
|
from django.contrib.gis.geos import Point
|
||||||
from django.contrib.gis.measure import Distance
|
from django.contrib.gis.measure import Distance
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from parks.models import Park
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,11 +32,11 @@ class Coordinates:
|
|||||||
"""Represents latitude and longitude coordinates."""
|
"""Represents latitude and longitude coordinates."""
|
||||||
latitude: float
|
latitude: float
|
||||||
longitude: float
|
longitude: float
|
||||||
|
|
||||||
def to_tuple(self) -> Tuple[float, float]:
|
def to_tuple(self) -> Tuple[float, float]:
|
||||||
"""Return as (lat, lon) tuple."""
|
"""Return as (lat, lon) tuple."""
|
||||||
return (self.latitude, self.longitude)
|
return (self.latitude, self.longitude)
|
||||||
|
|
||||||
def to_point(self) -> Point:
|
def to_point(self) -> Point:
|
||||||
"""Convert to Django Point object."""
|
"""Convert to Django Point object."""
|
||||||
return Point(self.longitude, self.latitude, srid=4326)
|
return Point(self.longitude, self.latitude, srid=4326)
|
||||||
@@ -47,14 +48,14 @@ class RouteInfo:
|
|||||||
distance_km: float
|
distance_km: float
|
||||||
duration_minutes: int
|
duration_minutes: int
|
||||||
geometry: Optional[str] = None # Encoded polyline
|
geometry: Optional[str] = None # Encoded polyline
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def formatted_distance(self) -> str:
|
def formatted_distance(self) -> str:
|
||||||
"""Return formatted distance string."""
|
"""Return formatted distance string."""
|
||||||
if self.distance_km < 1:
|
if self.distance_km < 1:
|
||||||
return f"{self.distance_km * 1000:.0f}m"
|
return f"{self.distance_km * 1000:.0f}m"
|
||||||
return f"{self.distance_km:.1f}km"
|
return f"{self.distance_km:.1f}km"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def formatted_duration(self) -> str:
|
def formatted_duration(self) -> str:
|
||||||
"""Return formatted duration string."""
|
"""Return formatted duration string."""
|
||||||
@@ -74,7 +75,7 @@ class TripLeg:
|
|||||||
from_park: 'Park'
|
from_park: 'Park'
|
||||||
to_park: 'Park'
|
to_park: 'Park'
|
||||||
route: RouteInfo
|
route: RouteInfo
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parks_along_route(self) -> List['Park']:
|
def parks_along_route(self) -> List['Park']:
|
||||||
"""Get parks along this route segment."""
|
"""Get parks along this route segment."""
|
||||||
@@ -89,12 +90,12 @@ class RoadTrip:
|
|||||||
legs: List[TripLeg]
|
legs: List[TripLeg]
|
||||||
total_distance_km: float
|
total_distance_km: float
|
||||||
total_duration_minutes: int
|
total_duration_minutes: int
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def formatted_total_distance(self) -> str:
|
def formatted_total_distance(self) -> str:
|
||||||
"""Return formatted total distance."""
|
"""Return formatted total distance."""
|
||||||
return f"{self.total_distance_km:.1f}km"
|
return f"{self.total_distance_km:.1f}km"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def formatted_total_duration(self) -> str:
|
def formatted_total_duration(self) -> str:
|
||||||
"""Return formatted total duration."""
|
"""Return formatted total duration."""
|
||||||
@@ -110,21 +111,21 @@ class RoadTrip:
|
|||||||
|
|
||||||
class RateLimiter:
|
class RateLimiter:
|
||||||
"""Simple rate limiter for API requests."""
|
"""Simple rate limiter for API requests."""
|
||||||
|
|
||||||
def __init__(self, max_requests_per_second: float = 1.0):
|
def __init__(self, max_requests_per_second: float = 1.0):
|
||||||
self.max_requests_per_second = max_requests_per_second
|
self.max_requests_per_second = max_requests_per_second
|
||||||
self.min_interval = 1.0 / max_requests_per_second
|
self.min_interval = 1.0 / max_requests_per_second
|
||||||
self.last_request_time = 0.0
|
self.last_request_time = 0.0
|
||||||
|
|
||||||
def wait_if_needed(self):
|
def wait_if_needed(self):
|
||||||
"""Wait if necessary to respect rate limits."""
|
"""Wait if necessary to respect rate limits."""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
time_since_last = current_time - self.last_request_time
|
time_since_last = current_time - self.last_request_time
|
||||||
|
|
||||||
if time_since_last < self.min_interval:
|
if time_since_last < self.min_interval:
|
||||||
wait_time = self.min_interval - time_since_last
|
wait_time = self.min_interval - time_since_last
|
||||||
time.sleep(wait_time)
|
time.sleep(wait_time)
|
||||||
|
|
||||||
self.last_request_time = time.time()
|
self.last_request_time = time.time()
|
||||||
|
|
||||||
|
|
||||||
@@ -137,74 +138,79 @@ class RoadTripService:
|
|||||||
"""
|
"""
|
||||||
Service for planning road trips between theme parks using OpenStreetMap APIs.
|
Service for planning road trips between theme parks using OpenStreetMap APIs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.nominatim_base_url = "https://nominatim.openstreetmap.org"
|
self.nominatim_base_url = "https://nominatim.openstreetmap.org"
|
||||||
self.osrm_base_url = "http://router.project-osrm.org/route/v1/driving"
|
self.osrm_base_url = "http://router.project-osrm.org/route/v1/driving"
|
||||||
|
|
||||||
# Configuration from Django settings
|
# Configuration from Django settings
|
||||||
self.cache_timeout = getattr(settings, 'ROADTRIP_CACHE_TIMEOUT', 3600 * 24)
|
self.cache_timeout = getattr(
|
||||||
self.route_cache_timeout = getattr(settings, 'ROADTRIP_ROUTE_CACHE_TIMEOUT', 3600 * 6)
|
settings, 'ROADTRIP_CACHE_TIMEOUT', 3600 * 24)
|
||||||
self.user_agent = getattr(settings, 'ROADTRIP_USER_AGENT', 'ThrillWiki Road Trip Planner')
|
self.route_cache_timeout = getattr(
|
||||||
self.request_timeout = getattr(settings, 'ROADTRIP_REQUEST_TIMEOUT', 10)
|
settings, 'ROADTRIP_ROUTE_CACHE_TIMEOUT', 3600 * 6)
|
||||||
|
self.user_agent = getattr(
|
||||||
|
settings, 'ROADTRIP_USER_AGENT', 'ThrillWiki Road Trip Planner')
|
||||||
|
self.request_timeout = getattr(
|
||||||
|
settings, 'ROADTRIP_REQUEST_TIMEOUT', 10)
|
||||||
self.max_retries = getattr(settings, 'ROADTRIP_MAX_RETRIES', 3)
|
self.max_retries = getattr(settings, 'ROADTRIP_MAX_RETRIES', 3)
|
||||||
self.backoff_factor = getattr(settings, 'ROADTRIP_BACKOFF_FACTOR', 2)
|
self.backoff_factor = getattr(settings, 'ROADTRIP_BACKOFF_FACTOR', 2)
|
||||||
|
|
||||||
# Rate limiter
|
# Rate limiter
|
||||||
max_rps = getattr(settings, 'ROADTRIP_MAX_REQUESTS_PER_SECOND', 1)
|
max_rps = getattr(settings, 'ROADTRIP_MAX_REQUESTS_PER_SECOND', 1)
|
||||||
self.rate_limiter = RateLimiter(max_rps)
|
self.rate_limiter = RateLimiter(max_rps)
|
||||||
|
|
||||||
# Request session with proper headers
|
# Request session with proper headers
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update({
|
self.session.headers.update({
|
||||||
'User-Agent': self.user_agent,
|
'User-Agent': self.user_agent,
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
})
|
})
|
||||||
|
|
||||||
def _make_request(self, url: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
def _make_request(self, url: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Make HTTP request with rate limiting, retries, and error handling.
|
Make HTTP request with rate limiting, retries, and error handling.
|
||||||
"""
|
"""
|
||||||
self.rate_limiter.wait_if_needed()
|
self.rate_limiter.wait_if_needed()
|
||||||
|
|
||||||
for attempt in range(self.max_retries):
|
for attempt in range(self.max_retries):
|
||||||
try:
|
try:
|
||||||
response = self.session.get(
|
response = self.session.get(
|
||||||
url,
|
url,
|
||||||
params=params,
|
params=params,
|
||||||
timeout=self.request_timeout
|
timeout=self.request_timeout
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
logger.warning(f"Request attempt {attempt + 1} failed: {e}")
|
logger.warning(f"Request attempt {attempt + 1} failed: {e}")
|
||||||
|
|
||||||
if attempt < self.max_retries - 1:
|
if attempt < self.max_retries - 1:
|
||||||
wait_time = self.backoff_factor ** attempt
|
wait_time = self.backoff_factor ** attempt
|
||||||
time.sleep(wait_time)
|
time.sleep(wait_time)
|
||||||
else:
|
else:
|
||||||
raise OSMAPIException(f"Failed to make request after {self.max_retries} attempts: {e}")
|
raise OSMAPIException(
|
||||||
|
f"Failed to make request after {self.max_retries} attempts: {e}")
|
||||||
|
|
||||||
def geocode_address(self, address: str) -> Optional[Coordinates]:
|
def geocode_address(self, address: str) -> Optional[Coordinates]:
|
||||||
"""
|
"""
|
||||||
Convert address to coordinates using Nominatim geocoding service.
|
Convert address to coordinates using Nominatim geocoding service.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
address: Address string to geocode
|
address: Address string to geocode
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Coordinates object or None if geocoding fails
|
Coordinates object or None if geocoding fails
|
||||||
"""
|
"""
|
||||||
if not address or not address.strip():
|
if not address or not address.strip():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check cache first
|
# Check cache first
|
||||||
cache_key = f"roadtrip:geocode:{hash(address.lower().strip())}"
|
cache_key = f"roadtrip:geocode:{hash(address.lower().strip())}"
|
||||||
cached_result = cache.get(cache_key)
|
cached_result = cache.get(cache_key)
|
||||||
if cached_result:
|
if cached_result:
|
||||||
return Coordinates(**cached_result)
|
return Coordinates(**cached_result)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
params = {
|
params = {
|
||||||
'q': address.strip(),
|
'q': address.strip(),
|
||||||
@@ -212,186 +218,193 @@ class RoadTripService:
|
|||||||
'limit': 1,
|
'limit': 1,
|
||||||
'addressdetails': 1,
|
'addressdetails': 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
url = f"{self.nominatim_base_url}/search"
|
url = f"{self.nominatim_base_url}/search"
|
||||||
response = self._make_request(url, params)
|
response = self._make_request(url, params)
|
||||||
|
|
||||||
if response and len(response) > 0:
|
if response and len(response) > 0:
|
||||||
result = response[0]
|
result = response[0]
|
||||||
coords = Coordinates(
|
coords = Coordinates(
|
||||||
latitude=float(result['lat']),
|
latitude=float(result['lat']),
|
||||||
longitude=float(result['lon'])
|
longitude=float(result['lon'])
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cache the result
|
# Cache the result
|
||||||
cache.set(cache_key, {
|
cache.set(cache_key, {
|
||||||
'latitude': coords.latitude,
|
'latitude': coords.latitude,
|
||||||
'longitude': coords.longitude
|
'longitude': coords.longitude
|
||||||
}, self.cache_timeout)
|
}, self.cache_timeout)
|
||||||
|
|
||||||
logger.info(f"Geocoded '{address}' to {coords.latitude}, {coords.longitude}")
|
logger.info(
|
||||||
|
f"Geocoded '{address}' to {coords.latitude}, {coords.longitude}")
|
||||||
return coords
|
return coords
|
||||||
else:
|
else:
|
||||||
logger.warning(f"No geocoding results for address: {address}")
|
logger.warning(f"No geocoding results for address: {address}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Geocoding failed for '{address}': {e}")
|
logger.error(f"Geocoding failed for '{address}': {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def calculate_route(self, start_coords: Coordinates, end_coords: Coordinates) -> Optional[RouteInfo]:
|
def calculate_route(self, start_coords: Coordinates, end_coords: Coordinates) -> Optional[RouteInfo]:
|
||||||
"""
|
"""
|
||||||
Calculate route between two coordinate points using OSRM.
|
Calculate route between two coordinate points using OSRM.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
start_coords: Starting coordinates
|
start_coords: Starting coordinates
|
||||||
end_coords: Ending coordinates
|
end_coords: Ending coordinates
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RouteInfo object or None if routing fails
|
RouteInfo object or None if routing fails
|
||||||
"""
|
"""
|
||||||
if not start_coords or not end_coords:
|
if not start_coords or not end_coords:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check cache first
|
# Check cache first
|
||||||
cache_key = f"roadtrip:route:{start_coords.latitude},{start_coords.longitude}:{end_coords.latitude},{end_coords.longitude}"
|
cache_key = f"roadtrip:route:{start_coords.latitude},{start_coords.longitude}:{end_coords.latitude},{end_coords.longitude}"
|
||||||
cached_result = cache.get(cache_key)
|
cached_result = cache.get(cache_key)
|
||||||
if cached_result:
|
if cached_result:
|
||||||
return RouteInfo(**cached_result)
|
return RouteInfo(**cached_result)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Format coordinates for OSRM (lon,lat format)
|
# Format coordinates for OSRM (lon,lat format)
|
||||||
coords_string = f"{start_coords.longitude},{start_coords.latitude};{end_coords.longitude},{end_coords.latitude}"
|
coords_string = f"{start_coords.longitude},{start_coords.latitude};{end_coords.longitude},{end_coords.latitude}"
|
||||||
url = f"{self.osrm_base_url}/{coords_string}"
|
url = f"{self.osrm_base_url}/{coords_string}"
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'overview': 'full',
|
'overview': 'full',
|
||||||
'geometries': 'polyline',
|
'geometries': 'polyline',
|
||||||
'steps': 'false',
|
'steps': 'false',
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self._make_request(url, params)
|
response = self._make_request(url, params)
|
||||||
|
|
||||||
if response.get('code') == 'Ok' and response.get('routes'):
|
if response.get('code') == 'Ok' and response.get('routes'):
|
||||||
route_data = response['routes'][0]
|
route_data = response['routes'][0]
|
||||||
|
|
||||||
# Distance is in meters, convert to km
|
# Distance is in meters, convert to km
|
||||||
distance_km = route_data['distance'] / 1000.0
|
distance_km = route_data['distance'] / 1000.0
|
||||||
# Duration is in seconds, convert to minutes
|
# Duration is in seconds, convert to minutes
|
||||||
duration_minutes = int(route_data['duration'] / 60)
|
duration_minutes = int(route_data['duration'] / 60)
|
||||||
|
|
||||||
route_info = RouteInfo(
|
route_info = RouteInfo(
|
||||||
distance_km=distance_km,
|
distance_km=distance_km,
|
||||||
duration_minutes=duration_minutes,
|
duration_minutes=duration_minutes,
|
||||||
geometry=route_data.get('geometry')
|
geometry=route_data.get('geometry')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cache the result
|
# Cache the result
|
||||||
cache.set(cache_key, {
|
cache.set(cache_key, {
|
||||||
'distance_km': route_info.distance_km,
|
'distance_km': route_info.distance_km,
|
||||||
'duration_minutes': route_info.duration_minutes,
|
'duration_minutes': route_info.duration_minutes,
|
||||||
'geometry': route_info.geometry
|
'geometry': route_info.geometry
|
||||||
}, self.route_cache_timeout)
|
}, self.route_cache_timeout)
|
||||||
|
|
||||||
logger.info(f"Route calculated: {route_info.formatted_distance}, {route_info.formatted_duration}")
|
logger.info(
|
||||||
|
f"Route calculated: {route_info.formatted_distance}, {route_info.formatted_duration}")
|
||||||
return route_info
|
return route_info
|
||||||
else:
|
else:
|
||||||
# Fallback to straight-line distance calculation
|
# Fallback to straight-line distance calculation
|
||||||
logger.warning(f"OSRM routing failed, falling back to straight-line distance")
|
logger.warning(
|
||||||
|
f"OSRM routing failed, falling back to straight-line distance")
|
||||||
return self._calculate_straight_line_route(start_coords, end_coords)
|
return self._calculate_straight_line_route(start_coords, end_coords)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Route calculation failed: {e}")
|
logger.error(f"Route calculation failed: {e}")
|
||||||
# Fallback to straight-line distance
|
# Fallback to straight-line distance
|
||||||
return self._calculate_straight_line_route(start_coords, end_coords)
|
return self._calculate_straight_line_route(start_coords, end_coords)
|
||||||
|
|
||||||
def _calculate_straight_line_route(self, start_coords: Coordinates, end_coords: Coordinates) -> RouteInfo:
|
def _calculate_straight_line_route(self, start_coords: Coordinates, end_coords: Coordinates) -> RouteInfo:
|
||||||
"""
|
"""
|
||||||
Calculate straight-line distance as fallback when routing fails.
|
Calculate straight-line distance as fallback when routing fails.
|
||||||
"""
|
"""
|
||||||
# Haversine formula for great-circle distance
|
# Haversine formula for great-circle distance
|
||||||
lat1, lon1 = math.radians(start_coords.latitude), math.radians(start_coords.longitude)
|
lat1, lon1 = math.radians(start_coords.latitude), math.radians(
|
||||||
lat2, lon2 = math.radians(end_coords.latitude), math.radians(end_coords.longitude)
|
start_coords.longitude)
|
||||||
|
lat2, lon2 = math.radians(
|
||||||
|
end_coords.latitude), math.radians(end_coords.longitude)
|
||||||
|
|
||||||
dlat = lat2 - lat1
|
dlat = lat2 - lat1
|
||||||
dlon = lon2 - lon1
|
dlon = lon2 - lon1
|
||||||
|
|
||||||
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
|
a = math.sin(dlat/2)**2 + math.cos(lat1) * \
|
||||||
|
math.cos(lat2) * math.sin(dlon/2)**2
|
||||||
c = 2 * math.asin(math.sqrt(a))
|
c = 2 * math.asin(math.sqrt(a))
|
||||||
|
|
||||||
# Earth's radius in kilometers
|
# Earth's radius in kilometers
|
||||||
earth_radius_km = 6371.0
|
earth_radius_km = 6371.0
|
||||||
distance_km = earth_radius_km * c
|
distance_km = earth_radius_km * c
|
||||||
|
|
||||||
# Estimate driving time (assume average 80 km/h with 25% extra for roads)
|
# Estimate driving time (assume average 80 km/h with 25% extra for roads)
|
||||||
estimated_duration_minutes = int((distance_km * 1.25 / 80.0) * 60)
|
estimated_duration_minutes = int((distance_km * 1.25 / 80.0) * 60)
|
||||||
|
|
||||||
return RouteInfo(
|
return RouteInfo(
|
||||||
distance_km=distance_km,
|
distance_km=distance_km,
|
||||||
duration_minutes=estimated_duration_minutes,
|
duration_minutes=estimated_duration_minutes,
|
||||||
geometry=None
|
geometry=None
|
||||||
)
|
)
|
||||||
|
|
||||||
def find_parks_along_route(self, start_park: 'Park', end_park: 'Park', max_detour_km: float = 50) -> List['Park']:
|
def find_parks_along_route(self, start_park: 'Park', end_park: 'Park', max_detour_km: float = 50) -> List['Park']:
|
||||||
"""
|
"""
|
||||||
Find parks along a route within specified detour distance.
|
Find parks along a route within specified detour distance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
start_park: Starting park
|
start_park: Starting park
|
||||||
end_park: Ending park
|
end_park: Ending park
|
||||||
max_detour_km: Maximum detour distance in kilometers
|
max_detour_km: Maximum detour distance in kilometers
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of parks along the route
|
List of parks along the route
|
||||||
"""
|
"""
|
||||||
from parks.models import Park
|
from parks.models import Park
|
||||||
|
|
||||||
if not hasattr(start_park, 'location') or not hasattr(end_park, 'location'):
|
if not hasattr(start_park, 'location') or not hasattr(end_park, 'location'):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not start_park.location or not end_park.location:
|
if not start_park.location or not end_park.location:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
start_coords = start_park.coordinates
|
start_coords = start_park.coordinates
|
||||||
end_coords = end_park.coordinates
|
end_coords = end_park.coordinates
|
||||||
|
|
||||||
if not start_coords or not end_coords:
|
if not start_coords or not end_coords:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
start_point = Point(start_coords[1], start_coords[0], srid=4326) # lon, lat
|
start_point = Point(
|
||||||
|
start_coords[1], start_coords[0], srid=4326) # lon, lat
|
||||||
end_point = Point(end_coords[1], end_coords[0], srid=4326)
|
end_point = Point(end_coords[1], end_coords[0], srid=4326)
|
||||||
|
|
||||||
# Find all parks within a reasonable distance from both start and end
|
# Find all parks within a reasonable distance from both start and end
|
||||||
max_search_distance = Distance(km=max_detour_km * 2)
|
max_search_distance = Distance(km=max_detour_km * 2)
|
||||||
|
|
||||||
candidate_parks = Park.objects.filter(
|
candidate_parks = Park.objects.filter(
|
||||||
location__point__distance_lte=(start_point, max_search_distance)
|
location__point__distance_lte=(start_point, max_search_distance)
|
||||||
).exclude(
|
).exclude(
|
||||||
id__in=[start_park.id, end_park.id]
|
id__in=[start_park.id, end_park.id]
|
||||||
).select_related('location')
|
).select_related('location')
|
||||||
|
|
||||||
parks_along_route = []
|
parks_along_route = []
|
||||||
|
|
||||||
for park in candidate_parks:
|
for park in candidate_parks:
|
||||||
if not park.location or not park.location.point:
|
if not park.location or not park.location.point:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
park_coords = park.coordinates
|
park_coords = park.coordinates
|
||||||
if not park_coords:
|
if not park_coords:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Calculate detour distance
|
# Calculate detour distance
|
||||||
detour_distance = self._calculate_detour_distance(
|
detour_distance = self._calculate_detour_distance(
|
||||||
Coordinates(*start_coords),
|
Coordinates(*start_coords),
|
||||||
Coordinates(*end_coords),
|
Coordinates(*end_coords),
|
||||||
Coordinates(*park_coords)
|
Coordinates(*park_coords)
|
||||||
)
|
)
|
||||||
|
|
||||||
if detour_distance and detour_distance <= max_detour_km:
|
if detour_distance and detour_distance <= max_detour_km:
|
||||||
parks_along_route.append(park)
|
parks_along_route.append(park)
|
||||||
|
|
||||||
return parks_along_route
|
return parks_along_route
|
||||||
|
|
||||||
def _calculate_detour_distance(self, start: Coordinates, end: Coordinates, waypoint: Coordinates) -> Optional[float]:
|
def _calculate_detour_distance(self, start: Coordinates, end: Coordinates, waypoint: Coordinates) -> Optional[float]:
|
||||||
"""
|
"""
|
||||||
Calculate the detour distance when visiting a waypoint.
|
Calculate the detour distance when visiting a waypoint.
|
||||||
@@ -401,128 +414,129 @@ class RoadTripService:
|
|||||||
direct_route = self.calculate_route(start, end)
|
direct_route = self.calculate_route(start, end)
|
||||||
if not direct_route:
|
if not direct_route:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Route via waypoint
|
# Route via waypoint
|
||||||
route_to_waypoint = self.calculate_route(start, waypoint)
|
route_to_waypoint = self.calculate_route(start, waypoint)
|
||||||
route_from_waypoint = self.calculate_route(waypoint, end)
|
route_from_waypoint = self.calculate_route(waypoint, end)
|
||||||
|
|
||||||
if not route_to_waypoint or not route_from_waypoint:
|
if not route_to_waypoint or not route_from_waypoint:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
detour_distance = (route_to_waypoint.distance_km + route_from_waypoint.distance_km) - direct_route.distance_km
|
detour_distance = (route_to_waypoint.distance_km +
|
||||||
|
route_from_waypoint.distance_km) - direct_route.distance_km
|
||||||
return max(0, detour_distance) # Don't return negative detours
|
return max(0, detour_distance) # Don't return negative detours
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to calculate detour distance: {e}")
|
logger.error(f"Failed to calculate detour distance: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def create_multi_park_trip(self, park_list: List['Park']) -> Optional[RoadTrip]:
|
def create_multi_park_trip(self, park_list: List['Park']) -> Optional[RoadTrip]:
|
||||||
"""
|
"""
|
||||||
Create optimized multi-park road trip using simple nearest neighbor heuristic.
|
Create optimized multi-park road trip using simple nearest neighbor heuristic.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
park_list: List of parks to visit
|
park_list: List of parks to visit
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RoadTrip object with optimized route
|
RoadTrip object with optimized route
|
||||||
"""
|
"""
|
||||||
if len(park_list) < 2:
|
if len(park_list) < 2:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# For small numbers of parks, try all permutations
|
# For small numbers of parks, try all permutations
|
||||||
if len(park_list) <= 6:
|
if len(park_list) <= 6:
|
||||||
return self._optimize_trip_exhaustive(park_list)
|
return self._optimize_trip_exhaustive(park_list)
|
||||||
else:
|
else:
|
||||||
return self._optimize_trip_nearest_neighbor(park_list)
|
return self._optimize_trip_nearest_neighbor(park_list)
|
||||||
|
|
||||||
def _optimize_trip_exhaustive(self, park_list: List['Park']) -> Optional[RoadTrip]:
|
def _optimize_trip_exhaustive(self, park_list: List['Park']) -> Optional[RoadTrip]:
|
||||||
"""
|
"""
|
||||||
Find optimal route by testing all permutations (for small lists).
|
Find optimal route by testing all permutations (for small lists).
|
||||||
"""
|
"""
|
||||||
best_trip = None
|
best_trip = None
|
||||||
best_distance = float('inf')
|
best_distance = float('inf')
|
||||||
|
|
||||||
# Try all possible orders (excluding the first park as starting point)
|
# Try all possible orders (excluding the first park as starting point)
|
||||||
for perm in permutations(park_list[1:]):
|
for perm in permutations(park_list[1:]):
|
||||||
ordered_parks = [park_list[0]] + list(perm)
|
ordered_parks = [park_list[0]] + list(perm)
|
||||||
trip = self._create_trip_from_order(ordered_parks)
|
trip = self._create_trip_from_order(ordered_parks)
|
||||||
|
|
||||||
if trip and trip.total_distance_km < best_distance:
|
if trip and trip.total_distance_km < best_distance:
|
||||||
best_distance = trip.total_distance_km
|
best_distance = trip.total_distance_km
|
||||||
best_trip = trip
|
best_trip = trip
|
||||||
|
|
||||||
return best_trip
|
return best_trip
|
||||||
|
|
||||||
def _optimize_trip_nearest_neighbor(self, park_list: List['Park']) -> Optional[RoadTrip]:
|
def _optimize_trip_nearest_neighbor(self, park_list: List['Park']) -> Optional[RoadTrip]:
|
||||||
"""
|
"""
|
||||||
Optimize trip using nearest neighbor heuristic (for larger lists).
|
Optimize trip using nearest neighbor heuristic (for larger lists).
|
||||||
"""
|
"""
|
||||||
if not park_list:
|
if not park_list:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Start with the first park
|
# Start with the first park
|
||||||
current_park = park_list[0]
|
current_park = park_list[0]
|
||||||
ordered_parks = [current_park]
|
ordered_parks = [current_park]
|
||||||
remaining_parks = park_list[1:]
|
remaining_parks = park_list[1:]
|
||||||
|
|
||||||
while remaining_parks:
|
while remaining_parks:
|
||||||
# Find nearest unvisited park
|
# Find nearest unvisited park
|
||||||
nearest_park = None
|
nearest_park = None
|
||||||
min_distance = float('inf')
|
min_distance = float('inf')
|
||||||
|
|
||||||
current_coords = current_park.coordinates
|
current_coords = current_park.coordinates
|
||||||
if not current_coords:
|
if not current_coords:
|
||||||
break
|
break
|
||||||
|
|
||||||
for park in remaining_parks:
|
for park in remaining_parks:
|
||||||
park_coords = park.coordinates
|
park_coords = park.coordinates
|
||||||
if not park_coords:
|
if not park_coords:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
route = self.calculate_route(
|
route = self.calculate_route(
|
||||||
Coordinates(*current_coords),
|
Coordinates(*current_coords),
|
||||||
Coordinates(*park_coords)
|
Coordinates(*park_coords)
|
||||||
)
|
)
|
||||||
|
|
||||||
if route and route.distance_km < min_distance:
|
if route and route.distance_km < min_distance:
|
||||||
min_distance = route.distance_km
|
min_distance = route.distance_km
|
||||||
nearest_park = park
|
nearest_park = park
|
||||||
|
|
||||||
if nearest_park:
|
if nearest_park:
|
||||||
ordered_parks.append(nearest_park)
|
ordered_parks.append(nearest_park)
|
||||||
remaining_parks.remove(nearest_park)
|
remaining_parks.remove(nearest_park)
|
||||||
current_park = nearest_park
|
current_park = nearest_park
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
return self._create_trip_from_order(ordered_parks)
|
return self._create_trip_from_order(ordered_parks)
|
||||||
|
|
||||||
def _create_trip_from_order(self, ordered_parks: List['Park']) -> Optional[RoadTrip]:
|
def _create_trip_from_order(self, ordered_parks: List['Park']) -> Optional[RoadTrip]:
|
||||||
"""
|
"""
|
||||||
Create a RoadTrip object from an ordered list of parks.
|
Create a RoadTrip object from an ordered list of parks.
|
||||||
"""
|
"""
|
||||||
if len(ordered_parks) < 2:
|
if len(ordered_parks) < 2:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
legs = []
|
legs = []
|
||||||
total_distance = 0
|
total_distance = 0
|
||||||
total_duration = 0
|
total_duration = 0
|
||||||
|
|
||||||
for i in range(len(ordered_parks) - 1):
|
for i in range(len(ordered_parks) - 1):
|
||||||
from_park = ordered_parks[i]
|
from_park = ordered_parks[i]
|
||||||
to_park = ordered_parks[i + 1]
|
to_park = ordered_parks[i + 1]
|
||||||
|
|
||||||
from_coords = from_park.coordinates
|
from_coords = from_park.coordinates
|
||||||
to_coords = to_park.coordinates
|
to_coords = to_park.coordinates
|
||||||
|
|
||||||
if not from_coords or not to_coords:
|
if not from_coords or not to_coords:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
route = self.calculate_route(
|
route = self.calculate_route(
|
||||||
Coordinates(*from_coords),
|
Coordinates(*from_coords),
|
||||||
Coordinates(*to_coords)
|
Coordinates(*to_coords)
|
||||||
)
|
)
|
||||||
|
|
||||||
if route:
|
if route:
|
||||||
legs.append(TripLeg(
|
legs.append(TripLeg(
|
||||||
from_park=from_park,
|
from_park=from_park,
|
||||||
@@ -531,58 +545,59 @@ class RoadTripService:
|
|||||||
))
|
))
|
||||||
total_distance += route.distance_km
|
total_distance += route.distance_km
|
||||||
total_duration += route.duration_minutes
|
total_duration += route.duration_minutes
|
||||||
|
|
||||||
if not legs:
|
if not legs:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return RoadTrip(
|
return RoadTrip(
|
||||||
parks=ordered_parks,
|
parks=ordered_parks,
|
||||||
legs=legs,
|
legs=legs,
|
||||||
total_distance_km=total_distance,
|
total_distance_km=total_distance,
|
||||||
total_duration_minutes=total_duration
|
total_duration_minutes=total_duration
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_park_distances(self, center_park: 'Park', radius_km: float = 100) -> List[Dict[str, Any]]:
|
def get_park_distances(self, center_park: 'Park', radius_km: float = 100) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get all parks within radius of a center park with distances.
|
Get all parks within radius of a center park with distances.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
center_park: Center park for search
|
center_park: Center park for search
|
||||||
radius_km: Search radius in kilometers
|
radius_km: Search radius in kilometers
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of dictionaries with park and distance information
|
List of dictionaries with park and distance information
|
||||||
"""
|
"""
|
||||||
from parks.models import Park
|
from parks.models import Park
|
||||||
|
|
||||||
if not hasattr(center_park, 'location') or not center_park.location:
|
if not hasattr(center_park, 'location') or not center_park.location:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
center_coords = center_park.coordinates
|
center_coords = center_park.coordinates
|
||||||
if not center_coords:
|
if not center_coords:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
center_point = Point(center_coords[1], center_coords[0], srid=4326) # lon, lat
|
center_point = Point(
|
||||||
|
center_coords[1], center_coords[0], srid=4326) # lon, lat
|
||||||
search_distance = Distance(km=radius_km)
|
search_distance = Distance(km=radius_km)
|
||||||
|
|
||||||
nearby_parks = Park.objects.filter(
|
nearby_parks = Park.objects.filter(
|
||||||
location__point__distance_lte=(center_point, search_distance)
|
location__point__distance_lte=(center_point, search_distance)
|
||||||
).exclude(
|
).exclude(
|
||||||
id=center_park.id
|
id=center_park.id
|
||||||
).select_related('location')
|
).select_related('location')
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
for park in nearby_parks:
|
for park in nearby_parks:
|
||||||
park_coords = park.coordinates
|
park_coords = park.coordinates
|
||||||
if not park_coords:
|
if not park_coords:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
route = self.calculate_route(
|
route = self.calculate_route(
|
||||||
Coordinates(*center_coords),
|
Coordinates(*center_coords),
|
||||||
Coordinates(*park_coords)
|
Coordinates(*park_coords)
|
||||||
)
|
)
|
||||||
|
|
||||||
if route:
|
if route:
|
||||||
results.append({
|
results.append({
|
||||||
'park': park,
|
'park': park,
|
||||||
@@ -591,31 +606,31 @@ class RoadTripService:
|
|||||||
'formatted_distance': route.formatted_distance,
|
'formatted_distance': route.formatted_distance,
|
||||||
'formatted_duration': route.formatted_duration,
|
'formatted_duration': route.formatted_duration,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort by distance
|
# Sort by distance
|
||||||
results.sort(key=lambda x: x['distance_km'])
|
results.sort(key=lambda x: x['distance_km'])
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def geocode_park_if_needed(self, park: 'Park') -> bool:
|
def geocode_park_if_needed(self, park: 'Park') -> bool:
|
||||||
"""
|
"""
|
||||||
Geocode park location if coordinates are missing.
|
Geocode park location if coordinates are missing.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
park: Park to geocode
|
park: Park to geocode
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if geocoding succeeded or wasn't needed, False otherwise
|
True if geocoding succeeded or wasn't needed, False otherwise
|
||||||
"""
|
"""
|
||||||
if not hasattr(park, 'location') or not park.location:
|
if not hasattr(park, 'location') or not park.location:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
location = park.location
|
location = park.location
|
||||||
|
|
||||||
# If we already have coordinates, no need to geocode
|
# If we already have coordinates, no need to geocode
|
||||||
if location.point:
|
if location.point:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Build address string for geocoding
|
# Build address string for geocoding
|
||||||
address_parts = [
|
address_parts = [
|
||||||
park.name,
|
park.name,
|
||||||
@@ -625,15 +640,16 @@ class RoadTripService:
|
|||||||
location.country
|
location.country
|
||||||
]
|
]
|
||||||
address = ", ".join(part for part in address_parts if part)
|
address = ", ".join(part for part in address_parts if part)
|
||||||
|
|
||||||
if not address:
|
if not address:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
coords = self.geocode_address(address)
|
coords = self.geocode_address(address)
|
||||||
if coords:
|
if coords:
|
||||||
location.set_coordinates(coords.latitude, coords.longitude)
|
location.set_coordinates(coords.latitude, coords.longitude)
|
||||||
location.save()
|
location.save()
|
||||||
logger.info(f"Geocoded park '{park.name}' to {coords.latitude}, {coords.longitude}")
|
logger.info(
|
||||||
|
f"Geocoded park '{park.name}' to {coords.latitude}, {coords.longitude}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, History
|
|||||||
from core.views import SlugRedirectMixin
|
from core.views import SlugRedirectMixin
|
||||||
from .filters import ParkFilter
|
from .filters import ParkFilter
|
||||||
from .forms import ParkForm
|
from .forms import ParkForm
|
||||||
from .models import Park, ParkArea
|
from .models import Park, ParkArea, ParkReview as Review
|
||||||
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest, JsonResponse
|
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest, JsonResponse
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
|||||||
@@ -20,16 +20,18 @@ from .models import Park
|
|||||||
from .services.roadtrip import RoadTripService
|
from .services.roadtrip import RoadTripService
|
||||||
from core.services.map_service import unified_map_service
|
from core.services.map_service import unified_map_service
|
||||||
from core.services.data_structures import LocationType, MapFilters
|
from core.services.data_structures import LocationType, MapFilters
|
||||||
|
JSON_DECODE_ERROR_MSG = 'Invalid JSON data'
|
||||||
|
PARKS_ALONG_ROUTE_HTML = 'parks/partials/parks_along_route.html'
|
||||||
|
|
||||||
|
|
||||||
class RoadTripViewMixin:
|
class RoadTripViewMixin:
|
||||||
"""Mixin providing common functionality for road trip views."""
|
"""Mixin providing common functionality for road trip views."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.roadtrip_service = RoadTripService()
|
self.roadtrip_service = RoadTripService()
|
||||||
|
|
||||||
def get_roadtrip_context(self, request: HttpRequest) -> Dict[str, Any]:
|
def get_roadtrip_context(self) -> Dict[str, Any]:
|
||||||
"""Get common context data for road trip views."""
|
"""Get common context data for road trip views."""
|
||||||
return {
|
return {
|
||||||
'roadtrip_api_urls': {
|
'roadtrip_api_urls': {
|
||||||
@@ -46,21 +48,21 @@ class RoadTripViewMixin:
|
|||||||
class RoadTripPlannerView(RoadTripViewMixin, TemplateView):
|
class RoadTripPlannerView(RoadTripViewMixin, TemplateView):
|
||||||
"""
|
"""
|
||||||
Main road trip planning interface.
|
Main road trip planning interface.
|
||||||
|
|
||||||
URL: /roadtrip/
|
URL: /roadtrip/
|
||||||
"""
|
"""
|
||||||
template_name = 'parks/roadtrip_planner.html'
|
template_name = 'parks/roadtrip_planner.html'
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context.update(self.get_roadtrip_context(self.request))
|
context.update(self.get_roadtrip_context(self.request))
|
||||||
|
|
||||||
# Get popular parks for suggestions
|
# Get popular parks for suggestions
|
||||||
popular_parks = Park.objects.filter(
|
popular_parks = Park.objects.filter(
|
||||||
status='OPERATING',
|
status='OPERATING',
|
||||||
location__isnull=False
|
location__isnull=False
|
||||||
).select_related('location', 'operator').order_by('-ride_count')[:20]
|
).select_related('location', 'operator').order_by('-ride_count')[:20]
|
||||||
|
|
||||||
context.update({
|
context.update({
|
||||||
'page_title': 'Road Trip Planner',
|
'page_title': 'Road Trip Planner',
|
||||||
'popular_parks': popular_parks,
|
'popular_parks': popular_parks,
|
||||||
@@ -68,9 +70,9 @@ class RoadTripPlannerView(RoadTripViewMixin, TemplateView):
|
|||||||
'enable_route_optimization': True,
|
'enable_route_optimization': True,
|
||||||
'show_distance_estimates': True,
|
'show_distance_estimates': True,
|
||||||
})
|
})
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def _get_countries_with_parks(self) -> List[str]:
|
def _get_countries_with_parks(self) -> List[str]:
|
||||||
"""Get list of countries that have theme parks."""
|
"""Get list of countries that have theme parks."""
|
||||||
countries = Park.objects.filter(
|
countries = Park.objects.filter(
|
||||||
@@ -83,15 +85,15 @@ class RoadTripPlannerView(RoadTripViewMixin, TemplateView):
|
|||||||
class CreateTripView(RoadTripViewMixin, View):
|
class CreateTripView(RoadTripViewMixin, View):
|
||||||
"""
|
"""
|
||||||
Generate optimized road trip routes.
|
Generate optimized road trip routes.
|
||||||
|
|
||||||
URL: /roadtrip/create/
|
URL: /roadtrip/create/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def post(self, request: HttpRequest) -> HttpResponse:
|
def post(self, request: HttpRequest) -> HttpResponse:
|
||||||
"""Create a new road trip with optimized routing."""
|
"""Create a new road trip with optimized routing."""
|
||||||
try:
|
try:
|
||||||
data = json.loads(request.body)
|
data = json.loads(request.body)
|
||||||
|
|
||||||
# Parse park IDs
|
# Parse park IDs
|
||||||
park_ids = data.get('park_ids', [])
|
park_ids = data.get('park_ids', [])
|
||||||
if not park_ids or len(park_ids) < 2:
|
if not park_ids or len(park_ids) < 2:
|
||||||
@@ -99,34 +101,34 @@ class CreateTripView(RoadTripViewMixin, View):
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'At least 2 parks are required for a road trip'
|
'message': 'At least 2 parks are required for a road trip'
|
||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
if len(park_ids) > 10:
|
if len(park_ids) > 10:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Maximum 10 parks allowed per trip'
|
'message': 'Maximum 10 parks allowed per trip'
|
||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
# Get parks
|
# Get parks
|
||||||
parks = list(Park.objects.filter(
|
parks = list(Park.objects.filter(
|
||||||
id__in=park_ids,
|
id__in=park_ids,
|
||||||
location__isnull=False
|
location__isnull=False
|
||||||
).select_related('location', 'operator'))
|
).select_related('location', 'operator'))
|
||||||
|
|
||||||
if len(parks) != len(park_ids):
|
if len(parks) != len(park_ids):
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Some parks could not be found or do not have location data'
|
'message': 'Some parks could not be found or do not have location data'
|
||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
# Create optimized trip
|
# Create optimized trip
|
||||||
trip = self.roadtrip_service.create_multi_park_trip(parks)
|
trip = self.roadtrip_service.create_multi_park_trip(parks)
|
||||||
|
|
||||||
if not trip:
|
if not trip:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Could not create optimized route for the selected parks'
|
'message': 'Could not create optimized route for the selected parks'
|
||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
# Convert trip to dict for JSON response
|
# Convert trip to dict for JSON response
|
||||||
trip_data = {
|
trip_data = {
|
||||||
'parks': [self._park_to_dict(park) for park in trip.parks],
|
'parks': [self._park_to_dict(park) for park in trip.parks],
|
||||||
@@ -136,24 +138,24 @@ class CreateTripView(RoadTripViewMixin, View):
|
|||||||
'formatted_total_distance': trip.formatted_total_distance,
|
'formatted_total_distance': trip.formatted_total_distance,
|
||||||
'formatted_total_duration': trip.formatted_total_duration,
|
'formatted_total_duration': trip.formatted_total_duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'data': trip_data,
|
'data': trip_data,
|
||||||
'trip_url': reverse('parks:roadtrip_detail', kwargs={'trip_id': 'temp'})
|
'trip_url': reverse('parks:roadtrip_detail', kwargs={'trip_id': 'temp'})
|
||||||
})
|
})
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Invalid JSON data'
|
'message': JSON_DECODE_ERROR_MSG
|
||||||
}, status=400)
|
}, status=400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'Failed to create trip: {str(e)}'
|
'message': f'Failed to create trip: {str(e)}'
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
def _park_to_dict(self, park: Park) -> Dict[str, Any]:
|
def _park_to_dict(self, park: Park) -> Dict[str, Any]:
|
||||||
"""Convert park instance to dictionary."""
|
"""Convert park instance to dictionary."""
|
||||||
return {
|
return {
|
||||||
@@ -166,7 +168,7 @@ class CreateTripView(RoadTripViewMixin, View):
|
|||||||
'ride_count': getattr(park, 'ride_count', 0),
|
'ride_count': getattr(park, 'ride_count', 0),
|
||||||
'url': reverse('parks:park_detail', kwargs={'slug': park.slug}),
|
'url': reverse('parks:park_detail', kwargs={'slug': park.slug}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _leg_to_dict(self, leg) -> Dict[str, Any]:
|
def _leg_to_dict(self, leg) -> Dict[str, Any]:
|
||||||
"""Convert trip leg to dictionary."""
|
"""Convert trip leg to dictionary."""
|
||||||
return {
|
return {
|
||||||
@@ -183,49 +185,50 @@ class CreateTripView(RoadTripViewMixin, View):
|
|||||||
class TripDetailView(RoadTripViewMixin, TemplateView):
|
class TripDetailView(RoadTripViewMixin, TemplateView):
|
||||||
"""
|
"""
|
||||||
Show trip details and map.
|
Show trip details and map.
|
||||||
|
|
||||||
URL: /roadtrip/<trip_id>/
|
URL: /roadtrip/<trip_id>/
|
||||||
"""
|
"""
|
||||||
template_name = 'parks/trip_detail.html'
|
template_name = 'parks/trip_detail.html'
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context.update(self.get_roadtrip_context(self.request))
|
context.update(self.get_roadtrip_context(self.request))
|
||||||
|
|
||||||
# For now, this is a placeholder since we don't persist trips
|
# For now, this is a placeholder since we don't persist trips
|
||||||
# In a full implementation, you would retrieve the trip from database
|
# In a full implementation, you would retrieve the trip from database
|
||||||
trip_id = kwargs.get('trip_id')
|
trip_id = kwargs.get('trip_id')
|
||||||
|
|
||||||
context.update({
|
context.update({
|
||||||
'page_title': f'Road Trip #{trip_id}',
|
'page_title': f'Road Trip #{trip_id}',
|
||||||
'trip_id': trip_id,
|
'trip_id': trip_id,
|
||||||
'message': 'Trip details would be loaded here. Currently trips are not persisted.',
|
'message': 'Trip details would be loaded here. Currently trips are not persisted.',
|
||||||
})
|
})
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class FindParksAlongRouteView(RoadTripViewMixin, View):
|
class FindParksAlongRouteView(RoadTripViewMixin, View):
|
||||||
"""
|
"""
|
||||||
HTMX endpoint for route-based park discovery.
|
HTMX endpoint for route-based park discovery.
|
||||||
|
|
||||||
URL: /roadtrip/htmx/parks-along-route/
|
URL: /roadtrip/htmx/parks-along-route/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def post(self, request: HttpRequest) -> HttpResponse:
|
def post(self, request: HttpRequest) -> HttpResponse:
|
||||||
"""Find parks along a route between two points."""
|
"""Find parks along a route between two points."""
|
||||||
try:
|
try:
|
||||||
data = json.loads(request.body)
|
data = json.loads(request.body)
|
||||||
|
|
||||||
start_park_id = data.get('start_park_id')
|
start_park_id = data.get('start_park_id')
|
||||||
end_park_id = data.get('end_park_id')
|
end_park_id = data.get('end_park_id')
|
||||||
max_detour_km = min(100, max(10, float(data.get('max_detour_km', 50))))
|
max_detour_km = min(
|
||||||
|
100, max(10, float(data.get('max_detour_km', 50))))
|
||||||
|
|
||||||
if not start_park_id or not end_park_id:
|
if not start_park_id or not end_park_id:
|
||||||
return render(request, 'parks/partials/parks_along_route.html', {
|
return render(request, PARKS_ALONG_ROUTE_HTML, {
|
||||||
'error': 'Start and end parks are required'
|
'error': 'Start and end parks are required'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Get start and end parks
|
# Get start and end parks
|
||||||
try:
|
try:
|
||||||
start_park = Park.objects.select_related('location').get(
|
start_park = Park.objects.select_related('location').get(
|
||||||
@@ -235,29 +238,29 @@ class FindParksAlongRouteView(RoadTripViewMixin, View):
|
|||||||
id=end_park_id, location__isnull=False
|
id=end_park_id, location__isnull=False
|
||||||
)
|
)
|
||||||
except Park.DoesNotExist:
|
except Park.DoesNotExist:
|
||||||
return render(request, 'parks/partials/parks_along_route.html', {
|
return render(request, PARKS_ALONG_ROUTE_HTML, {
|
||||||
'error': 'One or both parks could not be found'
|
'error': 'One or both parks could not be found'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Find parks along route
|
# Find parks along route
|
||||||
parks_along_route = self.roadtrip_service.find_parks_along_route(
|
parks_along_route = self.roadtrip_service.find_parks_along_route(
|
||||||
start_park, end_park, max_detour_km
|
start_park, end_park, max_detour_km
|
||||||
)
|
)
|
||||||
|
|
||||||
return render(request, 'parks/partials/parks_along_route.html', {
|
return render(request, PARKS_ALONG_ROUTE_HTML, {
|
||||||
'parks': parks_along_route,
|
'parks': parks_along_route,
|
||||||
'start_park': start_park,
|
'start_park': start_park,
|
||||||
'end_park': end_park,
|
'end_park': end_park,
|
||||||
'max_detour_km': max_detour_km,
|
'max_detour_km': max_detour_km,
|
||||||
'count': len(parks_along_route)
|
'count': len(parks_along_route)
|
||||||
})
|
})
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return render(request, 'parks/partials/parks_along_route.html', {
|
return render(request, PARKS_ALONG_ROUTE_HTML, {
|
||||||
'error': 'Invalid request data'
|
'error': JSON_DECODE_ERROR_MSG
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return render(request, 'parks/partials/parks_along_route.html', {
|
return render(request, PARKS_ALONG_ROUTE_HTML, {
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -265,50 +268,50 @@ class FindParksAlongRouteView(RoadTripViewMixin, View):
|
|||||||
class GeocodeAddressView(RoadTripViewMixin, View):
|
class GeocodeAddressView(RoadTripViewMixin, View):
|
||||||
"""
|
"""
|
||||||
HTMX endpoint for geocoding addresses.
|
HTMX endpoint for geocoding addresses.
|
||||||
|
|
||||||
URL: /roadtrip/htmx/geocode/
|
URL: /roadtrip/htmx/geocode/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def post(self, request: HttpRequest) -> HttpResponse:
|
def post(self, request: HttpRequest) -> HttpResponse:
|
||||||
"""Geocode an address and find nearby parks."""
|
"""Geocode an address and find nearby parks."""
|
||||||
try:
|
try:
|
||||||
data = json.loads(request.body)
|
data = json.loads(request.body)
|
||||||
address = data.get('address', '').strip()
|
address = data.get('address', '').strip()
|
||||||
|
|
||||||
if not address:
|
if not address:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Address is required'
|
'message': 'Address is required'
|
||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
# Geocode the address
|
# Geocode the address
|
||||||
coordinates = self.roadtrip_service.geocode_address(address)
|
coordinates = self.roadtrip_service.geocode_address(address)
|
||||||
|
|
||||||
if not coordinates:
|
if not coordinates:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Could not geocode the provided address'
|
'message': 'Could not geocode the provided address'
|
||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
# Find nearby parks
|
# Find nearby parks
|
||||||
radius_km = min(200, max(10, float(data.get('radius_km', 100))))
|
radius_km = min(200, max(10, float(data.get('radius_km', 100))))
|
||||||
|
|
||||||
# Use map service to find parks near coordinates
|
# Use map service to find parks near coordinates
|
||||||
from core.services.data_structures import GeoBounds
|
from core.services.data_structures import GeoBounds
|
||||||
|
|
||||||
# Create a bounding box around the coordinates
|
# Create a bounding box around the coordinates
|
||||||
lat_delta = radius_km / 111.0 # Rough conversion: 1 degree ≈ 111km
|
lat_delta = radius_km / 111.0 # Rough conversion: 1 degree ≈ 111km
|
||||||
lng_delta = radius_km / (111.0 * abs(coordinates.latitude / 90.0))
|
lng_delta = radius_km / (111.0 * abs(coordinates.latitude / 90.0))
|
||||||
|
|
||||||
bounds = GeoBounds(
|
bounds = GeoBounds(
|
||||||
north=coordinates.latitude + lat_delta,
|
north=coordinates.latitude + lat_delta,
|
||||||
south=coordinates.latitude - lat_delta,
|
south=coordinates.latitude - lat_delta,
|
||||||
east=coordinates.longitude + lng_delta,
|
east=coordinates.longitude + lng_delta,
|
||||||
west=coordinates.longitude - lng_delta
|
west=coordinates.longitude - lng_delta
|
||||||
)
|
)
|
||||||
|
|
||||||
filters = MapFilters(location_types={LocationType.PARK})
|
filters = MapFilters(location_types={LocationType.PARK})
|
||||||
|
|
||||||
map_response = unified_map_service.get_locations_by_bounds(
|
map_response = unified_map_service.get_locations_by_bounds(
|
||||||
north=bounds.north,
|
north=bounds.north,
|
||||||
south=bounds.south,
|
south=bounds.south,
|
||||||
@@ -316,7 +319,7 @@ class GeocodeAddressView(RoadTripViewMixin, View):
|
|||||||
west=bounds.west,
|
west=bounds.west,
|
||||||
location_types={LocationType.PARK}
|
location_types={LocationType.PARK}
|
||||||
)
|
)
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'data': {
|
'data': {
|
||||||
@@ -329,11 +332,11 @@ class GeocodeAddressView(RoadTripViewMixin, View):
|
|||||||
'radius_km': radius_km
|
'radius_km': radius_km
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Invalid JSON data'
|
'message': JSON_DECODE_ERROR_MSG
|
||||||
}, status=400)
|
}, status=400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
@@ -345,24 +348,24 @@ class GeocodeAddressView(RoadTripViewMixin, View):
|
|||||||
class ParkDistanceCalculatorView(RoadTripViewMixin, View):
|
class ParkDistanceCalculatorView(RoadTripViewMixin, View):
|
||||||
"""
|
"""
|
||||||
HTMX endpoint for calculating distances between parks.
|
HTMX endpoint for calculating distances between parks.
|
||||||
|
|
||||||
URL: /roadtrip/htmx/distance/
|
URL: /roadtrip/htmx/distance/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def post(self, request: HttpRequest) -> HttpResponse:
|
def post(self, request: HttpRequest) -> HttpResponse:
|
||||||
"""Calculate distance and duration between two parks."""
|
"""Calculate distance and duration between two parks."""
|
||||||
try:
|
try:
|
||||||
data = json.loads(request.body)
|
data = json.loads(request.body)
|
||||||
|
|
||||||
park1_id = data.get('park1_id')
|
park1_id = data.get('park1_id')
|
||||||
park2_id = data.get('park2_id')
|
park2_id = data.get('park2_id')
|
||||||
|
|
||||||
if not park1_id or not park2_id:
|
if not park1_id or not park2_id:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Both park IDs are required'
|
'message': 'Both park IDs are required'
|
||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
# Get parks
|
# Get parks
|
||||||
try:
|
try:
|
||||||
park1 = Park.objects.select_related('location').get(
|
park1 = Park.objects.select_related('location').get(
|
||||||
@@ -376,30 +379,30 @@ class ParkDistanceCalculatorView(RoadTripViewMixin, View):
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'One or both parks could not be found'
|
'message': 'One or both parks could not be found'
|
||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
# Calculate route
|
# Calculate route
|
||||||
coords1 = park1.coordinates
|
coords1 = park1.coordinates
|
||||||
coords2 = park2.coordinates
|
coords2 = park2.coordinates
|
||||||
|
|
||||||
if not coords1 or not coords2:
|
if not coords1 or not coords2:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'One or both parks do not have coordinate data'
|
'message': 'One or both parks do not have coordinate data'
|
||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
from ..services.roadtrip import Coordinates
|
from services.roadtrip import Coordinates
|
||||||
|
|
||||||
route = self.roadtrip_service.calculate_route(
|
route = self.roadtrip_service.calculate_route(
|
||||||
Coordinates(*coords1),
|
Coordinates(*coords1),
|
||||||
Coordinates(*coords2)
|
Coordinates(*coords2)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not route:
|
if not route:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Could not calculate route between parks'
|
'message': 'Could not calculate route between parks'
|
||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'data': {
|
'data': {
|
||||||
@@ -417,14 +420,14 @@ class ParkDistanceCalculatorView(RoadTripViewMixin, View):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Invalid JSON data'
|
'message': JSON_DECODE_ERROR_MSG
|
||||||
}, status=400)
|
}, status=400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': str(e)
|
'message': str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.contrib.contenttypes.fields import GenericRelation
|
|||||||
from core.history import TrackedModel, DiffMixin
|
from core.history import TrackedModel, DiffMixin
|
||||||
from .events import get_ride_display_changes, get_ride_model_display_changes
|
from .events import get_ride_display_changes, get_ride_model_display_changes
|
||||||
import pghistory
|
import pghistory
|
||||||
from .company import Company
|
from .models import Company
|
||||||
|
|
||||||
# Shared choices that will be used by multiple models
|
# Shared choices that will be used by multiple models
|
||||||
CATEGORY_CHOICES = [
|
CATEGORY_CHOICES = [
|
||||||
@@ -17,13 +17,14 @@ CATEGORY_CHOICES = [
|
|||||||
('OT', 'Other'),
|
('OT', 'Other'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class RideEvent(models.Model, DiffMixin):
|
class RideEvent(models.Model, DiffMixin):
|
||||||
"""Event model for tracking Ride changes - uses existing pghistory table"""
|
"""Event model for tracking Ride changes - uses existing pghistory table"""
|
||||||
|
|
||||||
pgh_id = models.AutoField(primary_key=True)
|
pgh_id = models.AutoField(primary_key=True)
|
||||||
pgh_created_at = models.DateTimeField(auto_now_add=True)
|
pgh_created_at = models.DateTimeField(auto_now_add=True)
|
||||||
pgh_label = models.TextField()
|
pgh_label = models.TextField()
|
||||||
|
|
||||||
# Original model fields
|
# Original model fields
|
||||||
id = models.BigIntegerField()
|
id = models.BigIntegerField()
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
@@ -39,15 +40,16 @@ class RideEvent(models.Model, DiffMixin):
|
|||||||
max_height_in = models.PositiveIntegerField(null=True)
|
max_height_in = models.PositiveIntegerField(null=True)
|
||||||
capacity_per_hour = models.PositiveIntegerField(null=True)
|
capacity_per_hour = models.PositiveIntegerField(null=True)
|
||||||
ride_duration_seconds = models.PositiveIntegerField(null=True)
|
ride_duration_seconds = models.PositiveIntegerField(null=True)
|
||||||
average_rating = models.DecimalField(max_digits=3, decimal_places=2, null=True)
|
average_rating = models.DecimalField(
|
||||||
|
max_digits=3, decimal_places=2, null=True)
|
||||||
created_at = models.DateTimeField()
|
created_at = models.DateTimeField()
|
||||||
updated_at = models.DateTimeField()
|
updated_at = models.DateTimeField()
|
||||||
|
|
||||||
# Foreign keys as IDs
|
# Foreign keys as IDs
|
||||||
park_id = models.BigIntegerField()
|
park_id = models.BigIntegerField()
|
||||||
park_area_id = models.BigIntegerField(null=True)
|
park_area_id = models.BigIntegerField(null=True)
|
||||||
ride_model_id = models.BigIntegerField(null=True)
|
ride_model_id = models.BigIntegerField(null=True)
|
||||||
|
|
||||||
# Context fields
|
# Context fields
|
||||||
pgh_obj = models.ForeignKey('Ride', on_delete=models.CASCADE)
|
pgh_obj = models.ForeignKey('Ride', on_delete=models.CASCADE)
|
||||||
pgh_context = models.ForeignKey(
|
pgh_context = models.ForeignKey(
|
||||||
@@ -66,13 +68,14 @@ class RideEvent(models.Model, DiffMixin):
|
|||||||
"""Returns human-readable changes"""
|
"""Returns human-readable changes"""
|
||||||
return get_ride_display_changes(self.diff_against_previous())
|
return get_ride_display_changes(self.diff_against_previous())
|
||||||
|
|
||||||
|
|
||||||
class RideModelEvent(models.Model, DiffMixin):
|
class RideModelEvent(models.Model, DiffMixin):
|
||||||
"""Event model for tracking RideModel changes - uses existing pghistory table"""
|
"""Event model for tracking RideModel changes - uses existing pghistory table"""
|
||||||
|
|
||||||
pgh_id = models.AutoField(primary_key=True)
|
pgh_id = models.AutoField(primary_key=True)
|
||||||
pgh_created_at = models.DateTimeField(auto_now_add=True)
|
pgh_created_at = models.DateTimeField(auto_now_add=True)
|
||||||
pgh_label = models.TextField()
|
pgh_label = models.TextField()
|
||||||
|
|
||||||
# Original model fields
|
# Original model fields
|
||||||
id = models.BigIntegerField()
|
id = models.BigIntegerField()
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
@@ -80,10 +83,10 @@ class RideModelEvent(models.Model, DiffMixin):
|
|||||||
category = models.CharField(max_length=2)
|
category = models.CharField(max_length=2)
|
||||||
created_at = models.DateTimeField()
|
created_at = models.DateTimeField()
|
||||||
updated_at = models.DateTimeField()
|
updated_at = models.DateTimeField()
|
||||||
|
|
||||||
# Foreign keys as IDs
|
# Foreign keys as IDs
|
||||||
manufacturer_id = models.BigIntegerField(null=True)
|
manufacturer_id = models.BigIntegerField(null=True)
|
||||||
|
|
||||||
# Context fields
|
# Context fields
|
||||||
pgh_obj = models.ForeignKey('RideModel', on_delete=models.CASCADE)
|
pgh_obj = models.ForeignKey('RideModel', on_delete=models.CASCADE)
|
||||||
pgh_context = models.ForeignKey(
|
pgh_context = models.ForeignKey(
|
||||||
@@ -102,6 +105,7 @@ class RideModelEvent(models.Model, DiffMixin):
|
|||||||
"""Returns human-readable changes"""
|
"""Returns human-readable changes"""
|
||||||
return get_ride_model_display_changes(self.diff_against_previous())
|
return get_ride_model_display_changes(self.diff_against_previous())
|
||||||
|
|
||||||
|
|
||||||
class RideModel(TrackedModel):
|
class RideModel(TrackedModel):
|
||||||
"""
|
"""
|
||||||
Represents a specific model/type of ride that can be manufactured by different companies.
|
Represents a specific model/type of ride that can be manufactured by different companies.
|
||||||
@@ -114,7 +118,8 @@ class RideModel(TrackedModel):
|
|||||||
related_name='ride_models',
|
related_name='ride_models',
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
limit_choices_to={'roles__contains': [Company.CompanyRole.MANUFACTURER]}
|
limit_choices_to={'roles__contains': [
|
||||||
|
Company.CompanyRole.MANUFACTURER]}
|
||||||
)
|
)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
category = models.CharField(
|
category = models.CharField(
|
||||||
@@ -131,6 +136,7 @@ class RideModel(TrackedModel):
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.name if not self.manufacturer else f"{self.manufacturer.name} {self.name}"
|
return self.name if not self.manufacturer else f"{self.manufacturer.name} {self.name}"
|
||||||
|
|
||||||
|
|
||||||
class Ride(TrackedModel):
|
class Ride(TrackedModel):
|
||||||
"""Model for individual ride installations at parks"""
|
"""Model for individual ride installations at parks"""
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
@@ -177,7 +183,8 @@ class Ride(TrackedModel):
|
|||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name='manufactured_rides',
|
related_name='manufactured_rides',
|
||||||
limit_choices_to={'roles__contains': [Company.CompanyRole.MANUFACTURER]}
|
limit_choices_to={'roles__contains': [
|
||||||
|
Company.CompanyRole.MANUFACTURER]}
|
||||||
)
|
)
|
||||||
designer = models.ForeignKey(
|
designer = models.ForeignKey(
|
||||||
Company,
|
Company,
|
||||||
@@ -234,6 +241,7 @@ class Ride(TrackedModel):
|
|||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class RollerCoasterStats(models.Model):
|
class RollerCoasterStats(models.Model):
|
||||||
"""Model for tracking roller coaster specific statistics"""
|
"""Model for tracking roller coaster specific statistics"""
|
||||||
TRACK_MATERIAL_CHOICES = [
|
TRACK_MATERIAL_CHOICES = [
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ class RideSearchView(ListView):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Get filtered rides based on search form."""
|
"""Get filtered rides based on search form."""
|
||||||
from search.forms import RideSearchForm
|
from services.search import RideSearchForm
|
||||||
|
|
||||||
queryset = Ride.objects.select_related('park').order_by('name')
|
queryset = Ride.objects.select_related('park').order_by('name')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user