mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:51:09 -05:00
605 lines
21 KiB
Python
605 lines
21 KiB
Python
"""
|
|
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."""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# Get choices from Rich Choice registry
|
|
from apps.core.choices.registry import get_choices
|
|
|
|
# Get choices - let exceptions propagate if registry fails
|
|
category_choices = [(choice.value, choice.label) for choice in get_choices("categories", "rides")]
|
|
status_choices = [(choice.value, choice.label) for choice in get_choices("statuses", "rides")]
|
|
|
|
# Update field choices dynamically
|
|
self.fields['category'].choices = category_choices
|
|
self.fields['status'].choices = status_choices
|
|
|
|
category = forms.MultipleChoiceField(
|
|
choices=[], # Will be populated in __init__
|
|
required=False,
|
|
widget=forms.CheckboxSelectMultiple(attrs={"class": "grid grid-cols-2 gap-2"}),
|
|
)
|
|
|
|
status = forms.MultipleChoiceField(
|
|
choices=[], # Will be populated in __init__
|
|
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."""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# Get choices from Rich Choice registry
|
|
from apps.core.choices.registry import get_choices
|
|
|
|
# Get choices - let exceptions propagate if registry fails
|
|
track_material_choices = [(choice.value, choice.label) for choice in get_choices("track_materials", "rides")]
|
|
coaster_type_choices = [(choice.value, choice.label) for choice in get_choices("coaster_types", "rides")]
|
|
propulsion_system_choices = [(choice.value, choice.label) for choice in get_choices("propulsion_systems", "rides")]
|
|
|
|
# Update field choices dynamically
|
|
self.fields['track_material'].choices = track_material_choices
|
|
self.fields['coaster_type'].choices = coaster_type_choices
|
|
self.fields['propulsion_system'].choices = propulsion_system_choices
|
|
|
|
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=[], # Will be populated in __init__
|
|
required=False,
|
|
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
|
)
|
|
|
|
coaster_type = forms.MultipleChoiceField(
|
|
choices=[], # Will be populated in __init__
|
|
required=False,
|
|
widget=forms.CheckboxSelectMultiple(
|
|
attrs={"class": "grid grid-cols-2 gap-2 max-h-48 overflow-y-auto"}
|
|
),
|
|
)
|
|
|
|
propulsion_system = forms.MultipleChoiceField(
|
|
choices=[], # Will be populated in __init__
|
|
required=False,
|
|
widget=forms.CheckboxSelectMultiple(
|
|
attrs={"class": "space-y-2 max-h-48 overflow-y-auto"}
|
|
),
|
|
)
|
|
|
|
|
|
class CompanyForm(BaseFilterForm):
|
|
"""Form for company-related filters."""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# Get choices from Rich Choice registry
|
|
from apps.core.choices.registry import get_choices
|
|
|
|
# Get both rides and parks company roles - let exceptions propagate if registry fails
|
|
rides_roles = [(choice.value, choice.label) for choice in get_choices("company_roles", "rides")]
|
|
parks_roles = [(choice.value, choice.label) for choice in get_choices("company_roles", "parks")]
|
|
role_choices = rides_roles + parks_roles
|
|
|
|
# Update field choices dynamically
|
|
self.fields['manufacturer_roles'].choices = role_choices
|
|
self.fields['designer_roles'].choices = role_choices
|
|
|
|
manufacturer_roles = forms.MultipleChoiceField(
|
|
choices=[], # Will be populated in __init__
|
|
required=False,
|
|
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
|
)
|
|
|
|
designer_roles = forms.MultipleChoiceField(
|
|
choices=[], # Will be populated in __init__
|
|
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."""
|
|
|
|
# Static sorting choices - these are UI-specific and don't need Rich Choice Objects
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# Static sort choices for UI functionality
|
|
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)"),
|
|
]
|
|
|
|
self.fields['sort_by'].choices = sort_choices
|
|
|
|
sort_by = forms.ChoiceField(
|
|
choices=[], # Will be populated in __init__
|
|
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_filter_dict(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_search_filters(self) -> Dict[str, Any]:
|
|
"""Alias for get_filter_dict for backward compatibility."""
|
|
return self.get_filter_dict()
|
|
|
|
def get_filter_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",
|
|
"propulsion_system",
|
|
],
|
|
"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
|
|
|
|
def get_active_filters_summary(self) -> Dict[str, Any]:
|
|
"""Alias for get_filter_summary for backward compatibility."""
|
|
return self.get_filter_summary()
|
|
|
|
def has_active_filters(self) -> bool:
|
|
"""Check if any filters are currently active."""
|
|
if not self.is_valid():
|
|
return False
|
|
|
|
for field_name, value in self.cleaned_data.items():
|
|
if value: # If any field has a value, we have active filters
|
|
return True
|
|
|
|
return False
|