From bf7e0c0f40da551033b7455da0d8648dec5f4598 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Mon, 25 Aug 2025 12:03:22 -0400 Subject: [PATCH] feat: Implement comprehensive ride filtering system with API integration - Added `useRideFiltering` composable for managing ride filters and fetching rides from the API. - Created `useParkRideFiltering` for park-specific ride filtering. - Developed `useTheme` composable for theme management with localStorage support. - Established `rideFiltering` Pinia store for centralized state management of ride filters and UI state. - Defined enhanced filter types in `filters.ts` for better type safety and clarity. - Built `RideFilteringPage.vue` to provide a user interface for filtering rides with responsive design. - Integrated filter sidebar and ride list display components for a cohesive user experience. - Added support for filter presets and search suggestions. - Implemented computed properties for active filters, average ratings, and operating counts. --- .clinerules => .roo/rules/critical_rules | 7 +- backend/apps/api/v1/viewsets.py | 5 +- backend/apps/core/urls.py | 14 +- backend/apps/rides/api_urls.py | 23 + backend/apps/rides/api_views.py | 363 +++++++++ backend/apps/rides/forms/search.py | 591 ++++++++++++++ backend/apps/rides/serializers.py | 198 +++++ backend/apps/rides/services/search.py | 727 ++++++++++++++++++ backend/apps/rides/urls.py | 4 +- backend/apps/rides/views.py | 91 ++- backend/docs/ride_filtering_design.md | 169 ++++ .../rides/partials/filter_sidebar.html | 465 +++++++++++ .../rides/partials/ride_list_results.html | 411 +++++++--- backend/templates/rides/ride_list.html | 258 +++++++ .../length.bin | Bin 40000 -> 40000 bytes .../conport_vector_data/chroma.sqlite3 | Bin 176128 -> 176128 bytes context_portal/context.db | Bin 208896 -> 229376 bytes .../components/filters/ActiveFilterChip.vue | 109 +++ .../components/filters/DateRangeFilter.vue | 415 ++++++++++ .../src/components/filters/FilterSection.vue | 69 ++ .../src/components/filters/PresetItem.vue | 229 ++++++ .../src/components/filters/RangeFilter.vue | 431 +++++++++++ .../components/filters/RideFilterSidebar.vue | 484 ++++++++++++ .../components/filters/SavePresetDialog.vue | 401 ++++++++++ .../src/components/filters/SearchFilter.vue | 339 ++++++++ .../components/filters/SearchableSelect.vue | 484 ++++++++++++ .../src/components/filters/SelectFilter.vue | 356 +++++++++ frontend/src/components/rides/RideCard.vue | 289 +++++++ .../src/components/rides/RideListDisplay.vue | 516 +++++++++++++ frontend/src/components/ui/Icon.vue | 505 ++++++++++++ frontend/src/composables/useRideFiltering.ts | 379 +++++++++ frontend/src/composables/useTheme.ts | 100 +++ frontend/src/services/api.ts | 51 ++ frontend/src/stores/rideFiltering.ts | 441 +++++++++++ frontend/src/types/filters.ts | 172 +++++ frontend/src/types/index.ts | 22 +- frontend/src/views/RideFilteringPage.vue | 375 +++++++++ 37 files changed, 9350 insertions(+), 143 deletions(-) rename .clinerules => .roo/rules/critical_rules (84%) create mode 100644 backend/apps/rides/api_urls.py create mode 100644 backend/apps/rides/api_views.py create mode 100644 backend/apps/rides/forms/search.py create mode 100644 backend/apps/rides/serializers.py create mode 100644 backend/apps/rides/services/search.py create mode 100644 backend/docs/ride_filtering_design.md create mode 100644 backend/templates/rides/partials/filter_sidebar.html create mode 100644 backend/templates/rides/ride_list.html create mode 100644 frontend/src/components/filters/ActiveFilterChip.vue create mode 100644 frontend/src/components/filters/DateRangeFilter.vue create mode 100644 frontend/src/components/filters/FilterSection.vue create mode 100644 frontend/src/components/filters/PresetItem.vue create mode 100644 frontend/src/components/filters/RangeFilter.vue create mode 100644 frontend/src/components/filters/RideFilterSidebar.vue create mode 100644 frontend/src/components/filters/SavePresetDialog.vue create mode 100644 frontend/src/components/filters/SearchFilter.vue create mode 100644 frontend/src/components/filters/SearchableSelect.vue create mode 100644 frontend/src/components/filters/SelectFilter.vue create mode 100644 frontend/src/components/rides/RideCard.vue create mode 100644 frontend/src/components/rides/RideListDisplay.vue create mode 100644 frontend/src/components/ui/Icon.vue create mode 100644 frontend/src/composables/useRideFiltering.ts create mode 100644 frontend/src/composables/useTheme.ts create mode 100644 frontend/src/stores/rideFiltering.ts create mode 100644 frontend/src/types/filters.ts create mode 100644 frontend/src/views/RideFilteringPage.vue diff --git a/.clinerules b/.roo/rules/critical_rules similarity index 84% rename from .clinerules rename to .roo/rules/critical_rules index 40a282dd..8d2d957e 100644 --- a/.clinerules +++ b/.roo/rules/critical_rules @@ -9,7 +9,12 @@ ```bash cd $PROJECT_ROOT/backend && uv add ``` -- **Django Commands:** Always use `uv run manage.py ` for all management tasks (migrations, shell, superuser, etc.). Never use `python manage.py` or `uv run python manage.py`. +- **Django Commands:** Always use `cd backend && uv run manage.py ` for all management tasks (migrations, shell, superuser, etc.). Never use `python manage.py` or `uv run python manage.py`. + +## CRITICAL Frontend design rules +- EVERYTHING must support both dark and light mode. +- Make sure the light/dark mode toggle works with the Vue components and pages. +- Leverage Tailwind CSS 4 and Shadcn UI components. ## Frontend API URL Rules - **Vite Proxy:** Always check `frontend/vite.config.ts` for proxy rules before changing frontend API URLs. diff --git a/backend/apps/api/v1/viewsets.py b/backend/apps/api/v1/viewsets.py index f70aae48..3e35a518 100644 --- a/backend/apps/api/v1/viewsets.py +++ b/backend/apps/api/v1/viewsets.py @@ -675,15 +675,16 @@ class RideViewSet(ModelViewSet): if park_slug: # For nested endpoints, use the dedicated park selector from apps.rides.selectors import rides_in_park + return rides_in_park(park_slug=park_slug) - + # For global endpoints, parse filter parameters and use general selector filter_serializer = RideFilterInputSerializer( data=self.request.query_params # type: ignore[attr-defined] ) filter_serializer.is_valid(raise_exception=True) filters = filter_serializer.validated_data - + return ride_list_for_display(filters=filters) # type: ignore[arg-type] # For other actions, return base queryset diff --git a/backend/apps/core/urls.py b/backend/apps/core/urls.py index 3f4ff7b5..ebbd351f 100644 --- a/backend/apps/core/urls.py +++ b/backend/apps/core/urls.py @@ -9,16 +9,18 @@ from .views.entity_search import ( QuickEntitySuggestionView, ) -app_name = 'core' +app_name = "core" # Entity search endpoints entity_patterns = [ - path('search/', EntityFuzzySearchView.as_view(), name='entity_fuzzy_search'), - path('not-found/', EntityNotFoundView.as_view(), name='entity_not_found'), - path('suggestions/', QuickEntitySuggestionView.as_view(), name='entity_suggestions'), + path("search/", EntityFuzzySearchView.as_view(), name="entity_fuzzy_search"), + path("not-found/", EntityNotFoundView.as_view(), name="entity_not_found"), + path( + "suggestions/", QuickEntitySuggestionView.as_view(), name="entity_suggestions" + ), ] urlpatterns = [ # Entity fuzzy matching and search endpoints - path('entities/', include(entity_patterns)), -] \ No newline at end of file + path("entities/", include(entity_patterns)), +] diff --git a/backend/apps/rides/api_urls.py b/backend/apps/rides/api_urls.py new file mode 100644 index 00000000..87c75a32 --- /dev/null +++ b/backend/apps/rides/api_urls.py @@ -0,0 +1,23 @@ +from django.urls import path +from . import api_views + +app_name = "rides_api" + +urlpatterns = [ + # Main ride listing and filtering API + path("rides/", api_views.RideListAPIView.as_view(), name="ride_list"), + # Filter options endpoint + path("filter-options/", api_views.get_filter_options, name="filter_options"), + # Search endpoints + path("search/companies/", api_views.search_companies_api, name="search_companies"), + path( + "search/ride-models/", + api_views.search_ride_models_api, + name="search_ride_models", + ), + path( + "search/suggestions/", + api_views.get_search_suggestions_api, + name="search_suggestions", + ), +] diff --git a/backend/apps/rides/api_views.py b/backend/apps/rides/api_views.py new file mode 100644 index 00000000..3fd4547f --- /dev/null +++ b/backend/apps/rides/api_views.py @@ -0,0 +1,363 @@ +from rest_framework import generics +from rest_framework.response import Response +from rest_framework.decorators import api_view +from rest_framework.pagination import PageNumberPagination +from django.shortcuts import get_object_or_404 +from django.db.models import Count +from django.http import Http404 + +from .models.rides import Ride, Categories, RideModel +from .models.company import Company +from .forms.search import MasterFilterForm +from .services.search import RideSearchService +from .serializers import RideSerializer +from apps.parks.models import Park + + +class RidePagination(PageNumberPagination): + """Custom pagination for ride API""" + + page_size = 24 + page_size_query_param = "page_size" + max_page_size = 100 + + +class RideListAPIView(generics.ListAPIView): + """API endpoint for listing and filtering rides""" + + serializer_class = RideSerializer + pagination_class = RidePagination + + def get_queryset(self): + """Get filtered rides using the advanced search service""" + # Initialize search service + search_service = RideSearchService() + + # Parse filters from request + filter_form = MasterFilterForm(self.request.query_params) + + # Apply park context if available + park = None + park_slug = self.request.query_params.get("park_slug") + if park_slug: + try: + park = get_object_or_404(Park, slug=park_slug) + except Http404: + park = None + + if filter_form.is_valid(): + # Use advanced search service + queryset = search_service.search_rides( + filters=filter_form.get_filter_dict(), park=park + ) + else: + # Fallback to basic queryset with park filter + queryset = ( + Ride.objects.all() + .select_related("park", "ride_model", "ride_model__manufacturer") + .prefetch_related("photos") + ) + if park: + queryset = queryset.filter(park=park) + + return queryset + + def list(self, request, *args, **kwargs): + """Enhanced list response with filter metadata""" + queryset = self.filter_queryset(self.get_queryset()) + + # Get pagination + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + paginated_response = self.get_paginated_response(serializer.data) + + # Add filter metadata + filter_form = MasterFilterForm(request.query_params) + if filter_form.is_valid(): + active_filters = filter_form.get_filter_summary() + has_filters = filter_form.has_active_filters() + else: + active_filters = {} + has_filters = False + + # Add counts + park_slug = request.query_params.get("park_slug") + if park_slug: + try: + park = get_object_or_404(Park, slug=park_slug) + total_rides = Ride.objects.filter(park=park).count() + except Http404: + total_rides = Ride.objects.count() + else: + total_rides = Ride.objects.count() + + filtered_count = queryset.count() + + # Enhance response with metadata + paginated_response.data.update( + { + "filter_metadata": { + "active_filters": active_filters, + "has_filters": has_filters, + "total_rides": total_rides, + "filtered_count": filtered_count, + } + } + ) + + return paginated_response + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + +@api_view(["GET"]) +def get_filter_options(request): + """API endpoint to get all filter options for the frontend""" + + # Get park context if provided + park_slug = request.query_params.get("park_slug") + park = None + if park_slug: + try: + park = get_object_or_404(Park, slug=park_slug) + except Http404: + park = None + + # Base queryset + rides_queryset = Ride.objects.all() + if park: + rides_queryset = rides_queryset.filter(park=park) + + # Categories + categories = [{"code": code, "name": name} for code, name in Categories] + + # Manufacturers + manufacturer_ids = rides_queryset.values_list( + "ride_model__manufacturer_id", flat=True + ).distinct() + manufacturers = list( + Company.objects.filter( + id__in=manufacturer_ids, roles__contains=["MANUFACTURER"] + ) + .values("id", "name") + .order_by("name") + ) + + # Designers + designer_ids = rides_queryset.values_list("designer_id", flat=True).distinct() + designers = list( + Company.objects.filter(id__in=designer_ids, roles__contains=["DESIGNER"]) + .values("id", "name") + .order_by("name") + ) + + # Parks (for global view) + parks = [] + if not park: + parks = list( + Park.objects.filter( + id__in=rides_queryset.values_list("park_id", flat=True).distinct() + ) + .values("id", "name", "slug") + .order_by("name") + ) + + # Ride models + ride_model_ids = rides_queryset.values_list("ride_model_id", flat=True).distinct() + ride_models = list( + RideModel.objects.filter(id__in=ride_model_ids) + .select_related("manufacturer") + .values("id", "name", "manufacturer__name") + .order_by("name") + ) + + # Get value ranges for numeric fields + from django.db.models import Min, Max + + ranges = rides_queryset.aggregate( + height_min=Min("height_ft"), + height_max=Max("height_ft"), + speed_min=Min("max_speed_mph"), + speed_max=Max("max_speed_mph"), + capacity_min=Min("hourly_capacity"), + capacity_max=Max("hourly_capacity"), + duration_min=Min("duration_seconds"), + duration_max=Max("duration_seconds"), + ) + + # Get date ranges + date_ranges = rides_queryset.aggregate( + opening_min=Min("opening_date"), + opening_max=Max("opening_date"), + closing_min=Min("closing_date"), + closing_max=Max("closing_date"), + ) + + data = { + "categories": categories, + "manufacturers": manufacturers, + "designers": designers, + "parks": parks, + "ride_models": ride_models, + "ranges": { + "height": { + "min": ranges["height_min"] or 0, + "max": ranges["height_max"] or 500, + "step": 5, + }, + "speed": { + "min": ranges["speed_min"] or 0, + "max": ranges["speed_max"] or 150, + "step": 5, + }, + "capacity": { + "min": ranges["capacity_min"] or 0, + "max": ranges["capacity_max"] or 3000, + "step": 100, + }, + "duration": { + "min": ranges["duration_min"] or 0, + "max": ranges["duration_max"] or 600, + "step": 10, + }, + }, + "date_ranges": { + "opening": { + "min": ( + date_ranges["opening_min"].isoformat() + if date_ranges["opening_min"] + else None + ), + "max": ( + date_ranges["opening_max"].isoformat() + if date_ranges["opening_max"] + else None + ), + }, + "closing": { + "min": ( + date_ranges["closing_min"].isoformat() + if date_ranges["closing_min"] + else None + ), + "max": ( + date_ranges["closing_max"].isoformat() + if date_ranges["closing_max"] + else None + ), + }, + }, + } + + return Response(data) + + +@api_view(["GET"]) +def search_companies_api(request): + """API endpoint for company search""" + query = request.query_params.get("q", "").strip() + role = request.query_params.get("role", "").upper() + + companies = Company.objects.all().order_by("name") + if role: + companies = companies.filter(roles__contains=[role]) + if query: + companies = companies.filter(name__icontains=query) + + companies = companies[:10] + + data = [ + {"id": company.id, "name": company.name, "roles": company.roles} + for company in companies + ] + + return Response(data) + + +@api_view(["GET"]) +def search_ride_models_api(request): + """API endpoint for ride model search""" + query = request.query_params.get("q", "").strip() + manufacturer_id = request.query_params.get("manufacturer") + + ride_models = RideModel.objects.select_related("manufacturer").order_by("name") + if query: + ride_models = ride_models.filter(name__icontains=query) + if manufacturer_id: + ride_models = ride_models.filter(manufacturer_id=manufacturer_id) + + ride_models = ride_models[:10] + + data = [ + { + "id": model.id, + "name": model.name, + "manufacturer": ( + {"id": model.manufacturer.id, "name": model.manufacturer.name} + if model.manufacturer + else None + ), + } + for model in ride_models + ] + + return Response(data) + + +@api_view(["GET"]) +def get_search_suggestions_api(request): + """API endpoint for smart search suggestions""" + query = request.query_params.get("q", "").strip().lower() + suggestions = [] + + if query: + # Get common ride names + matching_names = ( + Ride.objects.filter(name__icontains=query) + .values("name") + .annotate(count=Count("id")) + .order_by("-count")[:3] + ) + + for match in matching_names: + suggestions.append( + { + "type": "ride", + "text": match["name"], + "count": match["count"], + } + ) + + # Get matching parks + from django.db.models import Q + + matching_parks = Park.objects.filter( + Q(name__icontains=query) | Q(location__city__icontains=query) + )[:3] + + for park in matching_parks: + suggestions.append( + { + "type": "park", + "text": park.name, + "location": park.location.city if park.location else None, + "slug": park.slug, + } + ) + + # Add category matches + for code, name in Categories: + if query in name.lower(): + ride_count = Ride.objects.filter(category=code).count() + suggestions.append( + { + "type": "category", + "code": code, + "text": name, + "count": ride_count, + } + ) + + return Response({"suggestions": suggestions, "query": query}) diff --git a/backend/apps/rides/forms/search.py b/backend/apps/rides/forms/search.py new file mode 100644 index 00000000..6572b036 --- /dev/null +++ b/backend/apps/rides/forms/search.py @@ -0,0 +1,591 @@ +""" +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_search_filters(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_active_filters_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", + "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 diff --git a/backend/apps/rides/serializers.py b/backend/apps/rides/serializers.py new file mode 100644 index 00000000..c6b6d483 --- /dev/null +++ b/backend/apps/rides/serializers.py @@ -0,0 +1,198 @@ +from rest_framework import serializers +from .models.rides import Ride, RideModel, Categories +from .models.company import Company +from apps.parks.models import Park + + +class CompanySerializer(serializers.ModelSerializer): + """Serializer for Company model""" + + class Meta: + model = Company + fields = ["id", "name", "roles"] + + +class RideModelSerializer(serializers.ModelSerializer): + """Serializer for RideModel""" + + manufacturer = CompanySerializer(read_only=True) + + class Meta: + model = RideModel + fields = ["id", "name", "manufacturer"] + + +class ParkSerializer(serializers.ModelSerializer): + """Serializer for Park model""" + + location_city = serializers.CharField(source="location.city", read_only=True) + location_country = serializers.CharField(source="location.country", read_only=True) + + class Meta: + model = Park + fields = ["id", "name", "slug", "location_city", "location_country"] + + +class RideSerializer(serializers.ModelSerializer): + """Serializer for Ride model with all necessary fields for filtering display""" + + park = ParkSerializer(read_only=True) + ride_model = RideModelSerializer(read_only=True) + manufacturer = serializers.SerializerMethodField() + designer = CompanySerializer(read_only=True) + category_display = serializers.SerializerMethodField() + status_display = serializers.SerializerMethodField() + + # Photo fields + primary_photo_url = serializers.SerializerMethodField() + photo_count = serializers.SerializerMethodField() + + # Computed fields + age_years = serializers.SerializerMethodField() + is_operating = serializers.SerializerMethodField() + + class Meta: + model = Ride + fields = [ + # Basic info + "id", + "name", + "slug", + "description", + # Relationships + "park", + "ride_model", + "manufacturer", + "designer", + # Categories and status + "category", + "category_display", + "status", + "status_display", + # Dates + "opening_date", + "closing_date", + "age_years", + "is_operating", + # Physical characteristics + "height_ft", + "max_speed_mph", + "duration_seconds", + "hourly_capacity", + "length_ft", + "inversions", + "max_g_force", + "max_angle_degrees", + # Features (coaster specific) + "lift_mechanism", + "restraint_type", + "track_material", + # Media + "primary_photo_url", + "photo_count", + # Metadata + "created_at", + "updated_at", + ] + + def get_manufacturer(self, obj): + """Get manufacturer from ride model if available""" + if obj.ride_model and obj.ride_model.manufacturer: + return CompanySerializer(obj.ride_model.manufacturer).data + return None + + def get_category_display(self, obj): + """Get human-readable category name""" + return dict(Categories).get(obj.category, obj.category) + + def get_status_display(self, obj): + """Get human-readable status""" + return obj.get_status_display() + + def get_primary_photo_url(self, obj): + """Get URL of primary photo if available""" + # This would need to be implemented based on your photo model + # For now, return None + return None + + def get_photo_count(self, obj): + """Get number of photos for this ride""" + # This would need to be implemented based on your photo model + # For now, return 0 + return 0 + + def get_age_years(self, obj): + """Calculate ride age in years""" + if obj.opening_date: + from datetime import date + + today = date.today() + return today.year - obj.opening_date.year + return None + + def get_is_operating(self, obj): + """Check if ride is currently operating""" + return obj.status == "OPERATING" + + +class RideListSerializer(RideSerializer): + """Lightweight serializer for ride lists""" + + class Meta(RideSerializer.Meta): + fields = [ + # Essential fields for list view + "id", + "name", + "slug", + "park", + "category", + "category_display", + "status", + "status_display", + "opening_date", + "age_years", + "is_operating", + "height_ft", + "max_speed_mph", + "manufacturer", + "primary_photo_url", + ] + + +class RideFilterOptionsSerializer(serializers.Serializer): + """Serializer for filter options response""" + + categories = serializers.ListField( + child=serializers.DictField(), help_text="Available ride categories" + ) + manufacturers = serializers.ListField( + child=serializers.DictField(), help_text="Available manufacturers" + ) + designers = serializers.ListField( + child=serializers.DictField(), help_text="Available designers" + ) + parks = serializers.ListField( + child=serializers.DictField(), help_text="Available parks (for global view)" + ) + ride_models = serializers.ListField( + child=serializers.DictField(), help_text="Available ride models" + ) + ranges = serializers.DictField(help_text="Value ranges for numeric filters") + date_ranges = serializers.DictField(help_text="Date ranges for date filters") + + +class FilterMetadataSerializer(serializers.Serializer): + """Serializer for filter metadata in list responses""" + + active_filters = serializers.DictField( + help_text="Summary of currently active filters" + ) + has_filters = serializers.BooleanField( + help_text="Whether any filters are currently active" + ) + total_rides = serializers.IntegerField( + help_text="Total number of rides before filtering" + ) + filtered_count = serializers.IntegerField( + help_text="Number of rides after filtering" + ) diff --git a/backend/apps/rides/services/search.py b/backend/apps/rides/services/search.py new file mode 100644 index 00000000..5c444aac --- /dev/null +++ b/backend/apps/rides/services/search.py @@ -0,0 +1,727 @@ +""" +Ride Search Service + +Provides comprehensive search and filtering capabilities for rides using PostgreSQL's +advanced full-text search features including SearchVector, SearchQuery, SearchRank, +and TrigramSimilarity for fuzzy matching. + +This service implements the filtering design specified in: +backend/docs/ride_filtering_design.md +""" + +from django.contrib.postgres.search import ( + SearchVector, + SearchQuery, + SearchRank, + TrigramSimilarity, +) +from django.db import models +from django.db.models import Q, F, Value +from django.db.models.functions import Greatest +from typing import Dict, List, Optional, Any, Tuple + +from apps.rides.models import Ride +from apps.parks.models import Park +from apps.rides.models.company import Company + + +class RideSearchService: + """ + Advanced search service for rides with PostgreSQL full-text search capabilities. + + Features: + - Full-text search with ranking and highlighting + - Fuzzy matching with trigram similarity + - Comprehensive filtering across 8 categories + - Range filtering for numeric fields + - Date range filtering + - Multi-select filtering + - Sorting with multiple options + - Search suggestions and autocomplete + """ + + # Search configuration + SEARCH_LANGUAGES = ["english"] + TRIGRAM_SIMILARITY_THRESHOLD = 0.3 + SEARCH_RANK_WEIGHTS = [0.1, 0.2, 0.4, 1.0] # D, C, B, A weights + + # Filter categories from our design + FILTER_CATEGORIES = { + "search_text": ["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"], + } + + # Sorting options + SORT_OPTIONS = { + "relevance": "search_rank", + "name_asc": "name", + "name_desc": "-name", + "opening_date_asc": "opening_date", + "opening_date_desc": "-opening_date", + "rating_asc": "average_rating", + "rating_desc": "-average_rating", + "height_asc": "rollercoasterstats__height_ft", + "height_desc": "-rollercoasterstats__height_ft", + "speed_asc": "rollercoasterstats__speed_mph", + "speed_desc": "-rollercoasterstats__speed_mph", + "capacity_asc": "capacity_per_hour", + "capacity_desc": "-capacity_per_hour", + "created_asc": "created_at", + "created_desc": "-created_at", + } + + def __init__(self): + """Initialize the search service.""" + self.base_queryset = self._get_base_queryset() + + def _get_base_queryset(self): + """ + Get the base queryset with all necessary relationships pre-loaded + for optimal performance. + """ + return Ride.objects.select_related( + "park", + "park_area", + "manufacturer", + "designer", + "ride_model", + "rollercoasterstats", + ).prefetch_related("manufacturer__roles", "designer__roles") + + def search_and_filter( + self, + filters: Dict[str, Any], + sort_by: str = "relevance", + page: int = 1, + page_size: int = 20, + ) -> Dict[str, Any]: + """ + Main search and filter method that combines all capabilities. + + Args: + filters: Dictionary of filter parameters + sort_by: Sorting option key + page: Page number for pagination + page_size: Number of results per page + + Returns: + Dictionary containing results, pagination info, and metadata + """ + queryset = self.base_queryset + search_metadata = {} + + # Apply text search with ranking + if filters.get("global_search"): + queryset, search_rank = self._apply_full_text_search( + queryset, filters["global_search"] + ) + search_metadata["search_applied"] = True + search_metadata["search_term"] = filters["global_search"] + else: + search_rank = Value(0) + + # Apply all filter categories + queryset = self._apply_basic_info_filters(queryset, filters) + queryset = self._apply_date_filters(queryset, filters) + queryset = self._apply_height_safety_filters(queryset, filters) + queryset = self._apply_performance_filters(queryset, filters) + queryset = self._apply_relationship_filters(queryset, filters) + queryset = self._apply_roller_coaster_filters(queryset, filters) + queryset = self._apply_company_filters(queryset, filters) + + # Add search rank to queryset for sorting + queryset = queryset.annotate(search_rank=search_rank) + + # Apply sorting + queryset = self._apply_sorting(queryset, sort_by) + + # Get total count before pagination + total_count = queryset.count() + + # Apply pagination + start_idx = (page - 1) * page_size + end_idx = start_idx + page_size + results = list(queryset[start_idx:end_idx]) + + # Generate search highlights if search was applied + if filters.get("global_search"): + results = self._add_search_highlights(results, filters["global_search"]) + + return { + "results": results, + "pagination": { + "page": page, + "page_size": page_size, + "total_count": total_count, + "total_pages": (total_count + page_size - 1) // page_size, + "has_next": end_idx < total_count, + "has_previous": page > 1, + }, + "metadata": search_metadata, + "applied_filters": self._get_applied_filters_summary(filters), + } + + def _apply_full_text_search( + self, queryset, search_term: str + ) -> Tuple[models.QuerySet, models.Expression]: + """ + Apply PostgreSQL full-text search with ranking and fuzzy matching. + """ + if not search_term or not search_term.strip(): + return queryset, Value(0) + + search_term = search_term.strip() + + # Create search vector combining multiple fields with different weights + search_vector = ( + SearchVector("name", weight="A") + + SearchVector("description", weight="B") + + SearchVector("park__name", weight="C") + + SearchVector("manufacturer__name", weight="C") + + SearchVector("designer__name", weight="C") + + SearchVector("ride_model__name", weight="D") + ) + + # Create search query - try different query types for best results + search_query = SearchQuery(search_term, config="english") + + # Calculate search rank + search_rank = SearchRank( + search_vector, search_query, weights=self.SEARCH_RANK_WEIGHTS + ) + + # Apply trigram similarity for fuzzy matching on name + trigram_similarity = TrigramSimilarity("name", search_term) + + # Combine full-text search with trigram similarity + queryset = queryset.annotate(trigram_similarity=trigram_similarity).filter( + Q(search_vector=search_query) + | Q(trigram_similarity__gte=self.TRIGRAM_SIMILARITY_THRESHOLD) + ) + + # Use the greatest of search rank and trigram similarity for final ranking + final_rank = Greatest(search_rank, F("trigram_similarity")) + + return queryset, final_rank + + def _apply_basic_info_filters( + self, queryset, filters: Dict[str, Any] + ) -> models.QuerySet: + """Apply basic information filters.""" + + # Category filter (multi-select) + if filters.get("category"): + categories = ( + filters["category"] + if isinstance(filters["category"], list) + else [filters["category"]] + ) + queryset = queryset.filter(category__in=categories) + + # Status filter (multi-select) + if filters.get("status"): + statuses = ( + filters["status"] + if isinstance(filters["status"], list) + else [filters["status"]] + ) + queryset = queryset.filter(status__in=statuses) + + # Park filter (multi-select) + if filters.get("park"): + parks = ( + filters["park"] + if isinstance(filters["park"], list) + else [filters["park"]] + ) + if isinstance(parks[0], str): # If slugs provided + queryset = queryset.filter(park__slug__in=parks) + else: # If IDs provided + queryset = queryset.filter(park_id__in=parks) + + # Park area filter (multi-select) + if filters.get("park_area"): + areas = ( + filters["park_area"] + if isinstance(filters["park_area"], list) + else [filters["park_area"]] + ) + if isinstance(areas[0], str): # If slugs provided + queryset = queryset.filter(park_area__slug__in=areas) + else: # If IDs provided + queryset = queryset.filter(park_area_id__in=areas) + + return queryset + + def _apply_date_filters(self, queryset, filters: Dict[str, Any]) -> models.QuerySet: + """Apply date range filters.""" + + # Opening date range + if filters.get("opening_date_range"): + date_range = filters["opening_date_range"] + if date_range.get("start"): + queryset = queryset.filter(opening_date__gte=date_range["start"]) + if date_range.get("end"): + queryset = queryset.filter(opening_date__lte=date_range["end"]) + + # Closing date range + if filters.get("closing_date_range"): + date_range = filters["closing_date_range"] + if date_range.get("start"): + queryset = queryset.filter(closing_date__gte=date_range["start"]) + if date_range.get("end"): + queryset = queryset.filter(closing_date__lte=date_range["end"]) + + # Status since range + if filters.get("status_since_range"): + date_range = filters["status_since_range"] + if date_range.get("start"): + queryset = queryset.filter(status_since__gte=date_range["start"]) + if date_range.get("end"): + queryset = queryset.filter(status_since__lte=date_range["end"]) + + return queryset + + def _apply_height_safety_filters( + self, queryset, filters: Dict[str, Any] + ) -> models.QuerySet: + """Apply height and safety requirement filters.""" + + # Minimum height range + if filters.get("min_height_range"): + height_range = filters["min_height_range"] + if height_range.get("min") is not None: + queryset = queryset.filter(min_height_in__gte=height_range["min"]) + if height_range.get("max") is not None: + queryset = queryset.filter(min_height_in__lte=height_range["max"]) + + # Maximum height range + if filters.get("max_height_range"): + height_range = filters["max_height_range"] + if height_range.get("min") is not None: + queryset = queryset.filter(max_height_in__gte=height_range["min"]) + if height_range.get("max") is not None: + queryset = queryset.filter(max_height_in__lte=height_range["max"]) + + return queryset + + def _apply_performance_filters( + self, queryset, filters: Dict[str, Any] + ) -> models.QuerySet: + """Apply performance metric filters.""" + + # Capacity range + if filters.get("capacity_range"): + capacity_range = filters["capacity_range"] + if capacity_range.get("min") is not None: + queryset = queryset.filter(capacity_per_hour__gte=capacity_range["min"]) + if capacity_range.get("max") is not None: + queryset = queryset.filter(capacity_per_hour__lte=capacity_range["max"]) + + # Duration range + if filters.get("duration_range"): + duration_range = filters["duration_range"] + if duration_range.get("min") is not None: + queryset = queryset.filter( + ride_duration_seconds__gte=duration_range["min"] + ) + if duration_range.get("max") is not None: + queryset = queryset.filter( + ride_duration_seconds__lte=duration_range["max"] + ) + + # Rating range + if filters.get("rating_range"): + rating_range = filters["rating_range"] + if rating_range.get("min") is not None: + queryset = queryset.filter(average_rating__gte=rating_range["min"]) + if rating_range.get("max") is not None: + queryset = queryset.filter(average_rating__lte=rating_range["max"]) + + return queryset + + def _apply_relationship_filters( + self, queryset, filters: Dict[str, Any] + ) -> models.QuerySet: + """Apply relationship filters (manufacturer, designer, ride model).""" + + # Manufacturer filter (multi-select) + if filters.get("manufacturer"): + manufacturers = ( + filters["manufacturer"] + if isinstance(filters["manufacturer"], list) + else [filters["manufacturer"]] + ) + if isinstance(manufacturers[0], str): # If slugs provided + queryset = queryset.filter(manufacturer__slug__in=manufacturers) + else: # If IDs provided + queryset = queryset.filter(manufacturer_id__in=manufacturers) + + # Designer filter (multi-select) + if filters.get("designer"): + designers = ( + filters["designer"] + if isinstance(filters["designer"], list) + else [filters["designer"]] + ) + if isinstance(designers[0], str): # If slugs provided + queryset = queryset.filter(designer__slug__in=designers) + else: # If IDs provided + queryset = queryset.filter(designer_id__in=designers) + + # Ride model filter (multi-select) + if filters.get("ride_model"): + models_list = ( + filters["ride_model"] + if isinstance(filters["ride_model"], list) + else [filters["ride_model"]] + ) + if isinstance(models_list[0], str): # If slugs provided + queryset = queryset.filter(ride_model__slug__in=models_list) + else: # If IDs provided + queryset = queryset.filter(ride_model_id__in=models_list) + + return queryset + + def _apply_roller_coaster_filters( + self, queryset, filters: Dict[str, Any] + ) -> models.QuerySet: + """Apply roller coaster specific filters.""" + queryset = self._apply_numeric_range_filter( + queryset, filters, "height_ft_range", "rollercoasterstats__height_ft" + ) + queryset = self._apply_numeric_range_filter( + queryset, filters, "length_ft_range", "rollercoasterstats__length_ft" + ) + queryset = self._apply_numeric_range_filter( + queryset, filters, "speed_mph_range", "rollercoasterstats__speed_mph" + ) + queryset = self._apply_numeric_range_filter( + queryset, filters, "inversions_range", "rollercoasterstats__inversions" + ) + + # Track material filter (multi-select) + if filters.get("track_material"): + materials = ( + filters["track_material"] + if isinstance(filters["track_material"], list) + else [filters["track_material"]] + ) + queryset = queryset.filter(rollercoasterstats__track_material__in=materials) + + # Coaster type filter (multi-select) + if filters.get("coaster_type"): + types = ( + filters["coaster_type"] + if isinstance(filters["coaster_type"], list) + else [filters["coaster_type"]] + ) + queryset = queryset.filter( + rollercoasterstats__roller_coaster_type__in=types + ) + + # Launch type filter (multi-select) + if filters.get("launch_type"): + launch_types = ( + filters["launch_type"] + if isinstance(filters["launch_type"], list) + else [filters["launch_type"]] + ) + queryset = queryset.filter(rollercoasterstats__launch_type__in=launch_types) + + return queryset + + def _apply_numeric_range_filter( + self, + queryset, + filters: Dict[str, Any], + filter_key: str, + field_name: str, + ) -> models.QuerySet: + """Apply numeric range filter to reduce complexity.""" + if filters.get(filter_key): + range_filter = filters[filter_key] + if range_filter.get("min") is not None: + queryset = queryset.filter( + **{f"{field_name}__gte": range_filter["min"]} + ) + if range_filter.get("max") is not None: + queryset = queryset.filter( + **{f"{field_name}__lte": range_filter["max"]} + ) + return queryset + + def _apply_company_filters( + self, queryset, filters: Dict[str, Any] + ) -> models.QuerySet: + """Apply company-related filters.""" + + # Manufacturer roles filter + if filters.get("manufacturer_roles"): + roles = ( + filters["manufacturer_roles"] + if isinstance(filters["manufacturer_roles"], list) + else [filters["manufacturer_roles"]] + ) + queryset = queryset.filter(manufacturer__roles__overlap=roles) + + # Designer roles filter + if filters.get("designer_roles"): + roles = ( + filters["designer_roles"] + if isinstance(filters["designer_roles"], list) + else [filters["designer_roles"]] + ) + queryset = queryset.filter(designer__roles__overlap=roles) + + # Founded date range + if filters.get("founded_date_range"): + date_range = filters["founded_date_range"] + if date_range.get("start"): + queryset = queryset.filter( + Q(manufacturer__founded_date__gte=date_range["start"]) + | Q(designer__founded_date__gte=date_range["start"]) + ) + if date_range.get("end"): + queryset = queryset.filter( + Q(manufacturer__founded_date__lte=date_range["end"]) + | Q(designer__founded_date__lte=date_range["end"]) + ) + + return queryset + + def _apply_sorting(self, queryset, sort_by: str) -> models.QuerySet: + """Apply sorting to the queryset.""" + + if sort_by not in self.SORT_OPTIONS: + sort_by = "relevance" + + sort_field = self.SORT_OPTIONS[sort_by] + + # Handle special case for relevance sorting + if sort_by == "relevance": + return queryset.order_by("-search_rank", "name") + + # Apply the sorting + return queryset.order_by( + sort_field, "name" + ) # Always add name as secondary sort + + def _add_search_highlights( + self, results: List[Ride], search_term: str + ) -> List[Ride]: + """Add search highlights to results using SearchHeadline.""" + + if not search_term or not results: + return results + + # Create search query for highlighting + SearchQuery(search_term, config="english") + + # Add highlights to each result + # (note: highlights would need to be processed at query time) + for ride in results: + # Store highlighted versions as dynamic attributes (for template use) + setattr(ride, "highlighted_name", ride.name) + setattr(ride, "highlighted_description", ride.description) + + return results + + def _get_applied_filters_summary(self, filters: Dict[str, Any]) -> Dict[str, Any]: + """Generate a summary of applied filters for the frontend.""" + + applied = {} + + # Count filters in each category + for category, filter_keys in self.FILTER_CATEGORIES.items(): + category_filters = [] + for key in filter_keys: + if filters.get(key): + category_filters.append( + { + "key": key, + "value": filters[key], + "display_name": self._get_filter_display_name(key), + } + ) + if category_filters: + applied[category] = category_filters + + return applied + + def _get_filter_display_name(self, filter_key: str) -> str: + """Convert filter key to human-readable display name.""" + + display_names = { + "global_search": "Search", + "category": "Category", + "status": "Status", + "park": "Park", + "park_area": "Park Area", + "opening_date_range": "Opening Date", + "closing_date_range": "Closing Date", + "status_since_range": "Status Since", + "min_height_range": "Minimum Height", + "max_height_range": "Maximum Height", + "capacity_range": "Capacity", + "duration_range": "Duration", + "rating_range": "Rating", + "manufacturer": "Manufacturer", + "designer": "Designer", + "ride_model": "Ride Model", + "height_ft_range": "Height (ft)", + "length_ft_range": "Length (ft)", + "speed_mph_range": "Speed (mph)", + "inversions_range": "Inversions", + "track_material": "Track Material", + "coaster_type": "Coaster Type", + "launch_type": "Launch Type", + "manufacturer_roles": "Manufacturer Roles", + "designer_roles": "Designer Roles", + "founded_date_range": "Founded Date", + } + + return display_names.get(filter_key, filter_key.replace("_", " ").title()) + + def get_search_suggestions( + self, query: str, limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Get search suggestions for autocomplete functionality. + """ + + if not query or len(query) < 2: + return [] + + suggestions = [] + + # Ride names with trigram similarity + ride_suggestions = ( + Ride.objects.annotate(similarity=TrigramSimilarity("name", query)) + .filter(similarity__gte=0.1) + .order_by("-similarity") + .values("name", "slug", "similarity")[: limit // 2] + ) + + for ride in ride_suggestions: + suggestions.append( + { + "type": "ride", + "text": ride["name"], + "slug": ride["slug"], + "score": ride["similarity"], + } + ) + + # Park names + park_suggestions = ( + Park.objects.annotate(similarity=TrigramSimilarity("name", query)) + .filter(similarity__gte=0.1) + .order_by("-similarity") + .values("name", "slug", "similarity")[: limit // 4] + ) + + for park in park_suggestions: + suggestions.append( + { + "type": "park", + "text": park["name"], + "slug": park["slug"], + "score": park["similarity"], + } + ) + + # Manufacturer names + manufacturer_suggestions = ( + Company.objects.filter(roles__contains=["MANUFACTURER"]) + .annotate(similarity=TrigramSimilarity("name", query)) + .filter(similarity__gte=0.1) + .order_by("-similarity") + .values("name", "slug", "similarity")[: limit // 4] + ) + + for manufacturer in manufacturer_suggestions: + suggestions.append( + { + "type": "manufacturer", + "text": manufacturer["name"], + "slug": manufacturer["slug"], + "score": manufacturer["similarity"], + } + ) + + # Sort by score and return top results + suggestions.sort(key=lambda x: x["score"], reverse=True) + return suggestions[:limit] + + def get_filter_options( + self, filter_type: str, context_filters: Optional[Dict[str, Any]] = None + ) -> List[Dict[str, Any]]: + """ + Get available options for a specific filter type. + Optionally filter options based on current context. + """ + + context_filters = context_filters or {} + base_queryset = self.base_queryset + + # Apply context filters to narrow down options + if context_filters: + temp_filters = context_filters.copy() + temp_filters.pop( + filter_type, None + ) # Remove the filter we're getting options for + base_queryset = self._apply_all_filters(base_queryset, temp_filters) + + if filter_type == "park": + return list( + base_queryset.values("park__name", "park__slug") + .distinct() + .order_by("park__name") + ) + + elif filter_type == "manufacturer": + return list( + base_queryset.filter(manufacturer__isnull=False) + .values("manufacturer__name", "manufacturer__slug") + .distinct() + .order_by("manufacturer__name") + ) + + elif filter_type == "designer": + return list( + base_queryset.filter(designer__isnull=False) + .values("designer__name", "designer__slug") + .distinct() + .order_by("designer__name") + ) + + # Add more filter options as needed + return [] + + def _apply_all_filters(self, queryset, filters: Dict[str, Any]) -> models.QuerySet: + """Apply all filters except search ranking.""" + + queryset = self._apply_basic_info_filters(queryset, filters) + queryset = self._apply_date_filters(queryset, filters) + queryset = self._apply_height_safety_filters(queryset, filters) + queryset = self._apply_performance_filters(queryset, filters) + queryset = self._apply_relationship_filters(queryset, filters) + queryset = self._apply_roller_coaster_filters(queryset, filters) + queryset = self._apply_company_filters(queryset, filters) + + return queryset diff --git a/backend/apps/rides/urls.py b/backend/apps/rides/urls.py index 2e14068f..9dc85adc 100644 --- a/backend/apps/rides/urls.py +++ b/backend/apps/rides/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import path, include from . import views app_name = "rides" @@ -70,6 +70,8 @@ urlpatterns = [ views.ranking_comparisons, name="ranking_comparisons", ), + # API endpoints for Vue.js frontend + path("api/", include("apps.rides.api_urls", namespace="rides_api")), # Park-specific URLs path("create/", views.RideCreateView.as_view(), name="ride_create"), path("/", views.RideDetailView.as_view(), name="ride_detail"), diff --git a/backend/apps/rides/views.py b/backend/apps/rides/views.py index 8d0d0f72..e1df6197 100644 --- a/backend/apps/rides/views.py +++ b/backend/apps/rides/views.py @@ -9,6 +9,8 @@ from django.db.models import Count from .models.rides import Ride, RideModel, Categories from .models.company import Company from .forms import RideForm, RideSearchForm +from .forms.search import MasterFilterForm +from .services.search import RideSearchService from apps.parks.models import Park from apps.moderation.mixins import EditSubmissionMixin, HistoryMixin from apps.moderation.models import EditSubmission @@ -213,66 +215,81 @@ class RideUpdateView( class RideListView(ListView): - """View for displaying a list of rides""" + """Enhanced view for displaying a list of rides with advanced filtering""" model = Ride template_name = "rides/ride_list.html" context_object_name = "rides" + paginate_by = 24 def get_queryset(self): - """Get filtered rides based on search and filters""" - queryset = ( - Ride.objects.all() - .select_related("park", "ride_model", "ride_model__manufacturer") - .prefetch_related("photos") - ) + """Get filtered rides using the advanced search service""" + # Initialize search service + search_service = RideSearchService() - # Park filter + # Parse filters from request + filter_form = MasterFilterForm(self.request.GET) + + # Apply park context if available + park = None if "park_slug" in self.kwargs: self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"]) - queryset = queryset.filter(park=self.park) + park = self.park - # Search term handling - search = self.request.GET.get("q", "").strip() - if search: - # Split search terms for more flexible matching - search_terms = search.split() - search_query = Q() - - for term in search_terms: - term_query = ( - Q(name__icontains=term) - | Q(park__name__icontains=term) - | Q(description__icontains=term) - ) - search_query &= term_query - - queryset = queryset.filter(search_query) - - # Category filter - category = self.request.GET.get("category") - if category and category != "all": - queryset = queryset.filter(category=category) - - # Operating status filter - if self.request.GET.get("operating") == "true": - queryset = queryset.filter(status="operating") + if filter_form.is_valid(): + # Use advanced search service + queryset = search_service.search_rides( + filters=filter_form.get_filter_dict(), park=park + ) + else: + # Fallback to basic queryset with park filter + queryset = ( + Ride.objects.all() + .select_related("park", "ride_model", "ride_model__manufacturer") + .prefetch_related("photos") + ) + if park: + queryset = queryset.filter(park=park) return queryset def get_template_names(self): """Return appropriate template based on request type""" - if self.request.htmx: + if hasattr(self.request, "htmx") and self.request.htmx: return ["rides/partials/ride_list_results.html"] return [self.template_name] def get_context_data(self, **kwargs): - """Add park and category choices to context""" + """Add filter form and context data""" context = super().get_context_data(**kwargs) + + # Add park context if hasattr(self, "park"): context["park"] = self.park context["park_slug"] = self.kwargs["park_slug"] + + # Add filter form + filter_form = MasterFilterForm(self.request.GET) + context["filter_form"] = filter_form context["category_choices"] = Categories + + # Add filter summary for display + if filter_form.is_valid(): + context["active_filters"] = filter_form.get_filter_summary() + context["has_filters"] = filter_form.has_active_filters() + else: + context["active_filters"] = {} + context["has_filters"] = False + + # Add total count before filtering (for display purposes) + if hasattr(self, "park"): + context["total_rides"] = Ride.objects.filter(park=self.park).count() + else: + context["total_rides"] = Ride.objects.count() + + # Add filtered count + context["filtered_count"] = self.get_queryset().count() + return context @@ -457,7 +474,7 @@ class RideSearchView(ListView): class RideRankingsView(ListView): - """View for displaying ride rankings using the Internet Roller Coaster Poll algorithm.""" + """View for displaying ride rankings using the Internet Roller Coaster Poll.""" model = RideRanking template_name = "rides/rankings.html" diff --git a/backend/docs/ride_filtering_design.md b/backend/docs/ride_filtering_design.md new file mode 100644 index 00000000..8f007cd5 --- /dev/null +++ b/backend/docs/ride_filtering_design.md @@ -0,0 +1,169 @@ +# Comprehensive Ride Filtering System Design + +## Overview +This document outlines the design for a robust ride filtering system with PostgreSQL full-text search capabilities, organized in logical filter categories with a beautiful Tailwind 4 UI. + +## Filter Categories + +### 1. Search & Text Filters +- **Main Search Bar**: Full-text search across name, description, park name using PostgreSQL SearchVector +- **Advanced Search**: Separate fields for ride name, park name, description +- **Fuzzy Search**: TrigramSimilarity for typo-tolerant searching + +### 2. Basic Ride Information +- **Category**: Multi-select dropdown (Roller Coaster, Dark Ride, Flat Ride, Water Ride, Transport, Other) +- **Status**: Multi-select dropdown (Operating, Temporarily Closed, SBNO, Closing, Permanently Closed, Under Construction, Demolished, Relocated) + +### 3. Date Filters +- **Opening Date Range**: Date picker for from/to dates +- **Closing Date Range**: Date picker for from/to dates +- **Status Since**: Date picker for from/to dates + +### 4. Height & Safety Requirements +- **Minimum Height**: Range slider (30-90 inches) +- **Maximum Height**: Range slider (30-90 inches) +- **Height Requirement Type**: No requirement, minimum only, maximum only, both + +### 5. Performance Metrics +- **Capacity per Hour**: Range slider (0-5000+ people) +- **Ride Duration**: Range slider (0-600+ seconds) +- **Average Rating**: Range slider (1-10 stars) + +### 6. Location & Relationships +- **Park**: Multi-select autocomplete dropdown +- **Park Area**: Multi-select dropdown (filtered by selected parks) +- **Manufacturer**: Multi-select autocomplete dropdown +- **Designer**: Multi-select autocomplete dropdown +- **Ride Model**: Multi-select autocomplete dropdown + +### 7. Roller Coaster Specific (only shown when RC category selected) +- **Height**: Range slider (0-500+ feet) +- **Length**: Range slider (0-10000+ feet) +- **Speed**: Range slider (0-150+ mph) +- **Inversions**: Range slider (0-20+) +- **Max Drop Height**: Range slider (0-500+ feet) +- **Track Material**: Multi-select (Steel, Wood, Hybrid) +- **Coaster Type**: Multi-select (Sit Down, Inverted, Flying, Stand Up, Wing, Dive, Family, Wild Mouse, Spinning, 4th Dimension, Other) +- **Launch Type**: Multi-select (Chain Lift, LSM Launch, Hydraulic Launch, Gravity, Other) + +### 8. Company Information (when manufacturer/designer filters are used) +- **Company Founded**: Date range picker +- **Company Roles**: Multi-select (Manufacturer, Designer, Operator, Property Owner) +- **Rides Count**: Range slider (1-500+) +- **Coasters Count**: Range slider (1-200+) + +## Sorting Options +- **Name** (A-Z, Z-A) +- **Opening Date** (Newest, Oldest) +- **Average Rating** (Highest, Lowest) +- **Height** (Tallest, Shortest) - for roller coasters +- **Speed** (Fastest, Slowest) - for roller coasters +- **Capacity** (Highest, Lowest) +- **Recently Updated** (using pghistory) +- **Relevance** (for text searches using SearchRank) + +## UI/UX Design Principles + +### Layout Structure +- **Desktop**: Sidebar filter panel (collapsible) + main content area +- **Mobile**: Overlay filter modal with bottom sheet design +- **Filter Organization**: Collapsible sections with clean headers and icons + +### Tailwind 4 Design System +- **Color Scheme**: Modern neutral palette with theme support +- **Typography**: Clear hierarchy with proper contrast +- **Spacing**: Consistent spacing scale (4, 8, 16, 24, 32px) +- **Interactive Elements**: Smooth transitions and hover states +- **Form Controls**: Custom-styled inputs, dropdowns, sliders + +### Dark Mode Support +- Full dark mode implementation using Tailwind's dark: variants +- Theme toggle in header +- Proper contrast ratios for accessibility +- Dark-friendly color selections for data visualization + +### Responsive Design +- **Mobile First**: Progressive enhancement approach +- **Breakpoints**: sm (640px), md (768px), lg (1024px), xl (1280px) +- **Touch Friendly**: Larger touch targets on mobile +- **Collapsible Filters**: Smart grouping on smaller screens + +## Technical Implementation + +### Backend Architecture +1. **Search Service**: Django service layer for search logic +2. **PostgreSQL Features**: SearchVector, SearchQuery, SearchRank, GIN indexes +3. **Fuzzy Matching**: TrigramSimilarity for typo tolerance +4. **Caching**: Redis for popular filter combinations +5. **Pagination**: Efficient pagination with search ranking + +### Frontend Features +1. **HTMX Integration**: Real-time filtering without full page reloads +2. **URL Persistence**: Filter state preserved in URL parameters +3. **Autocomplete**: Intelligent suggestions for text fields +4. **Filter Counts**: Show result counts for each filter option +5. **Active Filters**: Visual indicators of applied filters +6. **Reset Functionality**: Clear individual or all filters + +### Performance Optimizations +1. **Database Indexes**: GIN indexes on search vectors +2. **Query Optimization**: Efficient JOIN strategies +3. **Caching Layer**: Popular searches cached in Redis +4. **Debounced Input**: Prevent excessive API calls +5. **Lazy Loading**: Load expensive filter options on demand + +## Accessibility Features +- **Keyboard Navigation**: Full keyboard support for all controls +- **Screen Readers**: Proper ARIA labels and descriptions +- **Color Contrast**: WCAG AA compliance +- **Focus Management**: Clear focus indicators +- **Alternative Text**: Descriptive labels for all controls + +## Search Algorithm Details + +### PostgreSQL Full-Text Search +```sql +-- Search vector combining multiple fields with weights +search_vector = ( + setweight(to_tsvector('english', name), 'A') || + setweight(to_tsvector('english', park.name), 'B') || + setweight(to_tsvector('english', description), 'C') || + setweight(to_tsvector('english', manufacturer.name), 'D') +) + +-- Search query with ranking +SELECT *, ts_rank(search_vector, query) as rank +FROM rides +WHERE search_vector @@ query +ORDER BY rank DESC, name +``` + +### Trigram Similarity +```sql +-- Fuzzy matching for typos +SELECT * +FROM rides +WHERE similarity(name, 'search_term') > 0.3 +ORDER BY similarity(name, 'search_term') DESC +``` + +## Filter State Management +- **URL Parameters**: All filter state stored in URL for shareability +- **Local Storage**: Remember user preferences +- **Session State**: Maintain state during browsing session +- **Bookmark Support**: Full filter state can be bookmarked + +## Testing Strategy +1. **Unit Tests**: Individual filter components +2. **Integration Tests**: Full filtering workflows +3. **Performance Tests**: Large dataset filtering +4. **Accessibility Tests**: Screen reader and keyboard testing +5. **Mobile Tests**: Touch interface testing +6. **Browser Tests**: Cross-browser compatibility + +## Future Enhancements +1. **Saved Filters**: User accounts can save filter combinations +2. **Advanced Analytics**: Popular filter combinations tracking +3. **Machine Learning**: Personalized filter suggestions +4. **Export Features**: Export filtered results to CSV/PDF +5. **Comparison Mode**: Side-by-side ride comparisons \ No newline at end of file diff --git a/backend/templates/rides/partials/filter_sidebar.html b/backend/templates/rides/partials/filter_sidebar.html new file mode 100644 index 00000000..0b4e48ac --- /dev/null +++ b/backend/templates/rides/partials/filter_sidebar.html @@ -0,0 +1,465 @@ +{% load static %} + + +
+ +
+
+

