mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:11:08 -05:00
Add search app configuration, views, and templates for advanced filtering functionality
This commit is contained in:
106
search/README.md
Normal file
106
search/README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Search & Filter System
|
||||
|
||||
A flexible, reusable search and filtering system that can be used across any Django model in the project.
|
||||
|
||||
## Features
|
||||
|
||||
- Modular filter system with composable mixins
|
||||
- HTMX integration for dynamic updates
|
||||
- Responsive, accessible filter UI components
|
||||
- Automatic filter generation based on model fields
|
||||
- Location-based filtering support
|
||||
- Flexible template system
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Implementation
|
||||
|
||||
Add filtering to any ListView by using the `HTMXFilterableMixin`:
|
||||
|
||||
```python
|
||||
from django.views.generic import ListView
|
||||
from search.mixins import HTMXFilterableMixin
|
||||
|
||||
class MyModelListView(HTMXFilterableMixin, ListView):
|
||||
model = MyModel
|
||||
template_name = "myapp/mymodel_list.html"
|
||||
search_fields = ['name', 'description'] # Fields to include in text search
|
||||
```
|
||||
|
||||
### Custom Filters
|
||||
|
||||
Add custom filters for specific model needs:
|
||||
|
||||
```python
|
||||
additional_filters = {
|
||||
'category': ChoiceFilter(choices=MyModel.CATEGORY_CHOICES),
|
||||
'rating': RangeFilter(field_name='average_rating'),
|
||||
}
|
||||
```
|
||||
|
||||
### Template Integration
|
||||
|
||||
Extend the base filtered list template:
|
||||
|
||||
```html
|
||||
{% extends "search/layouts/filtered_list.html" %}
|
||||
|
||||
{% block list_actions %}
|
||||
<a href="{% url 'myapp:create' %}" class="btn btn-primary">
|
||||
Add New
|
||||
</a>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
### Custom Result Display
|
||||
|
||||
Create a custom results template in `templates/search/partials/mymodel_results.html`:
|
||||
|
||||
```html
|
||||
<div class="divide-y">
|
||||
{% for object in object_list %}
|
||||
<div class="p-4">
|
||||
<h3>{{ object.name }}</h3>
|
||||
<!-- Custom display logic -->
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### Mixins
|
||||
|
||||
- `LocationFilterMixin`: Adds location-based filtering
|
||||
- `RatingFilterMixin`: Adds rating range filters
|
||||
- `DateRangeFilterMixin`: Adds date range filtering
|
||||
|
||||
### Factory Function
|
||||
|
||||
Use `create_model_filter` to dynamically create filters:
|
||||
|
||||
```python
|
||||
MyModelFilter = create_model_filter(
|
||||
model=MyModel,
|
||||
search_fields=['name', 'description'],
|
||||
mixins=[LocationFilterMixin, RatingFilterMixin],
|
||||
additional_filters={...}
|
||||
)
|
||||
```
|
||||
|
||||
### Template Tags
|
||||
|
||||
- `model_name`: Get human-readable model name
|
||||
- `groupby_filters`: Group filter fields logically
|
||||
- `add_field_classes`: Add Tailwind classes to form fields
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Use `select_related` and `prefetch_related` in your querysets
|
||||
- Index commonly filtered fields
|
||||
- Consider caching for static filter choices
|
||||
- Use the built-in pagination
|
||||
|
||||
## Examples
|
||||
|
||||
See `search/examples.py` for detailed implementation examples across different model types.
|
||||
@@ -1,6 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SearchConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "search"
|
||||
|
||||
137
search/examples.py
Normal file
137
search/examples.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Examples of how to use the search/filter system in different contexts.
|
||||
These are example implementations - DO NOT import or use this file directly.
|
||||
"""
|
||||
|
||||
from django.views.generic import ListView
|
||||
from django_filters import CharFilter, ChoiceFilter, NumberFilter, DateFromToRangeFilter
|
||||
from django.db.models import Q
|
||||
|
||||
from .mixins import HTMXFilterableMixin
|
||||
from .filters import LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, create_model_filter
|
||||
|
||||
# Example 1: Basic List View with Filtering
|
||||
"""
|
||||
class RideListView(HTMXFilterableMixin, ListView):
|
||||
model = Ride
|
||||
template_name = "rides/ride_list.html"
|
||||
paginate_by = 20
|
||||
|
||||
# Define search fields for text search
|
||||
search_fields = ['name', 'description', 'manufacturer__name']
|
||||
|
||||
# Add any model-specific filters
|
||||
additional_filters = {
|
||||
'category': ChoiceFilter(choices=Ride.CATEGORY_CHOICES),
|
||||
'manufacturer': ModelChoiceFilter(queryset=Manufacturer.objects.all()),
|
||||
'status': ChoiceFilter(choices=Ride.STATUS_CHOICES),
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().select_related('park', 'manufacturer')
|
||||
"""
|
||||
|
||||
# Example 2: Using create_model_filter for Dynamic Filter Creation
|
||||
"""
|
||||
# Create a filter for Company model
|
||||
CompanyFilter = create_model_filter(
|
||||
model=Company,
|
||||
search_fields=['name', 'description', 'headquarters'],
|
||||
mixins=[LocationFilterMixin, DateRangeFilterMixin],
|
||||
additional_filters={
|
||||
'min_parks': NumberFilter(
|
||||
field_name='parks__count',
|
||||
lookup_expr='gte',
|
||||
label='Minimum Parks'
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
class CompanyListView(HTMXFilterableMixin, ListView):
|
||||
model = Company
|
||||
filter_class = CompanyFilter
|
||||
template_name = "companies/company_list.html"
|
||||
"""
|
||||
|
||||
# Example 3: Custom Filter Implementation
|
||||
"""
|
||||
class ManufacturerFilter(FilterSet):
|
||||
search = CharFilter(method='filter_search')
|
||||
country = ChoiceFilter(choices=COUNTRY_CHOICES)
|
||||
founded_date = DateFromToRangeFilter()
|
||||
min_rides = NumberFilter(field_name='rides__count', lookup_expr='gte')
|
||||
|
||||
class Meta:
|
||||
model = Manufacturer
|
||||
fields = {
|
||||
'status': ['exact'],
|
||||
'type': ['exact', 'in'],
|
||||
}
|
||||
|
||||
def filter_search(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value) |
|
||||
Q(rides__name__icontains=value)
|
||||
).distinct()
|
||||
"""
|
||||
|
||||
# Example 4: Custom Template Implementation
|
||||
"""
|
||||
{# templates/search/partials/ride_results.html #}
|
||||
<div class="divide-y">
|
||||
{% for ride in object_list %}
|
||||
<div class="p-4 flex items-start space-x-4">
|
||||
{% if ride.photos.exists %}
|
||||
<img src="{{ ride.photos.first.image.url }}"
|
||||
alt="{{ ride.name }}"
|
||||
class="w-24 h-24 object-cover rounded-lg">
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold">
|
||||
<a href="{{ ride.get_absolute_url }}">{{ ride.name }}</a>
|
||||
</h3>
|
||||
|
||||
<div class="mt-1 text-sm text-gray-500">
|
||||
{{ ride.get_category_display }} at
|
||||
<a href="{{ ride.park.get_absolute_url }}"
|
||||
class="text-blue-600 hover:underline">
|
||||
{{ ride.park.name }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if ride.manufacturer %}
|
||||
<div class="mt-1 text-sm">
|
||||
Built by {{ ride.manufacturer.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ ride.get_status_color }}">
|
||||
{{ ride.get_status_display }}
|
||||
</span>
|
||||
|
||||
{% if ride.opening_date %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Opened {{ ride.opening_date|date:"Y" }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.average_rating %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{{ ride.average_rating }} ★
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
No rides found matching your criteria
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
"""
|
||||
155
search/filters.py
Normal file
155
search/filters.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from typing import Any, Dict, Type
|
||||
from django.db import models
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
import django_filters
|
||||
|
||||
class SearchableFilterMixin:
|
||||
"""
|
||||
Mixin that adds basic search functionality to any filter
|
||||
"""
|
||||
search = django_filters.CharFilter(method='filter_search')
|
||||
search_fields: list[str] = [] # Override in child class
|
||||
|
||||
def filter_search(self, queryset: QuerySet, name: str, value: str) -> QuerySet:
|
||||
"""
|
||||
Generic search method that can be customized by setting search_fields
|
||||
"""
|
||||
if not value or not self.search_fields:
|
||||
return queryset
|
||||
|
||||
queries = Q()
|
||||
for field in self.search_fields:
|
||||
queries |= Q(**{f"{field}__icontains": value})
|
||||
return queryset.filter(queries).distinct()
|
||||
|
||||
class LocationFilterMixin:
|
||||
"""
|
||||
Mixin to add location-based filtering capabilities
|
||||
"""
|
||||
location = django_filters.CharFilter(method='filter_location')
|
||||
location_fields: list[str] = ['location__address_text'] # Override if needed
|
||||
|
||||
def filter_location(self, queryset: QuerySet, name: str, value: str) -> QuerySet:
|
||||
if not value or not self.location_fields:
|
||||
return queryset
|
||||
|
||||
queries = Q()
|
||||
for field in self.location_fields:
|
||||
queries |= Q(**{f"{field}__icontains": value})
|
||||
return queryset.filter(queries).distinct()
|
||||
|
||||
class RatingFilterMixin:
|
||||
"""
|
||||
Mixin to add rating-based filtering
|
||||
"""
|
||||
min_rating = django_filters.NumberFilter(field_name='average_rating', lookup_expr='gte')
|
||||
max_rating = django_filters.NumberFilter(field_name='average_rating', lookup_expr='lte')
|
||||
rating_range = django_filters.RangeFilter(field_name='average_rating')
|
||||
|
||||
class DateRangeFilterMixin:
|
||||
"""
|
||||
Mixin to add date range filtering
|
||||
"""
|
||||
date_field: str = 'created_at' # Override in child class
|
||||
start_date = django_filters.DateFilter(field_name=date_field, lookup_expr='gte')
|
||||
end_date = django_filters.DateFilter(field_name=date_field, lookup_expr='lte')
|
||||
date_range = django_filters.DateFromToRangeFilter(field_name=date_field)
|
||||
|
||||
class BaseModelFilter(django_filters.FilterSet):
|
||||
"""
|
||||
Base filter class that can be used with any model
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setup_dynamic_filters()
|
||||
|
||||
def setup_dynamic_filters(self):
|
||||
"""
|
||||
Set up any dynamic filters based on model fields
|
||||
"""
|
||||
model_fields = self.Meta.model._meta.get_fields()
|
||||
|
||||
for field in model_fields:
|
||||
# Add choice filters for fields with choices
|
||||
if hasattr(field, 'choices') and field.choices:
|
||||
self.filters[field.name] = django_filters.ChoiceFilter(
|
||||
choices=field.choices
|
||||
)
|
||||
|
||||
# Add related filters for ForeignKey fields
|
||||
elif isinstance(field, models.ForeignKey):
|
||||
self.filters[f"{field.name}"] = django_filters.ModelChoiceFilter(
|
||||
queryset=field.related_model.objects.all()
|
||||
)
|
||||
|
||||
def create_model_filter(
|
||||
model: Type[models.Model],
|
||||
search_fields: list[str] = None,
|
||||
exclude_fields: list[str] = None,
|
||||
additional_filters: Dict[str, Any] = None,
|
||||
mixins: list[Type] = None
|
||||
) -> Type[django_filters.FilterSet]:
|
||||
"""
|
||||
Factory function to create a filter class for any model with customizable options
|
||||
|
||||
Args:
|
||||
model: The Django model to create filters for
|
||||
search_fields: List of fields to include in text search
|
||||
exclude_fields: List of fields to exclude from automatic filter generation
|
||||
additional_filters: Dict of additional custom filters to add
|
||||
mixins: List of filter mixins to include
|
||||
|
||||
Returns:
|
||||
A new FilterSet class configured for the model
|
||||
"""
|
||||
if exclude_fields is None:
|
||||
exclude_fields = []
|
||||
if search_fields is None:
|
||||
search_fields = ['name', 'description']
|
||||
if additional_filters is None:
|
||||
additional_filters = {}
|
||||
if mixins is None:
|
||||
mixins = []
|
||||
|
||||
# Start with base mixins
|
||||
class_bases = tuple(mixins) + (SearchableFilterMixin, BaseModelFilter,)
|
||||
|
||||
# Create the Meta class
|
||||
meta_attrs = {
|
||||
'model': model,
|
||||
'exclude': exclude_fields,
|
||||
}
|
||||
|
||||
# Create the filter class
|
||||
filter_class_attrs = {
|
||||
'Meta': type('Meta', (), meta_attrs),
|
||||
'search_fields': search_fields,
|
||||
**additional_filters
|
||||
}
|
||||
|
||||
# Create and return the new filter class
|
||||
return type(
|
||||
f'{model.__name__}Filter',
|
||||
class_bases,
|
||||
filter_class_attrs
|
||||
)
|
||||
|
||||
# Example usage:
|
||||
"""
|
||||
# Create a filter for any model with location and rating capabilities:
|
||||
ParkFilter = create_model_filter(
|
||||
model=Park,
|
||||
search_fields=['name', 'description'],
|
||||
mixins=[LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin],
|
||||
additional_filters={
|
||||
'min_rides': django_filters.NumberFilter(field_name='ride_count', lookup_expr='gte'),
|
||||
'min_coasters': django_filters.NumberFilter(field_name='coaster_count', lookup_expr='gte')
|
||||
}
|
||||
)
|
||||
|
||||
# The filter can then be used in views:
|
||||
class ParkListView(FilterView):
|
||||
model = Park
|
||||
filterset_class = ParkFilter
|
||||
"""
|
||||
87
search/mixins.py
Normal file
87
search/mixins.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from typing import Any, Dict, Optional, Type
|
||||
from django.db.models import QuerySet
|
||||
from django.views.generic.list import ListView
|
||||
from django_filters import FilterSet
|
||||
from .filters import create_model_filter, LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin
|
||||
|
||||
class FilterableViewMixin:
|
||||
"""
|
||||
Mixin to add filtering capabilities to any ListView
|
||||
"""
|
||||
filter_class: Optional[Type[FilterSet]] = None
|
||||
search_fields: list[str] = None
|
||||
exclude_fields: list[str] = None
|
||||
additional_filters: Dict[str, Any] = None
|
||||
filter_mixins: list[Type] = None
|
||||
template_name_suffix = '_list'
|
||||
|
||||
def get_filter_class(self) -> Type[FilterSet]:
|
||||
"""
|
||||
Get or create the filter class for the view
|
||||
"""
|
||||
if self.filter_class is not None:
|
||||
return self.filter_class
|
||||
|
||||
if not self.model:
|
||||
raise ValueError("Model must be defined to use FilterableViewMixin")
|
||||
|
||||
return create_model_filter(
|
||||
model=self.model,
|
||||
search_fields=self.search_fields,
|
||||
exclude_fields=self.exclude_fields,
|
||||
additional_filters=self.additional_filters,
|
||||
mixins=self.filter_mixins or []
|
||||
)
|
||||
|
||||
def get_filter_mixins(self) -> list:
|
||||
"""
|
||||
Get the filter mixins to use. Override to customize.
|
||||
"""
|
||||
return [LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin]
|
||||
|
||||
def get_queryset(self) -> QuerySet:
|
||||
"""
|
||||
Apply filters to the queryset
|
||||
"""
|
||||
queryset = super().get_queryset()
|
||||
self.filterset = self.get_filter_class()(self.request.GET, queryset=queryset)
|
||||
return self.filterset.qs
|
||||
|
||||
def get_context_data(self, **kwargs) -> Dict[str, Any]:
|
||||
"""
|
||||
Add filter-related context
|
||||
"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['filter'] = self.filterset
|
||||
context['applied_filters'] = bool(self.request.GET)
|
||||
return context
|
||||
|
||||
class HTMXFilterableMixin(FilterableViewMixin):
|
||||
"""
|
||||
Extension of FilterableViewMixin that adds HTMX support
|
||||
"""
|
||||
def get_template_names(self) -> list[str]:
|
||||
"""
|
||||
Return different templates based on HTMX request
|
||||
"""
|
||||
if self.request.htmx:
|
||||
# If it's an HTMX request, return just the results partial
|
||||
return [f"search/partials/{self.model._meta.model_name}_results.html"]
|
||||
return super().get_template_names()
|
||||
|
||||
# Example Usage:
|
||||
"""
|
||||
class ParkListView(HTMXFilterableMixin, ListView):
|
||||
model = Park
|
||||
template_name = 'parks/park_list.html'
|
||||
search_fields = ['name', 'description']
|
||||
additional_filters = {
|
||||
'min_rides': django_filters.NumberFilter(field_name='ride_count', lookup_expr='gte')
|
||||
}
|
||||
|
||||
# Or with a custom filter class:
|
||||
class RideListView(HTMXFilterableMixin, ListView):
|
||||
model = Ride
|
||||
filter_class = CustomRideFilter
|
||||
template_name = 'rides/ride_list.html'
|
||||
"""
|
||||
81
search/templates/search/components/filter_form.html
Normal file
81
search/templates/search/components/filter_form.html
Normal file
@@ -0,0 +1,81 @@
|
||||
{% load static %}
|
||||
|
||||
<div class="filter-container bg-white rounded-lg shadow p-4" 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"/>
|
||||
</svg>
|
||||
<svg class="w-5 h-5" x-show="open" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 14L4 8H16L10 14Z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Filter Form #}
|
||||
<form hx-get="{{ request.path }}"
|
||||
hx-trigger="change delay:500ms, submit"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
class="mt-4 lg:mt-0"
|
||||
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="flex justify-between items-center">
|
||||
<h3 class="text-sm font-medium text-blue-800">Active Filters</h3>
|
||||
<a href="{{ request.path }}"
|
||||
class="text-sm text-blue-600 hover:text-blue-500"
|
||||
hx-get="{{ request.path }}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true">
|
||||
Clear All
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Filter Groups #}
|
||||
<div class="space-y-4">
|
||||
{% 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">
|
||||
{% for field in fieldset.fields %}
|
||||
<div>
|
||||
<label for="{{ field.id_for_label }}" class="text-sm text-gray-600">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
{{ field }}
|
||||
</div>
|
||||
{% if field.help_text %}
|
||||
<p class="mt-1 text-xs text-gray-500">{{ field.help_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Submit Button - Only visible on mobile #}
|
||||
<div class="mt-4 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">
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% block extra_scripts %}
|
||||
{# Add Alpine.js for mobile menu toggle if not already included #}
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
{% endblock %}
|
||||
28
search/templates/search/filters.html
Normal file
28
search/templates/search/filters.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<form hx-get="{% url 'search:search' %}" hx-target="#search-results" hx-swap="outerHTML" class="space-y-4">
|
||||
{% for field in filters.form %}
|
||||
<div class="flex flex-col">
|
||||
<label for="{{ field.id_for_label }}" class="text-sm font-medium text-gray-700">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
{{ field }}
|
||||
</div>
|
||||
{% if field.help_text %}
|
||||
<p class="text-sm text-gray-500">{{ field.help_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="flex justify-between">
|
||||
<button type="submit"
|
||||
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Apply Filters
|
||||
</button>
|
||||
{% if applied_filters %}
|
||||
<a href="{% url 'search:search' %}"
|
||||
class="inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Clear Filters
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
61
search/templates/search/layouts/filtered_list.html
Normal file
61
search/templates/search/layouts/filtered_list.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ view.model|model_name_plural|title }} - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
{# Filters Sidebar #}
|
||||
<div class="lg:w-1/4">
|
||||
{% include "search/components/filter_form.html" %}
|
||||
</div>
|
||||
|
||||
{# Results Section #}
|
||||
<div class="lg:w-3/4">
|
||||
<div id="results-container">
|
||||
{# Result count and sorting #}
|
||||
<div class="bg-white rounded-lg shadow mb-4">
|
||||
<div class="p-4 border-b">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold">
|
||||
{{ view.model|model_name_plural|title }}
|
||||
<span class="text-sm font-normal text-gray-500">({{ page_obj.paginator.count }} found)</span>
|
||||
</h2>
|
||||
|
||||
{% block list_actions %}
|
||||
{# Custom actions can be added here by extending views #}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Results list #}
|
||||
{% block results_list %}
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
{% include results_template|default:"search/partials/generic_results.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{# Pagination #}
|
||||
{% if is_paginated %}
|
||||
<div class="mt-4">
|
||||
{% include "search/components/pagination.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Handle browser back/forward with HTMX
|
||||
document.body.addEventListener('htmx:beforeOnLoad', function(evt) {
|
||||
if (evt.detail.requestConfig.verb === "get") {
|
||||
history.replaceState(null, '', evt.detail.requestConfig.path);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
134
search/templates/search/partials/generic_results.html
Normal file
134
search/templates/search/partials/generic_results.html
Normal file
@@ -0,0 +1,134 @@
|
||||
<div class="divide-y">
|
||||
{% for object in object_list %}
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold">
|
||||
<a href="{{ object.get_absolute_url }}" class="hover:text-blue-600">
|
||||
{{ object }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{% if object.description %}
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
{{ object.description|truncatewords:30 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% block object_metadata %}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{% if object.created_at %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Added {{ object.created_at|date }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if object.average_rating %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{{ object.average_rating }} ★
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if object.location.exists %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ object.location.first.get_formatted_address }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
<p>No {{ view.model|model_name_plural }} found matching your criteria.</p>
|
||||
{% if applied_filters %}
|
||||
<p class="mt-2">
|
||||
<a href="{{ request.path }}"
|
||||
class="text-blue-600 hover:text-blue-500"
|
||||
hx-get="{{ request.path }}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true">
|
||||
Clear all filters
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<div class="px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
hx-get="?page={{ page_obj.previous_page_number }}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true">
|
||||
Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}"
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
hx-get="?page={{ page_obj.next_page_number }}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true">
|
||||
Next
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Showing <span class="font-medium">{{ page_obj.start_index }}</span>
|
||||
to <span class="font-medium">{{ page_obj.end_index }}</span>
|
||||
of <span class="font-medium">{{ page_obj.paginator.count }}</span>
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
hx-get="?page={{ page_obj.previous_page_number }}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true">
|
||||
<span class="sr-only">Previous</span>
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for i in page_obj.paginator.page_range %}
|
||||
{% if i == page_obj.number %}
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-blue-50 text-sm font-medium text-blue-600">
|
||||
{{ i }}
|
||||
</span>
|
||||
{% elif i > page_obj.number|add:"-3" and i < page_obj.number|add:"3" %}
|
||||
<a href="?page={{ i }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
hx-get="?page={{ i }}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true">
|
||||
{{ i }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
hx-get="?page={{ page_obj.next_page_number }}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true">
|
||||
<span class="sr-only">Next</span>
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
100
search/templates/search/results.html
Normal file
100
search/templates/search/results.html
Normal file
@@ -0,0 +1,100 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Search Parks - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
<!-- Filters Sidebar -->
|
||||
<div class="lg:w-1/4">
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-bold mb-4">Filter Parks</h2>
|
||||
{% include "search/filters.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div class="lg:w-3/4" id="search-results">
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="p-6 border-b">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold">
|
||||
Search Results
|
||||
<span class="text-sm font-normal text-gray-500">({{ results.count }} found)</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y">
|
||||
{% for park in results %}
|
||||
<div class="p-6 flex flex-col md:flex-row gap-4">
|
||||
<!-- Park Image -->
|
||||
<div class="md:w-48 h-32 bg-gray-200 rounded-lg overflow-hidden">
|
||||
{% if park.photos.exists %}
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-400">
|
||||
No Image
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Park Details -->
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold">
|
||||
<a href="{{ park.get_absolute_url }}" class="hover:text-blue-600">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<div class="mt-2 text-sm text-gray-600">
|
||||
{% if park.formatted_location %}
|
||||
<p>{{ park.formatted_location }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{% if park.average_rating %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{{ park.average_rating }} ★
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
|
||||
{% if park.ride_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{{ park.ride_count }} Rides
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if park.coaster_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{{ park.coaster_count }} Coasters
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if park.description %}
|
||||
<p class="mt-2 text-sm text-gray-600 line-clamp-2">
|
||||
{{ park.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="p-6 text-center text-gray-500">
|
||||
No parks found matching your criteria.
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
100
search/templatetags/filter_utils.py
Normal file
100
search/templatetags/filter_utils.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from django import template
|
||||
from django.forms import Form
|
||||
from django.db.models import Model
|
||||
from django.utils.text import camel_case_to_spaces
|
||||
from typing import Dict, List, Any
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.filter
|
||||
def model_name(model: Model) -> str:
|
||||
"""
|
||||
Get a human-readable name for a model
|
||||
"""
|
||||
if hasattr(model, '_meta'):
|
||||
return model._meta.verbose_name
|
||||
return camel_case_to_spaces(model.__class__.__name__)
|
||||
|
||||
@register.filter
|
||||
def model_name_plural(model: Model) -> str:
|
||||
"""
|
||||
Get a human-readable plural name for a model
|
||||
"""
|
||||
if hasattr(model, '_meta'):
|
||||
return model._meta.verbose_name_plural
|
||||
return f"{camel_case_to_spaces(model.__class__.__name__)}s"
|
||||
|
||||
@register.filter
|
||||
def groupby_filters(form: Form) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Group form fields into logical sections for the filter form.
|
||||
Groups are determined by field name prefixes or types.
|
||||
"""
|
||||
groups = []
|
||||
|
||||
# Define groups and their patterns
|
||||
group_patterns = {
|
||||
'Search': lambda f: f.name in ['search', 'q'],
|
||||
'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'),
|
||||
}
|
||||
|
||||
# Initialize group containers
|
||||
grouped_fields: Dict[str, List] = {name: [] for name in group_patterns.keys()}
|
||||
ungrouped = []
|
||||
|
||||
# Sort fields into groups
|
||||
for field in form:
|
||||
grouped = False
|
||||
for group_name, matcher in group_patterns.items():
|
||||
if matcher(field):
|
||||
grouped_fields[group_name].append(field)
|
||||
grouped = True
|
||||
break
|
||||
if not grouped:
|
||||
ungrouped.append(field)
|
||||
|
||||
# Build final groups list, only including non-empty groups
|
||||
for name, fields in grouped_fields.items():
|
||||
if fields:
|
||||
groups.append({
|
||||
'name': name,
|
||||
'fields': fields
|
||||
})
|
||||
|
||||
# Add ungrouped fields at the end if any exist
|
||||
if ungrouped:
|
||||
groups.append({
|
||||
'name': 'Other',
|
||||
'fields': ungrouped
|
||||
})
|
||||
|
||||
return groups
|
||||
|
||||
@register.filter
|
||||
def get_field_type(field: Any) -> str:
|
||||
"""
|
||||
Get a normalized field type name for styling purposes
|
||||
"""
|
||||
return field.field.__class__.__name__.lower().replace('field', '')
|
||||
|
||||
@register.filter
|
||||
def add_field_classes(field: Any) -> Any:
|
||||
"""
|
||||
Add appropriate Tailwind classes based on field type
|
||||
"""
|
||||
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',
|
||||
}
|
||||
|
||||
field_type = get_field_type(field)
|
||||
css_class = classes.get(field_type, classes['default'])
|
||||
|
||||
return field.as_widget(attrs={'class': css_class})
|
||||
10
search/urls.py
Normal file
10
search/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'search'
|
||||
|
||||
urlpatterns = [
|
||||
# Park-specific advanced search
|
||||
path('parks/', views.AdaptiveSearchView.as_view(), name='search'),
|
||||
path('parks/filters/', views.FilterFormView.as_view(), name='filter_form'),
|
||||
]
|
||||
@@ -1,3 +1,48 @@
|
||||
from django.shortcuts import render
|
||||
from django.views.generic import TemplateView
|
||||
from parks.models import Park
|
||||
from .filters import ParkFilter
|
||||
|
||||
# Create your views here.
|
||||
class AdaptiveSearchView(TemplateView):
|
||||
template_name = "search/results.html"
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Get the base queryset, optimized with select_related and prefetch_related
|
||||
"""
|
||||
return Park.objects.select_related('owner').prefetch_related(
|
||||
'location',
|
||||
'photos'
|
||||
).all()
|
||||
|
||||
def get_filterset(self):
|
||||
"""
|
||||
Get the filterset instance
|
||||
"""
|
||||
return ParkFilter(self.request.GET, queryset=self.get_queryset())
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""
|
||||
Add filtered results and filter form to context
|
||||
"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
filterset = self.get_filterset()
|
||||
|
||||
context.update({
|
||||
'results': filterset.qs,
|
||||
'filters': filterset,
|
||||
'applied_filters': bool(self.request.GET), # Check if any filters are applied
|
||||
})
|
||||
|
||||
return context
|
||||
|
||||
class FilterFormView(TemplateView):
|
||||
"""
|
||||
View for rendering just the filter form for HTMX updates
|
||||
"""
|
||||
template_name = "search/filters.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
filterset = ParkFilter(self.request.GET, queryset=Park.objects.all())
|
||||
context['filters'] = filterset
|
||||
return context
|
||||
|
||||
@@ -55,6 +55,7 @@ INSTALLED_APPS = [
|
||||
"designers",
|
||||
"analytics",
|
||||
"location",
|
||||
"search.apps.SearchConfig", # Add search app
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
Reference in New Issue
Block a user