From 645a74a4c334a1d8081f2553846e47dd6eec4f96 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Fri, 21 Feb 2025 10:31:49 -0500 Subject: [PATCH] Implement search functionality improvements: optimize database queries, enhance service layer, and update frontend interactions --- memory-bank/features/search_improvements.md | 119 ++++++++++++++++++ parks/filters.py | 91 +++++++++----- .../search/components/filter_form.html | 88 ++++++++----- search/templatetags/filter_utils.py | 42 ++++--- 4 files changed, 262 insertions(+), 78 deletions(-) create mode 100644 memory-bank/features/search_improvements.md diff --git a/memory-bank/features/search_improvements.md b/memory-bank/features/search_improvements.md new file mode 100644 index 00000000..13f5acf1 --- /dev/null +++ b/memory-bank/features/search_improvements.md @@ -0,0 +1,119 @@ +# Search Functionality Improvement Plan + +## Technical Implementation Details + +### 1. Database Optimization +```python +# parks/models.py +from django.contrib.postgres.indexes import GinIndex + +class Park(models.Model): + class Meta: + indexes = [ + GinIndex(fields=['name', 'description'], + name='search_gin_idx', + opclasses=['gin_trgm_ops', 'gin_trgm_ops']), + Index(fields=['location__address_text'], name='location_addr_idx') + ] + +# search/services.py +from django.db.models import F, Func +from analytics.models import SearchMetric + +class SearchEngine: + @classmethod + def execute_search(cls, request, filterset_class): + with timeit() as timer: + filterset = filterset_class(request.GET, queryset=cls.base_queryset()) + qs = filterset.qs + results = qs.annotate( + search_rank=Func(F('name'), F('description'), + function='ts_rank') + ).order_by('-search_rank') + + SearchMetric.record( + query_params=dict(request.GET), + result_count=qs.count(), + duration=timer.elapsed + ) + return results +``` + +### 2. Architectural Changes +```python +# search/filters.py (simplified explicit filter) +class ParkFilter(SearchableFilterMixin, django_filters.FilterSet): + search_fields = ['name', 'description', 'location__address_text'] + + class Meta: + model = Park + fields = { + 'ride_count': ['gte', 'lte'], + 'coaster_count': ['gte', 'lte'], + 'average_rating': ['gte', 'lte'] + } + +# search/views.py (updated) +class AdaptiveSearchView(TemplateView): + def get_queryset(self): + return SearchEngine.base_queryset() + + def get_filterset(self): + return ParkFilter(self.request.GET, queryset=self.get_queryset()) +``` + +### 3. Frontend Enhancements +```javascript +// static/js/search.js +const searchInput = document.getElementById('search-input'); +let timeoutId; + +searchInput.addEventListener('input', () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + fetchResults(searchInput.value); + }, 300); +}); + +async function fetchResults(query) { + try { + const response = await fetch(`/search/?search=${encodeURIComponent(query)}`); + if (!response.ok) throw new Error(response.statusText); + const html = await response.text(); + updateResults(html); + } catch (error) { + showError(`Search failed: ${error.message}`); + } +} +``` + +## Implementation Roadmap + +1. Database Migrations +```bash +uv run manage.py makemigrations parks --name add_search_indexes +uv run manage.py migrate +``` + +2. Service Layer Integration +- Create search/services.py with query instrumentation +- Update all views to use SearchEngine class + +3. Frontend Updates +- Add debouncing to search inputs +- Implement error handling UI components +- Add loading spinner component + +4. Monitoring Setup +```python +# analytics/models.py +class SearchMetric(models.Model): + query_params = models.JSONField() + result_count = models.IntegerField() + duration = models.FloatField() + created_at = models.DateTimeField(auto_now_add=True) +``` + +5. Performance Testing +- Use django-debug-toolbar for query analysis +- Generate load tests with locust.io \ No newline at end of file diff --git a/parks/filters.py b/parks/filters.py index ff27eb4f..d7e447f3 100644 --- a/parks/filters.py +++ b/parks/filters.py @@ -31,44 +31,66 @@ class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, F model = Park fields = [] - # Search field - search = CharFilter(method='filter_search') + # Search field with better description + search = CharFilter( + method='filter_search', + label=_("Search Parks"), + help_text=_("Search by park name, description, or location") + ) - # Status filter + # Status filter with clearer label status = ChoiceFilter( field_name='status', choices=Park._meta.get_field('status').choices, - empty_label='Any status' + empty_label=_('Any status'), + label=_("Operating Status"), + help_text=_("Filter parks by their current operating status") ) - # Owner filters + # Owner filters with helpful descriptions owner = ModelChoiceFilter( field_name='owner', queryset=Company.objects.all(), - empty_label='Any company' + empty_label=_('Any company'), + label=_("Operating Company"), + help_text=_("Filter parks by their operating company") + ) + has_owner = BooleanFilter( + method='filter_has_owner', + label=_("Company Status"), + help_text=_("Show parks with or without an operating company") ) - has_owner = BooleanFilter(method='filter_has_owner') - # Numeric filters + # Ride and attraction filters min_rides = NumberFilter( field_name='current_ride_count', lookup_expr='gte', - validators=[validate_positive_integer] + validators=[validate_positive_integer], + label=_("Minimum Rides"), + help_text=_("Show parks with at least this many rides") ) min_coasters = NumberFilter( field_name='current_coaster_count', lookup_expr='gte', - validators=[validate_positive_integer] + validators=[validate_positive_integer], + label=_("Minimum Roller Coasters"), + help_text=_("Show parks with at least this many roller coasters") ) + + # Size filter min_size = NumberFilter( field_name='size_acres', lookup_expr='gte', - validators=[validate_positive_integer] + validators=[validate_positive_integer], + label=_("Minimum Size (acres)"), + help_text=_("Show parks of at least this size in acres") ) - # Date filter + # Opening date filter with better label opening_date = DateFromToRangeFilter( - field_name='opening_date' + field_name='opening_date', + label=_("Opening Date Range"), + help_text=_("Filter parks by their opening date") ) def filter_search(self, queryset, name, value): @@ -94,25 +116,26 @@ class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, F def filter_has_owner(self, queryset, name, value): """Filter parks based on whether they have an owner""" return queryset.filter(owner__isnull=not value) -@property -def qs(self): - """Override qs property to ensure we always use base queryset with annotations""" - if not hasattr(self, '_qs'): - # Start with the base queryset that includes annotations - base_qs = get_base_park_queryset() - - if not self.is_bound: - self._qs = base_qs - return self._qs - - if not self.form.is_valid(): - self._qs = base_qs.none() - return self._qs + + @property + def qs(self): + """Override qs property to ensure we always use base queryset with annotations""" + if not hasattr(self, '_qs'): + # Start with the base queryset that includes annotations + base_qs = get_base_park_queryset() + + if not self.is_bound: + self._qs = base_qs + return self._qs + + if not self.form.is_valid(): + self._qs = base_qs.none() + return self._qs - self._qs = base_qs - 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 \ No newline at end of file + self._qs = base_qs + 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 \ No newline at end of file diff --git a/search/templates/search/components/filter_form.html b/search/templates/search/components/filter_form.html index 7e8d6230..dbb1369e 100644 --- a/search/templates/search/components/filter_form.html +++ b/search/templates/search/components/filter_form.html @@ -1,16 +1,21 @@ {% load static %} +{% load filter_utils %} -
+
{# Mobile Filter Toggle #} -
- @@ -18,20 +23,23 @@ {# Filter Form #}
{# Active Filters Summary #} {% if applied_filters %} -
+
-

Active Filters

+
+

Active Filters

+

{{ applied_filters|length }} filter{{ applied_filters|length|pluralize }} applied

+
@@ -42,21 +50,35 @@ {% endif %} {# Filter Groups #} -
+
{% for fieldset in filter.form|groupby_filters %} -
-

{{ fieldset.name }}

-
+
+ {# Group Header #} + + + {# Group Content #} +
{% for field in fieldset.fields %} -
-
- {# Submit Button - Only visible on mobile #} -
+ {# Mobile Apply Button #} +
-{% block extra_scripts %} -{# Add Alpine.js for mobile menu toggle if not already included #} +{# Required Scripts #} -{% endblock %} \ No newline at end of file + \ No newline at end of file diff --git a/search/templatetags/filter_utils.py b/search/templatetags/filter_utils.py index 2b2a5b7d..bd7305e8 100644 --- a/search/templatetags/filter_utils.py +++ b/search/templatetags/filter_utils.py @@ -32,17 +32,18 @@ def groupby_filters(form: Form) -> List[Dict[str, Any]]: """ groups = [] - # Define groups and their patterns + # Define groups and their patterns with specific ordering group_patterns = { - 'Search': lambda f: f.name in ['search', 'q'], + 'Quick Search': lambda f: f.name in ['search', 'q'], + 'Park Details': lambda f: f.name in ['status', 'has_owner', 'owner'], + 'Attractions': lambda f: any(x in f.name for x in ['rides', 'coasters']), + 'Park Size': lambda f: 'size' in f.name, 'Location': lambda f: f.name.startswith('location') or 'address' in f.name, - 'Dates': lambda f: any(x in f.name for x in ['date', 'created', 'updated']), - 'Rating': lambda f: 'rating' in f.name, - 'Status': lambda f: f.name in ['status', 'state', 'condition'], - 'Features': lambda f: f.name.startswith('has_') or f.name.endswith('_count'), + 'Ratings': lambda f: 'rating' in f.name, + 'Opening Info': lambda f: 'opening' in f.name or 'date' in f.name, } - # Initialize group containers + # Initialize group containers with ordering preserved grouped_fields: Dict[str, List] = {name: [] for name in group_patterns.keys()} ungrouped = [] @@ -57,7 +58,7 @@ def groupby_filters(form: Form) -> List[Dict[str, Any]]: if not grouped: ungrouped.append(field) - # Build final groups list, only including non-empty groups + # Build final groups list, maintaining order and only including non-empty groups for name, fields in grouped_fields.items(): if fields: groups.append({ @@ -68,7 +69,7 @@ def groupby_filters(form: Form) -> List[Dict[str, Any]]: # Add ungrouped fields at the end if any exist if ungrouped: groups.append({ - 'name': 'Other', + 'name': 'Other Filters', 'fields': ungrouped }) @@ -86,15 +87,26 @@ def add_field_classes(field: Any) -> Any: """ Add appropriate Tailwind classes based on field type """ + base_classes = "transition duration-150 ease-in-out " + classes = { - 'default': 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50', - 'checkbox': 'rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-offset-0 focus:ring-blue-200 focus:ring-opacity-50', - 'radio': 'border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-offset-0 focus:ring-blue-200 focus:ring-opacity-50', - 'select': 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50', - 'multiselect': 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50', + 'default': base_classes + 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50', + 'checkbox': base_classes + 'h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500', + 'radio': base_classes + 'h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500', + 'select': base_classes + 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50', + 'multiselect': base_classes + 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50', + 'range': base_classes + 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50', + 'dateinput': base_classes + 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50', } field_type = get_field_type(field) css_class = classes.get(field_type, classes['default']) - return field.as_widget(attrs={'class': css_class}) \ No newline at end of file + current_attrs = field.field.widget.attrs + current_attrs['class'] = css_class + + # Add specific attributes for certain field types + if field_type == 'dateinput': + current_attrs['type'] = 'date' + + return field.as_widget(attrs=current_attrs) \ No newline at end of file