+ + Filters +

+
+ + {% if has_filters %} + + {{ active_filters|length }} active + + {% endif %} + + + {% if has_filters %} + + {% endif %} +
+
+ + + {% if has_filters %} +
+ {% for category, filters in active_filters.items %} + {% if filters %} +
+ {% for filter_name, filter_value in filters.items %} + + {{ filter_name }}: {{ filter_value }} + + + {% endfor %} +
+ {% endif %} + {% endfor %} +
+ {% endif %} +
+ + +
+ + +
+ +
+ {{ filter_form.search_text.label_tag }} + {{ filter_form.search_text }} + +
+ +
+
+
+ + +
+ +
+ +
+ {{ filter_form.categories.label_tag }} + {{ filter_form.categories }} +
+ + +
+ {{ filter_form.status.label_tag }} + {{ filter_form.status }} +
+ + +
+ {{ filter_form.parks.label_tag }} + {{ filter_form.parks }} +
+
+
+ + +
+ +
+ +
+ {{ filter_form.opening_date_range.label_tag }} + {{ filter_form.opening_date_range }} +
+ + +
+ {{ filter_form.closing_date_range.label_tag }} + {{ filter_form.closing_date_range }} +
+
+
+ + +
+ +
+ +
+ {{ filter_form.height_requirements.label_tag }} + {{ filter_form.height_requirements }} +
+ + +
+ {{ filter_form.height_range.label_tag }} + {{ filter_form.height_range }} +
+ + +
+ {{ filter_form.accessibility_features.label_tag }} + {{ filter_form.accessibility_features }} +
+
+
+ + +
+ +
+ +
+ {{ filter_form.speed_range.label_tag }} + {{ filter_form.speed_range }} +
+ + +
+ {{ filter_form.duration_range.label_tag }} + {{ filter_form.duration_range }} +
+ + +
+ {{ filter_form.capacity_range.label_tag }} + {{ filter_form.capacity_range }} +
+ + +
+ {{ filter_form.intensity_levels.label_tag }} + {{ filter_form.intensity_levels }} +
+
+
+ + +
+ +
+ +
+ {{ filter_form.manufacturers.label_tag }} + {{ filter_form.manufacturers }} +
+ + +
+ {{ filter_form.designers.label_tag }} + {{ filter_form.designers }} +
+ + +
+ {{ filter_form.ride_models.label_tag }} + {{ filter_form.ride_models }} +
+
+
+ + +
+ +
+ +
+ {{ filter_form.track_types.label_tag }} + {{ filter_form.track_types }} +
+ + +
+ {{ filter_form.launch_types.label_tag }} + {{ filter_form.launch_types }} +
+ + +
+ {{ filter_form.inversions_range.label_tag }} + {{ filter_form.inversions_range }} +
+ + +
+ {{ filter_form.drop_range.label_tag }} + {{ filter_form.drop_range }} +
+ + +
+ {{ filter_form.length_range.label_tag }} + {{ filter_form.length_range }} +
+ + +
+ +
+ + +
+
+
+
+ + +
+ +
+ +
+ {{ filter_form.sort_by.label_tag }} + {{ filter_form.sort_by }} +
+ + +
+ {{ filter_form.sort_order.label_tag }} + {{ filter_form.sort_order }} +
+
+
+
+
+ + + \ No newline at end of file diff --git a/backend/templates/rides/partials/ride_list_results.html b/backend/templates/rides/partials/ride_list_results.html index fbb1d348..9adb337c 100644 --- a/backend/templates/rides/partials/ride_list_results.html +++ b/backend/templates/rides/partials/ride_list_results.html @@ -1,105 +1,326 @@ -{% load ride_tags %} + +{% if has_filters %} +
+
+
+

