Add search app configuration, views, and templates for advanced filtering functionality

This commit is contained in:
pacnpal
2025-02-12 12:58:04 -05:00
parent 62723d0e33
commit af57592496
14 changed files with 1047 additions and 3 deletions

106
search/README.md Normal file
View 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.

View File

@@ -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
View 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
View 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
View 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'
"""

View 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 %}

View 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>

View 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 %}

View 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 %}

View 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 %}

View 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
View 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'),
]

View File

@@ -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

View File

@@ -55,6 +55,7 @@ INSTALLED_APPS = [
"designers",
"analytics",
"location",
"search.apps.SearchConfig", # Add search app
]
MIDDLEWARE = [