mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:31:07 -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
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class SearchConfig(AppConfig):
|
class SearchConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "search"
|
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",
|
"designers",
|
||||||
"analytics",
|
"analytics",
|
||||||
"location",
|
"location",
|
||||||
|
"search.apps.SearchConfig", # Add search app
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
Reference in New Issue
Block a user