+ + Active Filters ({{ active_filters|length }}) +

+ +
+ +
+ {% for filter_key, filter_info in active_filters.items %} + + {{ filter_info.label }}: {{ filter_info.value }} + + + {% endfor %} +
+
+
+{% endif %} - -
- {% for ride in rides %} -
- -
-

- - {{ ride.name }} - -

- {% if not park %} -

- at - {{ ride.park.name }} - -

- {% endif %} -
- - {{ ride.get_category_display }} - - - {{ ride.get_status_display }} - - {% if ride.average_rating %} - - - {{ ride.average_rating|floatformat:1 }}/10 - - {% endif %} -
- {% if ride.coaster_stats %} -
- {% if ride.coaster_stats.height_ft %} -
- Height: {{ ride.coaster_stats.height_ft }}ft -
- {% endif %} - {% if ride.coaster_stats.speed_mph %} -
- Speed: {{ ride.coaster_stats.speed_mph }}mph -
- {% endif %} -
- {% endif %} -
+ +
+
+

+ {% if filtered_count == total_rides %} + All {{ total_rides }} ride{{ total_rides|pluralize }} + {% else %} + {{ filtered_count }} of {{ total_rides }} ride{{ total_rides|pluralize }} + {% endif %} + {% if park %} + at {{ park.name }} + {% endif %} +

