mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 09:51:09 -05:00
Implement search functionality improvements: optimize database queries, enhance service layer, and update frontend interactions
This commit is contained in:
119
memory-bank/features/search_improvements.md
Normal file
119
memory-bank/features/search_improvements.md
Normal file
@@ -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
|
||||
@@ -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,8 +116,9 @@ 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):
|
||||
|
||||
@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
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
{% load static %}
|
||||
{% load filter_utils %}
|
||||
|
||||
<div class="filter-container bg-white rounded-lg shadow p-4" x-data="{ open: false }">
|
||||
<div class="filter-container" x-data="{ open: false }">
|
||||
{# Mobile Filter Toggle #}
|
||||
<div class="lg:hidden">
|
||||
<button @click="open = !open" type="button" class="w-full flex items-center justify-between p-2 text-gray-400 hover:text-gray-500">
|
||||
<span class="font-medium text-gray-900">Filters</span>
|
||||
<span class="ml-6 flex items-center">
|
||||
<svg class="w-5 h-5" x-show="!open" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 6L16 12H4L10 6Z"/>
|
||||
<div class="lg:hidden bg-white rounded-lg shadow p-4 mb-4">
|
||||
<button @click="open = !open" type="button" class="w-full flex items-center justify-between p-2">
|
||||
<span class="font-medium text-gray-900">
|
||||
<span class="mr-2">
|
||||
<svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5" x-show="open" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 14L4 8H16L10 14Z"/>
|
||||
</span>
|
||||
Filter Options
|
||||
</span>
|
||||
<span class="text-gray-500">
|
||||
<svg class="w-5 h-5 transition-transform duration-200" :class="{'rotate-180': open}" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
@@ -18,20 +23,23 @@
|
||||
|
||||
{# Filter Form #}
|
||||
<form hx-get="{{ request.path }}"
|
||||
hx-trigger="change delay:500ms, submit"
|
||||
hx-trigger="change delay:500ms"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
class="mt-4 lg:mt-0"
|
||||
class="space-y-6"
|
||||
x-show="open || $screen('lg')"
|
||||
x-transition>
|
||||
|
||||
{# Active Filters Summary #}
|
||||
{% if applied_filters %}
|
||||
<div class="bg-blue-50 p-4 rounded-lg mb-4">
|
||||
<div class="bg-blue-50 rounded-lg p-4 shadow-sm border border-blue-100">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-blue-800">Active Filters</h3>
|
||||
<p class="text-xs text-blue-600 mt-1">{{ applied_filters|length }} filter{{ applied_filters|length|pluralize }} applied</p>
|
||||
</div>
|
||||
<a href="{{ request.path }}"
|
||||
class="text-sm text-blue-600 hover:text-blue-500"
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-500 hover:underline"
|
||||
hx-get="{{ request.path }}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true">
|
||||
@@ -42,21 +50,35 @@
|
||||
{% endif %}
|
||||
|
||||
{# Filter Groups #}
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white rounded-lg shadow divide-y divide-gray-200">
|
||||
{% for fieldset in filter.form|groupby_filters %}
|
||||
<div class="border-b border-gray-200 pb-4">
|
||||
<h3 class="text-sm font-medium text-gray-900 mb-3">{{ fieldset.name }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="p-6" x-data="{ expanded: true }">
|
||||
{# Group Header #}
|
||||
<button type="button"
|
||||
@click="expanded = !expanded"
|
||||
class="w-full flex justify-between items-center text-left">
|
||||
<h3 class="text-lg font-medium text-gray-900">{{ fieldset.name }}</h3>
|
||||
<svg class="w-5 h-5 text-gray-500 transform transition-transform duration-200"
|
||||
:class="{'rotate-180': !expanded}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20">
|
||||
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{# Group Content #}
|
||||
<div class="mt-4 space-y-4" x-show="expanded" x-collapse>
|
||||
{% for field in fieldset.fields %}
|
||||
<div>
|
||||
<label for="{{ field.id_for_label }}" class="text-sm text-gray-600">
|
||||
<div class="filter-field">
|
||||
<label for="{{ field.id_for_label }}"
|
||||
class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
{{ field }}
|
||||
{{ field|add_field_classes }}
|
||||
</div>
|
||||
{% if field.help_text %}
|
||||
<p class="mt-1 text-xs text-gray-500">{{ field.help_text }}</p>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ field.help_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -65,17 +87,25 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Submit Button - Only visible on mobile #}
|
||||
<div class="mt-4 lg:hidden">
|
||||
{# Mobile Apply Button #}
|
||||
<div class="lg:hidden">
|
||||
<button type="submit"
|
||||
class="w-full bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
class="w-full bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition duration-150 ease-in-out">
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% block extra_scripts %}
|
||||
{# Add Alpine.js for mobile menu toggle if not already included #}
|
||||
{# Required Scripts #}
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
{% endblock %}
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('filterForm', () => ({
|
||||
expanded: true,
|
||||
toggle() {
|
||||
this.expanded = !this.expanded
|
||||
}
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
@@ -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})
|
||||
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)
|
||||
Reference in New Issue
Block a user