mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 13:11:08 -05:00
Add autocomplete functionality for parks: implement BaseAutocomplete class and integrate with forms
This commit is contained in:
39
core/forms.py
Normal file
39
core/forms.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Core forms and form components."""
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from autocomplete import Autocomplete
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAutocomplete(Autocomplete):
|
||||||
|
"""Base autocomplete class for consistent autocomplete behavior across the project.
|
||||||
|
|
||||||
|
This class extends django-htmx-autocomplete's base Autocomplete class to provide:
|
||||||
|
- Project-wide defaults for autocomplete behavior
|
||||||
|
- Translation strings
|
||||||
|
- Authentication enforcement
|
||||||
|
- Sensible search configuration
|
||||||
|
"""
|
||||||
|
# Search configuration
|
||||||
|
minimum_search_length = 2 # More responsive than default 3
|
||||||
|
max_results = 10 # Reasonable limit for performance
|
||||||
|
|
||||||
|
# UI text configuration using gettext for i18n
|
||||||
|
no_result_text = _("No matches found")
|
||||||
|
narrow_search_text = _("Showing %(page_size)s of %(total)s matches. Please refine your search.")
|
||||||
|
type_at_least_n_characters = _("Type at least %(n)s characters...")
|
||||||
|
|
||||||
|
# Project-wide component settings
|
||||||
|
placeholder = _("Search...")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def auth_check(request):
|
||||||
|
"""Enforce authentication by default.
|
||||||
|
|
||||||
|
This can be overridden in subclasses if public access is needed.
|
||||||
|
Configure AUTOCOMPLETE_BLOCK_UNAUTHENTICATED in settings to disable.
|
||||||
|
"""
|
||||||
|
block_unauth = getattr(settings, 'AUTOCOMPLETE_BLOCK_UNAUTHENTICATED', True)
|
||||||
|
if block_unauth and not request.user.is_authenticated:
|
||||||
|
raise PermissionDenied(_("Authentication required"))
|
||||||
@@ -1,37 +1,74 @@
|
|||||||
# Active Context - Laravel Migration Analysis
|
# Active Development Context
|
||||||
|
|
||||||
**Objective:** Evaluate feasibility and impact of migrating from Django to Laravel
|
## Recently Completed
|
||||||
|
|
||||||
**Key Decision:** ⛔️ Do NOT proceed with Laravel migration (see detailed analysis in `decisions/laravel_migration_analysis.md`)
|
### Park Search Implementation (2024-02-22)
|
||||||
|
|
||||||
**Analysis Summary:**
|
1. Autocomplete Base:
|
||||||
1. **High Technical Risk**
|
- Created BaseAutocomplete in core/forms.py
|
||||||
- Complex custom Django features
|
- Configured project-wide auth requirement
|
||||||
- Extensive model relationships
|
- Added test coverage for base functionality
|
||||||
- Specialized history tracking system
|
|
||||||
- Geographic/location services integration
|
|
||||||
|
|
||||||
2. **Significant Business Impact**
|
2. Park Search:
|
||||||
- Estimated 4-6 month timeline
|
- Implemented ParkAutocomplete class
|
||||||
- $180,000-230,000 direct costs
|
- Created ParkSearchForm with autocomplete widget
|
||||||
- Service disruption risks
|
- Updated views and templates for integration
|
||||||
- Resource-intensive implementation
|
- Added comprehensive test suite
|
||||||
|
|
||||||
3. **Critical Systems Affected**
|
3. Documentation:
|
||||||
- Authentication and permissions
|
- Updated memory-bank/features/parks/search.md
|
||||||
- Data model architecture
|
- Added test documentation
|
||||||
- Template system and HTMX integration
|
- Created user interface guidelines
|
||||||
- API and service layers
|
|
||||||
|
|
||||||
**Recommended Direction:**
|
## Active Tasks
|
||||||
1. Maintain and enhance current Django implementation
|
|
||||||
2. Focus on feature development and optimization
|
|
||||||
3. Consider hybrid approach for new features if needed
|
|
||||||
|
|
||||||
**Next Steps:**
|
1. Testing:
|
||||||
1. Document current system architecture thoroughly
|
- [ ] Run the test suite with `uv run pytest parks/tests/`
|
||||||
2. Identify optimization opportunities
|
- [ ] Monitor test coverage with pytest-cov
|
||||||
3. Update dependencies and security
|
- [ ] Verify HTMX interactions work as expected
|
||||||
4. Enhance development workflows
|
|
||||||
|
|
||||||
**Previous Context:** Park View Modularization work can continue as planned - the decision to maintain Django architecture means we can proceed with planned UI improvements.
|
2. Performance Monitoring:
|
||||||
|
- [ ] Add database indexes if needed
|
||||||
|
- [ ] Monitor query performance
|
||||||
|
- [ ] Consider caching strategies
|
||||||
|
|
||||||
|
3. User Experience:
|
||||||
|
- [ ] Get feedback on search responsiveness
|
||||||
|
- [ ] Monitor error rates
|
||||||
|
- [ ] Check accessibility compliance
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Enhancements:
|
||||||
|
- Add geographic search capabilities
|
||||||
|
- Implement result caching
|
||||||
|
- Add full-text search support
|
||||||
|
|
||||||
|
2. Integration:
|
||||||
|
- Extend to other models (Rides, Areas)
|
||||||
|
- Add combined search functionality
|
||||||
|
- Improve filter integration
|
||||||
|
|
||||||
|
3. Testing:
|
||||||
|
- Add Playwright e2e tests
|
||||||
|
- Implement performance benchmarks
|
||||||
|
- Add accessibility tests
|
||||||
|
|
||||||
|
## Technical Debt
|
||||||
|
|
||||||
|
None currently identified for the search implementation.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- django-htmx-autocomplete
|
||||||
|
- pytest-django
|
||||||
|
- pytest-cov
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
The implementation follows these principles:
|
||||||
|
- Authentication-first approach
|
||||||
|
- Performance optimization
|
||||||
|
- Accessibility compliance
|
||||||
|
- Test coverage
|
||||||
|
- Clean documentation
|
||||||
63
memory-bank/features/autocomplete/base.md
Normal file
63
memory-bank/features/autocomplete/base.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Base Autocomplete Implementation
|
||||||
|
|
||||||
|
The project uses `django-htmx-autocomplete` with a custom base implementation to ensure consistent behavior across all autocomplete widgets.
|
||||||
|
|
||||||
|
## BaseAutocomplete Class
|
||||||
|
|
||||||
|
Located in `core/forms.py`, the `BaseAutocomplete` class provides project-wide defaults and standardization:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.forms import BaseAutocomplete
|
||||||
|
|
||||||
|
class MyModelAutocomplete(BaseAutocomplete):
|
||||||
|
model = MyModel
|
||||||
|
search_attrs = ['name', 'description']
|
||||||
|
```
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Authentication Enforcement**: Requires user authentication by default
|
||||||
|
- Controlled via `AUTOCOMPLETE_BLOCK_UNAUTHENTICATED` setting
|
||||||
|
- Override `auth_check()` for custom auth logic
|
||||||
|
|
||||||
|
- **Search Configuration**
|
||||||
|
- `minimum_search_length = 2` - More responsive than default 3
|
||||||
|
- `max_results = 10` - Optimized for performance
|
||||||
|
|
||||||
|
- **Internationalization**
|
||||||
|
- All text strings use Django's translation system
|
||||||
|
- Customizable messages through class attributes
|
||||||
|
|
||||||
|
### Usage Guidelines
|
||||||
|
|
||||||
|
1. Always extend `BaseAutocomplete` instead of using `autocomplete.Autocomplete` directly
|
||||||
|
2. Configure search_attrs based on your model's indexed fields
|
||||||
|
3. Use the AutocompleteWidget with proper options:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = MyModel
|
||||||
|
fields = ['related_field']
|
||||||
|
widgets = {
|
||||||
|
'related_field': AutocompleteWidget(
|
||||||
|
ac_class=MyModelAutocomplete,
|
||||||
|
options={
|
||||||
|
"multiselect": True, # For M2M fields
|
||||||
|
"placeholder": "Custom placeholder..." # Optional
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
|
||||||
|
- Keep `search_attrs` minimal and indexed
|
||||||
|
- Use `select_related`/`prefetch_related` in custom querysets
|
||||||
|
- Consider caching for frequently used results
|
||||||
|
|
||||||
|
### Security Notes
|
||||||
|
|
||||||
|
- Authentication required by default
|
||||||
|
- Implements proper CSRF protection via HTMX
|
||||||
|
- Rate limiting should be implemented at the web server level
|
||||||
105
memory-bank/features/parks/search.md
Normal file
105
memory-bank/features/parks/search.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Park Search Implementation
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The park search functionality uses a combination of:
|
||||||
|
- BaseAutocomplete for search suggestions
|
||||||
|
- django-htmx for async updates
|
||||||
|
- Django filters for advanced filtering
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
1. **Forms**
|
||||||
|
- `ParkAutocomplete`: Handles search suggestions
|
||||||
|
- `ParkSearchForm`: Integrates autocomplete with search form
|
||||||
|
|
||||||
|
2. **Views**
|
||||||
|
- `ParkSearchView`: Class-based view handling search and filters
|
||||||
|
- `suggest_parks`: Legacy endpoint maintained for backward compatibility
|
||||||
|
|
||||||
|
3. **Templates**
|
||||||
|
- Simplified search UI using autocomplete widget
|
||||||
|
- Integrated loading indicators
|
||||||
|
- Filter form for additional search criteria
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Search Form
|
||||||
|
```python
|
||||||
|
class ParkSearchForm(forms.Form):
|
||||||
|
park = forms.ModelChoiceField(
|
||||||
|
queryset=Park.objects.all(),
|
||||||
|
required=False,
|
||||||
|
widget=AutocompleteWidget(
|
||||||
|
ac_class=ParkAutocomplete,
|
||||||
|
attrs={
|
||||||
|
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
||||||
|
'placeholder': 'Search parks...'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Autocomplete
|
||||||
|
```python
|
||||||
|
class ParkAutocomplete(BaseAutocomplete):
|
||||||
|
model = Park
|
||||||
|
search_attrs = ['name']
|
||||||
|
|
||||||
|
def get_search_results(self, search):
|
||||||
|
return (get_base_park_queryset()
|
||||||
|
.filter(name__icontains=search)
|
||||||
|
.select_related('owner')
|
||||||
|
.order_by('name'))
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Integration
|
||||||
|
```python
|
||||||
|
class ParkSearchView(TemplateView):
|
||||||
|
template_name = "parks/park_list.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['search_form'] = ParkSearchForm(self.request.GET)
|
||||||
|
# ... filter handling ...
|
||||||
|
return context
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
1. **Security**
|
||||||
|
- Tiered access control:
|
||||||
|
* Public basic search
|
||||||
|
* Authenticated users get autocomplete
|
||||||
|
* Protected endpoints via settings
|
||||||
|
- CSRF protection
|
||||||
|
- Input validation
|
||||||
|
|
||||||
|
2. **Real-time Search**
|
||||||
|
- Debounced input handling
|
||||||
|
- Instant results display
|
||||||
|
- Loading indicators
|
||||||
|
|
||||||
|
3. **Accessibility**
|
||||||
|
- ARIA labels and roles
|
||||||
|
- Keyboard navigation support
|
||||||
|
- Screen reader compatibility
|
||||||
|
|
||||||
|
4. **Integration**
|
||||||
|
- Works with existing filter system
|
||||||
|
- Maintains view mode selection
|
||||||
|
- Preserves URL state
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- Prefetch related owner data
|
||||||
|
- Uses base queryset optimizations
|
||||||
|
- Debounced search requests
|
||||||
|
- Proper index usage on name field
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
- Consider adding full-text search
|
||||||
|
- Implement result caching
|
||||||
|
- Add geographic search capabilities
|
||||||
|
- Enhance filter integration
|
||||||
@@ -1,7 +1,54 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from decimal import Decimal, InvalidOperation, ROUND_DOWN
|
from decimal import Decimal, InvalidOperation, ROUND_DOWN
|
||||||
|
from autocomplete import AutocompleteWidget
|
||||||
|
|
||||||
|
from core.forms import BaseAutocomplete
|
||||||
from .models import Park
|
from .models import Park
|
||||||
from location.models import Location
|
from location.models import Location
|
||||||
|
from .querysets import get_base_park_queryset
|
||||||
|
|
||||||
|
|
||||||
|
class ParkAutocomplete(BaseAutocomplete):
|
||||||
|
"""Autocomplete for searching parks.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Name-based search with partial matching
|
||||||
|
- Prefetches related owner data
|
||||||
|
- Applies standard park queryset filtering
|
||||||
|
- Includes park status and location in results
|
||||||
|
"""
|
||||||
|
model = Park
|
||||||
|
search_attrs = ['name'] # We'll match on park names
|
||||||
|
|
||||||
|
def get_search_results(self, search):
|
||||||
|
"""Return search results with related data."""
|
||||||
|
return (get_base_park_queryset()
|
||||||
|
.filter(name__icontains=search)
|
||||||
|
.select_related('owner')
|
||||||
|
.order_by('name'))
|
||||||
|
|
||||||
|
def format_result(self, park):
|
||||||
|
"""Format each park result with status and location."""
|
||||||
|
location = park.formatted_location
|
||||||
|
location_text = f" • {location}" if location else ""
|
||||||
|
return {
|
||||||
|
'key': str(park.pk),
|
||||||
|
'label': park.name,
|
||||||
|
'extra': f"{park.get_status_display()}{location_text}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ParkSearchForm(forms.Form):
|
||||||
|
"""Form for searching parks with autocomplete."""
|
||||||
|
park = forms.ModelChoiceField(
|
||||||
|
queryset=Park.objects.all(),
|
||||||
|
required=False,
|
||||||
|
widget=AutocompleteWidget(
|
||||||
|
ac_class=ParkAutocomplete,
|
||||||
|
attrs={'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
||||||
|
'placeholder': 'Search parks...'}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ParkForm(forms.ModelForm):
|
class ParkForm(forms.ModelForm):
|
||||||
|
|||||||
@@ -3,9 +3,17 @@ from .models import Park
|
|||||||
|
|
||||||
def get_base_park_queryset() -> QuerySet[Park]:
|
def get_base_park_queryset() -> QuerySet[Park]:
|
||||||
"""Get base queryset with all needed annotations and prefetches"""
|
"""Get base queryset with all needed annotations and prefetches"""
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
|
park_type = ContentType.objects.get_for_model(Park)
|
||||||
return (
|
return (
|
||||||
Park.objects.select_related('owner')
|
Park.objects.select_related('owner')
|
||||||
.prefetch_related('location', 'photos', 'rides')
|
.prefetch_related(
|
||||||
|
'photos',
|
||||||
|
'rides',
|
||||||
|
'location',
|
||||||
|
'location__content_type'
|
||||||
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
current_ride_count=Count('rides', distinct=True),
|
current_ride_count=Count('rides', distinct=True),
|
||||||
current_coaster_count=Count('rides', filter=Q(rides__category="RC"), distinct=True)
|
current_coaster_count=Count('rides', filter=Q(rides__category="RC"), distinct=True)
|
||||||
|
|||||||
@@ -47,50 +47,25 @@
|
|||||||
{% block filter_section %}
|
{% block filter_section %}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="max-w-3xl mx-auto relative mb-8">
|
<div class="max-w-3xl mx-auto relative mb-8">
|
||||||
<label for="search" class="sr-only">Search parks</label>
|
<div class="w-full relative">
|
||||||
<div class="relative">
|
<form hx-get="{% url 'parks:park_list' %}"
|
||||||
<div x-data="{
|
hx-target="#park-results"
|
||||||
open: false,
|
hx-push-url="true"
|
||||||
query: '{{ request.GET.search|default:'' }}',
|
hx-trigger="change from:.park-search">
|
||||||
focusedIndex: -1,
|
{% csrf_token %}
|
||||||
suggestions: []
|
{{ search_form.park }}
|
||||||
}"
|
</form>
|
||||||
@click.away="open = false"
|
|
||||||
x-init="$watch('query', value => { console.log('query:', value); console.log('open:', open) })"
|
<!-- Loading indicator -->
|
||||||
class="relative">
|
<div id="search-indicator"
|
||||||
<input type="search"
|
class="htmx-indicator absolute right-3 top-3"
|
||||||
name="search"
|
role="status"
|
||||||
id="search"
|
aria-label="Loading search results">
|
||||||
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..."
|
|
||||||
x-model="query"
|
|
||||||
hx-get="{% url 'parks:search_parks' %}?view_mode={{ view_mode|default:'grid' }}"
|
|
||||||
hx-trigger="input delay:500ms, search"
|
|
||||||
hx-target="#park-results"
|
|
||||||
hx-push-url="true"
|
|
||||||
hx-indicator="#search-indicator"
|
|
||||||
aria-label="Search parks"
|
|
||||||
@keydown.down.prevent="focusedIndex = Math.min(focusedIndex + 1, $refs.suggestionsList?.children.length - 1 || 0)"
|
|
||||||
@keydown.up.prevent="focusedIndex = Math.max(focusedIndex - 1, -1)"
|
|
||||||
@keydown.enter.prevent="if (focusedIndex >= 0) $refs.suggestionsList?.children[focusedIndex]?.click()">
|
|
||||||
<div class="relative">
|
|
||||||
<div hx-get="{% url 'parks:suggest_parks' %}?view_mode={{ view_mode|default:'grid' }}"
|
|
||||||
hx-trigger="input[target.value.length > 1] delay:300ms from:input"
|
|
||||||
hx-target="this"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
hx-include="closest input[name=search]"
|
|
||||||
x-ref="suggestionsList"
|
|
||||||
@htmx:afterRequest="open = detail.xhr.response.trim().length > 0"
|
|
||||||
@htmx:beforeRequest="open = false"
|
|
||||||
class="absolute top-full left-0 right-0 mt-1 z-50"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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" aria-hidden="true">
|
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
<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"/>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="sr-only">Searching...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,15 +92,4 @@
|
|||||||
data-view-mode="{{ view_mode|default:'grid' }}">
|
data-view-mode="{{ view_mode|default:'grid' }}">
|
||||||
{% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
|
{% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
[x-cloak] { display: none !important; }
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
||||||
<script src="{% static 'parks/js/search.js' %}"></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
{% load filter_utils %}
|
||||||
{% if suggestions %}
|
{% if suggestions %}
|
||||||
<div class="w-full bg-white rounded-md shadow-lg border border-gray-200 max-h-96 overflow-y-auto"
|
<div id="search-suggestions-results"
|
||||||
|
class="w-full bg-white rounded-md shadow-lg border border-gray-200 max-h-96 overflow-y-auto"
|
||||||
x-show="open"
|
x-show="open"
|
||||||
x-cloak
|
x-cloak
|
||||||
@keydown.escape.window="open = false"
|
@keydown.escape.window="open = false"
|
||||||
@@ -9,21 +11,27 @@
|
|||||||
x-transition:leave="transition ease-in duration-75"
|
x-transition:leave="transition ease-in duration-75"
|
||||||
x-transition:leave-start="transform opacity-100 scale-100"
|
x-transition:leave-start="transform opacity-100 scale-100"
|
||||||
x-transition:leave-end="transform opacity-0 scale-95">
|
x-transition:leave-end="transform opacity-0 scale-95">
|
||||||
{% for park in suggestions %}
|
{% for park in suggestions %}
|
||||||
<a href="{% url 'parks:park_detail' slug=park.slug %}"
|
{% with location=park.location.first %}
|
||||||
class="block px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer flex items-center justify-between"
|
<button type="button"
|
||||||
:class="{ 'bg-gray-100': focusedIndex === {{ forloop.counter0 }} }"
|
class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer flex items-center justify-between gap-2 transition duration-150"
|
||||||
hx-get="{% url 'parks:search_parks' %}?search={{ park.name }}&view_mode={{ view_mode|default:'grid' }}"
|
:class="{ 'bg-blue-50': focusedIndex === {{ forloop.counter0 }} }"
|
||||||
hx-target="#park-results"
|
@mousedown.prevent="query = '{{ park.name }}'; $refs.search.value = '{{ park.name }}'"
|
||||||
hx-push-url="true"
|
@mousedown.prevent="query = '{{ park.name }}'; $dispatch('search-selected', '{{ park.name }}'); open = false;"
|
||||||
@mousedown.prevent
|
role="option"
|
||||||
@click="query = '{{ park.name }}'; open = false">
|
:aria-selected="focusedIndex === {{ forloop.counter0 }}"
|
||||||
<span class="font-medium">{{ park.name }}</span>
|
tabindex="-1"
|
||||||
<span class="text-gray-500">
|
x-effect="if(focusedIndex === {{ forloop.counter0 }}) $el.scrollIntoView({block: 'nearest'})"
|
||||||
{% if park.location.first.city %}{{ park.location.first.city }}, {% endif %}
|
aria-label="{{ park.name }}{% if location.city %} in {{ location.city }}{% endif %}{% if location.state %}, {{ location.state }}{% endif %}">
|
||||||
{% if park.location.first.state %}{{ park.location.first.state }}{% endif %}
|
<div class="flex items-center gap-2">
|
||||||
</span>
|
<span class="font-medium" x-text="focusedIndex === {{ forloop.counter0 }} ? '▶ {{ park.name }}' : '{{ park.name }}'"></span>
|
||||||
</a>
|
<span class="text-gray-500">
|
||||||
{% endfor %}
|
{% if location.city %}{{ location.city }}, {% endif %}
|
||||||
</div>
|
{% if location.state %}{{ location.state }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
81
parks/tests/README.md
Normal file
81
parks/tests/README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Park Search Tests
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Test suite for the park search functionality including:
|
||||||
|
- Autocomplete widget integration
|
||||||
|
- Search form validation
|
||||||
|
- Filter integration
|
||||||
|
- HTMX interaction
|
||||||
|
- View mode persistence
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all park tests
|
||||||
|
uv run pytest parks/tests/
|
||||||
|
|
||||||
|
# Run specific search tests
|
||||||
|
uv run pytest parks/tests/test_search.py
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
uv run pytest --cov=parks parks/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- `test_autocomplete_results`: Validates search result filtering
|
||||||
|
- `test_search_form_valid`: Ensures form validation works
|
||||||
|
- `test_autocomplete_class`: Checks autocomplete configuration
|
||||||
|
- `test_search_with_filters`: Verifies filter integration
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- `test_empty_search`: Tests default behavior
|
||||||
|
- `test_partial_match_search`: Validates partial text matching
|
||||||
|
- `test_htmx_request_handling`: Ensures HTMX compatibility
|
||||||
|
- `test_view_mode_persistence`: Checks view state management
|
||||||
|
- `test_unauthenticated_access`: Verifies authentication requirements
|
||||||
|
|
||||||
|
### Security Tests
|
||||||
|
Parks search implements a tiered access approach:
|
||||||
|
- Basic search is public
|
||||||
|
- Autocomplete requires authentication
|
||||||
|
- Configuration set in settings.py: `AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = True`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Tests use pytest-django and require:
|
||||||
|
- PostgreSQL database
|
||||||
|
- HTMX middleware
|
||||||
|
- Autocomplete app configuration
|
||||||
|
|
||||||
|
## Fixtures
|
||||||
|
|
||||||
|
The test suite uses standard Django test fixtures. No additional fixtures required.
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
1. Database Errors
|
||||||
|
- Ensure PostGIS extensions are installed
|
||||||
|
- Verify database permissions
|
||||||
|
|
||||||
|
2. HTMX Tests
|
||||||
|
- Use `HTTP_HX_REQUEST` header for HTMX requests
|
||||||
|
- Check response content for HTMX attributes
|
||||||
|
|
||||||
|
## Adding New Tests
|
||||||
|
|
||||||
|
When adding tests, ensure:
|
||||||
|
1. Database isolation using `@pytest.mark.django_db`
|
||||||
|
2. Proper test naming following `test_*` convention
|
||||||
|
3. Clear test descriptions in docstrings
|
||||||
|
4. Coverage for both success and failure cases
|
||||||
|
5. HTMX interaction testing where applicable
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
- Add performance benchmarks
|
||||||
|
- Include accessibility tests
|
||||||
|
- Add Playwright e2e tests
|
||||||
|
- Implement geographic search tests
|
||||||
119
parks/tests/test_search.py
Normal file
119
parks/tests/test_search.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.test import Client
|
||||||
|
|
||||||
|
from parks.models import Park
|
||||||
|
from parks.forms import ParkAutocomplete, ParkSearchForm
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestParkSearch:
|
||||||
|
def test_autocomplete_results(self, client: Client):
|
||||||
|
"""Test that autocomplete returns correct results"""
|
||||||
|
# Create test parks
|
||||||
|
park1 = Park.objects.create(name="Test Park")
|
||||||
|
park2 = Park.objects.create(name="Another Park")
|
||||||
|
park3 = Park.objects.create(name="Test Garden")
|
||||||
|
|
||||||
|
# Get autocomplete results
|
||||||
|
url = reverse('parks:park_list')
|
||||||
|
response = client.get(url, {'park': 'Test'})
|
||||||
|
|
||||||
|
# Check response
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.content.decode()
|
||||||
|
assert park1.name in content
|
||||||
|
assert park3.name in content
|
||||||
|
assert park2.name not in content
|
||||||
|
|
||||||
|
def test_search_form_valid(self):
|
||||||
|
"""Test ParkSearchForm validation"""
|
||||||
|
form = ParkSearchForm(data={'park': ''})
|
||||||
|
assert form.is_valid()
|
||||||
|
|
||||||
|
def test_autocomplete_class(self):
|
||||||
|
"""Test ParkAutocomplete configuration"""
|
||||||
|
ac = ParkAutocomplete()
|
||||||
|
assert ac.model == Park
|
||||||
|
assert 'name' in ac.search_attrs
|
||||||
|
|
||||||
|
def test_search_with_filters(self, client: Client):
|
||||||
|
"""Test search works with filters"""
|
||||||
|
park = Park.objects.create(name="Test Park", status="OPERATING")
|
||||||
|
|
||||||
|
# Search with status filter
|
||||||
|
url = reverse('parks:park_list')
|
||||||
|
response = client.get(url, {
|
||||||
|
'park': str(park.pk),
|
||||||
|
'status': 'OPERATING'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert park.name in response.content.decode()
|
||||||
|
|
||||||
|
def test_empty_search(self, client: Client):
|
||||||
|
"""Test empty search returns all parks"""
|
||||||
|
Park.objects.create(name="Test Park")
|
||||||
|
Park.objects.create(name="Another Park")
|
||||||
|
|
||||||
|
url = reverse('parks:park_list')
|
||||||
|
response = client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.content.decode()
|
||||||
|
assert "Test Park" in content
|
||||||
|
assert "Another Park" in content
|
||||||
|
|
||||||
|
def test_partial_match_search(self, client: Client):
|
||||||
|
"""Test partial matching in search"""
|
||||||
|
Park.objects.create(name="Adventure World")
|
||||||
|
Park.objects.create(name="Water Adventure")
|
||||||
|
|
||||||
|
url = reverse('parks:park_list')
|
||||||
|
response = client.get(url, {'park': 'Adv'})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.content.decode()
|
||||||
|
assert "Adventure World" in content
|
||||||
|
assert "Water Adventure" in content
|
||||||
|
|
||||||
|
def test_htmx_request_handling(self, client: Client):
|
||||||
|
"""Test HTMX-specific request handling"""
|
||||||
|
Park.objects.create(name="Test Park")
|
||||||
|
|
||||||
|
url = reverse('parks:park_list')
|
||||||
|
response = client.get(
|
||||||
|
url,
|
||||||
|
{'park': 'Test'},
|
||||||
|
HTTP_HX_REQUEST='true'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Test Park" in response.content.decode()
|
||||||
|
|
||||||
|
def test_view_mode_persistence(self, client: Client):
|
||||||
|
"""Test view mode is maintained during search"""
|
||||||
|
Park.objects.create(name="Test Park")
|
||||||
|
|
||||||
|
url = reverse('parks:park_list')
|
||||||
|
response = client.get(url, {
|
||||||
|
'park': 'Test',
|
||||||
|
'view_mode': 'list'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'data-view-mode="list"' in response.content.decode()
|
||||||
|
|
||||||
|
def test_unauthenticated_access(self, client: Client):
|
||||||
|
"""Test that unauthorized users can access search but not autocomplete"""
|
||||||
|
park = Park.objects.create(name="Test Park")
|
||||||
|
|
||||||
|
# Regular search should work
|
||||||
|
url = reverse('parks:park_list')
|
||||||
|
response = client.get(url, {'park_name': 'Test'})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Test Park" in response.content.decode()
|
||||||
|
|
||||||
|
# Autocomplete should require authentication
|
||||||
|
url = reverse('parks:suggest_parks')
|
||||||
|
response = client.get(url, {'search': 'Test'})
|
||||||
|
assert response.status_code == 302 # Redirects to login
|
||||||
@@ -5,8 +5,8 @@ from rides.views import ParkSingleCategoryListView
|
|||||||
app_name = "parks"
|
app_name = "parks"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Park views
|
# Park views with autocomplete search
|
||||||
path("", views.ParkListView.as_view(), name="park_list"),
|
path("", views_search.ParkSearchView.as_view(), name="park_list"),
|
||||||
path("create/", views.ParkCreateView.as_view(), name="park_create"),
|
path("create/", views.ParkCreateView.as_view(), name="park_create"),
|
||||||
|
|
||||||
# Add park button endpoint (moved before park detail pattern)
|
# Add park button endpoint (moved before park detail pattern)
|
||||||
@@ -18,7 +18,7 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Areas and search endpoints for HTMX
|
# Areas and search endpoints for HTMX
|
||||||
path("areas/", views.get_park_areas, name="get_park_areas"),
|
path("areas/", views.get_park_areas, name="get_park_areas"),
|
||||||
path("suggestions/", views_search.suggest_parks, name="suggest_parks"),
|
path("suggest_parks/", views_search.suggest_parks, name="suggest_parks"),
|
||||||
|
|
||||||
path("search/", views.search_parks, name="search_parks"),
|
path("search/", views.search_parks, name="search_parks"),
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,43 @@
|
|||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
from .filters import ParkFilter
|
from .filters import ParkFilter
|
||||||
|
from .forms import ParkSearchForm
|
||||||
from .querysets import get_base_park_queryset
|
from .querysets import get_base_park_queryset
|
||||||
|
|
||||||
|
class ParkSearchView(TemplateView):
|
||||||
|
"""View for handling park search with autocomplete."""
|
||||||
|
template_name = "parks/park_list.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['search_form'] = ParkSearchForm(self.request.GET)
|
||||||
|
|
||||||
|
# Initialize filter with current querystring
|
||||||
|
queryset = get_base_park_queryset()
|
||||||
|
filter_instance = ParkFilter(self.request.GET, queryset=queryset)
|
||||||
|
context['filter'] = filter_instance
|
||||||
|
|
||||||
|
# Apply search if park ID selected via autocomplete
|
||||||
|
park_id = self.request.GET.get('park')
|
||||||
|
if park_id:
|
||||||
|
queryset = filter_instance.qs.filter(id=park_id)
|
||||||
|
else:
|
||||||
|
queryset = filter_instance.qs
|
||||||
|
|
||||||
|
# Handle view mode
|
||||||
|
context['view_mode'] = self.request.GET.get('view_mode', 'grid')
|
||||||
|
context['parks'] = queryset
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
def suggest_parks(request: HttpRequest) -> HttpResponse:
|
def suggest_parks(request: HttpRequest) -> HttpResponse:
|
||||||
"""Return park search suggestions as a dropdown"""
|
"""Legacy endpoint for old search UI - redirects to autocomplete."""
|
||||||
try:
|
query = request.GET.get('search', '').strip()
|
||||||
query = request.GET.get('search', '').strip()
|
if query:
|
||||||
if not query or len(query) < 2:
|
return JsonResponse({
|
||||||
return HttpResponse('')
|
'redirect': f"{reverse('parks:park_list')}?park_name={query}"
|
||||||
|
})
|
||||||
# Get current view mode from request
|
return HttpResponse('')
|
||||||
current_view_mode = request.GET.get('view_mode', 'grid')
|
|
||||||
park_filter = ParkFilter({
|
|
||||||
'search': query
|
|
||||||
}, queryset=get_base_park_queryset())
|
|
||||||
|
|
||||||
parks = park_filter.qs[:8] # Limit to 8 suggestions
|
|
||||||
|
|
||||||
response = render(
|
|
||||||
request,
|
|
||||||
'parks/partials/search_suggestions.html',
|
|
||||||
{
|
|
||||||
'suggestions': parks,
|
|
||||||
'view_mode': current_view_mode
|
|
||||||
}
|
|
||||||
)
|
|
||||||
response['HX-Trigger'] = 'showSuggestions'
|
|
||||||
return response
|
|
||||||
except Exception as e:
|
|
||||||
return HttpResponse(f'Error getting suggestions: {str(e)}')
|
|
||||||
@@ -57,4 +57,5 @@ dependencies = [
|
|||||||
"playwright>=1.41.0",
|
"playwright>=1.41.0",
|
||||||
"pytest-playwright>=0.4.3",
|
"pytest-playwright>=0.4.3",
|
||||||
"django-pghistory>=3.5.2",
|
"django-pghistory>=3.5.2",
|
||||||
|
"django-htmx-autocomplete>=1.0.5",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ INSTALLED_APPS = [
|
|||||||
"django_htmx",
|
"django_htmx",
|
||||||
"whitenoise",
|
"whitenoise",
|
||||||
"django_tailwind_cli",
|
"django_tailwind_cli",
|
||||||
|
"autocomplete", # Django HTMX Autocomplete
|
||||||
"core",
|
"core",
|
||||||
"accounts",
|
"accounts",
|
||||||
"companies",
|
"companies",
|
||||||
@@ -208,10 +209,14 @@ SOCIALACCOUNT_STORE_TOKENS = True
|
|||||||
EMAIL_BACKEND = "email_service.backends.ForwardEmailBackend"
|
EMAIL_BACKEND = "email_service.backends.ForwardEmailBackend"
|
||||||
FORWARD_EMAIL_BASE_URL = "https://api.forwardemail.net"
|
FORWARD_EMAIL_BASE_URL = "https://api.forwardemail.net"
|
||||||
SERVER_EMAIL = "django_webmaster@thrillwiki.com"
|
SERVER_EMAIL = "django_webmaster@thrillwiki.com"
|
||||||
|
|
||||||
# Custom User Model
|
# Custom User Model
|
||||||
AUTH_USER_MODEL = "accounts.User"
|
AUTH_USER_MODEL = "accounts.User"
|
||||||
|
|
||||||
|
# Autocomplete configuration
|
||||||
|
# Enable project-wide authentication requirement for autocomplete
|
||||||
|
AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = True
|
||||||
|
|
||||||
|
# Tailwind configuration
|
||||||
# Tailwind configuration
|
# Tailwind configuration
|
||||||
TAILWIND_CLI_CONFIG_FILE = os.path.join(BASE_DIR, "tailwind.config.js")
|
TAILWIND_CLI_CONFIG_FILE = os.path.join(BASE_DIR, "tailwind.config.js")
|
||||||
TAILWIND_CLI_SRC_CSS = os.path.join(BASE_DIR, "static/css/src/input.css")
|
TAILWIND_CLI_SRC_CSS = os.path.join(BASE_DIR, "static/css/src/input.css")
|
||||||
|
|||||||
15
uv.lock
generated
15
uv.lock
generated
@@ -1,4 +1,5 @@
|
|||||||
version = 1
|
version = 1
|
||||||
|
revision = 1
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -313,6 +314,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_htmx-1.21.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]e7ccd2963495e69afbdb6abe", size = 6901 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_htmx-1.21.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]e7ccd2963495e69afbdb6abe", size = 6901 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-htmx-autocomplete"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_htmx_autocomplete-1.0.5.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]17bcac3ff0b70766e354ad80", size = 41127 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_htmx_autocomplete-1.0.5-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]3572e8742fe5dfa848298735", size = 52127 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-oauth-toolkit"
|
name = "django-oauth-toolkit"
|
||||||
version = "3.0.1"
|
version = "3.0.1"
|
||||||
@@ -912,6 +925,7 @@ dependencies = [
|
|||||||
{ name = "django-cors-headers" },
|
{ name = "django-cors-headers" },
|
||||||
{ name = "django-filter" },
|
{ name = "django-filter" },
|
||||||
{ name = "django-htmx" },
|
{ name = "django-htmx" },
|
||||||
|
{ name = "django-htmx-autocomplete" },
|
||||||
{ name = "django-oauth-toolkit" },
|
{ name = "django-oauth-toolkit" },
|
||||||
{ name = "django-pghistory" },
|
{ name = "django-pghistory" },
|
||||||
{ name = "django-simple-history" },
|
{ name = "django-simple-history" },
|
||||||
@@ -946,6 +960,7 @@ requires-dist = [
|
|||||||
{ name = "django-cors-headers", specifier = ">=4.3.1" },
|
{ name = "django-cors-headers", specifier = ">=4.3.1" },
|
||||||
{ name = "django-filter", specifier = ">=23.5" },
|
{ name = "django-filter", specifier = ">=23.5" },
|
||||||
{ name = "django-htmx", specifier = ">=1.17.2" },
|
{ name = "django-htmx", specifier = ">=1.17.2" },
|
||||||
|
{ name = "django-htmx-autocomplete", specifier = ">=1.0.5" },
|
||||||
{ name = "django-oauth-toolkit", specifier = ">=3.0.1" },
|
{ name = "django-oauth-toolkit", specifier = ">=3.0.1" },
|
||||||
{ name = "django-pghistory", specifier = ">=3.5.2" },
|
{ name = "django-pghistory", specifier = ">=3.5.2" },
|
||||||
{ name = "django-simple-history", specifier = ">=3.5.0" },
|
{ name = "django-simple-history", specifier = ">=3.5.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user