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 5d229f1f..fe6a9007 100644 Binary files a/context_portal/conport_vector_data/422ad254-2341-4dce-9133-98aaf02fdb1b/length.bin and b/context_portal/conport_vector_data/422ad254-2341-4dce-9133-98aaf02fdb1b/length.bin differ diff --git a/context_portal/conport_vector_data/chroma.sqlite3 b/context_portal/conport_vector_data/chroma.sqlite3 index 99a80112..8fb715ab 100644 Binary files a/context_portal/conport_vector_data/chroma.sqlite3 and b/context_portal/conport_vector_data/chroma.sqlite3 differ diff --git a/context_portal/context.db b/context_portal/context.db index 8492a4cc..d71e2e51 100644 Binary files a/context_portal/context.db and b/context_portal/context.db differ 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 @@ +