diff --git a/search/README.md b/search/README.md new file mode 100644 index 00000000..2a92de60 --- /dev/null +++ b/search/README.md @@ -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 %} + + Add New + +{% endblock %} +``` + +### Custom Result Display + +Create a custom results template in `templates/search/partials/mymodel_results.html`: + +```html +
+ {% for object in object_list %} +
+

{{ object.name }}

+ +
+ {% endfor %} +
+``` + +## 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. \ No newline at end of file diff --git a/search/apps.py b/search/apps.py index 1c3a606d..8ebc9a8f 100644 --- a/search/apps.py +++ b/search/apps.py @@ -1,6 +1,5 @@ from django.apps import AppConfig - class SearchConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "search" diff --git a/search/examples.py b/search/examples.py new file mode 100644 index 00000000..df81b34f --- /dev/null +++ b/search/examples.py @@ -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 #} +
+ {% for ride in object_list %} +
+ {% if ride.photos.exists %} + {{ ride.name }} + {% endif %} + +
+

+ {{ ride.name }} +

+ +
+ {{ ride.get_category_display }} at + + {{ ride.park.name }} + +
+ + {% if ride.manufacturer %} +
+ Built by {{ ride.manufacturer.name }} +
+ {% endif %} + +
+ + {{ ride.get_status_display }} + + + {% if ride.opening_date %} + + Opened {{ ride.opening_date|date:"Y" }} + + {% endif %} + + {% if ride.average_rating %} + + {{ ride.average_rating }} ★ + + {% endif %} +
+
+
+ {% empty %} +
+ No rides found matching your criteria +
+ {% endfor %} +
+""" diff --git a/search/filters.py b/search/filters.py new file mode 100644 index 00000000..ad090042 --- /dev/null +++ b/search/filters.py @@ -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 +""" \ No newline at end of file diff --git a/search/mixins.py b/search/mixins.py new file mode 100644 index 00000000..4b2df82b --- /dev/null +++ b/search/mixins.py @@ -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' +""" \ No newline at end of file diff --git a/search/templates/search/components/filter_form.html b/search/templates/search/components/filter_form.html new file mode 100644 index 00000000..7e8d6230 --- /dev/null +++ b/search/templates/search/components/filter_form.html @@ -0,0 +1,81 @@ +{% load static %} + +
+ {# Mobile Filter Toggle #} +
+ +
+ + {# Filter Form #} +
+ + {# Active Filters Summary #} + {% if applied_filters %} +
+
+

Active Filters

+ + Clear All + +
+
+ {% endif %} + + {# Filter Groups #} +
+ {% for fieldset in filter.form|groupby_filters %} +
+

{{ fieldset.name }}

+
+ {% for field in fieldset.fields %} +
+ +
+ {{ field }} +
+ {% if field.help_text %} +

{{ field.help_text }}

+ {% endif %} +
+ {% endfor %} +
+
+ {% endfor %} +
+ + {# Submit Button - Only visible on mobile #} +
+ +
+
+
+ +{% block extra_scripts %} +{# Add Alpine.js for mobile menu toggle if not already included #} + +{% endblock %} \ No newline at end of file diff --git a/search/templates/search/filters.html b/search/templates/search/filters.html new file mode 100644 index 00000000..71033e33 --- /dev/null +++ b/search/templates/search/filters.html @@ -0,0 +1,28 @@ +
+ {% for field in filters.form %} +
+ +
+ {{ field }} +
+ {% if field.help_text %} +

{{ field.help_text }}

+ {% endif %} +
+ {% endfor %} + +
+ + {% if applied_filters %} + + Clear Filters + + {% endif %} +
+
\ No newline at end of file diff --git a/search/templates/search/layouts/filtered_list.html b/search/templates/search/layouts/filtered_list.html new file mode 100644 index 00000000..2c0c14ab --- /dev/null +++ b/search/templates/search/layouts/filtered_list.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}{{ view.model|model_name_plural|title }} - ThrillWiki{% endblock %} + +{% block content %} +
+
+ {# Filters Sidebar #} +
+ {% include "search/components/filter_form.html" %} +
+ + {# Results Section #} +
+
+ {# Result count and sorting #} +
+
+
+

+ {{ view.model|model_name_plural|title }} + ({{ page_obj.paginator.count }} found) +

+ + {% block list_actions %} + {# Custom actions can be added here by extending views #} + {% endblock %} +
+
+
+ + {# Results list #} + {% block results_list %} +
+ {% include results_template|default:"search/partials/generic_results.html" %} +
+ {% endblock %} + + {# Pagination #} + {% if is_paginated %} +
+ {% include "search/components/pagination.html" %} +
+ {% endif %} +
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} \ No newline at end of file diff --git a/search/templates/search/partials/generic_results.html b/search/templates/search/partials/generic_results.html new file mode 100644 index 00000000..dc766ee8 --- /dev/null +++ b/search/templates/search/partials/generic_results.html @@ -0,0 +1,134 @@ +
+ {% for object in object_list %} +
+

+ + {{ object }} + +

+ + {% if object.description %} +

+ {{ object.description|truncatewords:30 }} +

+ {% endif %} + + {% block object_metadata %} +
+ {% if object.created_at %} + + Added {{ object.created_at|date }} + + {% endif %} + + {% if object.average_rating %} + + {{ object.average_rating }} ★ + + {% endif %} + + {% if object.location.exists %} + + {{ object.location.first.get_formatted_address }} + + {% endif %} +
+ {% endblock %} +
+ {% empty %} +
+

No {{ view.model|model_name_plural }} found matching your criteria.

+ {% if applied_filters %} +

+ + Clear all filters + +

+ {% endif %} +
+ {% endfor %} +
+ +{% if is_paginated %} +
+
+ {% if page_obj.has_previous %} + + Previous + + {% endif %} + {% if page_obj.has_next %} + + Next + + {% endif %} +
+ +
+{% endif %} \ No newline at end of file diff --git a/search/templates/search/results.html b/search/templates/search/results.html new file mode 100644 index 00000000..df958b49 --- /dev/null +++ b/search/templates/search/results.html @@ -0,0 +1,100 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Search Parks - ThrillWiki{% endblock %} + +{% block content %} +
+
+ +
+
+

Filter Parks

+ {% include "search/filters.html" %} +
+
+ + +
+
+
+
+

+ Search Results + ({{ results.count }} found) +

+
+
+ +
+ {% for park in results %} +
+ +
+ {% if park.photos.exists %} + {{ park.name }} + {% else %} +
+ No Image +
+ {% endif %} +
+ + +
+

+ + {{ park.name }} + +

+ +
+ {% if park.formatted_location %} +

{{ park.formatted_location }}

+ {% endif %} +
+ +
+ {% if park.average_rating %} + + {{ park.average_rating }} ★ + + {% endif %} + + + {{ park.get_status_display }} + + + {% if park.ride_count %} + + {{ park.ride_count }} Rides + + {% endif %} + + {% if park.coaster_count %} + + {{ park.coaster_count }} Coasters + + {% endif %} +
+ + {% if park.description %} +

+ {{ park.description }} +

+ {% endif %} +
+
+ {% empty %} +
+ No parks found matching your criteria. +
+ {% endfor %} +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/search/templatetags/filter_utils.py b/search/templatetags/filter_utils.py new file mode 100644 index 00000000..2b2a5b7d --- /dev/null +++ b/search/templatetags/filter_utils.py @@ -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}) \ No newline at end of file diff --git a/search/urls.py b/search/urls.py new file mode 100644 index 00000000..b91119eb --- /dev/null +++ b/search/urls.py @@ -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'), +] \ No newline at end of file diff --git a/search/views.py b/search/views.py index 91ea44a2..4699e468 100644 --- a/search/views.py +++ b/search/views.py @@ -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 diff --git a/thrillwiki/settings.py b/thrillwiki/settings.py index 3220b6a7..1bb99527 100644 --- a/thrillwiki/settings.py +++ b/thrillwiki/settings.py @@ -55,6 +55,7 @@ INSTALLED_APPS = [ "designers", "analytics", "location", + "search.apps.SearchConfig", # Add search app ] MIDDLEWARE = [