mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 18:31:09 -05:00
611 lines
20 KiB
Python
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
|