+ {% if query %} +

+ + Searching for: "{{ query }}" +

+ {% endif %} +
+ + +
+ + +
+
+ + +{% if rides %} +
+ {% for ride in rides %} +
+ +
+ {% if ride.image %} + {{ ride.name }} + {% else %} +
+
- {% empty %} -
-

No rides found matching your criteria.

+ {% endif %} + + +
+ {% if ride.operating_status == 'operating' %} + + + Operating + + {% elif ride.operating_status == 'closed_temporarily' %} + + + Temporarily Closed + + {% elif ride.operating_status == 'closed_permanently' %} + + + Permanently Closed + + {% elif ride.operating_status == 'under_construction' %} + + + Under Construction + + {% endif %} +
+
+ + +
+ +
+

+ + {{ ride.name }} + +

+
+ + {{ ride.category|default:"Ride" }} + + {% if ride.park %} + + + {{ ride.park.name }} + + {% endif %}
- {% endfor %} +
+ + +
+ {% if ride.height %} +
+
{{ ride.height }}ft
+
Height
+
+ {% endif %} + + {% if ride.rollercoaster_stats.max_speed %} +
+
{{ ride.rollercoaster_stats.max_speed }}mph
+
Top Speed
+
+ {% elif ride.max_speed %} +
+
{{ ride.max_speed }}mph
+
Max Speed
+
+ {% endif %} + + {% if ride.capacity_per_hour %} +
+
{{ ride.capacity_per_hour }}
+
Capacity/Hr
+
+ {% endif %} + + {% if ride.duration %} +
+
{{ ride.duration }}s
+
Duration
+
+ {% endif %} +
+ + + {% if ride.has_inversions or ride.has_launches or ride.rollercoaster_stats %} +
+ {% if ride.has_inversions %} + + + {% if ride.rollercoaster_stats.number_of_inversions %} + {{ ride.rollercoaster_stats.number_of_inversions }} Inversion{{ ride.rollercoaster_stats.number_of_inversions|pluralize }} + {% else %} + Inversions + {% endif %} + + {% endif %} + + {% if ride.has_launches %} + + + {% if ride.rollercoaster_stats.number_of_launches %} + {{ ride.rollercoaster_stats.number_of_launches }} Launch{{ ride.rollercoaster_stats.number_of_launches|pluralize }} + {% else %} + Launched + {% endif %} + + {% endif %} + + {% if ride.rollercoaster_stats.track_type %} + + {{ ride.rollercoaster_stats.track_type|title }} + + {% endif %} +
+ {% endif %} + + + {% if ride.opened_date %} +
+ + Opened {{ ride.opened_date|date:"F j, Y" }} +
+ {% endif %} + + + {% if ride.manufacturer %} +
+ + {{ ride.manufacturer.name }} + {% if ride.designer and ride.designer != ride.manufacturer %} + • Designed by {{ ride.designer.name }} + {% endif %} +
+ {% endif %} +
+
+ {% endfor %}
{% if is_paginated %} -
-
- {% if page_obj.has_previous %} - « First - Previous - {% endif %} - - - Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} - - - {% if page_obj.has_next %} - Next - Last » +
+
+

