mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:11:08 -05:00
- Introduced Next.js integration guide for ThrillWiki API, detailing authentication, core domain APIs, data structures, and implementation patterns. - Documented the migration to Rich Choice Objects, highlighting changes for frontend developers and enhanced metadata availability. - Fixed the missing `get_by_slug` method in the Ride model, ensuring proper functionality of ride detail endpoints. - Created a test script to verify manufacturer syncing with ride models, ensuring data integrity across related models.
655 lines
23 KiB
Python
655 lines
23 KiB
Python
from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
|
from django.shortcuts import get_object_or_404, render
|
|
from django.urls import reverse
|
|
from django.db.models import Q
|
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
from django.http import HttpRequest, HttpResponse, Http404
|
|
from django.db.models import Count
|
|
from .models.rides import Ride, RideModel
|
|
from .choices import RIDE_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.services import ModerationService
|
|
from .models.rankings import RideRanking, RankingSnapshot
|
|
from .services.ranking_service import RideRankingService
|
|
|
|
|
|
class ParkContextRequired:
|
|
"""Mixin to require park context for views"""
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
if "park_slug" not in self.kwargs:
|
|
raise Http404("Park context is required")
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
|
|
def show_coaster_fields(request: HttpRequest) -> HttpResponse:
|
|
"""Show roller coaster specific fields based on category selection"""
|
|
category = request.GET.get("category")
|
|
if category != "RC": # Only show for roller coasters
|
|
return HttpResponse("")
|
|
return render(request, "rides/partials/coaster_fields.html")
|
|
|
|
|
|
class RideDetailView(HistoryMixin, DetailView):
|
|
"""View for displaying ride details"""
|
|
|
|
model = Ride
|
|
template_name = "rides/ride_detail.html"
|
|
slug_url_kwarg = "ride_slug"
|
|
|
|
def get_queryset(self):
|
|
"""Get ride for the specific park if park_slug is provided"""
|
|
queryset = (
|
|
Ride.objects.all()
|
|
.select_related("park", "ride_model", "ride_model__manufacturer")
|
|
.prefetch_related("photos")
|
|
)
|
|
|
|
if "park_slug" in self.kwargs:
|
|
queryset = queryset.filter(park__slug=self.kwargs["park_slug"])
|
|
|
|
return queryset
|
|
|
|
def get_context_data(self, **kwargs):
|
|
"""Add context data"""
|
|
context = super().get_context_data(**kwargs)
|
|
if "park_slug" in self.kwargs:
|
|
context["park_slug"] = self.kwargs["park_slug"]
|
|
context["park"] = self.object.park
|
|
|
|
return context
|
|
|
|
|
|
class RideCreateView(LoginRequiredMixin, ParkContextRequired, CreateView):
|
|
"""View for creating a new ride"""
|
|
|
|
model = Ride
|
|
form_class = RideForm
|
|
template_name = "rides/ride_form.html"
|
|
|
|
def get_success_url(self):
|
|
"""Get URL to redirect to after successful creation"""
|
|
return reverse(
|
|
"parks:rides:ride_detail",
|
|
kwargs={
|
|
"park_slug": self.park.slug,
|
|
"ride_slug": self.object.slug,
|
|
},
|
|
)
|
|
|
|
def get_form_kwargs(self):
|
|
"""Pass park to the form"""
|
|
kwargs = super().get_form_kwargs()
|
|
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
|
kwargs["park"] = self.park
|
|
return kwargs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
"""Add park and park_slug to context"""
|
|
context = super().get_context_data(**kwargs)
|
|
context["park"] = self.park
|
|
context["park_slug"] = self.park.slug
|
|
context["is_edit"] = False
|
|
return context
|
|
|
|
def form_valid(self, form):
|
|
"""Handle form submission including new items"""
|
|
# Check for new manufacturer
|
|
manufacturer_name = form.cleaned_data.get("manufacturer_search")
|
|
if manufacturer_name and not form.cleaned_data.get("manufacturer"):
|
|
ModerationService.create_edit_submission_with_queue(
|
|
content_object=None,
|
|
changes={"name": manufacturer_name, "roles": ["MANUFACTURER"]},
|
|
submitter=self.request.user,
|
|
submission_type="CREATE",
|
|
reason=f"New manufacturer suggested during ride creation: {manufacturer_name}",
|
|
)
|
|
|
|
# Check for new designer
|
|
designer_name = form.cleaned_data.get("designer_search")
|
|
if designer_name and not form.cleaned_data.get("designer"):
|
|
ModerationService.create_edit_submission_with_queue(
|
|
content_object=None,
|
|
changes={"name": designer_name, "roles": ["DESIGNER"]},
|
|
submitter=self.request.user,
|
|
submission_type="CREATE",
|
|
reason=f"New designer suggested during ride creation: {designer_name}",
|
|
)
|
|
|
|
# Check for new ride model
|
|
ride_model_name = form.cleaned_data.get("ride_model_search")
|
|
manufacturer = form.cleaned_data.get("manufacturer")
|
|
if ride_model_name and not form.cleaned_data.get("ride_model") and manufacturer:
|
|
ModerationService.create_edit_submission_with_queue(
|
|
content_object=None,
|
|
changes={
|
|
"name": ride_model_name,
|
|
"manufacturer": manufacturer.id,
|
|
},
|
|
submitter=self.request.user,
|
|
submission_type="CREATE",
|
|
reason=f"New ride model suggested during ride creation: {ride_model_name}",
|
|
)
|
|
|
|
return super().form_valid(form)
|
|
|
|
|
|
class RideUpdateView(
|
|
LoginRequiredMixin, ParkContextRequired, EditSubmissionMixin, UpdateView
|
|
):
|
|
"""View for updating an existing ride"""
|
|
|
|
model = Ride
|
|
form_class = RideForm
|
|
template_name = "rides/ride_form.html"
|
|
slug_url_kwarg = "ride_slug"
|
|
|
|
def get_success_url(self):
|
|
"""Get URL to redirect to after successful update"""
|
|
return reverse(
|
|
"parks:rides:ride_detail",
|
|
kwargs={
|
|
"park_slug": self.park.slug,
|
|
"ride_slug": self.object.slug,
|
|
},
|
|
)
|
|
|
|
def get_queryset(self):
|
|
"""Get ride for the specific park"""
|
|
return Ride.objects.filter(park__slug=self.kwargs["park_slug"])
|
|
|
|
def get_form_kwargs(self):
|
|
"""Pass park to the form"""
|
|
kwargs = super().get_form_kwargs()
|
|
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
|
kwargs["park"] = self.park
|
|
return kwargs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
"""Add park and park_slug to context"""
|
|
context = super().get_context_data(**kwargs)
|
|
context["park"] = self.park
|
|
context["park_slug"] = self.park.slug
|
|
context["is_edit"] = True
|
|
return context
|
|
|
|
def form_valid(self, form):
|
|
"""Handle form submission including new items"""
|
|
# Check for new manufacturer
|
|
manufacturer_name = form.cleaned_data.get("manufacturer_search")
|
|
if manufacturer_name and not form.cleaned_data.get("manufacturer"):
|
|
ModerationService.create_edit_submission_with_queue(
|
|
content_object=None,
|
|
changes={"name": manufacturer_name, "roles": ["MANUFACTURER"]},
|
|
submitter=self.request.user,
|
|
submission_type="CREATE",
|
|
reason=f"New manufacturer suggested during ride update: {manufacturer_name}",
|
|
)
|
|
|
|
# Check for new designer
|
|
designer_name = form.cleaned_data.get("designer_search")
|
|
if designer_name and not form.cleaned_data.get("designer"):
|
|
ModerationService.create_edit_submission_with_queue(
|
|
content_object=None,
|
|
changes={"name": designer_name, "roles": ["DESIGNER"]},
|
|
submitter=self.request.user,
|
|
submission_type="CREATE",
|
|
reason=f"New designer suggested during ride update: {designer_name}",
|
|
)
|
|
|
|
# Check for new ride model
|
|
ride_model_name = form.cleaned_data.get("ride_model_search")
|
|
manufacturer = form.cleaned_data.get("manufacturer")
|
|
if ride_model_name and not form.cleaned_data.get("ride_model") and manufacturer:
|
|
ModerationService.create_edit_submission_with_queue(
|
|
content_object=None,
|
|
changes={
|
|
"name": ride_model_name,
|
|
"manufacturer": manufacturer.id,
|
|
},
|
|
submitter=self.request.user,
|
|
submission_type="CREATE",
|
|
reason=f"New ride model suggested during ride update: {ride_model_name}",
|
|
)
|
|
|
|
return super().form_valid(form)
|
|
|
|
|
|
class RideListView(ListView):
|
|
"""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 using the advanced search service"""
|
|
# Initialize search service
|
|
search_service = RideSearchService()
|
|
|
|
# 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"])
|
|
park = self.park
|
|
|
|
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 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 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
|
|
# Use Rich Choice registry directly
|
|
from apps.core.choices.registry import get_choices
|
|
choices = get_choices("categories", "rides")
|
|
context["category_choices"] = [(choice.value, choice.label) for choice in choices]
|
|
|
|
# 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
|
|
|
|
|
|
class SingleCategoryListView(ListView):
|
|
"""View for displaying rides of a specific category"""
|
|
|
|
model = Ride
|
|
template_name = "rides/park_category_list.html"
|
|
context_object_name = "rides"
|
|
|
|
def get_queryset(self):
|
|
"""Get rides filtered by category and optionally by park"""
|
|
category = self.kwargs.get("category")
|
|
queryset = Ride.objects.filter(category=category).select_related(
|
|
"park", "ride_model", "ride_model__manufacturer"
|
|
)
|
|
|
|
if "park_slug" in self.kwargs:
|
|
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
|
queryset = queryset.filter(park=self.park)
|
|
|
|
return queryset
|
|
|
|
def get_context_data(self, **kwargs):
|
|
"""Add park and category information to context"""
|
|
context = super().get_context_data(**kwargs)
|
|
if hasattr(self, "park"):
|
|
context["park"] = self.park
|
|
context["park_slug"] = self.kwargs["park_slug"]
|
|
# Find the category choice by value using Rich Choice registry
|
|
from apps.core.choices.registry import get_choices
|
|
choices = get_choices("categories", "rides")
|
|
category_choice = next((choice for choice in choices if choice.value == self.kwargs["category"]), None)
|
|
context["category"] = category_choice.label if category_choice else "Unknown"
|
|
return context
|
|
|
|
|
|
# Alias for parks app to maintain backward compatibility
|
|
ParkSingleCategoryListView = SingleCategoryListView
|
|
|
|
|
|
def search_companies(request: HttpRequest) -> HttpResponse:
|
|
"""Search companies and return results for HTMX"""
|
|
query = request.GET.get("q", "").strip()
|
|
role = request.GET.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]
|
|
|
|
return render(
|
|
request,
|
|
"rides/partials/company_search_results.html",
|
|
{"companies": companies, "search_term": query},
|
|
)
|
|
|
|
|
|
def search_ride_models(request: HttpRequest) -> HttpResponse:
|
|
"""Search ride models and return results for HTMX"""
|
|
query = request.GET.get("q", "").strip()
|
|
manufacturer_id = request.GET.get("manufacturer")
|
|
|
|
# Show all ride models on click, filter on input
|
|
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]
|
|
|
|
return render(
|
|
request,
|
|
"rides/partials/ride_model_search_results.html",
|
|
{
|
|
"ride_models": ride_models,
|
|
"search_term": query,
|
|
"manufacturer_id": manufacturer_id,
|
|
},
|
|
)
|
|
|
|
|
|
def get_search_suggestions(request: HttpRequest) -> HttpResponse:
|
|
"""Get smart search suggestions for rides
|
|
|
|
Returns suggestions including:
|
|
- Common matching ride names
|
|
- Matching parks
|
|
- Matching categories
|
|
"""
|
|
query = request.GET.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
|
|
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,
|
|
}
|
|
)
|
|
|
|
# Add category matches
|
|
from apps.core.choices.registry import get_choices
|
|
choices = get_choices("categories", "rides")
|
|
for choice in choices:
|
|
if query in choice.label.lower():
|
|
ride_count = Ride.objects.filter(category=choice.value).count()
|
|
suggestions.append(
|
|
{
|
|
"type": "category",
|
|
"code": choice.value,
|
|
"text": choice.label,
|
|
"count": ride_count,
|
|
}
|
|
)
|
|
|
|
return render(
|
|
request,
|
|
"rides/partials/search_suggestions.html",
|
|
{"suggestions": suggestions, "query": query},
|
|
)
|
|
|
|
|
|
class RideSearchView(ListView):
|
|
"""View for ride search functionality with HTMX support."""
|
|
|
|
model = Ride
|
|
template_name = "search/partials/ride_search_results.html"
|
|
context_object_name = "rides"
|
|
paginate_by = 20
|
|
|
|
def get_queryset(self):
|
|
"""Get filtered rides based on search form."""
|
|
queryset = Ride.objects.select_related("park").order_by("name")
|
|
|
|
# Process search form
|
|
form = RideSearchForm(self.request.GET)
|
|
if form.is_valid():
|
|
ride = form.cleaned_data.get("ride")
|
|
if ride:
|
|
# If specific ride selected, return just that ride
|
|
queryset = queryset.filter(id=ride.id)
|
|
else:
|
|
# If no specific ride, filter by search term
|
|
search_term = self.request.GET.get("ride", "").strip()
|
|
if search_term:
|
|
queryset = queryset.filter(name__icontains=search_term)
|
|
|
|
return queryset
|
|
|
|
def get_template_names(self):
|
|
"""Return appropriate template based on request type."""
|
|
if self.request.htmx:
|
|
return ["search/partials/ride_search_results.html"]
|
|
return ["search/ride_search.html"]
|
|
|
|
def get_context_data(self, **kwargs):
|
|
"""Add search form to context."""
|
|
context = super().get_context_data(**kwargs)
|
|
context["search_form"] = RideSearchForm(self.request.GET)
|
|
return context
|
|
|
|
|
|
class RideRankingsView(ListView):
|
|
"""View for displaying ride rankings using the Internet Roller Coaster Poll."""
|
|
|
|
model = RideRanking
|
|
template_name = "rides/rankings.html"
|
|
context_object_name = "rankings"
|
|
paginate_by = 50
|
|
|
|
def get_queryset(self):
|
|
"""Get rankings with optimized queries."""
|
|
queryset = RideRanking.objects.select_related(
|
|
"ride", "ride__park", "ride__manufacturer", "ride__ride_model"
|
|
).order_by("rank")
|
|
|
|
# Filter by category if specified
|
|
category = self.request.GET.get("category")
|
|
if category and category != "all":
|
|
queryset = queryset.filter(ride__category=category)
|
|
|
|
# Filter by minimum mutual riders
|
|
min_riders = self.request.GET.get("min_riders")
|
|
if min_riders:
|
|
try:
|
|
min_riders = int(min_riders)
|
|
queryset = queryset.filter(mutual_riders_count__gte=min_riders)
|
|
except ValueError:
|
|
pass
|
|
|
|
return queryset
|
|
|
|
def get_template_names(self):
|
|
"""Return appropriate template based on request type."""
|
|
if self.request.htmx:
|
|
return ["rides/partials/rankings_table.html"]
|
|
return [self.template_name]
|
|
|
|
def get_context_data(self, **kwargs):
|
|
"""Add context for rankings view."""
|
|
context = super().get_context_data(**kwargs)
|
|
# Use Rich Choice registry directly
|
|
from apps.core.choices.registry import get_choices
|
|
choices = get_choices("categories", "rides")
|
|
context["category_choices"] = [(choice.value, choice.label) for choice in choices]
|
|
context["selected_category"] = self.request.GET.get("category", "all")
|
|
context["min_riders"] = self.request.GET.get("min_riders", "")
|
|
|
|
# Add statistics
|
|
if self.object_list:
|
|
context["total_ranked"] = RideRanking.objects.count()
|
|
context["last_updated"] = (
|
|
self.object_list[0].last_calculated if self.object_list else None
|
|
)
|
|
|
|
return context
|
|
|
|
|
|
class RideRankingDetailView(DetailView):
|
|
"""View for displaying detailed ranking information for a specific ride."""
|
|
|
|
model = Ride
|
|
template_name = "rides/ranking_detail.html"
|
|
slug_url_kwarg = "ride_slug"
|
|
|
|
def get_queryset(self):
|
|
"""Get ride with ranking data."""
|
|
return Ride.objects.select_related(
|
|
"park", "manufacturer", "ranking"
|
|
).prefetch_related("comparisons_as_a", "comparisons_as_b", "ranking_history")
|
|
|
|
def get_context_data(self, **kwargs):
|
|
"""Add ranking details to context."""
|
|
context = super().get_context_data(**kwargs)
|
|
|
|
# Get ranking details from service
|
|
service = RideRankingService()
|
|
ranking_details = service.get_ride_ranking_details(self.object)
|
|
|
|
if ranking_details:
|
|
context.update(ranking_details)
|
|
|
|
# Get recent movement
|
|
recent_snapshots = RankingSnapshot.objects.filter(
|
|
ride=self.object
|
|
).order_by("-snapshot_date")[:7]
|
|
|
|
if len(recent_snapshots) >= 2:
|
|
context["rank_change"] = (
|
|
recent_snapshots[0].rank - recent_snapshots[1].rank
|
|
)
|
|
context["previous_rank"] = recent_snapshots[1].rank
|
|
else:
|
|
context["not_ranked"] = True
|
|
|
|
return context
|
|
|
|
|
|
def ranking_history_chart(request: HttpRequest, ride_slug: str) -> HttpResponse:
|
|
"""HTMX endpoint for ranking history chart data."""
|
|
ride = get_object_or_404(Ride, slug=ride_slug)
|
|
|
|
# Get last 30 days of ranking history
|
|
history = RankingSnapshot.objects.filter(ride=ride).order_by("-snapshot_date")[:30]
|
|
|
|
# Prepare data for chart
|
|
chart_data = [
|
|
{
|
|
"date": snapshot.snapshot_date.isoformat(),
|
|
"rank": snapshot.rank,
|
|
"win_pct": float(snapshot.winning_percentage) * 100,
|
|
}
|
|
for snapshot in reversed(history)
|
|
]
|
|
|
|
return render(
|
|
request,
|
|
"rides/partials/ranking_chart.html",
|
|
{"chart_data": chart_data, "ride": ride},
|
|
)
|
|
|
|
|
|
def ranking_comparisons(request: HttpRequest, ride_slug: str) -> HttpResponse:
|
|
"""HTMX endpoint for ride head-to-head comparisons."""
|
|
ride = get_object_or_404(Ride, slug=ride_slug)
|
|
|
|
# Get head-to-head comparisons
|
|
from django.db.models import Q
|
|
from .models.rankings import RidePairComparison
|
|
|
|
comparisons = (
|
|
RidePairComparison.objects.filter(Q(ride_a=ride) | Q(ride_b=ride))
|
|
.select_related("ride_a", "ride_b", "ride_a__park", "ride_b__park")
|
|
.order_by("-mutual_riders_count")[:20]
|
|
)
|
|
|
|
# Format comparisons for display
|
|
comparison_data = []
|
|
for comp in comparisons:
|
|
if comp.ride_a == ride:
|
|
opponent = comp.ride_b
|
|
wins = comp.ride_a_wins
|
|
losses = comp.ride_b_wins
|
|
else:
|
|
opponent = comp.ride_a
|
|
wins = comp.ride_b_wins
|
|
losses = comp.ride_a_wins
|
|
|
|
result = "win" if wins > losses else "loss" if losses > wins else "tie"
|
|
|
|
comparison_data.append(
|
|
{
|
|
"opponent": opponent,
|
|
"wins": wins,
|
|
"losses": losses,
|
|
"ties": comp.ties,
|
|
"result": result,
|
|
"mutual_riders": comp.mutual_riders_count,
|
|
}
|
|
)
|
|
|
|
return render(
|
|
request,
|
|
"rides/partials/ranking_comparisons.html",
|
|
{"comparisons": comparison_data, "ride": ride},
|
|
)
|