mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-21 06:51:08 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
@@ -1,23 +0,0 @@
|
||||
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",
|
||||
),
|
||||
]
|
||||
@@ -1,363 +0,0 @@
|
||||
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})
|
||||
19
backend/apps/rides/forms/__init__.py
Normal file
19
backend/apps/rides/forms/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Forms package for the rides app.
|
||||
|
||||
This package contains form classes for ride-related functionality including:
|
||||
- Advanced search and filtering forms
|
||||
- Form validation and data processing
|
||||
"""
|
||||
|
||||
# Import forms from the search module in this package
|
||||
from .search import MasterFilterForm
|
||||
|
||||
# Import forms from the base module in this package
|
||||
from .base import RideForm, RideSearchForm
|
||||
|
||||
__all__ = [
|
||||
"MasterFilterForm",
|
||||
"RideForm",
|
||||
"RideSearchForm",
|
||||
]
|
||||
379
backend/apps/rides/forms/base.py
Normal file
379
backend/apps/rides/forms/base.py
Normal file
@@ -0,0 +1,379 @@
|
||||
from apps.parks.models import Park, ParkArea
|
||||
from django import forms
|
||||
from django.forms import ModelChoiceField
|
||||
from django.urls import reverse_lazy
|
||||
from ..models.company import Company
|
||||
from ..models.rides import Ride, RideModel
|
||||
|
||||
Manufacturer = Company
|
||||
Designer = Company
|
||||
|
||||
|
||||
class RideForm(forms.ModelForm):
|
||||
park_search = forms.CharField(
|
||||
label="Park *",
|
||||
required=True,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a park...",
|
||||
"hx-get": "/parks/search/",
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#park-search-results",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
manufacturer_search = forms.CharField(
|
||||
label="Manufacturer",
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a manufacturer...",
|
||||
"hx-get": reverse_lazy("rides:search_companies"),
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#manufacturer-search-results",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
designer_search = forms.CharField(
|
||||
label="Designer",
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a designer...",
|
||||
"hx-get": reverse_lazy("rides:search_companies"),
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#designer-search-results",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
ride_model_search = forms.CharField(
|
||||
label="Ride Model",
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Search for a ride model...",
|
||||
"hx-get": reverse_lazy("rides:search_ride_models"),
|
||||
"hx-trigger": "click, input delay:200ms",
|
||||
"hx-target": "#ride-model-search-results",
|
||||
"hx-include": "[name='manufacturer']",
|
||||
"name": "q",
|
||||
"autocomplete": "off",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
park = forms.ModelChoiceField(
|
||||
queryset=Park.objects.all(),
|
||||
required=True,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
manufacturer = forms.ModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
designer = forms.ModelChoiceField(
|
||||
queryset=Designer.objects.all(),
|
||||
required=False,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
ride_model = forms.ModelChoiceField(
|
||||
queryset=RideModel.objects.all(),
|
||||
required=False,
|
||||
label="",
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
park_area = ModelChoiceField(
|
||||
queryset=ParkArea.objects.none(),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Select an area within the park...",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Ride
|
||||
fields = [
|
||||
"name",
|
||||
"category",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"ride_model",
|
||||
"status",
|
||||
"post_closing_status",
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"status_since",
|
||||
"min_height_in",
|
||||
"max_height_in",
|
||||
"capacity_per_hour",
|
||||
"ride_duration_seconds",
|
||||
"description",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Official name of the ride",
|
||||
}
|
||||
),
|
||||
"category": forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"hx-get": reverse_lazy("rides:coaster_fields"),
|
||||
"hx-target": "#coaster-fields",
|
||||
"hx-trigger": "change",
|
||||
"hx-include": "this",
|
||||
"hx-swap": "innerHTML",
|
||||
}
|
||||
),
|
||||
"status": forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Current operational status",
|
||||
"x-model": "status",
|
||||
"@change": "handleStatusChange",
|
||||
}
|
||||
),
|
||||
"post_closing_status": forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Status after closing",
|
||||
"x-show": "status === 'CLOSING'",
|
||||
}
|
||||
),
|
||||
"opening_date": forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Date when ride first opened",
|
||||
}
|
||||
),
|
||||
"closing_date": forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Date when ride will close",
|
||||
"x-show": "['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(status)",
|
||||
":required": "status === 'CLOSING'",
|
||||
}
|
||||
),
|
||||
"status_since": forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Date when current status took effect",
|
||||
}
|
||||
),
|
||||
"min_height_in": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Minimum height requirement in inches",
|
||||
}
|
||||
),
|
||||
"max_height_in": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Maximum height limit in inches (if applicable)",
|
||||
}
|
||||
),
|
||||
"capacity_per_hour": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Theoretical hourly ride capacity",
|
||||
}
|
||||
),
|
||||
"ride_duration_seconds": forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"min": "0",
|
||||
"placeholder": "Total duration of one ride cycle in seconds",
|
||||
}
|
||||
),
|
||||
"description": forms.Textarea(
|
||||
attrs={
|
||||
"rows": 4,
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-textarea "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "General description and notable features of the ride",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
park = kwargs.pop("park", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Make category required
|
||||
self.fields["category"].required = True
|
||||
|
||||
# Clear any default values for date fields
|
||||
self.fields["opening_date"].initial = None
|
||||
self.fields["closing_date"].initial = None
|
||||
self.fields["status_since"].initial = None
|
||||
|
||||
# Move fields to the beginning in desired order
|
||||
field_order = [
|
||||
"park_search",
|
||||
"park",
|
||||
"park_area",
|
||||
"name",
|
||||
"manufacturer_search",
|
||||
"manufacturer",
|
||||
"designer_search",
|
||||
"designer",
|
||||
"ride_model_search",
|
||||
"ride_model",
|
||||
"category",
|
||||
"status",
|
||||
"post_closing_status",
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"status_since",
|
||||
"min_height_in",
|
||||
"max_height_in",
|
||||
"capacity_per_hour",
|
||||
"ride_duration_seconds",
|
||||
"description",
|
||||
]
|
||||
self.order_fields(field_order)
|
||||
|
||||
if park:
|
||||
# If park is provided, set it as the initial value
|
||||
self.fields["park"].initial = park
|
||||
# Hide the park search field since we know the park
|
||||
del self.fields["park_search"]
|
||||
# Create new park_area field with park's areas
|
||||
self.fields["park_area"] = forms.ModelChoiceField(
|
||||
queryset=park.areas.all(),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-select "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"placeholder": "Select an area within the park...",
|
||||
}
|
||||
),
|
||||
)
|
||||
else:
|
||||
# If no park provided, show park search and disable park_area until
|
||||
# park is selected
|
||||
self.fields["park_area"].widget.attrs["disabled"] = True
|
||||
# Initialize park search with current park name if editing
|
||||
if self.instance and self.instance.pk and self.instance.park:
|
||||
self.fields["park_search"].initial = self.instance.park.name
|
||||
self.fields["park"].initial = self.instance.park
|
||||
|
||||
# Initialize manufacturer, designer, and ride model search fields if
|
||||
# editing
|
||||
if self.instance and self.instance.pk:
|
||||
if self.instance.manufacturer:
|
||||
self.fields["manufacturer_search"].initial = (
|
||||
self.instance.manufacturer.name
|
||||
)
|
||||
self.fields["manufacturer"].initial = self.instance.manufacturer
|
||||
if self.instance.designer:
|
||||
self.fields["designer_search"].initial = self.instance.designer.name
|
||||
self.fields["designer"].initial = self.instance.designer
|
||||
if self.instance.ride_model:
|
||||
self.fields["ride_model_search"].initial = self.instance.ride_model.name
|
||||
self.fields["ride_model"].initial = self.instance.ride_model
|
||||
|
||||
|
||||
class RideSearchForm(forms.Form):
|
||||
"""Form for searching rides with HTMX autocomplete."""
|
||||
|
||||
ride = forms.ModelChoiceField(
|
||||
queryset=Ride.objects.all(),
|
||||
label="Find a ride",
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full border-gray-300 rounded-lg form-input "
|
||||
"dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
),
|
||||
"hx-get": reverse_lazy("rides:search"),
|
||||
"hx-trigger": "change",
|
||||
"hx-target": "#ride-search-results",
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -530,7 +530,7 @@ class MasterFilterForm(BaseFilterForm):
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def get_search_filters(self) -> Dict[str, Any]:
|
||||
def get_filter_dict(self) -> Dict[str, Any]:
|
||||
"""Convert form data to search service filter format."""
|
||||
if not self.is_valid():
|
||||
return {}
|
||||
@@ -544,13 +544,32 @@ class MasterFilterForm(BaseFilterForm):
|
||||
|
||||
return filters
|
||||
|
||||
def get_active_filters_summary(self) -> Dict[str, Any]:
|
||||
def get_search_filters(self) -> Dict[str, Any]:
|
||||
"""Alias for get_filter_dict for backward compatibility."""
|
||||
return self.get_filter_dict()
|
||||
|
||||
def get_filter_summary(self) -> Dict[str, Any]:
|
||||
"""Get summary of active filters for display."""
|
||||
active_filters = {}
|
||||
|
||||
if not self.is_valid():
|
||||
return active_filters
|
||||
|
||||
def get_active_filters_summary(self) -> Dict[str, Any]:
|
||||
"""Alias for get_filter_summary for backward compatibility."""
|
||||
return self.get_filter_summary()
|
||||
|
||||
def has_active_filters(self) -> bool:
|
||||
"""Check if any filters are currently active."""
|
||||
if not self.is_valid():
|
||||
return False
|
||||
|
||||
for field_name, value in self.cleaned_data.items():
|
||||
if value: # If any field has a value, we have active filters
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# Group filters by category
|
||||
categories = {
|
||||
"Search": ["global_search", "name_search", "description_search"],
|
||||
|
||||
@@ -7,11 +7,11 @@ enabling imports like: from rides.models import Ride, Manufacturer
|
||||
The Company model is aliased as Manufacturer to clarify its role as ride manufacturers,
|
||||
while maintaining backward compatibility through the Company alias.
|
||||
"""
|
||||
|
||||
from .rides import Ride, RideModel, RollerCoasterStats, Categories, CATEGORY_CHOICES
|
||||
from .location import RideLocation
|
||||
from .reviews import RideReview
|
||||
from .rankings import RideRanking, RidePairComparison, RankingSnapshot
|
||||
from .media import RidePhoto
|
||||
|
||||
__all__ = [
|
||||
# Primary models
|
||||
@@ -20,6 +20,7 @@ __all__ = [
|
||||
"RollerCoasterStats",
|
||||
"RideLocation",
|
||||
"RideReview",
|
||||
"RidePhoto",
|
||||
# Rankings
|
||||
"RideRanking",
|
||||
"RidePairComparison",
|
||||
|
||||
143
backend/apps/rides/models/media.py
Normal file
143
backend/apps/rides/models/media.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Ride-specific media models for ThrillWiki.
|
||||
|
||||
This module contains media models specific to rides domain.
|
||||
"""
|
||||
|
||||
from typing import Any, Optional, cast
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from apps.core.history import TrackedModel
|
||||
from apps.core.services.media_service import MediaService
|
||||
import pghistory
|
||||
|
||||
|
||||
def ride_photo_upload_path(instance: models.Model, filename: str) -> str:
|
||||
"""Generate upload path for ride photos."""
|
||||
photo = cast('RidePhoto', instance)
|
||||
ride = photo.ride
|
||||
|
||||
if ride is None:
|
||||
raise ValueError("Ride cannot be None")
|
||||
|
||||
return MediaService.generate_upload_path(
|
||||
domain="park",
|
||||
identifier=ride.slug,
|
||||
filename=filename,
|
||||
subdirectory=ride.park.slug
|
||||
)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RidePhoto(TrackedModel):
|
||||
"""Photo model specific to rides."""
|
||||
|
||||
ride = models.ForeignKey(
|
||||
'rides.Ride',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='photos'
|
||||
)
|
||||
|
||||
image = models.ImageField(
|
||||
upload_to=ride_photo_upload_path,
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
caption = models.CharField(max_length=255, blank=True)
|
||||
alt_text = models.CharField(max_length=255, blank=True)
|
||||
is_primary = models.BooleanField(default=False)
|
||||
is_approved = models.BooleanField(default=False)
|
||||
|
||||
# Ride-specific metadata
|
||||
photo_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('exterior', 'Exterior View'),
|
||||
('queue', 'Queue Area'),
|
||||
('station', 'Station'),
|
||||
('onride', 'On-Ride'),
|
||||
('construction', 'Construction'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
default='exterior'
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
date_taken = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# User who uploaded the photo
|
||||
uploaded_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name="uploaded_ride_photos",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
app_label = "rides"
|
||||
ordering = ["-is_primary", "-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["ride", "is_primary"]),
|
||||
models.Index(fields=["ride", "is_approved"]),
|
||||
models.Index(fields=["ride", "photo_type"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
]
|
||||
constraints = [
|
||||
# Only one primary photo per ride
|
||||
models.UniqueConstraint(
|
||||
fields=['ride'],
|
||||
condition=models.Q(is_primary=True),
|
||||
name='unique_primary_ride_photo'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Photo of {self.ride.name} - {self.caption or 'No caption'}"
|
||||
|
||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||
# Extract EXIF date if this is a new photo
|
||||
if not self.pk and not self.date_taken and self.image:
|
||||
self.date_taken = MediaService.extract_exif_date(self.image)
|
||||
|
||||
# Set default caption if not provided
|
||||
if not self.caption and self.uploaded_by:
|
||||
self.caption = MediaService.generate_default_caption(
|
||||
self.uploaded_by.username
|
||||
)
|
||||
|
||||
# If this is marked as primary, unmark other primary photos for this ride
|
||||
if self.is_primary:
|
||||
RidePhoto.objects.filter(
|
||||
ride=self.ride,
|
||||
is_primary=True,
|
||||
).exclude(pk=self.pk).update(is_primary=False)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def file_size(self) -> Optional[int]:
|
||||
"""Get file size in bytes."""
|
||||
try:
|
||||
return self.image.size
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
@property
|
||||
def dimensions(self) -> Optional[tuple]:
|
||||
"""Get image dimensions as (width, height)."""
|
||||
try:
|
||||
return (self.image.width, self.image.height)
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
"""Get absolute URL for this photo."""
|
||||
return f"/parks/{self.ride.park.slug}/rides/{self.ride.slug}/photos/{self.pk}/"
|
||||
|
||||
@property
|
||||
def park(self):
|
||||
"""Get the park this ride belongs to."""
|
||||
return self.ride.park
|
||||
@@ -1,198 +0,0 @@
|
||||
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"
|
||||
)
|
||||
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
Services for the rides app.
|
||||
"""
|
||||
from .location_service import RideLocationService
|
||||
from .media_service import RideMediaService
|
||||
|
||||
from .ranking_service import RideRankingService
|
||||
|
||||
__all__ = ["RideRankingService"]
|
||||
__all__ = ["RideLocationService", "RideMediaService"]
|
||||
|
||||
362
backend/apps/rides/services/location_service.py
Normal file
362
backend/apps/rides/services/location_service.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Rides-specific location services with OpenStreetMap integration.
|
||||
Handles location management for individual rides within parks.
|
||||
"""
|
||||
|
||||
import requests
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
import logging
|
||||
|
||||
from ..models import RideLocation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RideLocationService:
|
||||
"""
|
||||
Location service specifically for rides using OpenStreetMap integration.
|
||||
Focuses on precise positioning within parks and navigation assistance.
|
||||
"""
|
||||
|
||||
NOMINATIM_BASE_URL = "https://nominatim.openstreetmap.org"
|
||||
USER_AGENT = "ThrillWiki/1.0 (https://thrillwiki.com)"
|
||||
|
||||
@classmethod
|
||||
def create_ride_location(
|
||||
cls,
|
||||
*,
|
||||
ride,
|
||||
latitude: Optional[float] = None,
|
||||
longitude: Optional[float] = None,
|
||||
park_area: str = "",
|
||||
notes: str = "",
|
||||
entrance_notes: str = "",
|
||||
accessibility_notes: str = "",
|
||||
) -> RideLocation:
|
||||
"""
|
||||
Create a location for a ride within a park.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
latitude: Latitude coordinate (optional for rides)
|
||||
longitude: Longitude coordinate (optional for rides)
|
||||
park_area: Themed area within the park
|
||||
notes: General location notes
|
||||
entrance_notes: Entrance and navigation notes
|
||||
accessibility_notes: Accessibility information
|
||||
|
||||
Returns:
|
||||
Created RideLocation instance
|
||||
"""
|
||||
with transaction.atomic():
|
||||
ride_location = RideLocation(
|
||||
ride=ride,
|
||||
park_area=park_area,
|
||||
notes=notes,
|
||||
entrance_notes=entrance_notes,
|
||||
accessibility_notes=accessibility_notes,
|
||||
)
|
||||
|
||||
# Set coordinates if provided
|
||||
if latitude is not None and longitude is not None:
|
||||
ride_location.set_coordinates(latitude, longitude)
|
||||
|
||||
ride_location.full_clean()
|
||||
ride_location.save()
|
||||
|
||||
return ride_location
|
||||
|
||||
@classmethod
|
||||
def update_ride_location(
|
||||
cls, ride_location: RideLocation, **updates
|
||||
) -> RideLocation:
|
||||
"""
|
||||
Update ride location with validation.
|
||||
|
||||
Args:
|
||||
ride_location: RideLocation instance to update
|
||||
**updates: Fields to update
|
||||
|
||||
Returns:
|
||||
Updated RideLocation instance
|
||||
"""
|
||||
with transaction.atomic():
|
||||
# Handle coordinates separately
|
||||
latitude = updates.pop("latitude", None)
|
||||
longitude = updates.pop("longitude", None)
|
||||
|
||||
# Update regular fields
|
||||
for field, value in updates.items():
|
||||
if hasattr(ride_location, field):
|
||||
setattr(ride_location, field, value)
|
||||
|
||||
# Update coordinates if provided
|
||||
if latitude is not None and longitude is not None:
|
||||
ride_location.set_coordinates(latitude, longitude)
|
||||
|
||||
ride_location.full_clean()
|
||||
ride_location.save()
|
||||
|
||||
return ride_location
|
||||
|
||||
@classmethod
|
||||
def find_rides_in_area(cls, park, park_area: str) -> List[RideLocation]:
|
||||
"""
|
||||
Find all rides in a specific park area.
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
park_area: Name of the park area/land
|
||||
|
||||
Returns:
|
||||
List of RideLocation instances in the area
|
||||
"""
|
||||
return list(
|
||||
RideLocation.objects.filter(ride__park=park, park_area__icontains=park_area)
|
||||
.select_related("ride")
|
||||
.order_by("ride__name")
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def find_nearby_rides(
|
||||
cls, latitude: float, longitude: float, park=None, radius_meters: float = 500
|
||||
) -> List[RideLocation]:
|
||||
"""
|
||||
Find rides near given coordinates using PostGIS.
|
||||
Useful for finding rides near a specific location within a park.
|
||||
|
||||
Args:
|
||||
latitude: Center latitude
|
||||
longitude: Center longitude
|
||||
park: Optional park to limit search to
|
||||
radius_meters: Search radius in meters (default: 500m)
|
||||
|
||||
Returns:
|
||||
List of nearby RideLocation instances
|
||||
"""
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import Distance
|
||||
|
||||
center_point = Point(longitude, latitude, srid=4326)
|
||||
|
||||
queryset = RideLocation.objects.filter(
|
||||
point__distance_lte=(center_point, Distance(m=radius_meters)),
|
||||
point__isnull=False,
|
||||
)
|
||||
|
||||
if park:
|
||||
queryset = queryset.filter(ride__park=park)
|
||||
|
||||
return list(
|
||||
queryset.select_related("ride", "ride__park").order_by("point__distance")
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_ride_navigation_info(cls, ride_location: RideLocation) -> Dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive navigation information for a ride.
|
||||
|
||||
Args:
|
||||
ride_location: RideLocation instance
|
||||
|
||||
Returns:
|
||||
Dictionary with navigation information
|
||||
"""
|
||||
info = {
|
||||
"ride_name": ride_location.ride.name,
|
||||
"park_name": ride_location.ride.park.name,
|
||||
"park_area": ride_location.park_area,
|
||||
"has_coordinates": ride_location.has_coordinates,
|
||||
"entrance_notes": ride_location.entrance_notes,
|
||||
"accessibility_notes": ride_location.accessibility_notes,
|
||||
"general_notes": ride_location.notes,
|
||||
}
|
||||
|
||||
# Add coordinate information if available
|
||||
if ride_location.has_coordinates:
|
||||
info.update(
|
||||
{
|
||||
"latitude": ride_location.latitude,
|
||||
"longitude": ride_location.longitude,
|
||||
"coordinates": ride_location.coordinates,
|
||||
}
|
||||
)
|
||||
|
||||
# Calculate distance to park entrance if park has location
|
||||
park_location = getattr(ride_location.ride.park, "location", None)
|
||||
if park_location and park_location.point:
|
||||
distance_km = ride_location.distance_to_park_location()
|
||||
if distance_km is not None:
|
||||
info["distance_from_park_entrance_km"] = round(distance_km, 2)
|
||||
|
||||
return info
|
||||
|
||||
@classmethod
|
||||
def estimate_ride_coordinates_from_park(
|
||||
cls,
|
||||
ride_location: RideLocation,
|
||||
area_offset_meters: Dict[str, Tuple[float, float]] = None,
|
||||
) -> Optional[Tuple[float, float]]:
|
||||
"""
|
||||
Estimate ride coordinates based on park location and area.
|
||||
Useful when exact ride coordinates are not available.
|
||||
|
||||
Args:
|
||||
ride_location: RideLocation instance
|
||||
area_offset_meters: Dictionary mapping area names to (north_offset, east_offset) in meters
|
||||
|
||||
Returns:
|
||||
Estimated (latitude, longitude) tuple or None
|
||||
"""
|
||||
park_location = getattr(ride_location.ride.park, "location", None)
|
||||
if not park_location or not park_location.point:
|
||||
return None
|
||||
|
||||
# Default area offsets (rough estimates for common themed areas)
|
||||
default_offsets = {
|
||||
"main street": (0, 0), # Usually at entrance
|
||||
"fantasyland": (200, 100), # Often north-east
|
||||
"tomorrowland": (100, 200), # Often east
|
||||
"frontierland": (-100, -200), # Often south-west
|
||||
"adventureland": (-200, 100), # Often south-east
|
||||
"new orleans square": (-150, -100),
|
||||
"critter country": (-200, -200),
|
||||
"galaxy's edge": (300, 300), # Often on periphery
|
||||
"cars land": (200, -200),
|
||||
"pixar pier": (0, 300), # Often waterfront
|
||||
}
|
||||
|
||||
offsets = area_offset_meters or default_offsets
|
||||
|
||||
# Find matching area offset
|
||||
area_lower = ride_location.park_area.lower()
|
||||
offset = None
|
||||
|
||||
for area_name, area_offset in offsets.items():
|
||||
if area_name in area_lower:
|
||||
offset = area_offset
|
||||
break
|
||||
|
||||
if not offset:
|
||||
# Default small random offset if no specific area match
|
||||
import random
|
||||
|
||||
offset = (random.randint(-100, 100), random.randint(-100, 100))
|
||||
|
||||
# Convert meter offsets to coordinate offsets
|
||||
# Rough conversion: 1 degree latitude ≈ 111,000 meters
|
||||
# 1 degree longitude varies by latitude, but we'll use a rough approximation
|
||||
lat_offset = offset[0] / 111000 # North offset in degrees
|
||||
lon_offset = offset[1] / (
|
||||
111000 * abs(park_location.latitude) * 0.01
|
||||
) # East offset
|
||||
|
||||
estimated_lat = park_location.latitude + lat_offset
|
||||
estimated_lon = park_location.longitude + lon_offset
|
||||
|
||||
return (estimated_lat, estimated_lon)
|
||||
|
||||
@classmethod
|
||||
def bulk_update_ride_areas_from_osm(cls, park) -> int:
|
||||
"""
|
||||
Bulk update ride locations for a park using OSM data.
|
||||
Attempts to find more precise locations for rides within the park.
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
|
||||
Returns:
|
||||
Number of ride locations updated
|
||||
"""
|
||||
updated_count = 0
|
||||
park_location = getattr(park, "location", None)
|
||||
|
||||
if not park_location or not park_location.point:
|
||||
return updated_count
|
||||
|
||||
# Get all rides in the park that don't have precise coordinates
|
||||
ride_locations = RideLocation.objects.filter(
|
||||
ride__park=park, point__isnull=True
|
||||
).select_related("ride")
|
||||
|
||||
for ride_location in ride_locations:
|
||||
# Try to search for the specific ride within the park area
|
||||
search_query = f"{ride_location.ride.name} {park.name}"
|
||||
|
||||
try:
|
||||
# Search for the ride specifically
|
||||
params = {
|
||||
"q": search_query,
|
||||
"format": "json",
|
||||
"limit": 5,
|
||||
"addressdetails": 1,
|
||||
"bounded": 1, # Restrict to viewbox
|
||||
# Create a bounding box around the park (roughly 2km radius)
|
||||
"viewbox": f"{park_location.longitude - 0.02},{park_location.latitude + 0.02},{park_location.longitude + 0.02},{park_location.latitude - 0.02}",
|
||||
}
|
||||
|
||||
headers = {"User-Agent": cls.USER_AGENT}
|
||||
|
||||
response = requests.get(
|
||||
f"{cls.NOMINATIM_BASE_URL}/search",
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
results = response.json()
|
||||
|
||||
# Look for results that might be the ride
|
||||
for result in results:
|
||||
display_name = result.get("display_name", "").lower()
|
||||
if (
|
||||
ride_location.ride.name.lower() in display_name
|
||||
and park.name.lower() in display_name
|
||||
):
|
||||
|
||||
# Update the ride location
|
||||
ride_location.set_coordinates(
|
||||
float(result["lat"]), float(result["lon"])
|
||||
)
|
||||
ride_location.save()
|
||||
updated_count += 1
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error updating ride location for {ride_location.ride.name}: {str(e)}"
|
||||
)
|
||||
continue
|
||||
|
||||
return updated_count
|
||||
|
||||
@classmethod
|
||||
def generate_park_area_map(cls, park) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Generate a map of park areas and the rides in each area.
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
|
||||
Returns:
|
||||
Dictionary mapping area names to lists of ride names
|
||||
"""
|
||||
area_map = {}
|
||||
|
||||
ride_locations = (
|
||||
RideLocation.objects.filter(ride__park=park)
|
||||
.select_related("ride")
|
||||
.order_by("park_area", "ride__name")
|
||||
)
|
||||
|
||||
for ride_location in ride_locations:
|
||||
area = ride_location.park_area or "Unknown Area"
|
||||
if area not in area_map:
|
||||
area_map[area] = []
|
||||
area_map[area].append(ride_location.ride.name)
|
||||
|
||||
return area_map
|
||||
305
backend/apps/rides/services/media_service.py
Normal file
305
backend/apps/rides/services/media_service.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Ride-specific media service for ThrillWiki.
|
||||
|
||||
This module provides media management functionality specific to rides.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db import transaction
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.core.services.media_service import MediaService
|
||||
from ..models import Ride, RidePhoto
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RideMediaService:
|
||||
"""Service for managing ride-specific media operations."""
|
||||
|
||||
@staticmethod
|
||||
def upload_photo(
|
||||
ride: Ride,
|
||||
image_file: UploadedFile,
|
||||
user: User,
|
||||
caption: str = "",
|
||||
alt_text: str = "",
|
||||
photo_type: str = "exterior",
|
||||
is_primary: bool = False,
|
||||
auto_approve: bool = False
|
||||
) -> RidePhoto:
|
||||
"""
|
||||
Upload a photo for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
image_file: Uploaded image file
|
||||
user: User uploading the photo
|
||||
caption: Photo caption
|
||||
alt_text: Alt text for accessibility
|
||||
photo_type: Type of photo (exterior, queue, station, etc.)
|
||||
is_primary: Whether this should be the primary photo
|
||||
auto_approve: Whether to auto-approve the photo
|
||||
|
||||
Returns:
|
||||
Created RidePhoto instance
|
||||
|
||||
Raises:
|
||||
ValueError: If image validation fails
|
||||
"""
|
||||
# Validate image file
|
||||
is_valid, error_message = MediaService.validate_image_file(image_file)
|
||||
if not is_valid:
|
||||
raise ValueError(error_message)
|
||||
|
||||
# Process image
|
||||
processed_image = MediaService.process_image(image_file)
|
||||
|
||||
with transaction.atomic():
|
||||
# Create photo instance
|
||||
photo = RidePhoto(
|
||||
ride=ride,
|
||||
image=processed_image,
|
||||
caption=caption or MediaService.generate_default_caption(user.username),
|
||||
alt_text=alt_text,
|
||||
photo_type=photo_type,
|
||||
is_primary=is_primary,
|
||||
is_approved=auto_approve,
|
||||
uploaded_by=user
|
||||
)
|
||||
|
||||
# Extract EXIF date
|
||||
photo.date_taken = MediaService.extract_exif_date(processed_image)
|
||||
|
||||
photo.save()
|
||||
|
||||
logger.info(f"Photo uploaded for ride {ride.slug} by user {user.username}")
|
||||
return photo
|
||||
|
||||
@staticmethod
|
||||
def get_ride_photos(
|
||||
ride: Ride,
|
||||
approved_only: bool = True,
|
||||
primary_first: bool = True,
|
||||
photo_type: Optional[str] = None
|
||||
) -> List[RidePhoto]:
|
||||
"""
|
||||
Get photos for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
approved_only: Whether to only return approved photos
|
||||
primary_first: Whether to order primary photos first
|
||||
photo_type: Filter by photo type (optional)
|
||||
|
||||
Returns:
|
||||
List of RidePhoto instances
|
||||
"""
|
||||
queryset = ride.photos.all()
|
||||
|
||||
if approved_only:
|
||||
queryset = queryset.filter(is_approved=True)
|
||||
|
||||
if photo_type:
|
||||
queryset = queryset.filter(photo_type=photo_type)
|
||||
|
||||
if primary_first:
|
||||
queryset = queryset.order_by('-is_primary', '-created_at')
|
||||
else:
|
||||
queryset = queryset.order_by('-created_at')
|
||||
|
||||
return list(queryset)
|
||||
|
||||
@staticmethod
|
||||
def get_primary_photo(ride: Ride) -> Optional[RidePhoto]:
|
||||
"""
|
||||
Get the primary photo for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
|
||||
Returns:
|
||||
Primary RidePhoto instance or None
|
||||
"""
|
||||
try:
|
||||
return ride.photos.filter(is_primary=True, is_approved=True).first()
|
||||
except RidePhoto.DoesNotExist:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_photos_by_type(ride: Ride, photo_type: str) -> List[RidePhoto]:
|
||||
"""
|
||||
Get photos of a specific type for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
photo_type: Type of photos to retrieve
|
||||
|
||||
Returns:
|
||||
List of RidePhoto instances
|
||||
"""
|
||||
return list(
|
||||
ride.photos.filter(
|
||||
photo_type=photo_type,
|
||||
is_approved=True
|
||||
).order_by('-created_at')
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def set_primary_photo(ride: Ride, photo: RidePhoto) -> bool:
|
||||
"""
|
||||
Set a photo as the primary photo for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
photo: RidePhoto to set as primary
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if photo.ride != ride:
|
||||
return False
|
||||
|
||||
with transaction.atomic():
|
||||
# Unset current primary
|
||||
ride.photos.filter(is_primary=True).update(is_primary=False)
|
||||
|
||||
# Set new primary
|
||||
photo.is_primary = True
|
||||
photo.save()
|
||||
|
||||
logger.info(f"Set photo {photo.pk} as primary for ride {ride.slug}")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def approve_photo(photo: RidePhoto, approved_by: User) -> bool:
|
||||
"""
|
||||
Approve a ride photo.
|
||||
|
||||
Args:
|
||||
photo: RidePhoto to approve
|
||||
approved_by: User approving the photo
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
photo.is_approved = True
|
||||
photo.save()
|
||||
|
||||
logger.info(f"Photo {photo.pk} approved by user {approved_by.username}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to approve photo {photo.pk}: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def delete_photo(photo: RidePhoto, deleted_by: User) -> bool:
|
||||
"""
|
||||
Delete a ride photo.
|
||||
|
||||
Args:
|
||||
photo: RidePhoto to delete
|
||||
deleted_by: User deleting the photo
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
ride_slug = photo.ride.slug
|
||||
photo_id = photo.pk
|
||||
|
||||
# Delete the file and database record
|
||||
if photo.image:
|
||||
photo.image.delete(save=False)
|
||||
photo.delete()
|
||||
|
||||
logger.info(
|
||||
f"Photo {photo_id} deleted from ride {ride_slug} by user {deleted_by.username}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete photo {photo.pk}: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_photo_stats(ride: Ride) -> Dict[str, Any]:
|
||||
"""
|
||||
Get photo statistics for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
|
||||
Returns:
|
||||
Dictionary with photo statistics
|
||||
"""
|
||||
photos = ride.photos.all()
|
||||
|
||||
# Get counts by photo type
|
||||
type_counts = {}
|
||||
for photo_type, _ in RidePhoto._meta.get_field('photo_type').choices:
|
||||
type_counts[photo_type] = photos.filter(photo_type=photo_type).count()
|
||||
|
||||
return {
|
||||
"total_photos": photos.count(),
|
||||
"approved_photos": photos.filter(is_approved=True).count(),
|
||||
"pending_photos": photos.filter(is_approved=False).count(),
|
||||
"has_primary": photos.filter(is_primary=True).exists(),
|
||||
"recent_uploads": photos.order_by('-created_at')[:5].count(),
|
||||
"by_type": type_counts
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def bulk_approve_photos(photos: List[RidePhoto], approved_by: User) -> int:
|
||||
"""
|
||||
Bulk approve multiple photos.
|
||||
|
||||
Args:
|
||||
photos: List of RidePhoto instances to approve
|
||||
approved_by: User approving the photos
|
||||
|
||||
Returns:
|
||||
Number of photos successfully approved
|
||||
"""
|
||||
approved_count = 0
|
||||
|
||||
with transaction.atomic():
|
||||
for photo in photos:
|
||||
if RideMediaService.approve_photo(photo, approved_by):
|
||||
approved_count += 1
|
||||
|
||||
logger.info(
|
||||
f"Bulk approved {approved_count} photos by user {approved_by.username}")
|
||||
return approved_count
|
||||
|
||||
@staticmethod
|
||||
def get_construction_timeline(ride: Ride) -> List[RidePhoto]:
|
||||
"""
|
||||
Get construction photos ordered chronologically.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
|
||||
Returns:
|
||||
List of construction RidePhoto instances ordered by date taken
|
||||
"""
|
||||
return list(
|
||||
ride.photos.filter(
|
||||
photo_type='construction',
|
||||
is_approved=True
|
||||
).order_by('date_taken', 'created_at')
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_onride_photos(ride: Ride) -> List[RidePhoto]:
|
||||
"""
|
||||
Get on-ride photos for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
|
||||
Returns:
|
||||
List of on-ride RidePhoto instances
|
||||
"""
|
||||
return RideMediaService.get_photos_by_type(ride, 'onride')
|
||||
@@ -70,8 +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")),
|
||||
# API endpoints moved to centralized backend/api/v1/rides/ structure
|
||||
# Frontend requests to /api/ are proxied to /api/v1/ by Vite
|
||||
# Park-specific URLs
|
||||
path("create/", views.RideCreateView.as_view(), name="ride_create"),
|
||||
path("<slug:ride_slug>/", views.RideDetailView.as_view(), name="ride_detail"),
|
||||
|
||||
Reference in New Issue
Block a user