mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 14:51:08 -05:00
Integrate parks app with site-wide search system; add filter configuration, error handling, and search interfaces
This commit is contained in:
@@ -110,7 +110,14 @@
|
|||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
|
||||||
### Last Update: 2025-02-06
|
### Last Update: 2025-02-12
|
||||||
|
- Integrated parks app with site-wide search system
|
||||||
|
* Added comprehensive filter configuration
|
||||||
|
* Implemented error handling
|
||||||
|
* Created both full and quick search interfaces
|
||||||
|
* See `features/search/park-search.md` for details
|
||||||
|
|
||||||
|
### Previous Update: 2025-02-06
|
||||||
1. Memory Bank Initialization
|
1. Memory Bank Initialization
|
||||||
- Created core documentation structure
|
- Created core documentation structure
|
||||||
- Migrated existing documentation
|
- Migrated existing documentation
|
||||||
|
|||||||
76
memory-bank/features/park-search-integration.md
Normal file
76
memory-bank/features/park-search-integration.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Park Search Integration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Integrated the parks app with the site-wide search system to provide consistent filtering and search capabilities across the platform.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### 1. Filter Configuration
|
||||||
|
```python
|
||||||
|
# parks/filters.py
|
||||||
|
ParkFilter = create_model_filter(
|
||||||
|
model=Park,
|
||||||
|
search_fields=['name', 'description', 'location__city', 'location__state', 'location__country'],
|
||||||
|
mixins=[LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin],
|
||||||
|
additional_filters={
|
||||||
|
'status': {
|
||||||
|
'field_class': 'django_filters.ChoiceFilter',
|
||||||
|
'field_kwargs': {'choices': Park._meta.get_field('status').choices}
|
||||||
|
},
|
||||||
|
'opening_date': {
|
||||||
|
'field_class': 'django_filters.DateFromToRangeFilter',
|
||||||
|
},
|
||||||
|
'owner': {
|
||||||
|
'field_class': 'django_filters.ModelChoiceFilter',
|
||||||
|
'field_kwargs': {'queryset': 'companies.Company.objects.all()'}
|
||||||
|
},
|
||||||
|
'min_rides': {
|
||||||
|
'field_class': 'django_filters.NumberFilter',
|
||||||
|
'field_kwargs': {'field_name': 'ride_count', 'lookup_expr': 'gte'}
|
||||||
|
},
|
||||||
|
'min_coasters': {
|
||||||
|
'field_class': 'django_filters.NumberFilter',
|
||||||
|
'field_kwargs': {'field_name': 'coaster_count', 'lookup_expr': 'gte'}
|
||||||
|
},
|
||||||
|
'min_size': {
|
||||||
|
'field_class': 'django_filters.NumberFilter',
|
||||||
|
'field_kwargs': {'field_name': 'size_acres', 'lookup_expr': 'gte'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. View Integration
|
||||||
|
- Updated `ParkListView` to use `HTMXFilterableMixin`
|
||||||
|
- Configured proper queryset optimization with `select_related` and `prefetch_related`
|
||||||
|
- Added pagination support
|
||||||
|
- Maintained ride count annotations
|
||||||
|
|
||||||
|
### 3. Template Structure
|
||||||
|
- Created `search/templates/search/partials/park_results.html` for consistent result display
|
||||||
|
- Includes:
|
||||||
|
- Park image thumbnails
|
||||||
|
- Basic park information
|
||||||
|
- Location details
|
||||||
|
- Status indicators
|
||||||
|
- Ride count badges
|
||||||
|
- Rating display
|
||||||
|
|
||||||
|
### 4. Quick Search Support
|
||||||
|
- Modified `search_parks` view for dropdown/quick search scenarios
|
||||||
|
- Uses the same filter system but with simplified output
|
||||||
|
- Limited to 10 results for performance
|
||||||
|
- Added location preloading
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
1. Consistent filtering across the platform
|
||||||
|
2. Enhanced search capabilities with location and rating filters
|
||||||
|
3. Improved performance through proper query optimization
|
||||||
|
4. Better maintainability using the site-wide search system
|
||||||
|
5. HTMX-powered dynamic updates
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
- Uses django-filter backend
|
||||||
|
- Integrates with location and rating mixins
|
||||||
|
- Supports both full search and quick search use cases
|
||||||
|
- Maintains existing functionality while improving code organization
|
||||||
170
memory-bank/features/search/park-search.md
Normal file
170
memory-bank/features/search/park-search.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# Park Search Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Integration of the parks app with the site-wide search system, providing both full search functionality and quick search for dropdowns.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Filter Configuration (parks/filters.py)
|
||||||
|
```python
|
||||||
|
ParkFilter = create_model_filter(
|
||||||
|
model=Park,
|
||||||
|
search_fields=['name', 'description', 'location__city', 'location__state', 'location__country'],
|
||||||
|
mixins=[LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin],
|
||||||
|
additional_filters={
|
||||||
|
'status': {
|
||||||
|
'field_class': 'django_filters.ChoiceFilter',
|
||||||
|
'field_kwargs': {
|
||||||
|
'choices': Park._meta.get_field('status').choices,
|
||||||
|
'empty_label': 'Any status',
|
||||||
|
'null_label': 'Unknown'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'opening_date': {
|
||||||
|
'field_class': 'django_filters.DateFromToRangeFilter',
|
||||||
|
'field_kwargs': {
|
||||||
|
'label': 'Opening date range',
|
||||||
|
'help_text': 'Enter dates in YYYY-MM-DD format'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
# Additional filters for rides, size, etc.
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. View Implementation (parks/views.py)
|
||||||
|
|
||||||
|
#### Full Search (ParkListView)
|
||||||
|
```python
|
||||||
|
class ParkListView(HTMXFilterableMixin, ListView):
|
||||||
|
model = Park
|
||||||
|
filter_class = ParkFilter
|
||||||
|
paginate_by = 20
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
super()
|
||||||
|
.get_queryset()
|
||||||
|
.select_related("owner")
|
||||||
|
.prefetch_related(
|
||||||
|
"photos",
|
||||||
|
"location",
|
||||||
|
"rides",
|
||||||
|
"rides__manufacturer"
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_rides=Count("rides"),
|
||||||
|
total_coasters=Count("rides", filter=Q(rides__category="RC")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(self.request, f"Error loading parks: {str(e)}")
|
||||||
|
return Park.objects.none()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Quick Search
|
||||||
|
```python
|
||||||
|
def search_parks(request):
|
||||||
|
try:
|
||||||
|
queryset = (
|
||||||
|
Park.objects.prefetch_related('location', 'photos')
|
||||||
|
.order_by('name')
|
||||||
|
)
|
||||||
|
filter_params = {'search': request.GET.get('q', '').strip()}
|
||||||
|
park_filter = ParkFilter(filter_params, queryset=queryset)
|
||||||
|
parks = park_filter.qs[:10]
|
||||||
|
|
||||||
|
return render(request, "parks/partials/park_search_results.html", {
|
||||||
|
"parks": parks,
|
||||||
|
"is_quick_search": True
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return render(..., {"error": str(e)})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Template Structure
|
||||||
|
|
||||||
|
#### Main Search Page (parks/templates/parks/park_list.html)
|
||||||
|
- Extends: search/layouts/filtered_list.html
|
||||||
|
- Blocks:
|
||||||
|
* filter_errors: Validation error display
|
||||||
|
* list_header: Park list header + actions
|
||||||
|
* filter_section: Filter form with clear option
|
||||||
|
* results_section: Park results with pagination
|
||||||
|
|
||||||
|
#### Results Display (search/templates/search/partials/park_results.html)
|
||||||
|
- Full park information
|
||||||
|
- Status indicators
|
||||||
|
- Ride statistics
|
||||||
|
- Location details
|
||||||
|
- Error state handling
|
||||||
|
|
||||||
|
#### Quick Search Results (parks/partials/park_search_results.html)
|
||||||
|
- Simplified park display
|
||||||
|
- Basic location info
|
||||||
|
- Fallback for missing images
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
### 4. Error Handling
|
||||||
|
|
||||||
|
#### View Level
|
||||||
|
- Try/except blocks around queryset operations
|
||||||
|
- Filter validation errors captured
|
||||||
|
- Generic error states handled
|
||||||
|
- User-friendly error messages
|
||||||
|
|
||||||
|
#### Template Level
|
||||||
|
- Error states in both quick and full search
|
||||||
|
- Safe data access (using with and conditionals)
|
||||||
|
- Fallback content for missing data
|
||||||
|
- Clear error messaging
|
||||||
|
|
||||||
|
### 5. Query Optimization
|
||||||
|
|
||||||
|
#### Full Search
|
||||||
|
- select_related: owner
|
||||||
|
- prefetch_related: photos, location, rides, rides__manufacturer
|
||||||
|
- Proper annotations for counts
|
||||||
|
- Pagination for large results
|
||||||
|
|
||||||
|
#### Quick Search
|
||||||
|
- Limited to 10 results
|
||||||
|
- Minimal related data loading
|
||||||
|
- Basic ordering optimization
|
||||||
|
|
||||||
|
### 6. Known Limitations
|
||||||
|
|
||||||
|
1. Testing Coverage
|
||||||
|
- Need unit tests for filters
|
||||||
|
- Need integration tests for error cases
|
||||||
|
- Need performance testing
|
||||||
|
|
||||||
|
2. Performance
|
||||||
|
- Large dataset behavior unknown
|
||||||
|
- Complex filter combinations untested
|
||||||
|
|
||||||
|
3. Security
|
||||||
|
- SQL injection prevention needs review
|
||||||
|
- Permission checks need audit
|
||||||
|
|
||||||
|
4. Accessibility
|
||||||
|
- ARIA labels needed
|
||||||
|
- Color contrast validation needed
|
||||||
|
|
||||||
|
### 7. Next Steps
|
||||||
|
|
||||||
|
1. Testing
|
||||||
|
- Implement comprehensive test suite
|
||||||
|
- Add performance benchmarks
|
||||||
|
- Test edge cases
|
||||||
|
|
||||||
|
2. Monitoring
|
||||||
|
- Add error logging
|
||||||
|
- Implement performance tracking
|
||||||
|
- Add usage analytics
|
||||||
|
|
||||||
|
3. Optimization
|
||||||
|
- Profile query performance
|
||||||
|
- Optimize filter combinations
|
||||||
|
- Consider caching strategies
|
||||||
132
memory-bank/features/search/testing-implementation.md
Normal file
132
memory-bank/features/search/testing-implementation.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Park Search Testing Implementation
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
### 1. Model Tests (parks/tests/test_models.py)
|
||||||
|
|
||||||
|
#### Park Model Tests
|
||||||
|
- Basic CRUD Operations
|
||||||
|
* Creation with required fields
|
||||||
|
* Update operations
|
||||||
|
* Deletion and cascading
|
||||||
|
* Validation rules
|
||||||
|
|
||||||
|
- Slug Operations
|
||||||
|
* Auto-generation on creation
|
||||||
|
* Historical slug tracking and lookup (via HistoricalSlug model)
|
||||||
|
* pghistory integration for model tracking
|
||||||
|
* Uniqueness constraints
|
||||||
|
* Fallback lookup strategies
|
||||||
|
|
||||||
|
- Location Integration
|
||||||
|
* Formatted location string
|
||||||
|
* Coordinates retrieval
|
||||||
|
* Location relationship integrity
|
||||||
|
|
||||||
|
- Status Management
|
||||||
|
* Default status
|
||||||
|
* Status color mapping
|
||||||
|
* Status transitions
|
||||||
|
|
||||||
|
- Property Methods
|
||||||
|
* formatted_location
|
||||||
|
* coordinates
|
||||||
|
* get_status_color
|
||||||
|
|
||||||
|
### 2. Filter Tests (parks/tests/test_filters.py)
|
||||||
|
|
||||||
|
#### Search Functionality
|
||||||
|
- Text Search Fields
|
||||||
|
* Name searching
|
||||||
|
* Description searching
|
||||||
|
* Location field searching (city, state, country)
|
||||||
|
* Combined field searching
|
||||||
|
|
||||||
|
#### Filter Operations
|
||||||
|
- Status Filtering
|
||||||
|
* Each status value
|
||||||
|
* Empty/null handling
|
||||||
|
* Invalid status values
|
||||||
|
|
||||||
|
- Date Range Filtering
|
||||||
|
* Opening date ranges
|
||||||
|
* Invalid date formats
|
||||||
|
* Edge cases (future dates, very old dates)
|
||||||
|
|
||||||
|
- Company/Owner Filtering
|
||||||
|
* Existing company
|
||||||
|
* No owner (null)
|
||||||
|
* Invalid company IDs
|
||||||
|
|
||||||
|
- Numeric Filtering
|
||||||
|
* Minimum rides count
|
||||||
|
* Minimum coasters count
|
||||||
|
* Minimum size validation
|
||||||
|
* Negative value handling
|
||||||
|
|
||||||
|
#### Mixin Integration
|
||||||
|
- LocationFilterMixin
|
||||||
|
* Distance-based filtering
|
||||||
|
* Location search functionality
|
||||||
|
|
||||||
|
- RatingFilterMixin
|
||||||
|
* Rating range filtering
|
||||||
|
* Invalid rating values
|
||||||
|
|
||||||
|
- DateRangeFilterMixin
|
||||||
|
* Date range application
|
||||||
|
* Invalid date handling
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
1. ✓ Created test directory structure
|
||||||
|
2. ✓ Set up test fixtures in both test files
|
||||||
|
3. ✓ Implemented Park model tests
|
||||||
|
- Basic CRUD operations
|
||||||
|
- Advanced slug functionality:
|
||||||
|
* Automatic slug generation from name
|
||||||
|
* Historical slug tracking with HistoricalSlug model
|
||||||
|
* Dual tracking with pghistory integration
|
||||||
|
* Comprehensive lookup system with fallbacks
|
||||||
|
- Status color mapping with complete coverage
|
||||||
|
- Location integration with error handling
|
||||||
|
- Property methods with null safety
|
||||||
|
4. ✓ Implemented ParkFilter tests
|
||||||
|
- Text search functionality
|
||||||
|
- Status filtering
|
||||||
|
- Date range filtering
|
||||||
|
- Company/owner filtering
|
||||||
|
- Numeric filtering with validation
|
||||||
|
- Location, Rating, and DateRange mixin integration
|
||||||
|
- Performance testing with multiple filters
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
1. Monitoring Implementation
|
||||||
|
- [ ] Add error logging
|
||||||
|
- [ ] Implement performance tracking
|
||||||
|
- [ ] Add usage analytics
|
||||||
|
|
||||||
|
2. Performance Optimization
|
||||||
|
- [ ] Profile query performance in production
|
||||||
|
- [ ] Implement caching strategies
|
||||||
|
- [ ] Optimize complex filter combinations
|
||||||
|
|
||||||
|
3. Documentation Updates
|
||||||
|
- [ ] Add test coverage reports
|
||||||
|
- [ ] Document common test patterns
|
||||||
|
- [ ] Update API documentation with filter examples
|
||||||
|
|
||||||
|
### Running the Tests
|
||||||
|
|
||||||
|
To run the test suite:
|
||||||
|
```bash
|
||||||
|
python manage.py test parks.tests
|
||||||
|
```
|
||||||
|
|
||||||
|
To run specific test classes:
|
||||||
|
```bash
|
||||||
|
python manage.py test parks.tests.test_models.ParkModelTests
|
||||||
|
python manage.py test parks.tests.test_filters.ParkFilterTests
|
||||||
|
```
|
||||||
115
parks/filters.py
Normal file
115
parks/filters.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.db import models
|
||||||
|
from search.filters import create_model_filter, LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin
|
||||||
|
from django_filters import (
|
||||||
|
NumberFilter,
|
||||||
|
ModelChoiceFilter,
|
||||||
|
DateFromToRangeFilter,
|
||||||
|
ChoiceFilter,
|
||||||
|
FilterSet
|
||||||
|
)
|
||||||
|
from .models import Park
|
||||||
|
from companies.models import Company
|
||||||
|
|
||||||
|
def validate_positive(value):
|
||||||
|
if value and value < 0:
|
||||||
|
raise ValidationError(_('Value must be positive'))
|
||||||
|
return value
|
||||||
|
|
||||||
|
# Create dynamic filter for Park model with null value handling and validation
|
||||||
|
class ParkFilterSet(FilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = Park
|
||||||
|
fields = []
|
||||||
|
|
||||||
|
# Custom filter fields
|
||||||
|
status = ChoiceFilter(
|
||||||
|
field_name='status',
|
||||||
|
choices=Park._meta.get_field('status').choices,
|
||||||
|
empty_label='Any status',
|
||||||
|
null_label='Unknown'
|
||||||
|
)
|
||||||
|
|
||||||
|
owner = ModelChoiceFilter(
|
||||||
|
field_name='owner',
|
||||||
|
queryset=Company.objects.all(),
|
||||||
|
empty_label='Any company',
|
||||||
|
null_label='No owner',
|
||||||
|
null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_queryset(self, queryset):
|
||||||
|
"""Custom filtering to handle null values and empty inputs"""
|
||||||
|
for name, value in self.form.cleaned_data.items():
|
||||||
|
if value in [None, '', 0]: # Skip empty values
|
||||||
|
continue
|
||||||
|
|
||||||
|
field = self.filters[name]
|
||||||
|
if hasattr(field, 'null') and field.null and value == 'null':
|
||||||
|
lookup = f"{field.field_name}__isnull"
|
||||||
|
queryset = queryset.filter(**{lookup: True})
|
||||||
|
else:
|
||||||
|
queryset = field.filter(queryset, value)
|
||||||
|
return queryset.distinct()
|
||||||
|
|
||||||
|
min_rides = NumberFilter(
|
||||||
|
field_name='ride_count',
|
||||||
|
lookup_expr='gte',
|
||||||
|
validators=[validate_positive],
|
||||||
|
help_text='Minimum number of rides'
|
||||||
|
)
|
||||||
|
|
||||||
|
min_coasters = NumberFilter(
|
||||||
|
field_name='coaster_count',
|
||||||
|
lookup_expr='gte',
|
||||||
|
validators=[validate_positive],
|
||||||
|
help_text='Minimum number of coasters'
|
||||||
|
)
|
||||||
|
|
||||||
|
min_size = NumberFilter(
|
||||||
|
field_name='size_acres',
|
||||||
|
lookup_expr='gte',
|
||||||
|
validators=[validate_positive],
|
||||||
|
help_text='Minimum size in acres'
|
||||||
|
)
|
||||||
|
|
||||||
|
opening_date = DateFromToRangeFilter(
|
||||||
|
label='Opening date range',
|
||||||
|
help_text='Enter dates in YYYY-MM-DD format'
|
||||||
|
)
|
||||||
|
|
||||||
|
class ExtendedParkFilterSet(ParkFilterSet):
|
||||||
|
"""Extends ParkFilterSet with search functionality"""
|
||||||
|
def filter_search(self, queryset, name, value):
|
||||||
|
if not value:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
search_fields = [
|
||||||
|
'name__icontains',
|
||||||
|
'description__icontains',
|
||||||
|
'location__city__icontains',
|
||||||
|
'location__state__icontains',
|
||||||
|
'location__country__icontains'
|
||||||
|
]
|
||||||
|
|
||||||
|
queries = [models.Q(**{field: value}) for field in search_fields]
|
||||||
|
query = queries.pop()
|
||||||
|
for item in queries:
|
||||||
|
query |= item
|
||||||
|
|
||||||
|
return queryset.filter(query).distinct()
|
||||||
|
|
||||||
|
ParkFilter = create_model_filter(
|
||||||
|
model=Park,
|
||||||
|
mixins=[LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin],
|
||||||
|
additional_filters={
|
||||||
|
'status': ExtendedParkFilterSet.base_filters['status'],
|
||||||
|
'owner': ExtendedParkFilterSet.base_filters['owner'],
|
||||||
|
'min_rides': ExtendedParkFilterSet.base_filters['min_rides'],
|
||||||
|
'min_coasters': ExtendedParkFilterSet.base_filters['min_coasters'],
|
||||||
|
'min_size': ExtendedParkFilterSet.base_filters['min_size'],
|
||||||
|
'opening_date': ExtendedParkFilterSet.base_filters['opening_date'],
|
||||||
|
},
|
||||||
|
base_class=ExtendedParkFilterSet
|
||||||
|
)
|
||||||
@@ -72,13 +72,52 @@ class Park(TrackedModel):
|
|||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||||
if not self.slug:
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from history_tracking.models import HistoricalSlug
|
||||||
|
|
||||||
|
# Get old instance if it exists
|
||||||
|
if self.pk:
|
||||||
|
try:
|
||||||
|
old_instance = type(self).objects.get(pk=self.pk)
|
||||||
|
old_name = old_instance.name
|
||||||
|
old_slug = old_instance.slug
|
||||||
|
except type(self).DoesNotExist:
|
||||||
|
old_name = None
|
||||||
|
old_slug = None
|
||||||
|
else:
|
||||||
|
old_name = None
|
||||||
|
old_slug = None
|
||||||
|
|
||||||
|
# Generate new slug if name has changed or slug is missing
|
||||||
|
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
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# If slug has changed, save historical record
|
||||||
|
if old_slug and old_slug != self.slug:
|
||||||
|
HistoricalSlug.objects.create(
|
||||||
|
content_type=ContentType.objects.get_for_model(self),
|
||||||
|
object_id=self.pk,
|
||||||
|
slug=old_slug
|
||||||
|
)
|
||||||
|
|
||||||
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})
|
||||||
|
|
||||||
|
def get_status_color(self) -> str:
|
||||||
|
"""Get Tailwind color classes for park status"""
|
||||||
|
status_colors = {
|
||||||
|
'OPERATING': 'bg-green-100 text-green-800',
|
||||||
|
'CLOSED_TEMP': 'bg-yellow-100 text-yellow-800',
|
||||||
|
'CLOSED_PERM': 'bg-red-100 text-red-800',
|
||||||
|
'UNDER_CONSTRUCTION': 'bg-blue-100 text-blue-800',
|
||||||
|
'DEMOLISHED': 'bg-gray-100 text-gray-800',
|
||||||
|
'RELOCATED': 'bg-purple-100 text-purple-800',
|
||||||
|
}
|
||||||
|
return status_colors.get(self.status, 'bg-gray-100 text-gray-500')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def formatted_location(self) -> str:
|
def formatted_location(self) -> str:
|
||||||
if self.location.exists():
|
if self.location.exists():
|
||||||
@@ -99,20 +138,58 @@ class Park(TrackedModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def get_by_slug(cls, slug: str) -> Tuple['Park', bool]:
|
def get_by_slug(cls, slug: str) -> Tuple['Park', bool]:
|
||||||
"""Get park by current or historical slug"""
|
"""Get park by current or historical slug"""
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from history_tracking.models import HistoricalSlug
|
||||||
|
|
||||||
|
print(f"\nLooking up slug: {slug}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return cls.objects.get(slug=slug), False
|
park = cls.objects.get(slug=slug)
|
||||||
|
print(f"Found current park with slug: {slug}")
|
||||||
|
return park, False
|
||||||
except cls.DoesNotExist:
|
except cls.DoesNotExist:
|
||||||
# Check historical slugs using pghistory
|
print(f"No current park found with slug: {slug}")
|
||||||
history_model = cls.get_history_model()
|
|
||||||
history = history_model.objects.filter(
|
# Try historical slugs in HistoricalSlug model
|
||||||
|
content_type = ContentType.objects.get_for_model(cls)
|
||||||
|
print(f"Searching HistoricalSlug with content_type: {content_type}")
|
||||||
|
historical = HistoricalSlug.objects.filter(
|
||||||
|
content_type=content_type,
|
||||||
|
slug=slug
|
||||||
|
).order_by('-created_at').first()
|
||||||
|
|
||||||
|
if historical:
|
||||||
|
print(f"Found historical slug record for object_id: {historical.object_id}")
|
||||||
|
try:
|
||||||
|
park = cls.objects.get(pk=historical.object_id)
|
||||||
|
print(f"Found park from historical slug: {park.name}")
|
||||||
|
return park, True
|
||||||
|
except cls.DoesNotExist:
|
||||||
|
print(f"Park not found for historical slug record")
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
print("No historical slug record found")
|
||||||
|
|
||||||
|
# Try pghistory events
|
||||||
|
print(f"Searching pghistory events")
|
||||||
|
event_model = getattr(cls, 'event_model', None)
|
||||||
|
if event_model:
|
||||||
|
historical_event = event_model.objects.filter(
|
||||||
slug=slug
|
slug=slug
|
||||||
).order_by('-pgh_created_at').first()
|
).order_by('-pgh_created_at').first()
|
||||||
|
|
||||||
if history:
|
if historical_event:
|
||||||
|
print(f"Found pghistory event for pgh_obj_id: {historical_event.pgh_obj_id}")
|
||||||
try:
|
try:
|
||||||
return cls.objects.get(pk=history.pgh_obj_id), True
|
park = cls.objects.get(pk=historical_event.pgh_obj_id)
|
||||||
except cls.DoesNotExist as e:
|
print(f"Found park from pghistory: {park.name}")
|
||||||
raise cls.DoesNotExist("No park found with this slug") from e
|
return park, True
|
||||||
|
except cls.DoesNotExist:
|
||||||
|
print(f"Park not found for pghistory event")
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
print("No pghistory event found")
|
||||||
|
|
||||||
raise cls.DoesNotExist("No park found with this slug")
|
raise cls.DoesNotExist("No park found with this slug")
|
||||||
|
|
||||||
@pghistory.track()
|
@pghistory.track()
|
||||||
|
|||||||
55
parks/templates/parks/park_list.html
Normal file
55
parks/templates/parks/park_list.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{% extends "search/layouts/filtered_list.html" %}
|
||||||
|
{% load filter_utils %}
|
||||||
|
|
||||||
|
{% block page_title %}Parks{% endblock %}
|
||||||
|
|
||||||
|
{% block filter_errors %}
|
||||||
|
{% if filter.errors %}
|
||||||
|
<div class="bg-red-50 border-l-4 border-red-400 p-4 mb-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-red-800">Please correct the following errors:</h3>
|
||||||
|
<div class="mt-2 text-sm text-red-700">
|
||||||
|
<ul class="list-disc pl-5 space-y-1">
|
||||||
|
{% for field, errors in filter.errors.items %}
|
||||||
|
{% for error in errors %}
|
||||||
|
<li>{{ field }}: {{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block list_header %}
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Parks</h1>
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<a href="{% url 'parks:park_create' %}" class="btn btn-primary">
|
||||||
|
Add Park
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block list_description %}
|
||||||
|
Browse and filter amusement parks, theme parks, and water parks from around the world.
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block filter_section_title %}Find Parks{% endblock %}
|
||||||
|
|
||||||
|
{% block results_section_title %}Parks{% endblock %}
|
||||||
|
|
||||||
|
{% block no_results_message %}
|
||||||
|
<div class="text-center p-8 text-gray-500">
|
||||||
|
No parks found matching your criteria. Try adjusting your filters or <a href="{% url 'parks:park_create' %}" class="text-blue-600 hover:underline">add a new park</a>.
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
35
parks/templates/parks/partials/park_search_results.html
Normal file
35
parks/templates/parks/partials/park_search_results.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{% if error %}
|
||||||
|
<div class="p-2 text-red-600">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% for park in parks %}
|
||||||
|
<div class="p-2 hover:bg-gray-100 cursor-pointer">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
{% if park.photos.exists %}
|
||||||
|
<img src="{{ park.photos.first.image.url }}"
|
||||||
|
alt="{{ park.name }}"
|
||||||
|
class="w-8 h-8 object-cover rounded">
|
||||||
|
{% else %}
|
||||||
|
<div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center">
|
||||||
|
<span class="text-xs text-gray-500">{{ park.name|first|upper }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ park.name }}</div>
|
||||||
|
{% with location=park.location.first %}
|
||||||
|
{% if location %}
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
{{ location.city }}{% if location.state %}, {{ location.state }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="p-2 text-gray-500 text-center">
|
||||||
|
No parks found
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
1
parks/tests/__init__.py
Normal file
1
parks/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Parks app test suite
|
||||||
250
parks/tests/test_filters.py
Normal file
250
parks/tests/test_filters.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"""
|
||||||
|
Tests for park filtering functionality including search, status filtering,
|
||||||
|
date ranges, and numeric validations.
|
||||||
|
"""
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from parks.models import Park
|
||||||
|
from parks.filters import ParkFilter
|
||||||
|
from companies.models import Company
|
||||||
|
from location.models import Location
|
||||||
|
|
||||||
|
class ParkFilterTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
"""Set up test data for all filter tests"""
|
||||||
|
# Create companies
|
||||||
|
cls.company1 = Company.objects.create(
|
||||||
|
name="Thrilling Adventures Inc",
|
||||||
|
slug="thrilling-adventures"
|
||||||
|
)
|
||||||
|
cls.company2 = Company.objects.create(
|
||||||
|
name="Family Fun Corp",
|
||||||
|
slug="family-fun"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create parks with various attributes for testing all filters
|
||||||
|
cls.park1 = Park.objects.create(
|
||||||
|
name="Thrilling Adventures Park",
|
||||||
|
description="A thrilling park with lots of roller coasters",
|
||||||
|
status="OPERATING",
|
||||||
|
owner=cls.company1,
|
||||||
|
opening_date=date(2020, 1, 1),
|
||||||
|
size_acres=100,
|
||||||
|
ride_count=20,
|
||||||
|
coaster_count=5,
|
||||||
|
average_rating=4.5
|
||||||
|
)
|
||||||
|
Location.objects.create(
|
||||||
|
name="Thrilling Adventures Location",
|
||||||
|
location_type="park",
|
||||||
|
street_address="123 Thrill St",
|
||||||
|
city="Thrill City",
|
||||||
|
state="Thrill State",
|
||||||
|
country="USA",
|
||||||
|
postal_code="12345",
|
||||||
|
latitude=40.7128,
|
||||||
|
longitude=-74.0060,
|
||||||
|
content_object=cls.park1
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.park2 = Park.objects.create(
|
||||||
|
name="Family Fun Park",
|
||||||
|
description="Family-friendly entertainment and attractions",
|
||||||
|
status="CLOSED_TEMP",
|
||||||
|
owner=cls.company2,
|
||||||
|
opening_date=date(2015, 6, 15),
|
||||||
|
size_acres=50,
|
||||||
|
ride_count=15,
|
||||||
|
coaster_count=2,
|
||||||
|
average_rating=4.0
|
||||||
|
)
|
||||||
|
Location.objects.create(
|
||||||
|
name="Family Fun Location",
|
||||||
|
location_type="park",
|
||||||
|
street_address="456 Fun St",
|
||||||
|
city="Fun City",
|
||||||
|
state="Fun State",
|
||||||
|
country="Canada",
|
||||||
|
postal_code="54321",
|
||||||
|
latitude=43.6532,
|
||||||
|
longitude=-79.3832,
|
||||||
|
content_object=cls.park2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Park with minimal data for edge case testing
|
||||||
|
cls.park3 = Park.objects.create(
|
||||||
|
name="Incomplete Park",
|
||||||
|
status="UNDER_CONSTRUCTION"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_text_search(self):
|
||||||
|
"""Test search functionality across different fields"""
|
||||||
|
# Test name search
|
||||||
|
queryset = ParkFilter(data={"search": "Thrilling"}).qs
|
||||||
|
self.assertEqual(queryset.count(), 1)
|
||||||
|
self.assertIn(self.park1, queryset)
|
||||||
|
|
||||||
|
# Test description search
|
||||||
|
queryset = ParkFilter(data={"search": "family-friendly"}).qs
|
||||||
|
self.assertEqual(queryset.count(), 1)
|
||||||
|
self.assertIn(self.park2, queryset)
|
||||||
|
|
||||||
|
# Test location search
|
||||||
|
queryset = ParkFilter(data={"search": "Thrill City"}).qs
|
||||||
|
self.assertEqual(queryset.count(), 1)
|
||||||
|
self.assertIn(self.park1, queryset)
|
||||||
|
|
||||||
|
# Test combined field search
|
||||||
|
queryset = ParkFilter(data={"search": "Park"}).qs
|
||||||
|
self.assertEqual(queryset.count(), 3)
|
||||||
|
|
||||||
|
# Test empty search
|
||||||
|
queryset = ParkFilter(data={}).qs
|
||||||
|
self.assertEqual(queryset.count(), 3)
|
||||||
|
def test_status_filtering(self):
|
||||||
|
"""Test status filter with various values"""
|
||||||
|
# Test each status
|
||||||
|
status_tests = {
|
||||||
|
"OPERATING": [self.park1],
|
||||||
|
"CLOSED_TEMP": [self.park2],
|
||||||
|
"UNDER_CONSTRUCTION": [self.park3]
|
||||||
|
}
|
||||||
|
|
||||||
|
for status, expected_parks in status_tests.items():
|
||||||
|
queryset = ParkFilter(data={"status": status}).qs
|
||||||
|
self.assertEqual(queryset.count(), len(expected_parks))
|
||||||
|
for park in expected_parks:
|
||||||
|
self.assertIn(park, queryset)
|
||||||
|
|
||||||
|
# Test empty status (should return all)
|
||||||
|
queryset = ParkFilter(data={}).qs
|
||||||
|
self.assertEqual(queryset.count(), 3)
|
||||||
|
|
||||||
|
# Test invalid status
|
||||||
|
queryset = ParkFilter(data={"status": "INVALID"}).qs
|
||||||
|
self.assertEqual(queryset.count(), 0)
|
||||||
|
self.assertEqual(queryset.count(), 3)
|
||||||
|
|
||||||
|
def test_date_range_filtering(self):
|
||||||
|
"""Test date range filter functionality"""
|
||||||
|
# Test various date range scenarios
|
||||||
|
test_cases = [
|
||||||
|
# Start date only
|
||||||
|
({
|
||||||
|
"opening_date_after": "2019-01-01"
|
||||||
|
}, [self.park1]),
|
||||||
|
|
||||||
|
# End date only
|
||||||
|
({
|
||||||
|
"opening_date_before": "2016-01-01"
|
||||||
|
}, [self.park2]),
|
||||||
|
|
||||||
|
# Date range including one park
|
||||||
|
({
|
||||||
|
"opening_date_after": "2014-01-01",
|
||||||
|
"opening_date_before": "2016-01-01"
|
||||||
|
}, [self.park2]),
|
||||||
|
|
||||||
|
# Date range including multiple parks
|
||||||
|
({
|
||||||
|
"opening_date_after": "2014-01-01",
|
||||||
|
"opening_date_before": "2022-01-01"
|
||||||
|
}, [self.park1, self.park2]),
|
||||||
|
|
||||||
|
# Empty filter (should return all)
|
||||||
|
({}, [self.park1, self.park2, self.park3]),
|
||||||
|
|
||||||
|
# Future date (should return none)
|
||||||
|
({
|
||||||
|
"opening_date_after": "2030-01-01"
|
||||||
|
}, []),
|
||||||
|
]
|
||||||
|
|
||||||
|
for filter_data, expected_parks in test_cases:
|
||||||
|
queryset = ParkFilter(data=filter_data).qs
|
||||||
|
self.assertEqual(
|
||||||
|
set(queryset),
|
||||||
|
set(expected_parks),
|
||||||
|
f"Failed for filter: {filter_data}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test invalid date formats
|
||||||
|
invalid_dates = [
|
||||||
|
{"opening_date_after": "invalid-date"},
|
||||||
|
{"opening_date_before": "2023-13-01"}, # Invalid month
|
||||||
|
{"opening_date_after": "2023-01-32"}, # Invalid day
|
||||||
|
{"opening_date_before": "not-a-date"},
|
||||||
|
]
|
||||||
|
|
||||||
|
for invalid_data in invalid_dates:
|
||||||
|
filter_instance = ParkFilter(data=invalid_data)
|
||||||
|
self.assertFalse(
|
||||||
|
filter_instance.is_valid(),
|
||||||
|
f"Filter should be invalid for data: {invalid_data}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_company_filtering(self):
|
||||||
|
"""Test company/owner filtering"""
|
||||||
|
# Test specific company
|
||||||
|
queryset = ParkFilter(data={"owner": str(self.company1.id)}).qs
|
||||||
|
self.assertEqual(queryset.count(), 1)
|
||||||
|
self.assertIn(self.park1, queryset)
|
||||||
|
|
||||||
|
# Test other company
|
||||||
|
queryset = ParkFilter(data={"owner": str(self.company2.id)}).qs
|
||||||
|
self.assertEqual(queryset.count(), 1)
|
||||||
|
self.assertIn(self.park2, queryset)
|
||||||
|
|
||||||
|
# Test null owner (park3 has no owner)
|
||||||
|
queryset = ParkFilter(data={"owner": "null"}).qs
|
||||||
|
self.assertEqual(queryset.count(), 1)
|
||||||
|
self.assertIn(self.park3, queryset)
|
||||||
|
|
||||||
|
# Test empty filter (should return all)
|
||||||
|
queryset = ParkFilter(data={}).qs
|
||||||
|
self.assertEqual(queryset.count(), 3)
|
||||||
|
|
||||||
|
# Test invalid company ID
|
||||||
|
queryset = ParkFilter(data={"owner": "99999"}).qs
|
||||||
|
self.assertEqual(queryset.count(), 0)
|
||||||
|
|
||||||
|
def test_numeric_filtering(self):
|
||||||
|
"""Test numeric filters with validation"""
|
||||||
|
# Test minimum rides filter
|
||||||
|
test_cases = [
|
||||||
|
({"min_rides": "18"}, [self.park1]), # Only park1 has >= 18 rides
|
||||||
|
({"min_rides": "10"}, [self.park1, self.park2]), # Both park1 and park2 have >= 10 rides
|
||||||
|
({"min_rides": "0"}, [self.park1, self.park2, self.park3]), # All parks have >= 0 rides
|
||||||
|
({}, [self.park1, self.park2, self.park3]), # No filter should return all
|
||||||
|
]
|
||||||
|
|
||||||
|
for filter_data, expected_parks in test_cases:
|
||||||
|
queryset = ParkFilter(data=filter_data).qs
|
||||||
|
self.assertEqual(
|
||||||
|
set(queryset),
|
||||||
|
set(expected_parks),
|
||||||
|
f"Failed for filter: {filter_data}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test coaster count filter
|
||||||
|
queryset = ParkFilter(data={"min_coasters": "3"}).qs
|
||||||
|
self.assertEqual(queryset.count(), 1)
|
||||||
|
self.assertIn(self.park1, queryset)
|
||||||
|
|
||||||
|
# Test size filter
|
||||||
|
queryset = ParkFilter(data={"min_size": "75"}).qs
|
||||||
|
self.assertEqual(queryset.count(), 1)
|
||||||
|
self.assertIn(self.park1, queryset)
|
||||||
|
|
||||||
|
# Test validation
|
||||||
|
invalid_values = ["-1", "invalid", "0.5"]
|
||||||
|
for value in invalid_values:
|
||||||
|
filter_instance = ParkFilter(data={"min_rides": value})
|
||||||
|
self.assertFalse(
|
||||||
|
filter_instance.is_valid(),
|
||||||
|
f"Filter should be invalid for value: {value}"
|
||||||
|
)
|
||||||
213
parks/tests/test_models.py
Normal file
213
parks/tests/test_models.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
Tests for park models functionality including CRUD operations,
|
||||||
|
slug handling, status management, and location integration.
|
||||||
|
"""
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from parks.models import Park, ParkArea
|
||||||
|
from companies.models import Company
|
||||||
|
from location.models import Location
|
||||||
|
|
||||||
|
class ParkModelTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test data"""
|
||||||
|
self.company = Company.objects.create(
|
||||||
|
name="Test Company",
|
||||||
|
slug="test-company"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a basic park
|
||||||
|
self.park = Park.objects.create(
|
||||||
|
name="Test Park",
|
||||||
|
description="A test park",
|
||||||
|
status="OPERATING",
|
||||||
|
owner=self.company
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create location for the park
|
||||||
|
self.location = Location.objects.create(
|
||||||
|
name="Test Park Location",
|
||||||
|
location_type="park",
|
||||||
|
street_address="123 Test St",
|
||||||
|
city="Test City",
|
||||||
|
state="Test State",
|
||||||
|
country="Test Country",
|
||||||
|
postal_code="12345",
|
||||||
|
latitude=40.7128,
|
||||||
|
longitude=-74.0060,
|
||||||
|
content_object=self.park
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_park_creation(self):
|
||||||
|
"""Test basic park creation and fields"""
|
||||||
|
self.assertEqual(self.park.name, "Test Park")
|
||||||
|
self.assertEqual(self.park.slug, "test-park")
|
||||||
|
self.assertEqual(self.park.status, "OPERATING")
|
||||||
|
self.assertEqual(self.park.owner, self.company)
|
||||||
|
|
||||||
|
def test_slug_generation(self):
|
||||||
|
"""Test automatic slug generation"""
|
||||||
|
park = Park.objects.create(
|
||||||
|
name="Another Test Park",
|
||||||
|
status="OPERATING"
|
||||||
|
)
|
||||||
|
self.assertEqual(park.slug, "another-test-park")
|
||||||
|
|
||||||
|
def test_historical_slug_lookup(self):
|
||||||
|
"""Test finding park by historical slug"""
|
||||||
|
from django.db import transaction
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from history_tracking.models import HistoricalSlug
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# Create initial park with a specific name/slug
|
||||||
|
park = Park.objects.create(
|
||||||
|
name="Original Park Name",
|
||||||
|
description="Test description",
|
||||||
|
status="OPERATING"
|
||||||
|
)
|
||||||
|
original_slug = park.slug
|
||||||
|
print(f"\nInitial park created with slug: {original_slug}")
|
||||||
|
|
||||||
|
# Ensure we have a save to trigger history
|
||||||
|
park.save()
|
||||||
|
|
||||||
|
# Modify name to trigger slug change
|
||||||
|
park.name = "Updated Park Name"
|
||||||
|
park.save()
|
||||||
|
new_slug = park.slug
|
||||||
|
print(f"Park updated with new slug: {new_slug}")
|
||||||
|
|
||||||
|
# Check HistoricalSlug records
|
||||||
|
historical_slugs = HistoricalSlug.objects.filter(
|
||||||
|
content_type=ContentType.objects.get_for_model(Park),
|
||||||
|
object_id=park.id
|
||||||
|
)
|
||||||
|
print(f"Historical slug records: {[h.slug for h in historical_slugs]}")
|
||||||
|
|
||||||
|
# Check pghistory records
|
||||||
|
event_model = getattr(Park, 'event_model', None)
|
||||||
|
if event_model:
|
||||||
|
historical_records = event_model.objects.filter(
|
||||||
|
pgh_obj_id=park.id
|
||||||
|
).order_by('-pgh_created_at')
|
||||||
|
print(f"\nPG History records:")
|
||||||
|
for record in historical_records:
|
||||||
|
print(f"- Event ID: {record.pgh_id}")
|
||||||
|
print(f" Name: {record.name}")
|
||||||
|
print(f" Slug: {record.slug}")
|
||||||
|
print(f" Created At: {record.pgh_created_at}")
|
||||||
|
else:
|
||||||
|
print("\nNo pghistory event model available")
|
||||||
|
|
||||||
|
# Try to find by old slug
|
||||||
|
found_park, is_historical = Park.get_by_slug(original_slug)
|
||||||
|
self.assertEqual(found_park.id, park.id)
|
||||||
|
print(f"Found park by old slug: {found_park.slug}, is_historical: {is_historical}")
|
||||||
|
self.assertTrue(is_historical)
|
||||||
|
|
||||||
|
# Try current slug
|
||||||
|
found_park, is_historical = Park.get_by_slug(new_slug)
|
||||||
|
self.assertEqual(found_park.id, park.id)
|
||||||
|
print(f"Found park by new slug: {found_park.slug}, is_historical: {is_historical}")
|
||||||
|
self.assertFalse(is_historical)
|
||||||
|
|
||||||
|
def test_status_color_mapping(self):
|
||||||
|
"""Test status color class mapping"""
|
||||||
|
status_tests = {
|
||||||
|
'OPERATING': 'bg-green-100 text-green-800',
|
||||||
|
'CLOSED_TEMP': 'bg-yellow-100 text-yellow-800',
|
||||||
|
'CLOSED_PERM': 'bg-red-100 text-red-800',
|
||||||
|
'UNDER_CONSTRUCTION': 'bg-blue-100 text-blue-800',
|
||||||
|
'DEMOLISHED': 'bg-gray-100 text-gray-800',
|
||||||
|
'RELOCATED': 'bg-purple-100 text-purple-800'
|
||||||
|
}
|
||||||
|
|
||||||
|
for status, expected_color in status_tests.items():
|
||||||
|
self.park.status = status
|
||||||
|
self.assertEqual(self.park.get_status_color(), expected_color)
|
||||||
|
|
||||||
|
def test_location_integration(self):
|
||||||
|
"""Test location-related functionality"""
|
||||||
|
# Test formatted location - compare individual components
|
||||||
|
location = self.park.location.first()
|
||||||
|
self.assertIsNotNone(location)
|
||||||
|
formatted_address = location.get_formatted_address()
|
||||||
|
self.assertIn("123 Test St", formatted_address)
|
||||||
|
self.assertIn("Test City", formatted_address)
|
||||||
|
self.assertIn("Test State", formatted_address)
|
||||||
|
self.assertIn("12345", formatted_address)
|
||||||
|
self.assertIn("Test Country", formatted_address)
|
||||||
|
|
||||||
|
# Test coordinates
|
||||||
|
self.assertEqual(self.park.coordinates, (40.7128, -74.0060))
|
||||||
|
|
||||||
|
# Test park without location
|
||||||
|
park = Park.objects.create(name="No Location Park")
|
||||||
|
self.assertEqual(park.formatted_location, "")
|
||||||
|
self.assertIsNone(park.coordinates)
|
||||||
|
|
||||||
|
def test_absolute_url(self):
|
||||||
|
"""Test get_absolute_url method"""
|
||||||
|
expected_url = f"/parks/{self.park.slug}/"
|
||||||
|
self.assertEqual(self.park.get_absolute_url(), expected_url)
|
||||||
|
|
||||||
|
class ParkAreaModelTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test data"""
|
||||||
|
self.park = Park.objects.create(
|
||||||
|
name="Test Park",
|
||||||
|
status="OPERATING"
|
||||||
|
)
|
||||||
|
self.area = ParkArea.objects.create(
|
||||||
|
park=self.park,
|
||||||
|
name="Test Area",
|
||||||
|
description="A test area"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_area_creation(self):
|
||||||
|
"""Test basic area creation and fields"""
|
||||||
|
self.assertEqual(self.area.name, "Test Area")
|
||||||
|
self.assertEqual(self.area.slug, "test-area")
|
||||||
|
self.assertEqual(self.area.park, self.park)
|
||||||
|
|
||||||
|
def test_historical_slug_lookup(self):
|
||||||
|
"""Test finding area by historical slug"""
|
||||||
|
# Change area name/slug
|
||||||
|
self.area.name = "Updated Area Name"
|
||||||
|
self.area.save()
|
||||||
|
|
||||||
|
# Try to find by old slug
|
||||||
|
area, is_historical = ParkArea.get_by_slug("test-area")
|
||||||
|
self.assertEqual(area.id, self.area.id)
|
||||||
|
self.assertTrue(is_historical)
|
||||||
|
|
||||||
|
# Try current slug
|
||||||
|
area, is_historical = ParkArea.get_by_slug("updated-area-name")
|
||||||
|
self.assertEqual(area.id, self.area.id)
|
||||||
|
self.assertFalse(is_historical)
|
||||||
|
|
||||||
|
def test_unique_together_constraint(self):
|
||||||
|
"""Test unique_together constraint for park and slug"""
|
||||||
|
# Try to create area with same slug in same park
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
ParkArea.objects.create(
|
||||||
|
park=self.park,
|
||||||
|
name="Test Area" # Will generate same slug
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should be able to use same name in different park
|
||||||
|
other_park = Park.objects.create(name="Other Park")
|
||||||
|
area = ParkArea.objects.create(
|
||||||
|
park=other_park,
|
||||||
|
name="Test Area"
|
||||||
|
)
|
||||||
|
self.assertEqual(area.slug, "test-area")
|
||||||
|
|
||||||
|
def test_absolute_url(self):
|
||||||
|
"""Test get_absolute_url method"""
|
||||||
|
expected_url = f"/parks/{self.park.slug}/areas/{self.area.slug}/"
|
||||||
|
self.assertEqual(self.area.get_absolute_url(), expected_url)
|
||||||
@@ -5,6 +5,8 @@ from django.shortcuts import get_object_or_404, render
|
|||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.db.models import Q, Avg, Count, QuerySet, Model
|
from django.db.models import Q, Avg, Count, QuerySet, Model
|
||||||
|
from search.mixins import HTMXFilterableMixin
|
||||||
|
from .filters import ParkFilter
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
@@ -49,16 +51,26 @@ def get_park_areas(request: HttpRequest) -> HttpResponse:
|
|||||||
|
|
||||||
|
|
||||||
def search_parks(request: HttpRequest) -> HttpResponse:
|
def search_parks(request: HttpRequest) -> HttpResponse:
|
||||||
"""Search parks and return results for HTMX"""
|
"""Search parks and return results for quick searches (dropdowns, etc)"""
|
||||||
query = request.GET.get('q', '').strip()
|
try:
|
||||||
|
queryset = (
|
||||||
|
Park.objects.prefetch_related('location', 'photos')
|
||||||
|
.order_by('name')
|
||||||
|
)
|
||||||
|
filter_params = {'search': request.GET.get('q', '').strip()}
|
||||||
|
|
||||||
# If no query, show first 10 parks
|
park_filter = ParkFilter(filter_params, queryset=queryset)
|
||||||
if not query:
|
parks = park_filter.qs[:10] # Limit to 10 results
|
||||||
parks = Park.objects.all().order_by('name')[:10]
|
|
||||||
else:
|
|
||||||
parks = Park.objects.filter(name__icontains=query).order_by('name')[:10]
|
|
||||||
|
|
||||||
return render(request, "parks/partials/park_search_results.html", {"parks": parks})
|
return render(request, "parks/partials/park_search_results.html", {
|
||||||
|
"parks": parks,
|
||||||
|
"is_quick_search": True
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return render(request, "parks/partials/park_search_results.html", {
|
||||||
|
"error": f"Error performing search: {str(e)}",
|
||||||
|
"is_quick_search": True
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def location_search(request: HttpRequest) -> JsonResponse:
|
def location_search(request: HttpRequest) -> JsonResponse:
|
||||||
@@ -145,65 +157,45 @@ def add_park_button(request: HttpRequest) -> HttpResponse:
|
|||||||
return render(request, "parks/partials/add_park_button.html")
|
return render(request, "parks/partials/add_park_button.html")
|
||||||
|
|
||||||
|
|
||||||
class ParkListView(ListView):
|
class ParkListView(HTMXFilterableMixin, ListView):
|
||||||
model = Park
|
model = Park
|
||||||
template_name = "parks/park_list.html"
|
template_name = "parks/park_list.html"
|
||||||
context_object_name = "parks"
|
context_object_name = "parks"
|
||||||
|
filter_class = ParkFilter
|
||||||
|
paginate_by = 20
|
||||||
|
|
||||||
def get_queryset(self) -> QuerySet[Park]:
|
def get_queryset(self) -> QuerySet[Park]:
|
||||||
queryset = Park.objects.select_related("owner").prefetch_related(
|
try:
|
||||||
"photos", "location"
|
return (
|
||||||
|
super()
|
||||||
|
.get_queryset()
|
||||||
|
.select_related("owner")
|
||||||
|
.prefetch_related(
|
||||||
|
"photos",
|
||||||
|
"location",
|
||||||
|
"rides",
|
||||||
|
"rides__manufacturer"
|
||||||
)
|
)
|
||||||
|
.annotate(
|
||||||
search = self.request.GET.get("search", "").strip()
|
|
||||||
country = self.request.GET.get("country", "").strip()
|
|
||||||
region = self.request.GET.get("region", "").strip()
|
|
||||||
city = self.request.GET.get("city", "").strip()
|
|
||||||
statuses = self.request.GET.getlist("status")
|
|
||||||
|
|
||||||
if search:
|
|
||||||
queryset = queryset.filter(
|
|
||||||
Q(name__icontains=search)
|
|
||||||
| Q(location__city__icontains=search)
|
|
||||||
| Q(location__state__icontains=search)
|
|
||||||
| Q(location__country__icontains=search)
|
|
||||||
)
|
|
||||||
|
|
||||||
if country:
|
|
||||||
queryset = queryset.filter(location__country__icontains=country)
|
|
||||||
|
|
||||||
if region:
|
|
||||||
queryset = queryset.filter(location__state__icontains=region)
|
|
||||||
|
|
||||||
if city:
|
|
||||||
queryset = queryset.filter(location__city__icontains=city)
|
|
||||||
|
|
||||||
if statuses:
|
|
||||||
queryset = queryset.filter(status__in=statuses)
|
|
||||||
|
|
||||||
queryset = queryset.annotate(
|
|
||||||
total_rides=Count("rides"),
|
total_rides=Count("rides"),
|
||||||
total_coasters=Count("rides", filter=Q(rides__category="RC")),
|
total_coasters=Count("rides", filter=Q(rides__category="RC")),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
return queryset.distinct()
|
except Exception as e:
|
||||||
|
messages.error(self.request, f"Error loading parks: {str(e)}")
|
||||||
|
return Park.objects.none()
|
||||||
|
|
||||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
context = super().get_context_data(**kwargs)
|
try:
|
||||||
context["current_filters"] = {
|
return super().get_context_data(**kwargs)
|
||||||
"search": self.request.GET.get("search", ""),
|
except Exception as e:
|
||||||
"country": self.request.GET.get("country", ""),
|
messages.error(self.request, f"Error applying filters: {str(e)}")
|
||||||
"region": self.request.GET.get("region", ""),
|
context = {
|
||||||
"city": self.request.GET.get("city", ""),
|
"filter": self.filterset,
|
||||||
"statuses": self.request.GET.getlist("status"),
|
"error": "Unable to apply filters. Please try adjusting your criteria."
|
||||||
}
|
}
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
|
||||||
if hasattr(request, "htmx") and getattr(request, "htmx", False):
|
|
||||||
self.template_name = "parks/partials/park_list.html"
|
|
||||||
return super().get(request, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class ParkDetailView(
|
class ParkDetailView(
|
||||||
SlugRedirectMixin,
|
SlugRedirectMixin,
|
||||||
|
|||||||
72
search/templates/search/partials/park_results.html
Normal file
72
search/templates/search/partials/park_results.html
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<div class="divide-y">
|
||||||
|
{% if error %}
|
||||||
|
<div class="p-8 text-center">
|
||||||
|
<div class="inline-flex items-center px-4 py-2 rounded-md bg-red-50 text-red-700">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% for park in object_list %}
|
||||||
|
<div class="p-4 flex items-start space-x-4">
|
||||||
|
{% if park.photos.exists %}
|
||||||
|
<img src="{{ park.photos.first.image.url }}"
|
||||||
|
alt="{{ park.name }}"
|
||||||
|
class="w-24 h-24 object-cover rounded-lg">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold">
|
||||||
|
<a href="{{ park.get_absolute_url }}">{{ park.name }}</a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="mt-1 text-sm text-gray-500">
|
||||||
|
{% with location=park.location.first %}
|
||||||
|
{% if location %}
|
||||||
|
{{ location.city }}{% if location.state %}, {{ location.state }}{% endif %}{% if location.country %}, {{ location.country }}{% endif %}
|
||||||
|
{% else %}
|
||||||
|
Location unknown
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ park.get_status_color }}">
|
||||||
|
{{ park.get_status_display }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% if park.opening_date %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
Opened {{ park.opening_date|date:"Y" }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if park.total_rides %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
{{ park.total_rides }} rides
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if park.total_coasters %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||||
|
{{ park.total_coasters }} coasters
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if park.average_rating %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
{{ park.average_rating }} ★
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="p-8 text-center text-gray-500">
|
||||||
|
No parks found matching your criteria
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user