mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-21 06:51:08 -05:00
19
backend/apps/rides/forms/__init__.py
Normal file
19
backend/apps/rides/forms/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Forms package for the rides app.
|
||||
|
||||
This package contains form classes for ride-related functionality including:
|
||||
- Advanced search and filtering forms
|
||||
- Form validation and data processing
|
||||
"""
|
||||
|
||||
# Import forms from the search module in this package
|
||||
from .search import MasterFilterForm
|
||||
|
||||
# Import forms from the base module in this package
|
||||
from .base import RideForm, RideSearchForm
|
||||
|
||||
__all__ = [
|
||||
"MasterFilterForm",
|
||||
"RideForm",
|
||||
"RideSearchForm",
|
||||
]
|
||||
379
backend/apps/rides/forms/base.py
Normal file
379
backend/apps/rides/forms/base.py
Normal file
@@ -0,0 +1,379 @@
|
||||
from apps.parks.models import Park, ParkArea
|
||||
from django import forms
|
||||
from django.forms import ModelChoiceField
|
||||
from django.urls import reverse_lazy
|
||||
from ..models.company import Company
|
||||
from ..models.rides import Ride, RideModel
|
||||
|
||||
Manufacturer = Company
|
||||
Designer = Company
|
||||
|
||||
|
||||
class RideForm(forms.ModelForm):
|
||||
park_search = forms.CharField(
|
||||
label="Park *",
|
||||
required=True,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a park...",
|
||||
"hx-get": "/parks/search/",
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#park-search-results",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
manufacturer_search = forms.CharField(
|
||||
label="Manufacturer",
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a manufacturer...",
|
||||
"hx-get": reverse_lazy("rides:search_companies"),
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#manufacturer-search-results",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
designer_search = forms.CharField(
|
||||
label="Designer",
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a designer...",
|
||||
"hx-get": reverse_lazy("rides:search_companies"),
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#designer-search-results",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
ride_model_search = forms.CharField(
|
||||
label="Ride Model",
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a ride model...",
|
||||
"hx-get": reverse_lazy("rides:search_ride_models"),
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#ride-model-search-results",
|
||||
"hx-include": "[name='manufacturer']",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
park = forms.ModelChoiceField(
|
||||
queryset=Park.objects.all(),
|
||||
required=True,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
manufacturer = forms.ModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
designer = forms.ModelChoiceField(
|
||||
queryset=Designer.objects.all(),
|
||||
required=False,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
ride_model = forms.ModelChoiceField(
|
||||
queryset=RideModel.objects.all(),
|
||||
required=False,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
park_area = ModelChoiceField(
|
||||
queryset=ParkArea.objects.none(),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Select an area within the park...",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Ride
|
||||
fields = [
|
||||
"name",
|
||||
"category",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"ride_model",
|
||||
"status",
|
||||
"post_closing_status",
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"status_since",
|
||||
"min_height_in",
|
||||
"max_height_in",
|
||||
"capacity_per_hour",
|
||||
"ride_duration_seconds",
|
||||
"description",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Official name of the ride",
|
||||
}
|
||||
),
|
||||
"category": forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"hx-get": reverse_lazy("rides:coaster_fields"),
|
||||
"hx-target": "#coaster-fields",
|
||||
"hx-trigger": "change",
|
||||
"hx-include": "this",
|
||||
"hx-swap": "innerHTML",
|
||||
}
|
||||
),
|
||||
"status": forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Current operational status",
|
||||
"x-model": "status",
|
||||
"@change": "handleStatusChange",
|
||||
}
|
||||
),
|
||||
"post_closing_status": forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Status after closing",
|
||||
"x-show": "status === 'CLOSING'",
|
||||
}
|
||||
),
|
||||
"opening_date": forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Date when ride first opened",
|
||||
}
|
||||
),
|
||||
"closing_date": forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Date when ride will close",
|
||||
"x-show": "['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(status)",
|
||||
":required": "status === 'CLOSING'",
|
||||
}
|
||||
),
|
||||
"status_since": forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Date when current status took effect",
|
||||
}
|
||||
),
|
||||
"min_height_in": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Minimum height requirement in inches",
|
||||
}
|
||||
),
|
||||
"max_height_in": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Maximum height limit in inches (if applicable)",
|
||||
}
|
||||
),
|
||||
"capacity_per_hour": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Theoretical hourly ride capacity",
|
||||
}
|
||||
),
|
||||
"ride_duration_seconds": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Total duration of one ride cycle in seconds",
|
||||
}
|
||||
),
|
||||
"description": forms.Textarea(
|
||||
attrs={
|
||||
"rows": 4,
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-textarea "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "General description and notable features of the ride",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
park = kwargs.pop("park", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Make category required
|
||||
self.fields["category"].required = True
|
||||
|
||||
# Clear any default values for date fields
|
||||
self.fields["opening_date"].initial = None
|
||||
self.fields["closing_date"].initial = None
|
||||
self.fields["status_since"].initial = None
|
||||
|
||||
# Move fields to the beginning in desired order
|
||||
field_order = [
|
||||
"park_search",
|
||||
"park",
|
||||
"park_area",
|
||||
"name",
|
||||
"manufacturer_search",
|
||||
"manufacturer",
|
||||
"designer_search",
|
||||
"designer",
|
||||
"ride_model_search",
|
||||
"ride_model",
|
||||
"category",
|
||||
"status",
|
||||
"post_closing_status",
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"status_since",
|
||||
"min_height_in",
|
||||
"max_height_in",
|
||||
"capacity_per_hour",
|
||||
"ride_duration_seconds",
|
||||
"description",
|
||||
]
|
||||
self.order_fields(field_order)
|
||||
|
||||
if park:
|
||||
# If park is provided, set it as the initial value
|
||||
self.fields["park"].initial = park
|
||||
# Hide the park search field since we know the park
|
||||
del self.fields["park_search"]
|
||||
# Create new park_area field with park's areas
|
||||
self.fields["park_area"] = forms.ModelChoiceField(
|
||||
queryset=park.areas.all(),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Select an area within the park...",
|
||||
}
|
||||
),
|
||||
)
|
||||
else:
|
||||
# If no park provided, show park search and disable park_area until
|
||||
# park is selected
|
||||
self.fields["park_area"].widget.attrs["disabled"] = True
|
||||
# Initialize park search with current park name if editing
|
||||
if self.instance and self.instance.pk and self.instance.park:
|
||||
self.fields["park_search"].initial = self.instance.park.name
|
||||
self.fields["park"].initial = self.instance.park
|
||||
|
||||
# Initialize manufacturer, designer, and ride model search fields if
|
||||
# editing
|
||||
if self.instance and self.instance.pk:
|
||||
if self.instance.manufacturer:
|
||||
self.fields["manufacturer_search"].initial = (
|
||||
self.instance.manufacturer.name
|
||||
)
|
||||
self.fields["manufacturer"].initial = self.instance.manufacturer
|
||||
if self.instance.designer:
|
||||
self.fields["designer_search"].initial = self.instance.designer.name
|
||||
self.fields["designer"].initial = self.instance.designer
|
||||
if self.instance.ride_model:
|
||||
self.fields["ride_model_search"].initial = self.instance.ride_model.name
|
||||
self.fields["ride_model"].initial = self.instance.ride_model
|
||||
|
||||
|
||||
class RideSearchForm(forms.Form):
|
||||
"""Form for searching rides with HTMX autocomplete."""
|
||||
|
||||
ride = forms.ModelChoiceField(
|
||||
queryset=Ride.objects.all(),
|
||||
label="Find a ride",
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"hx-get": reverse_lazy("rides:search"),
|
||||
"hx-trigger": "change",
|
||||
"hx-target": "#ride-search-results",
|
||||
}
|
||||
),
|
||||
)
|
||||
604
backend/apps/rides/forms/search.py
Normal file
604
backend/apps/rides/forms/search.py
Normal file
@@ -0,0 +1,604 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user