mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 09:31:09 -05:00
Enhance search functionality with loading indicators, dark mode support, and improved UI; implement event handling for search results and refine park filter tests for better coverage
This commit is contained in:
@@ -93,30 +93,40 @@
|
|||||||
- Location integration with error handling
|
- Location integration with error handling
|
||||||
- Property methods with null safety
|
- Property methods with null safety
|
||||||
4. ✓ Implemented ParkFilter tests
|
4. ✓ Implemented ParkFilter tests
|
||||||
- Text search functionality
|
- Text search with multiple field support
|
||||||
- Status filtering
|
- Status filtering with validation and choice handling
|
||||||
- Date range filtering
|
- Date range filtering with format validation
|
||||||
- Company/owner filtering
|
- Company/owner filtering with comprehensive null handling
|
||||||
- Numeric filtering with validation
|
- Numeric filtering with integer validation and bounds checking
|
||||||
- Location, Rating, and DateRange mixin integration
|
- Empty value handling across all filters
|
||||||
- Performance testing with multiple filters
|
- Test coverage for edge cases and invalid inputs
|
||||||
|
- Performance validation for complex filter combinations
|
||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
|
|
||||||
1. Monitoring Implementation
|
1. Performance Optimization
|
||||||
- [ ] Add error logging
|
- [ ] Add query count assertions to tests
|
||||||
- [ ] Implement performance tracking
|
- [ ] Profile filter combinations impact
|
||||||
- [ ] Add usage analytics
|
- [ ] Implement caching for common filters
|
||||||
|
- [ ] Add database indexes for frequently filtered fields
|
||||||
|
|
||||||
2. Performance Optimization
|
2. Monitoring and Analytics
|
||||||
- [ ] Profile query performance in production
|
- [ ] Add filter usage tracking
|
||||||
- [ ] Implement caching strategies
|
- [ ] Implement performance monitoring
|
||||||
- [ ] Optimize complex filter combinations
|
- [ ] Track common filter combinations
|
||||||
|
- [ ] Monitor query execution times
|
||||||
|
|
||||||
3. Documentation Updates
|
3. Documentation and Maintenance
|
||||||
- [ ] Add test coverage reports
|
- [ ] Add filter example documentation
|
||||||
- [ ] Document common test patterns
|
- [ ] Document filter combinations and best practices
|
||||||
- [ ] Update API documentation with filter examples
|
- [ ] Create performance troubleshooting guide
|
||||||
|
- [ ] Add test coverage reports and analysis
|
||||||
|
|
||||||
|
4. Future Enhancements
|
||||||
|
- [ ] Add saved filter support
|
||||||
|
- [ ] Implement filter presets
|
||||||
|
- [ ] Add advanced combination operators (AND/OR)
|
||||||
|
- [ ] Support dynamic field filtering
|
||||||
|
|
||||||
### Running the Tests
|
### Running the Tests
|
||||||
|
|
||||||
|
|||||||
105
parks/filters.py
105
parks/filters.py
@@ -1,87 +1,77 @@
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from search.filters import create_model_filter, LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin
|
from search.filters import LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin
|
||||||
from django_filters import (
|
from django_filters import (
|
||||||
NumberFilter,
|
NumberFilter,
|
||||||
ModelChoiceFilter,
|
ModelChoiceFilter,
|
||||||
DateFromToRangeFilter,
|
DateFromToRangeFilter,
|
||||||
ChoiceFilter,
|
ChoiceFilter,
|
||||||
FilterSet
|
FilterSet,
|
||||||
|
CharFilter,
|
||||||
|
BooleanFilter
|
||||||
)
|
)
|
||||||
from .models import Park
|
from .models import Park
|
||||||
from companies.models import Company
|
from companies.models import Company
|
||||||
|
|
||||||
def validate_positive(value):
|
def validate_positive_integer(value):
|
||||||
if value and value < 0:
|
"""Validate that a value is a positive integer"""
|
||||||
raise ValidationError(_('Value must be positive'))
|
try:
|
||||||
return value
|
value = float(value)
|
||||||
|
if not value.is_integer() or value < 0:
|
||||||
|
raise ValidationError(_('Value must be a positive integer'))
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValidationError(_('Invalid number format'))
|
||||||
|
|
||||||
# Create dynamic filter for Park model with null value handling and validation
|
class ParkFilter(FilterSet, LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin):
|
||||||
class ParkFilterSet(FilterSet):
|
"""Filter set for parks with search and validation capabilities"""
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Park
|
model = Park
|
||||||
fields = []
|
fields = []
|
||||||
|
|
||||||
# Custom filter fields
|
# Search field
|
||||||
|
search = CharFilter(method='filter_search')
|
||||||
|
|
||||||
|
# Status filter
|
||||||
status = ChoiceFilter(
|
status = ChoiceFilter(
|
||||||
field_name='status',
|
field_name='status',
|
||||||
choices=Park._meta.get_field('status').choices,
|
choices=Park._meta.get_field('status').choices,
|
||||||
empty_label='Any status',
|
empty_label='Any status'
|
||||||
null_label='Unknown'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Owner filters
|
||||||
owner = ModelChoiceFilter(
|
owner = ModelChoiceFilter(
|
||||||
field_name='owner',
|
field_name='owner',
|
||||||
queryset=Company.objects.all(),
|
queryset=Company.objects.all(),
|
||||||
empty_label='Any company',
|
empty_label='Any company'
|
||||||
null_label='No owner',
|
|
||||||
null=True
|
|
||||||
)
|
)
|
||||||
|
has_owner = BooleanFilter(method='filter_has_owner')
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
# Numeric filters
|
||||||
"""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(
|
min_rides = NumberFilter(
|
||||||
field_name='ride_count',
|
field_name='ride_count',
|
||||||
lookup_expr='gte',
|
lookup_expr='gte',
|
||||||
validators=[validate_positive],
|
validators=[validate_positive_integer]
|
||||||
help_text='Minimum number of rides'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
min_coasters = NumberFilter(
|
min_coasters = NumberFilter(
|
||||||
field_name='coaster_count',
|
field_name='coaster_count',
|
||||||
lookup_expr='gte',
|
lookup_expr='gte',
|
||||||
validators=[validate_positive],
|
validators=[validate_positive_integer]
|
||||||
help_text='Minimum number of coasters'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
min_size = NumberFilter(
|
min_size = NumberFilter(
|
||||||
field_name='size_acres',
|
field_name='size_acres',
|
||||||
lookup_expr='gte',
|
lookup_expr='gte',
|
||||||
validators=[validate_positive],
|
validators=[validate_positive_integer]
|
||||||
help_text='Minimum size in acres'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Date filter
|
||||||
opening_date = DateFromToRangeFilter(
|
opening_date = DateFromToRangeFilter(
|
||||||
label='Opening date range',
|
field_name='opening_date'
|
||||||
help_text='Enter dates in YYYY-MM-DD format'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class ExtendedParkFilterSet(ParkFilterSet):
|
|
||||||
"""Extends ParkFilterSet with search functionality"""
|
|
||||||
def filter_search(self, queryset, name, value):
|
def filter_search(self, queryset, name, value):
|
||||||
|
"""Custom search implementation"""
|
||||||
if not value:
|
if not value:
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@@ -100,16 +90,25 @@ class ExtendedParkFilterSet(ParkFilterSet):
|
|||||||
|
|
||||||
return queryset.filter(query).distinct()
|
return queryset.filter(query).distinct()
|
||||||
|
|
||||||
ParkFilter = create_model_filter(
|
def filter_has_owner(self, queryset, name, value):
|
||||||
model=Park,
|
"""Filter parks based on whether they have an owner"""
|
||||||
mixins=[LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin],
|
return queryset.filter(owner__isnull=not value)
|
||||||
additional_filters={
|
|
||||||
'status': ExtendedParkFilterSet.base_filters['status'],
|
@property
|
||||||
'owner': ExtendedParkFilterSet.base_filters['owner'],
|
def qs(self):
|
||||||
'min_rides': ExtendedParkFilterSet.base_filters['min_rides'],
|
"""Override qs property to ensure we always start with all parks"""
|
||||||
'min_coasters': ExtendedParkFilterSet.base_filters['min_coasters'],
|
if not hasattr(self, '_qs'):
|
||||||
'min_size': ExtendedParkFilterSet.base_filters['min_size'],
|
if not self.is_bound:
|
||||||
'opening_date': ExtendedParkFilterSet.base_filters['opening_date'],
|
self._qs = self.queryset.all()
|
||||||
},
|
return self._qs
|
||||||
base_class=ExtendedParkFilterSet
|
if not self.form.is_valid():
|
||||||
)
|
self._qs = self.queryset.none()
|
||||||
|
return self._qs
|
||||||
|
|
||||||
|
self._qs = self.queryset.all()
|
||||||
|
for name, value in self.form.cleaned_data.items():
|
||||||
|
if value in [None, '', 0] and name not in ['has_owner']:
|
||||||
|
continue
|
||||||
|
self._qs = self.filters[name].filter(self._qs, value)
|
||||||
|
self._qs = self._qs.distinct()
|
||||||
|
return self._qs
|
||||||
32
parks/static/parks/css/search.css
Normal file
32
parks/static/parks/css/search.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* Loading indicator */
|
||||||
|
.htmx-indicator {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-request .htmx-indicator {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search results spacing */
|
||||||
|
#search-results {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode adjustments */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
#search-results .bg-white {
|
||||||
|
background-color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-results .text-gray-900 {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-results .text-gray-500 {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-results .hover\:bg-gray-50:hover {
|
||||||
|
background-color: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
parks/static/parks/js/search.js
Normal file
28
parks/static/parks/js/search.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const searchInput = document.getElementById('search');
|
||||||
|
const searchResults = document.getElementById('search-results');
|
||||||
|
|
||||||
|
if (!searchInput || !searchResults) return;
|
||||||
|
|
||||||
|
// Clear search results when clicking outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!searchResults.contains(e.target) && e.target !== searchInput) {
|
||||||
|
searchResults.innerHTML = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear results on escape key
|
||||||
|
searchInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
searchResults.innerHTML = '';
|
||||||
|
searchInput.value = '';
|
||||||
|
searchInput.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle back button
|
||||||
|
window.addEventListener('popstate', function() {
|
||||||
|
searchResults.innerHTML = '';
|
||||||
|
searchInput.value = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends "search/layouts/filtered_list.html" %}
|
{% extends "search/layouts/filtered_list.html" %}
|
||||||
|
{% load static %}
|
||||||
{% load filter_utils %}
|
{% load filter_utils %}
|
||||||
|
|
||||||
{% block page_title %}Parks{% endblock %}
|
{% block page_title %}Parks{% endblock %}
|
||||||
@@ -6,25 +7,14 @@
|
|||||||
{% block filter_errors %}
|
{% block filter_errors %}
|
||||||
{% if filter.errors %}
|
{% if filter.errors %}
|
||||||
<div class="bg-red-50 border-l-4 border-red-400 p-4 mb-4">
|
<div class="bg-red-50 border-l-4 border-red-400 p-4 mb-4">
|
||||||
<div class="flex">
|
<h3 class="text-sm font-medium text-red-800">Please correct the following errors:</h3>
|
||||||
<div class="flex-shrink-0">
|
<ul class="mt-2 text-sm text-red-700 list-disc pl-5 space-y-1">
|
||||||
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
{% for field, errors in filter.errors.items %}
|
||||||
<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"/>
|
{% for error in errors %}
|
||||||
</svg>
|
<li>{{ field }}: {{ error }}</li>
|
||||||
</div>
|
{% endfor %}
|
||||||
<div class="ml-3">
|
{% endfor %}
|
||||||
<h3 class="text-sm font-medium text-red-800">Please correct the following errors:</h3>
|
</ul>
|
||||||
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -44,12 +34,60 @@
|
|||||||
Browse and filter amusement parks, theme parks, and water parks from around the world.
|
Browse and filter amusement parks, theme parks, and water parks from around the world.
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block filter_section_title %}Find Parks{% endblock %}
|
{% block filter_section %}
|
||||||
|
<div class="mb-6">
|
||||||
|
{# Quick Search #}
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<label for="search" class="sr-only">Search parks</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="search"
|
||||||
|
name="search"
|
||||||
|
id="search"
|
||||||
|
class="block w-full rounded-md border-gray-300 bg-white py-3 pl-4 pr-10 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 sm:text-sm"
|
||||||
|
placeholder="Search parks by name or location..."
|
||||||
|
hx-get="{% url 'parks:search_parks' %}"
|
||||||
|
hx-trigger="keyup changed delay:300ms, search"
|
||||||
|
hx-target="#search-results"
|
||||||
|
hx-indicator="#search-indicator"
|
||||||
|
autocomplete="off">
|
||||||
|
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||||
|
<div id="search-indicator" class="htmx-indicator">
|
||||||
|
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="search-results" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% block results_section_title %}Parks{% endblock %}
|
{# Advanced Filters #}
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-lg font-medium leading-6 text-gray-900">Advanced Filters</h3>
|
||||||
|
<div class="mt-4">
|
||||||
|
{% include "search/partials/filter_form.html" with filter=filter %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block results_section_title %}All Parks{% endblock %}
|
||||||
|
|
||||||
{% block no_results_message %}
|
{% block no_results_message %}
|
||||||
<div class="text-center p-8 text-gray-500">
|
<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>.
|
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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block results_section %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
{% for park in parks %}
|
||||||
|
{% include "search/partials/park_results.html" with park=park %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,35 +1,58 @@
|
|||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="p-2 text-red-600">
|
<div class="text-red-600 bg-red-50 p-4 rounded-md" role="alert">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for park in parks %}
|
<div class="space-y-4">
|
||||||
<div class="p-2 hover:bg-gray-100 cursor-pointer">
|
{% for park in parks %}
|
||||||
<div class="flex items-center space-x-3">
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
{% if park.photos.exists %}
|
<a href="{% url 'parks:park_detail' park.slug %}" class="block px-4 py-4 hover:bg-gray-50">
|
||||||
<img src="{{ park.photos.first.image.url }}"
|
<div class="flex items-start space-x-4">
|
||||||
alt="{{ park.name }}"
|
{% if park.photos.exists %}
|
||||||
class="w-8 h-8 object-cover rounded">
|
<img src="{{ park.photos.first.image.url }}"
|
||||||
{% else %}
|
alt="{{ park.name }}"
|
||||||
<div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center">
|
class="h-20 w-20 object-cover rounded-lg">
|
||||||
<span class="text-xs text-gray-500">{{ park.name|first|upper }}</span>
|
{% else %}
|
||||||
</div>
|
<div class="h-20 w-20 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||||
{% endif %}
|
<span class="text-2xl font-medium text-gray-400">{{ park.name|first|upper }}</span>
|
||||||
<div>
|
</div>
|
||||||
<div class="font-medium">{{ park.name }}</div>
|
{% endif %}
|
||||||
{% with location=park.location.first %}
|
|
||||||
{% if location %}
|
<div class="flex-1 min-w-0">
|
||||||
<div class="text-sm text-gray-500">
|
<div class="flex justify-between">
|
||||||
{{ location.city }}{% if location.state %}, {{ location.state }}{% endif %}
|
<h3 class="font-medium text-gray-900">{{ park.name }}</h3>
|
||||||
|
<span class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {{ park.get_status_color }}">
|
||||||
|
{{ park.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with location=park.location.first %}
|
||||||
|
{% if location %}
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
{{ location.city }}{% if location.state %}, {{ location.state }}{% endif %}
|
||||||
|
{% if location.country %}, {{ location.country }}{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="mt-2 flex items-center space-x-2 text-sm text-gray-500">
|
||||||
|
{% if park.ride_count %}
|
||||||
|
<span>{{ park.ride_count }} rides</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if park.coaster_count %}
|
||||||
|
<span>•</span>
|
||||||
|
<span>{{ park.coaster_count }} coasters</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</a>
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="text-center py-8 bg-white shadow sm:rounded-lg">
|
||||||
|
<p class="text-gray-500">No parks found matching your search.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
|
||||||
<div class="p-2 text-gray-500 text-center">
|
|
||||||
No parks found
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -124,10 +124,13 @@ class ParkFilterTests(TestCase):
|
|||||||
queryset = ParkFilter(data={}).qs
|
queryset = ParkFilter(data={}).qs
|
||||||
self.assertEqual(queryset.count(), 3)
|
self.assertEqual(queryset.count(), 3)
|
||||||
|
|
||||||
# Test invalid status
|
# Test empty string status (should return all)
|
||||||
|
queryset = ParkFilter(data={"status": ""}).qs
|
||||||
|
self.assertEqual(queryset.count(), 3)
|
||||||
|
|
||||||
|
# Test invalid status (should return no results)
|
||||||
queryset = ParkFilter(data={"status": "INVALID"}).qs
|
queryset = ParkFilter(data={"status": "INVALID"}).qs
|
||||||
self.assertEqual(queryset.count(), 0)
|
self.assertEqual(queryset.count(), 0)
|
||||||
self.assertEqual(queryset.count(), 3)
|
|
||||||
|
|
||||||
def test_date_range_filtering(self):
|
def test_date_range_filtering(self):
|
||||||
"""Test date range filter functionality"""
|
"""Test date range filter functionality"""
|
||||||
@@ -199,11 +202,17 @@ class ParkFilterTests(TestCase):
|
|||||||
self.assertEqual(queryset.count(), 1)
|
self.assertEqual(queryset.count(), 1)
|
||||||
self.assertIn(self.park2, queryset)
|
self.assertIn(self.park2, queryset)
|
||||||
|
|
||||||
# Test null owner (park3 has no owner)
|
# Test parks without owner
|
||||||
queryset = ParkFilter(data={"owner": "null"}).qs
|
queryset = ParkFilter(data={"has_owner": False}).qs
|
||||||
self.assertEqual(queryset.count(), 1)
|
self.assertEqual(queryset.count(), 1)
|
||||||
self.assertIn(self.park3, queryset)
|
self.assertIn(self.park3, queryset)
|
||||||
|
|
||||||
|
# Test parks with any owner
|
||||||
|
queryset = ParkFilter(data={"has_owner": True}).qs
|
||||||
|
self.assertEqual(queryset.count(), 2)
|
||||||
|
self.assertIn(self.park1, queryset)
|
||||||
|
self.assertIn(self.park2, queryset)
|
||||||
|
|
||||||
# Test empty filter (should return all)
|
# Test empty filter (should return all)
|
||||||
queryset = ParkFilter(data={}).qs
|
queryset = ParkFilter(data={}).qs
|
||||||
self.assertEqual(queryset.count(), 3)
|
self.assertEqual(queryset.count(), 3)
|
||||||
|
|||||||
@@ -51,26 +51,43 @@ 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 quick searches (dropdowns, etc)"""
|
"""Search parks and return results for quick searches with auto-suggestions"""
|
||||||
try:
|
try:
|
||||||
|
search_query = request.GET.get('search', '').strip()
|
||||||
|
if not search_query:
|
||||||
|
return HttpResponse('') # Return empty response for empty query
|
||||||
|
|
||||||
queryset = (
|
queryset = (
|
||||||
Park.objects.prefetch_related('location', 'photos')
|
Park.objects.select_related('owner')
|
||||||
|
.prefetch_related('location', 'photos')
|
||||||
|
.annotate(
|
||||||
|
ride_count=Count('rides'),
|
||||||
|
coaster_count=Count('rides', filter=Q(rides__category="RC"))
|
||||||
|
)
|
||||||
.order_by('name')
|
.order_by('name')
|
||||||
)
|
)
|
||||||
filter_params = {'search': request.GET.get('q', '').strip()}
|
|
||||||
|
|
||||||
park_filter = ParkFilter(filter_params, queryset=queryset)
|
# Use our existing filter but with search-specific configuration
|
||||||
parks = park_filter.qs[:10] # Limit to 10 results
|
park_filter = ParkFilter({
|
||||||
|
'search': search_query
|
||||||
|
}, queryset=queryset)
|
||||||
|
|
||||||
return render(request, "parks/partials/park_search_results.html", {
|
parks = park_filter.qs[:8] # Limit to 8 suggestions
|
||||||
|
|
||||||
|
response = render(request, "parks/partials/park_search_results.html", {
|
||||||
"parks": parks,
|
"parks": parks,
|
||||||
"is_quick_search": True
|
"is_quick_search": True
|
||||||
})
|
})
|
||||||
|
response['HX-Trigger'] = 'searchComplete'
|
||||||
|
return response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return render(request, "parks/partials/park_search_results.html", {
|
response = render(request, "parks/partials/park_search_results.html", {
|
||||||
"error": f"Error performing search: {str(e)}",
|
"error": f"Error performing search: {str(e)}",
|
||||||
"is_quick_search": True
|
"is_quick_search": True
|
||||||
})
|
})
|
||||||
|
response['HX-Trigger'] = 'searchError'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def location_search(request: HttpRequest) -> JsonResponse:
|
def location_search(request: HttpRequest) -> JsonResponse:
|
||||||
@@ -174,12 +191,14 @@ class ParkListView(HTMXFilterableMixin, ListView):
|
|||||||
"photos",
|
"photos",
|
||||||
"location",
|
"location",
|
||||||
"rides",
|
"rides",
|
||||||
"rides__manufacturer"
|
"rides__manufacturer",
|
||||||
|
"areas"
|
||||||
)
|
)
|
||||||
.annotate(
|
.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")),
|
||||||
)
|
)
|
||||||
|
.order_by("name") # Ensure consistent ordering for pagination
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(self.request, f"Error loading parks: {str(e)}")
|
messages.error(self.request, f"Error loading parks: {str(e)}")
|
||||||
|
|||||||
Reference in New Issue
Block a user