+ Showing {{ page_obj.start_index }} to {{ page_obj.end_index }} of {{ filtered_count }} results +

+
+ +
+ {% if page_obj.has_previous %} + + + Previous + + {% endif %} + + + + + {% if page_obj.has_next %} + + Next + + + {% endif %} +
-{% endif %} \ No newline at end of file +{% endif %} + +{% else %} + +
+
+ +
+

No rides found

+

+ {% if has_filters %} + Try adjusting your filters to see more results. + {% else %} + No rides match your current search criteria. + {% endif %} +

+ {% if has_filters %} + + {% endif %} +
+{% endif %} + + +
+
+
+ Loading results... +
+
\ No newline at end of file diff --git a/backend/templates/rides/ride_list.html b/backend/templates/rides/ride_list.html new file mode 100644 index 00000000..8ccfe24e --- /dev/null +++ b/backend/templates/rides/ride_list.html @@ -0,0 +1,258 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %} + {% if park %} + {{ park.name }} - Rides + {% else %} + All Rides + {% endif %} +{% endblock %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} +
+ + + + +
+
+
+ +
+

+ {% if park %} + + {{ park.name }} - Rides + {% else %} + + All Rides + {% endif %} +

+ + + +
+ + + +
+
+
+ + +
+ + + + +
+
+

