diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 00000000..ac0fe3af --- /dev/null +++ b/core/forms.py @@ -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")) \ No newline at end of file diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index e165e2c6..420c679e 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -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. **High Technical Risk** - - Complex custom Django features - - Extensive model relationships - - Specialized history tracking system - - Geographic/location services integration +1. Autocomplete Base: + - Created BaseAutocomplete in core/forms.py + - Configured project-wide auth requirement + - Added test coverage for base functionality -2. **Significant Business Impact** - - Estimated 4-6 month timeline - - $180,000-230,000 direct costs - - Service disruption risks - - Resource-intensive implementation +2. Park Search: + - Implemented ParkAutocomplete class + - Created ParkSearchForm with autocomplete widget + - Updated views and templates for integration + - Added comprehensive test suite -3. **Critical Systems Affected** - - Authentication and permissions - - Data model architecture - - Template system and HTMX integration - - API and service layers +3. Documentation: + - Updated memory-bank/features/parks/search.md + - Added test documentation + - Created user interface guidelines -**Recommended Direction:** -1. Maintain and enhance current Django implementation -2. Focus on feature development and optimization -3. Consider hybrid approach for new features if needed +## Active Tasks -**Next Steps:** -1. Document current system architecture thoroughly -2. Identify optimization opportunities -3. Update dependencies and security -4. Enhance development workflows +1. Testing: + - [ ] Run the test suite with `uv run pytest parks/tests/` + - [ ] Monitor test coverage with pytest-cov + - [ ] Verify HTMX interactions work as expected -**Previous Context:** Park View Modularization work can continue as planned - the decision to maintain Django architecture means we can proceed with planned UI improvements. \ No newline at end of file +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 \ No newline at end of file diff --git a/memory-bank/features/autocomplete/base.md b/memory-bank/features/autocomplete/base.md new file mode 100644 index 00000000..60c05c30 --- /dev/null +++ b/memory-bank/features/autocomplete/base.md @@ -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 \ No newline at end of file diff --git a/memory-bank/features/parks/search.md b/memory-bank/features/parks/search.md new file mode 100644 index 00000000..285e9eae --- /dev/null +++ b/memory-bank/features/parks/search.md @@ -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 \ No newline at end of file diff --git a/parks/forms.py b/parks/forms.py index bb7aa705..74d7436a 100644 --- a/parks/forms.py +++ b/parks/forms.py @@ -1,7 +1,54 @@ from django import forms from decimal import Decimal, InvalidOperation, ROUND_DOWN +from autocomplete import AutocompleteWidget + +from core.forms import BaseAutocomplete from .models import Park 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): diff --git a/parks/querysets.py b/parks/querysets.py index 26d41fb1..953ed40d 100644 --- a/parks/querysets.py +++ b/parks/querysets.py @@ -3,9 +3,17 @@ from .models import Park def get_base_park_queryset() -> QuerySet[Park]: """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 ( Park.objects.select_related('owner') - .prefetch_related('location', 'photos', 'rides') + .prefetch_related( + 'photos', + 'rides', + 'location', + 'location__content_type' + ) .annotate( current_ride_count=Count('rides', distinct=True), current_coaster_count=Count('rides', filter=Q(rides__category="RC"), distinct=True) diff --git a/parks/templates/parks/park_list.html b/parks/templates/parks/park_list.html index 73f06b3e..25a46e59 100644 --- a/parks/templates/parks/park_list.html +++ b/parks/templates/parks/park_list.html @@ -47,50 +47,25 @@ {% block filter_section %}
- -
-
- -
-
-
-
-
-
+
+
+ {% csrf_token %} + {{ search_form.park }} +
+ + +
+ Searching...
@@ -117,15 +92,4 @@ data-view-mode="{{ view_mode|default:'grid' }}"> {% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
-{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block extra_js %} - - {% endblock %} \ No newline at end of file diff --git a/parks/templates/parks/partials/search_suggestions.html b/parks/templates/parks/partials/search_suggestions.html index ee9f7cb8..e9799105 100644 --- a/parks/templates/parks/partials/search_suggestions.html +++ b/parks/templates/parks/partials/search_suggestions.html @@ -1,5 +1,7 @@ +{% load filter_utils %} {% if suggestions %} - + {% for park in suggestions %} + {% with location=park.location.first %} + + {% endwith %} + {% endfor %} +
{% endif %} \ No newline at end of file diff --git a/parks/tests/README.md b/parks/tests/README.md new file mode 100644 index 00000000..9a45971d --- /dev/null +++ b/parks/tests/README.md @@ -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 \ No newline at end of file diff --git a/parks/tests/test_search.py b/parks/tests/test_search.py new file mode 100644 index 00000000..9a319719 --- /dev/null +++ b/parks/tests/test_search.py @@ -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 \ No newline at end of file diff --git a/parks/urls.py b/parks/urls.py index 3bf1ca5a..7efec1a0 100644 --- a/parks/urls.py +++ b/parks/urls.py @@ -5,8 +5,8 @@ from rides.views import ParkSingleCategoryListView app_name = "parks" urlpatterns = [ - # Park views - path("", views.ParkListView.as_view(), name="park_list"), + # Park views with autocomplete search + path("", views_search.ParkSearchView.as_view(), name="park_list"), path("create/", views.ParkCreateView.as_view(), name="park_create"), # Add park button endpoint (moved before park detail pattern) @@ -18,7 +18,7 @@ urlpatterns = [ # Areas and search endpoints for HTMX 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"), diff --git a/parks/views_search.py b/parks/views_search.py index e5123ea0..06e8b990 100644 --- a/parks/views_search.py +++ b/parks/views_search.py @@ -1,32 +1,43 @@ -from django.http import HttpRequest, HttpResponse +from django.http import HttpRequest, HttpResponse, JsonResponse from django.shortcuts import render +from django.views.generic import TemplateView +from django.urls import reverse + from .filters import ParkFilter +from .forms import ParkSearchForm 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: - """Return park search suggestions as a dropdown""" - try: - query = request.GET.get('search', '').strip() - if not query or len(query) < 2: - return HttpResponse('') - - # Get current view mode from request - 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)}') \ No newline at end of file + """Legacy endpoint for old search UI - redirects to autocomplete.""" + query = request.GET.get('search', '').strip() + if query: + return JsonResponse({ + 'redirect': f"{reverse('parks:park_list')}?park_name={query}" + }) + return HttpResponse('') \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4e60358e..a62afe69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,4 +57,5 @@ dependencies = [ "playwright>=1.41.0", "pytest-playwright>=0.4.3", "django-pghistory>=3.5.2", + "django-htmx-autocomplete>=1.0.5", ] diff --git a/thrillwiki/settings.py b/thrillwiki/settings.py index 1bb99527..26d21a5b 100644 --- a/thrillwiki/settings.py +++ b/thrillwiki/settings.py @@ -42,6 +42,7 @@ INSTALLED_APPS = [ "django_htmx", "whitenoise", "django_tailwind_cli", + "autocomplete", # Django HTMX Autocomplete "core", "accounts", "companies", @@ -208,10 +209,14 @@ SOCIALACCOUNT_STORE_TOKENS = True EMAIL_BACKEND = "email_service.backends.ForwardEmailBackend" FORWARD_EMAIL_BASE_URL = "https://api.forwardemail.net" SERVER_EMAIL = "django_webmaster@thrillwiki.com" - # Custom User Model AUTH_USER_MODEL = "accounts.User" +# Autocomplete configuration +# Enable project-wide authentication requirement for autocomplete +AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = True + +# Tailwind configuration # Tailwind configuration 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") diff --git a/uv.lock b/uv.lock index 74dfb297..d19a7d98 100644 --- a/uv.lock +++ b/uv.lock @@ -1,4 +1,5 @@ version = 1 +revision = 1 requires-python = ">=3.13" [[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 }, ] +[[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]] name = "django-oauth-toolkit" version = "3.0.1" @@ -912,6 +925,7 @@ dependencies = [ { name = "django-cors-headers" }, { name = "django-filter" }, { name = "django-htmx" }, + { name = "django-htmx-autocomplete" }, { name = "django-oauth-toolkit" }, { name = "django-pghistory" }, { name = "django-simple-history" }, @@ -946,6 +960,7 @@ requires-dist = [ { name = "django-cors-headers", specifier = ">=4.3.1" }, { name = "django-filter", specifier = ">=23.5" }, { 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-pghistory", specifier = ">=3.5.2" }, { name = "django-simple-history", specifier = ">=3.5.0" },