Files
thrillwiki_django_no_react/backend/apps/rides/forms/search.py

611 lines
20 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."""
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_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
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
# 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