mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 05:11:09 -05:00
feat: Implement comprehensive ride filtering system with API integration
- Added `useRideFiltering` composable for managing ride filters and fetching rides from the API. - Created `useParkRideFiltering` for park-specific ride filtering. - Developed `useTheme` composable for theme management with localStorage support. - Established `rideFiltering` Pinia store for centralized state management of ride filters and UI state. - Defined enhanced filter types in `filters.ts` for better type safety and clarity. - Built `RideFilteringPage.vue` to provide a user interface for filtering rides with responsive design. - Integrated filter sidebar and ride list display components for a cohesive user experience. - Added support for filter presets and search suggestions. - Implemented computed properties for active filters, average ratings, and operating counts.
This commit is contained in:
591
backend/apps/rides/forms/search.py
Normal file
591
backend/apps/rides/forms/search.py
Normal file
@@ -0,0 +1,591 @@
|
||||
"""
|
||||
Ride Search and Filter Forms
|
||||
|
||||
Forms for the comprehensive ride filtering system with 8 categories:
|
||||
1. Search text
|
||||
2. Basic info
|
||||
3. Dates
|
||||
4. Height/safety
|
||||
5. Performance
|
||||
6. Relationships
|
||||
7. Roller coaster
|
||||
8. Company
|
||||
|
||||
Each form handles validation and provides clean data for the RideSearchService.
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from typing import Dict, Any
|
||||
|
||||
from apps.parks.models import Park, ParkArea
|
||||
from apps.rides.models.company import Company
|
||||
from apps.rides.models.rides import RideModel
|
||||
|
||||
|
||||
class BaseFilterForm(forms.Form):
|
||||
"""Base form with common functionality for all filter forms."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Add common CSS classes
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({"class": "form-input"})
|
||||
|
||||
|
||||
class SearchTextForm(BaseFilterForm):
|
||||
"""Form for text-based search filters."""
|
||||
|
||||
global_search = forms.CharField(
|
||||
required=False,
|
||||
max_length=255,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": "Search rides, parks, manufacturers...",
|
||||
"class": (
|
||||
"w-full px-4 py-2 border border-gray-300 "
|
||||
"dark:border-gray-600 rounded-lg focus:ring-2 "
|
||||
"focus:ring-blue-500 focus:border-transparent "
|
||||
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
),
|
||||
"autocomplete": "off",
|
||||
"hx-get": "",
|
||||
"hx-trigger": "input changed delay:300ms",
|
||||
"hx-target": "#search-results",
|
||||
"hx-indicator": "#search-loading",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
name_search = forms.CharField(
|
||||
required=False,
|
||||
max_length=255,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": "Search by ride name...",
|
||||
"class": (
|
||||
"w-full px-3 py-2 border border-gray-300 "
|
||||
"dark:border-gray-600 rounded-md focus:ring-2 "
|
||||
"focus:ring-blue-500 focus:border-transparent "
|
||||
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
description_search = forms.CharField(
|
||||
required=False,
|
||||
max_length=255,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": "Search in descriptions...",
|
||||
"class": (
|
||||
"w-full px-3 py-2 border border-gray-300 "
|
||||
"dark:border-gray-600 rounded-md focus:ring-2 "
|
||||
"focus:ring-blue-500 focus:border-transparent "
|
||||
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BasicInfoForm(BaseFilterForm):
|
||||
"""Form for basic ride information filters."""
|
||||
|
||||
CATEGORY_CHOICES = [
|
||||
("", "All Categories"),
|
||||
("roller_coaster", "Roller Coaster"),
|
||||
("water_ride", "Water Ride"),
|
||||
("flat_ride", "Flat Ride"),
|
||||
("dark_ride", "Dark Ride"),
|
||||
("kiddie_ride", "Kiddie Ride"),
|
||||
("transport", "Transport"),
|
||||
("show", "Show"),
|
||||
("other", "Other"),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
("", "All Statuses"),
|
||||
("operating", "Operating"),
|
||||
("closed_temporary", "Temporarily Closed"),
|
||||
("closed_permanent", "Permanently Closed"),
|
||||
("under_construction", "Under Construction"),
|
||||
("announced", "Announced"),
|
||||
("rumored", "Rumored"),
|
||||
]
|
||||
|
||||
category = forms.MultipleChoiceField(
|
||||
choices=CATEGORY_CHOICES[1:], # Exclude "All Categories"
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "grid grid-cols-2 gap-2"}),
|
||||
)
|
||||
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=STATUS_CHOICES[1:], # Exclude "All Statuses"
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
|
||||
park = forms.ModelMultipleChoiceField(
|
||||
queryset=Park.objects.all(),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
|
||||
),
|
||||
)
|
||||
|
||||
park_area = forms.ModelMultipleChoiceField(
|
||||
queryset=ParkArea.objects.all(),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DateRangeWidget(forms.MultiWidget):
|
||||
"""Custom widget for date range inputs."""
|
||||
|
||||
def __init__(self, attrs=None):
|
||||
widgets = [
|
||||
forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"px-3 py-2 border border-gray-300 "
|
||||
"dark:border-gray-600 rounded-md focus:ring-2 "
|
||||
"focus:ring-blue-500 focus:border-transparent "
|
||||
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"px-3 py-2 border border-gray-300 "
|
||||
"dark:border-gray-600 rounded-md focus:ring-2 "
|
||||
"focus:ring-blue-500 focus:border-transparent "
|
||||
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
]
|
||||
super().__init__(widgets, attrs)
|
||||
|
||||
def decompress(self, value):
|
||||
if value:
|
||||
return [value.get("start"), value.get("end")]
|
||||
return [None, None]
|
||||
|
||||
|
||||
class DateRangeField(forms.MultiValueField):
|
||||
"""Custom field for date ranges."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
fields = [forms.DateField(required=False), forms.DateField(required=False)]
|
||||
super().__init__(fields, *args, **kwargs)
|
||||
self.widget = DateRangeWidget()
|
||||
|
||||
def compress(self, data_list):
|
||||
if data_list:
|
||||
start_date, end_date = data_list
|
||||
if start_date or end_date:
|
||||
return {"start": start_date, "end": end_date}
|
||||
return None
|
||||
|
||||
def validate(self, value):
|
||||
super().validate(value)
|
||||
if value and value.get("start") and value.get("end"):
|
||||
if value["start"] > value["end"]:
|
||||
raise ValidationError("Start date must be before end date.")
|
||||
|
||||
|
||||
class DateFiltersForm(BaseFilterForm):
|
||||
"""Form for date range filters."""
|
||||
|
||||
opening_date_range = DateRangeField(required=False, label="Opening Date Range")
|
||||
|
||||
closing_date_range = DateRangeField(required=False, label="Closing Date Range")
|
||||
|
||||
status_since_range = DateRangeField(required=False, label="Status Since Range")
|
||||
|
||||
|
||||
class NumberRangeWidget(forms.MultiWidget):
|
||||
"""Custom widget for number range inputs."""
|
||||
|
||||
def __init__(self, attrs=None, min_val=None, max_val=None, step=None):
|
||||
widgets = [
|
||||
forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"px-3 py-2 border border-gray-300 dark:border-gray-600 "
|
||||
"rounded-md focus:ring-2 focus:ring-blue-500 "
|
||||
"focus:border-transparent bg-white dark:bg-gray-800 "
|
||||
"text-gray-900 dark:text-white"
|
||||
),
|
||||
"placeholder": "Min",
|
||||
"min": min_val,
|
||||
"max": max_val,
|
||||
"step": step,
|
||||
}
|
||||
),
|
||||
forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"px-3 py-2 border border-gray-300 dark:border-gray-600 "
|
||||
"rounded-md focus:ring-2 focus:ring-blue-500 "
|
||||
"focus:border-transparent bg-white dark:bg-gray-800 "
|
||||
"text-gray-900 dark:text-white"
|
||||
),
|
||||
"placeholder": "Max",
|
||||
"min": min_val,
|
||||
"max": max_val,
|
||||
"step": step,
|
||||
}
|
||||
),
|
||||
]
|
||||
super().__init__(widgets, attrs)
|
||||
|
||||
def decompress(self, value):
|
||||
if value:
|
||||
return [value.get("min"), value.get("max")]
|
||||
return [None, None]
|
||||
|
||||
|
||||
class NumberRangeField(forms.MultiValueField):
|
||||
"""Custom field for number ranges."""
|
||||
|
||||
def __init__(self, min_val=None, max_val=None, step=None, *args, **kwargs):
|
||||
fields = [
|
||||
forms.FloatField(required=False, min_value=min_val, max_value=max_val),
|
||||
forms.FloatField(required=False, min_value=min_val, max_value=max_val),
|
||||
]
|
||||
super().__init__(fields, *args, **kwargs)
|
||||
self.widget = NumberRangeWidget(min_val=min_val, max_val=max_val, step=step)
|
||||
|
||||
def compress(self, data_list):
|
||||
if data_list:
|
||||
min_val, max_val = data_list
|
||||
if min_val is not None or max_val is not None:
|
||||
return {"min": min_val, "max": max_val}
|
||||
return None
|
||||
|
||||
def validate(self, value):
|
||||
super().validate(value)
|
||||
if value and value.get("min") is not None and value.get("max") is not None:
|
||||
if value["min"] > value["max"]:
|
||||
raise ValidationError("Minimum value must be less than maximum value.")
|
||||
|
||||
|
||||
class HeightSafetyForm(BaseFilterForm):
|
||||
"""Form for height and safety requirement filters."""
|
||||
|
||||
min_height_range = NumberRangeField(
|
||||
min_val=0,
|
||||
max_val=84, # 7 feet in inches
|
||||
step=1,
|
||||
required=False,
|
||||
label="Minimum Height (inches)",
|
||||
)
|
||||
|
||||
max_height_range = NumberRangeField(
|
||||
min_val=0, max_val=84, step=1, required=False, label="Maximum Height (inches)"
|
||||
)
|
||||
|
||||
|
||||
class PerformanceForm(BaseFilterForm):
|
||||
"""Form for performance metric filters."""
|
||||
|
||||
capacity_range = NumberRangeField(
|
||||
min_val=0, max_val=5000, step=50, required=False, label="Capacity per Hour"
|
||||
)
|
||||
|
||||
duration_range = NumberRangeField(
|
||||
min_val=0,
|
||||
max_val=1800, # 30 minutes in seconds
|
||||
step=10,
|
||||
required=False,
|
||||
label="Duration (seconds)",
|
||||
)
|
||||
|
||||
rating_range = NumberRangeField(
|
||||
min_val=0.0, max_val=10.0, step=0.1, required=False, label="Average Rating"
|
||||
)
|
||||
|
||||
|
||||
class RelationshipsForm(BaseFilterForm):
|
||||
"""Form for relationship filters (manufacturer, designer, ride model)."""
|
||||
|
||||
manufacturer = forms.ModelMultipleChoiceField(
|
||||
queryset=Company.objects.filter(roles__contains=["MANUFACTURER"]),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
|
||||
),
|
||||
)
|
||||
|
||||
designer = forms.ModelMultipleChoiceField(
|
||||
queryset=Company.objects.filter(roles__contains=["DESIGNER"]),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
|
||||
),
|
||||
)
|
||||
|
||||
ride_model = forms.ModelMultipleChoiceField(
|
||||
queryset=RideModel.objects.all(),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RollerCoasterForm(BaseFilterForm):
|
||||
"""Form for roller coaster specific filters."""
|
||||
|
||||
TRACK_MATERIAL_CHOICES = [
|
||||
("steel", "Steel"),
|
||||
("wood", "Wood"),
|
||||
("hybrid", "Hybrid"),
|
||||
]
|
||||
|
||||
COASTER_TYPE_CHOICES = [
|
||||
("sit_down", "Sit Down"),
|
||||
("inverted", "Inverted"),
|
||||
("floorless", "Floorless"),
|
||||
("flying", "Flying"),
|
||||
("suspended", "Suspended"),
|
||||
("stand_up", "Stand Up"),
|
||||
("spinning", "Spinning"),
|
||||
("launched", "Launched"),
|
||||
("hypercoaster", "Hypercoaster"),
|
||||
("giga_coaster", "Giga Coaster"),
|
||||
("strata_coaster", "Strata Coaster"),
|
||||
]
|
||||
|
||||
LAUNCH_TYPE_CHOICES = [
|
||||
("none", "No Launch"),
|
||||
("lim", "LIM (Linear Induction Motor)"),
|
||||
("lsm", "LSM (Linear Synchronous Motor)"),
|
||||
("hydraulic", "Hydraulic"),
|
||||
("pneumatic", "Pneumatic"),
|
||||
("cable", "Cable"),
|
||||
("flywheel", "Flywheel"),
|
||||
]
|
||||
|
||||
height_ft_range = NumberRangeField(
|
||||
min_val=0, max_val=500, step=1, required=False, label="Height (feet)"
|
||||
)
|
||||
|
||||
length_ft_range = NumberRangeField(
|
||||
min_val=0, max_val=10000, step=10, required=False, label="Length (feet)"
|
||||
)
|
||||
|
||||
speed_mph_range = NumberRangeField(
|
||||
min_val=0, max_val=150, step=1, required=False, label="Speed (mph)"
|
||||
)
|
||||
|
||||
inversions_range = NumberRangeField(
|
||||
min_val=0, max_val=20, step=1, required=False, label="Number of Inversions"
|
||||
)
|
||||
|
||||
track_material = forms.MultipleChoiceField(
|
||||
choices=TRACK_MATERIAL_CHOICES,
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
|
||||
coaster_type = forms.MultipleChoiceField(
|
||||
choices=COASTER_TYPE_CHOICES,
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "grid grid-cols-2 gap-2 max-h-48 overflow-y-auto"}
|
||||
),
|
||||
)
|
||||
|
||||
launch_type = forms.MultipleChoiceField(
|
||||
choices=LAUNCH_TYPE_CHOICES,
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "space-y-2 max-h-48 overflow-y-auto"}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CompanyForm(BaseFilterForm):
|
||||
"""Form for company-related filters."""
|
||||
|
||||
ROLE_CHOICES = [
|
||||
("MANUFACTURER", "Manufacturer"),
|
||||
("DESIGNER", "Designer"),
|
||||
("OPERATOR", "Operator"),
|
||||
]
|
||||
|
||||
manufacturer_roles = forms.MultipleChoiceField(
|
||||
choices=ROLE_CHOICES,
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
|
||||
designer_roles = forms.MultipleChoiceField(
|
||||
choices=ROLE_CHOICES,
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
|
||||
founded_date_range = DateRangeField(
|
||||
required=False, label="Company Founded Date Range"
|
||||
)
|
||||
|
||||
|
||||
class SortingForm(BaseFilterForm):
|
||||
"""Form for sorting options."""
|
||||
|
||||
SORT_CHOICES = [
|
||||
("relevance", "Relevance"),
|
||||
("name_asc", "Name (A-Z)"),
|
||||
("name_desc", "Name (Z-A)"),
|
||||
("opening_date_asc", "Opening Date (Oldest)"),
|
||||
("opening_date_desc", "Opening Date (Newest)"),
|
||||
("rating_asc", "Rating (Lowest)"),
|
||||
("rating_desc", "Rating (Highest)"),
|
||||
("height_asc", "Height (Shortest)"),
|
||||
("height_desc", "Height (Tallest)"),
|
||||
("speed_asc", "Speed (Slowest)"),
|
||||
("speed_desc", "Speed (Fastest)"),
|
||||
("capacity_asc", "Capacity (Lowest)"),
|
||||
("capacity_desc", "Capacity (Highest)"),
|
||||
]
|
||||
|
||||
sort_by = forms.ChoiceField(
|
||||
choices=SORT_CHOICES,
|
||||
required=False,
|
||||
initial="relevance",
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"px-3 py-2 border border-gray-300 dark:border-gray-600 "
|
||||
"rounded-md focus:ring-2 focus:ring-blue-500 "
|
||||
"focus:border-transparent bg-white dark:bg-gray-800 "
|
||||
"text-gray-900 dark:text-white"
|
||||
),
|
||||
"hx-get": "",
|
||||
"hx-trigger": "change",
|
||||
"hx-target": "#search-results",
|
||||
"hx-indicator": "#search-loading",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MasterFilterForm(BaseFilterForm):
|
||||
"""Master form that combines all filter categories."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize all sub-forms
|
||||
self.search_text_form = SearchTextForm(*args, **kwargs)
|
||||
self.basic_info_form = BasicInfoForm(*args, **kwargs)
|
||||
self.date_filters_form = DateFiltersForm(*args, **kwargs)
|
||||
self.height_safety_form = HeightSafetyForm(*args, **kwargs)
|
||||
self.performance_form = PerformanceForm(*args, **kwargs)
|
||||
self.relationships_form = RelationshipsForm(*args, **kwargs)
|
||||
self.roller_coaster_form = RollerCoasterForm(*args, **kwargs)
|
||||
self.company_form = CompanyForm(*args, **kwargs)
|
||||
self.sorting_form = SortingForm(*args, **kwargs)
|
||||
|
||||
# Add all fields to this form
|
||||
self.fields.update(self.search_text_form.fields)
|
||||
self.fields.update(self.basic_info_form.fields)
|
||||
self.fields.update(self.date_filters_form.fields)
|
||||
self.fields.update(self.height_safety_form.fields)
|
||||
self.fields.update(self.performance_form.fields)
|
||||
self.fields.update(self.relationships_form.fields)
|
||||
self.fields.update(self.roller_coaster_form.fields)
|
||||
self.fields.update(self.company_form.fields)
|
||||
self.fields.update(self.sorting_form.fields)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# Validate all sub-forms
|
||||
for form in [
|
||||
self.search_text_form,
|
||||
self.basic_info_form,
|
||||
self.date_filters_form,
|
||||
self.height_safety_form,
|
||||
self.performance_form,
|
||||
self.relationships_form,
|
||||
self.roller_coaster_form,
|
||||
self.company_form,
|
||||
self.sorting_form,
|
||||
]:
|
||||
if not form.is_valid():
|
||||
for field, errors in form.errors.items():
|
||||
self.add_error(field, errors)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def get_search_filters(self) -> Dict[str, Any]:
|
||||
"""Convert form data to search service filter format."""
|
||||
if not self.is_valid():
|
||||
return {}
|
||||
|
||||
filters = {}
|
||||
|
||||
# Add all cleaned data to filters
|
||||
for field_name, value in self.cleaned_data.items():
|
||||
if value: # Only include non-empty values
|
||||
filters[field_name] = value
|
||||
|
||||
return filters
|
||||
|
||||
def get_active_filters_summary(self) -> Dict[str, Any]:
|
||||
"""Get summary of active filters for display."""
|
||||
active_filters = {}
|
||||
|
||||
if not self.is_valid():
|
||||
return active_filters
|
||||
|
||||
# Group filters by category
|
||||
categories = {
|
||||
"Search": ["global_search", "name_search", "description_search"],
|
||||
"Basic Info": ["category", "status", "park", "park_area"],
|
||||
"Dates": ["opening_date_range", "closing_date_range", "status_since_range"],
|
||||
"Height & Safety": ["min_height_range", "max_height_range"],
|
||||
"Performance": ["capacity_range", "duration_range", "rating_range"],
|
||||
"Relationships": ["manufacturer", "designer", "ride_model"],
|
||||
"Roller Coaster": [
|
||||
"height_ft_range",
|
||||
"length_ft_range",
|
||||
"speed_mph_range",
|
||||
"inversions_range",
|
||||
"track_material",
|
||||
"coaster_type",
|
||||
"launch_type",
|
||||
],
|
||||
"Company": ["manufacturer_roles", "designer_roles", "founded_date_range"],
|
||||
}
|
||||
|
||||
for category, field_names in categories.items():
|
||||
category_filters = []
|
||||
for field_name in field_names:
|
||||
value = self.cleaned_data.get(field_name)
|
||||
if value:
|
||||
category_filters.append(
|
||||
{
|
||||
"field": field_name,
|
||||
"value": value,
|
||||
"label": self.fields[field_name].label
|
||||
or field_name.replace("_", " ").title(),
|
||||
}
|
||||
)
|
||||
|
||||
if category_filters:
|
||||
active_filters[category] = category_filters
|
||||
|
||||
return active_filters
|
||||
Reference in New Issue
Block a user