Files
thrillwiki_django_no_react/backend/apps/rides/forms/search.py
pacnpal 540f40e689 Revert "update"
This reverts commit 75cc618c2b.
2025-09-21 20:11:00 -04:00

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