Filters

+ +
+ {% include "rides/partials/filter_sidebar.html" %} +
+ + +
+
+ {% include "rides/partials/ride_list_results.html" %} +
+
+
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/context_portal/conport_vector_data/422ad254-2341-4dce-9133-98aaf02fdb1b/length.bin b/context_portal/conport_vector_data/422ad254-2341-4dce-9133-98aaf02fdb1b/length.bin index 5d229f1f99df8b63accdf54319bc51221008c03a..fe6a9007345887b6b0b5166a60cb6a49e07208af 100644 GIT binary patch literal 40000 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 literal 40000 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 a@{X1GNyhi91^<8j21z!yl-F^!wtoizw?#?- delta 223 zcmZp8z}4`8Yl1YR&_o$$Mxl)fOY}LI_`fmm|K|U;SFf!N z#z5hEuI)Rs7`<7UCHb_cvloEHKJsqYu4B9+$jG{F0wYU;nIyL)1AiJn11}@bZ0<+= zYx%l)pYc56{=t8SN1R)dSDbGZzZmy4-WhzXJdyl0d{MmVJR7-Ffrj7b+AjH<@%0im UMvguPjy|AB3&-}p6--6!0C4<8lmGw# diff --git a/context_portal/context.db b/context_portal/context.db index 8492a4cc70d9f85f317d4e12e34222e9c7a4f26a..d71e2e51125fee008277d1d89d665aa0442bbb35 100644 GIT binary patch delta 5938 zcmeHLUu+!38Nc<#jUC0TE* znVC&;X{}AAHqS)??xiY1qV%Cc{INvfwNHJB)V`v=RDI#0FCakT1&QyQnLA%hptOA{ z!Vf<0?9P1i{r|q-cl{p+uV26Cw-1iZl}e>k@Q44s=Z`ITb8kKShZDo2CrYCyZXTML z-hOoIlac+epYisU{(h+I-5R>L`}rqBqeO~Et~6=#STo7fh%|*HQ7jrXCNvF6_x!=~ z_lHSFBi221d+34g+NVPYYKf5S?niZt4Ia;^Y?JQv56bryhDMG2jFFVFkZ3_#^fDuO zAQ62Q6v>2Ayb-e+S*2}8xF)d>Z4wC)l3XE+N&{z9hGc{C_zDLuXGtb$pn1SljhwX? zILQ>FGHCI%h{!R#>xq^61_>|GG!i7BDG|*klO&57RqW`|qsIx21>A9k>lQr5qz&{H z>~bms`l}a!!VXWUr3ND^&wx%dPk{}Q(zsTsl1iI(e94~6xn$kHy)|^8SVc(#kz|s! zSgJTEz^Z;nZalYgHDGbfQcW&%m4hilrg)To6Z!G@x;1J~^vIplt!JJ5%e&;qLrZrN z)~aE*XpS2^^n?I4?Q)j~3-g81T=QlgSHZj?lPS4y){@(?nz=T-Ow#}y!g32X$!LSe zTyv)I`)VF*4(4%sg72C|Lb|W(jK-6iC%w<;9#=-tc0b)%efrjkAD4gc?b}#eUc2@9 z{pI()35=3q_&GfO3jCH9ZXSHRvTyvg@mIDFO}sa}{jZgGMi0RI*(2~X^P}xQt^NGX z?|EY%mBv1T4IhjZzdq=Vl$Mg;uwNZNIdyVwa_ZFN$vHATyEu1hab~7AKQ%i)y>R*t z`>yklku&}Hs&|xT30Xp?)GW}sWVkQ*hRZ2jE4P(0~f9K^i?%4F=SOvo%uBvrI^>Odurmk|@B# zjU*HT6dqX&vC)(wAt+^)tPAj3d0J4AQPwrps%o+bN=Va=@q}2p;QAWF1JgmAhiJ{Z ziQlI{Q$kh?w>+OS*{Qn4I!)X58ZCJwX+jiFcuXbN9h1(Q`L%0sH`SO2qS|JquTXR_ z>v#BM5{0t~u5&(V;|>>)mUQ$($<%W(F11TNm5b;v-uUg(_-$~JKabDOl#|i_)B|o1 z?*gHf-n4}o8P($plK}G6rekD#X;;|( zyHtdHEQKN0g(-`7$w9{wn&wR?-;fHWQ67{!d;+bcsFMz)fGTM*9<_9ps0`YNDq%XP z9Y^92{!uSSLNbEfndS-fGJ(raxMvxPMnUA@PJC@@%cNp4sKPO3YEB)J9$qYr3C*j% z*vD6Pn9~1PvmY=m1d9EwDcXC%+u94>|BK*#v4>t21grXAwsu$bg~gLowfVV)nfV#AhthUsyzdH1o9K0Gko4y<^WS>o;qs#+ zH;bQK-)my_nwWuh=UdgpK<5GQ$+SCuueAwH%Z(`4$0Fxh&}(;BJlbkaQ$lB`en>&n(!2E^PzFfELd2!^B-hivf|Fs(IB2jJOHg} zOgjR=K!Yk6005()mK26D999;zqvWS0gSMEscE67!8Z>A#AoJCYePMOd2KP87aL=Zv^#hgpZ0(8%zJ!v^Cn1<1t%pQlX$>&*&6FJsC(Vb>tQLKkqbDg>de zQXRhV@eQ62VuAC;{@Nb$;a$KCBC`<0FBEMu-7$A3JoVjcZopnbuMWIGc>n_dDglrK zR4nkzXaKVbA3pdkkxRVWgg}@h3Isq3NJ7Ba5kJ6;RKoDpxA@SoQAie>eX7Nse<1F} z-T2?9U|3uX;woI*VFuf|&xD*IX(I;0(nJh69z={`*2&h?NB}01k#6~gT^S(eCs_+W z@f7$}2w!leVH(@^Vd4TfjGB4Lt_yT0Gb)cM3`|>oZ;8qarO0|3go{vvE^2!ge{8b? eL1OqJ}x6(Z+>N9|Zsa diff --git a/frontend/src/components/filters/ActiveFilterChip.vue b/frontend/src/components/filters/ActiveFilterChip.vue new file mode 100644 index 00000000..d38ab328 --- /dev/null +++ b/frontend/src/components/filters/ActiveFilterChip.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/src/components/filters/DateRangeFilter.vue b/frontend/src/components/filters/DateRangeFilter.vue new file mode 100644 index 00000000..077e038a --- /dev/null +++ b/frontend/src/components/filters/DateRangeFilter.vue @@ -0,0 +1,415 @@ + + + + + diff --git a/frontend/src/components/filters/FilterSection.vue b/frontend/src/components/filters/FilterSection.vue new file mode 100644 index 00000000..e2efc470 --- /dev/null +++ b/frontend/src/components/filters/FilterSection.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/frontend/src/components/filters/PresetItem.vue b/frontend/src/components/filters/PresetItem.vue new file mode 100644 index 00000000..c503d87b --- /dev/null +++ b/frontend/src/components/filters/PresetItem.vue @@ -0,0 +1,229 @@ + + + + + diff --git a/frontend/src/components/filters/RangeFilter.vue b/frontend/src/components/filters/RangeFilter.vue new file mode 100644 index 00000000..6733edc7 --- /dev/null +++ b/frontend/src/components/filters/RangeFilter.vue @@ -0,0 +1,431 @@ + + + + + diff --git a/frontend/src/components/filters/RideFilterSidebar.vue b/frontend/src/components/filters/RideFilterSidebar.vue new file mode 100644 index 00000000..6e5f9727 --- /dev/null +++ b/frontend/src/components/filters/RideFilterSidebar.vue @@ -0,0 +1,484 @@ + + + + + diff --git a/frontend/src/components/filters/SavePresetDialog.vue b/frontend/src/components/filters/SavePresetDialog.vue new file mode 100644 index 00000000..11b649c6 --- /dev/null +++ b/frontend/src/components/filters/SavePresetDialog.vue @@ -0,0 +1,401 @@ +