Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-08-26 13:19:04 -04:00
parent bf7e0c0f40
commit 831be6a2ee
151 changed files with 16260 additions and 9137 deletions

View File

@@ -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",
),
]

View File

@@ -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})

View 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",
]

View 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",
}
),
)

View File

@@ -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"],

View File

@@ -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",

View 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

View File

@@ -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"
)

View File

@@ -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"]

View 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

View 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')

View File

@@ -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"),