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
|
||||
|
||||
### 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
|
||||
- Created core documentation structure
|
||||
- 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
|
||||
|
||||
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)
|
||||
|
||||
# Save the model
|
||||
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:
|
||||
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
|
||||
def formatted_location(self) -> str:
|
||||
if self.location.exists():
|
||||
@@ -99,20 +138,58 @@ class Park(TrackedModel):
|
||||
@classmethod
|
||||
def get_by_slug(cls, slug: str) -> Tuple['Park', bool]:
|
||||
"""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:
|
||||
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:
|
||||
# Check historical slugs using pghistory
|
||||
history_model = cls.get_history_model()
|
||||
history = history_model.objects.filter(
|
||||
slug=slug
|
||||
).order_by('-pgh_created_at').first()
|
||||
print(f"No current park found with slug: {slug}")
|
||||
|
||||
if history:
|
||||
# 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:
|
||||
return cls.objects.get(pk=history.pgh_obj_id), True
|
||||
except cls.DoesNotExist as e:
|
||||
raise cls.DoesNotExist("No park found with this slug") from e
|
||||
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
|
||||
).order_by('-pgh_created_at').first()
|
||||
|
||||
if historical_event:
|
||||
print(f"Found pghistory event for pgh_obj_id: {historical_event.pgh_obj_id}")
|
||||
try:
|
||||
park = cls.objects.get(pk=historical_event.pgh_obj_id)
|
||||
print(f"Found park from pghistory: {park.name}")
|
||||
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")
|
||||
|
||||
@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)
|
||||
112
parks/views.py
112
parks/views.py
@@ -5,6 +5,8 @@ from django.shortcuts import get_object_or_404, render
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.urls import reverse
|
||||
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.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@@ -49,16 +51,26 @@ def get_park_areas(request: HttpRequest) -> HttpResponse:
|
||||
|
||||
|
||||
def search_parks(request: HttpRequest) -> HttpResponse:
|
||||
"""Search parks and return results for HTMX"""
|
||||
query = request.GET.get('q', '').strip()
|
||||
|
||||
# If no query, show first 10 parks
|
||||
if not query:
|
||||
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})
|
||||
"""Search parks and return results for quick searches (dropdowns, etc)"""
|
||||
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] # Limit to 10 results
|
||||
|
||||
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:
|
||||
@@ -145,64 +157,44 @@ def add_park_button(request: HttpRequest) -> HttpResponse:
|
||||
return render(request, "parks/partials/add_park_button.html")
|
||||
|
||||
|
||||
class ParkListView(ListView):
|
||||
class ParkListView(HTMXFilterableMixin, ListView):
|
||||
model = Park
|
||||
template_name = "parks/park_list.html"
|
||||
context_object_name = "parks"
|
||||
filter_class = ParkFilter
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self) -> QuerySet[Park]:
|
||||
queryset = Park.objects.select_related("owner").prefetch_related(
|
||||
"photos", "location"
|
||||
)
|
||||
|
||||
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)
|
||||
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")),
|
||||
)
|
||||
)
|
||||
|
||||
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_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]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["current_filters"] = {
|
||||
"search": self.request.GET.get("search", ""),
|
||||
"country": self.request.GET.get("country", ""),
|
||||
"region": self.request.GET.get("region", ""),
|
||||
"city": self.request.GET.get("city", ""),
|
||||
"statuses": self.request.GET.getlist("status"),
|
||||
}
|
||||
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)
|
||||
try:
|
||||
return super().get_context_data(**kwargs)
|
||||
except Exception as e:
|
||||
messages.error(self.request, f"Error applying filters: {str(e)}")
|
||||
context = {
|
||||
"filter": self.filterset,
|
||||
"error": "Unable to apply filters. Please try adjusting your criteria."
|
||||
}
|
||||
return context
|
||||
|
||||
|
||||
class ParkDetailView(
|
||||
|
||||
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