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,8 +1,7 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from apps.parks.models import ParkReview, Park
from apps.rides.models import Ride
from apps.media.models import Photo
from apps.parks.models import ParkReview, Park, ParkPhoto
from apps.rides.models import Ride, RidePhoto
User = get_user_model()
@@ -25,11 +24,18 @@ class Command(BaseCommand):
reviews.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
# Delete test photos
photos = Photo.objects.filter(uploader__username__in=["testuser", "moderator"])
count = photos.count()
photos.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test photos"))
# Delete test photos - both park and ride photos
park_photos = ParkPhoto.objects.filter(
uploader__username__in=["testuser", "moderator"])
park_count = park_photos.count()
park_photos.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {park_count} test park photos"))
ride_photos = RidePhoto.objects.filter(
uploader__username__in=["testuser", "moderator"])
ride_count = ride_photos.count()
ride_photos.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {ride_count} test ride photos"))
# Delete test parks
parks = Park.objects.filter(name__startswith="Test Park")

View File

@@ -1,5 +1,6 @@
"""
Consolidated API app for ThrillWiki.
Centralized API package for ThrillWiki
This app provides a unified, versioned API interface for all ThrillWiki resources.
All API endpoints MUST be defined here under the /api/v1/ structure.
This enforces consistent API architecture and prevents rogue endpoint creation.
"""

View File

@@ -1,17 +1,19 @@
"""Django app configuration for the consolidated API."""
"""
ThrillWiki API App Configuration
This module contains the Django app configuration for the centralized API application.
All API endpoints are routed through this app following the pattern:
- Frontend: /api/{endpoint}
- Vite Proxy: /api/ -> /api/v1/
- Django: backend/api/v1/{endpoint}
"""
from django.apps import AppConfig
class ApiConfig(AppConfig):
"""Configuration for the consolidated API app."""
"""Configuration for the centralized API app."""
default_auto_field = "django.db.models.BigAutoField"
name = "apps.api"
def ready(self):
"""Import schema extensions when app is ready."""
try:
import apps.api.v1.schema # noqa: F401
except ImportError:
pass
name = "api"
verbose_name = "ThrillWiki API"

5
backend/apps/api/urls.py Normal file
View File

@@ -0,0 +1,5 @@
from django.urls import path, include
urlpatterns = [
path("v1/", include("apps.api.v1.urls")),
]

View File

@@ -0,0 +1,3 @@
"""
Accounts API module for user profile and top list management.
"""

View File

@@ -0,0 +1,18 @@
"""
Accounts API URL Configuration
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
# Create router and register ViewSets
router = DefaultRouter()
router.register(r"profiles", views.UserProfileViewSet, basename="user-profile")
router.register(r"toplists", views.TopListViewSet, basename="top-list")
router.register(r"toplist-items", views.TopListItemViewSet, basename="top-list-item")
urlpatterns = [
# Include router URLs for ViewSets
path("", include(router.urls)),
]

View File

@@ -0,0 +1,204 @@
"""
Accounts API ViewSets for user profiles and top lists.
"""
from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework import status
from django.contrib.auth import get_user_model
from django.db.models import Q
from apps.accounts.models import UserProfile, TopList, TopListItem
from ..serializers import (
UserProfileCreateInputSerializer,
UserProfileUpdateInputSerializer,
UserProfileOutputSerializer,
TopListCreateInputSerializer,
TopListUpdateInputSerializer,
TopListOutputSerializer,
TopListItemCreateInputSerializer,
TopListItemUpdateInputSerializer,
TopListItemOutputSerializer,
)
User = get_user_model()
class UserProfileViewSet(ModelViewSet):
"""ViewSet for managing user profiles."""
queryset = UserProfile.objects.select_related("user").all()
permission_classes = [IsAuthenticated]
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "create":
return UserProfileCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return UserProfileUpdateInputSerializer
return UserProfileOutputSerializer
def get_queryset(self):
"""Filter profiles based on user permissions."""
if self.request.user.is_staff:
return self.queryset
return self.queryset.filter(user=self.request.user)
@action(detail=False, methods=["get"])
def me(self, request):
"""Get current user's profile."""
try:
profile = UserProfile.objects.get(user=request.user)
serializer = self.get_serializer(profile)
return Response(serializer.data)
except UserProfile.DoesNotExist:
return Response(
{"error": "Profile not found"}, status=status.HTTP_404_NOT_FOUND
)
class TopListViewSet(ModelViewSet):
"""ViewSet for managing user top lists."""
queryset = (
TopList.objects.select_related("user").prefetch_related("items__ride").all()
)
permission_classes = [IsAuthenticated]
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "create":
return TopListCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return TopListUpdateInputSerializer
return TopListOutputSerializer
def get_queryset(self):
"""Filter lists based on user permissions and visibility."""
queryset = self.queryset
if not self.request.user.is_staff:
# Non-staff users can only see their own lists and public lists
queryset = queryset.filter(Q(user=self.request.user) | Q(is_public=True))
return queryset.order_by("-created_at")
def perform_create(self, serializer):
"""Set the user when creating a top list."""
serializer.save(user=self.request.user)
@action(detail=False, methods=["get"])
def my_lists(self, request):
"""Get current user's top lists."""
lists = self.get_queryset().filter(user=request.user)
serializer = self.get_serializer(lists, many=True)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def duplicate(self, request, pk=None):
"""Duplicate a top list for the current user."""
original_list = self.get_object()
# Create new list
new_list = TopList.objects.create(
user=request.user,
name=f"Copy of {original_list.name}",
description=original_list.description,
is_public=False, # Duplicated lists are private by default
)
# Copy all items
for item in original_list.items.all():
TopListItem.objects.create(
top_list=new_list,
ride=item.ride,
position=item.position,
notes=item.notes,
)
serializer = self.get_serializer(new_list)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class TopListItemViewSet(ModelViewSet):
"""ViewSet for managing top list items."""
queryset = TopListItem.objects.select_related("top_list__user", "ride").all()
permission_classes = [IsAuthenticated]
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "create":
return TopListItemCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return TopListItemUpdateInputSerializer
return TopListItemOutputSerializer
def get_queryset(self):
"""Filter items based on user permissions."""
queryset = self.queryset
if not self.request.user.is_staff:
# Non-staff users can only see items from their own lists or public lists
queryset = queryset.filter(
Q(top_list__user=self.request.user) | Q(top_list__is_public=True)
)
return queryset.order_by("top_list_id", "position")
def perform_create(self, serializer):
"""Validate user can add items to the list."""
top_list = serializer.validated_data["top_list"]
if top_list.user != self.request.user and not self.request.user.is_staff:
raise PermissionError("You can only add items to your own lists")
serializer.save()
def perform_update(self, serializer):
"""Validate user can update items in the list."""
top_list = serializer.instance.top_list
if top_list.user != self.request.user and not self.request.user.is_staff:
raise PermissionError("You can only update items in your own lists")
serializer.save()
def perform_destroy(self, instance):
"""Validate user can delete items from the list."""
if (
instance.top_list.user != self.request.user
and not self.request.user.is_staff
):
raise PermissionError("You can only delete items from your own lists")
instance.delete()
@action(detail=False, methods=["post"])
def reorder(self, request):
"""Reorder items in a top list."""
top_list_id = request.data.get("top_list_id")
item_ids = request.data.get("item_ids", [])
if not top_list_id or not item_ids:
return Response(
{"error": "top_list_id and item_ids are required"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
top_list = TopList.objects.get(id=top_list_id)
if top_list.user != request.user and not request.user.is_staff:
return Response(
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
)
# Update positions
for position, item_id in enumerate(item_ids, 1):
TopListItem.objects.filter(id=item_id, top_list=top_list).update(
position=position
)
return Response({"success": True})
except TopList.DoesNotExist:
return Response(
{"error": "Top list not found"}, status=status.HTTP_404_NOT_FOUND
)

View File

@@ -0,0 +1,26 @@
"""
Core API URL configuration.
Centralized from apps.core.urls
"""
from django.urls import path
from . import views
# Entity search endpoints - migrated from apps.core.urls
urlpatterns = [
path(
"entities/search/",
views.EntityFuzzySearchView.as_view(),
name="entity_fuzzy_search",
),
path(
"entities/not-found/",
views.EntityNotFoundView.as_view(),
name="entity_not_found",
),
path(
"entities/suggestions/",
views.QuickEntitySuggestionView.as_view(),
name="entity_suggestions",
),
]

View File

@@ -0,0 +1,354 @@
"""
Centralized core API views.
Migrated from apps.core.views.entity_search
"""
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from typing import Optional, List
from apps.core.services.entity_fuzzy_matching import (
entity_fuzzy_matcher,
EntityType,
)
class EntityFuzzySearchView(APIView):
"""
API endpoint for fuzzy entity search with authentication prompts.
Handles entity lookup failures by providing intelligent suggestions and
authentication prompts for entity creation.
Migrated from apps.core.views.entity_search.EntityFuzzySearchView
"""
permission_classes = [AllowAny] # Allow both authenticated and anonymous users
def post(self, request):
"""
Perform fuzzy entity search.
Request body:
{
"query": "entity name to search",
"entity_types": ["park", "ride", "company"], // optional
"include_suggestions": true // optional, default true
}
Response:
{
"success": true,
"query": "original query",
"matches": [
{
"entity_type": "park",
"name": "Cedar Point",
"slug": "cedar-point",
"score": 0.95,
"confidence": "high",
"match_reason": "Text similarity with 'Cedar Point'",
"url": "/parks/cedar-point/",
"entity_id": 123
}
],
"suggestion": {
"suggested_name": "New Entity Name",
"entity_type": "park",
"requires_authentication": true,
"login_prompt": "Log in to suggest adding...",
"signup_prompt": "Sign up to contribute...",
"creation_hint": "Help expand ThrillWiki..."
},
"user_authenticated": false
}
"""
try:
# Parse request data
query = request.data.get("query", "").strip()
entity_types_raw = request.data.get(
"entity_types", ["park", "ride", "company"]
)
include_suggestions = request.data.get("include_suggestions", True)
# Validate query
if not query or len(query) < 2:
return Response(
{
"success": False,
"error": "Query must be at least 2 characters long",
"code": "INVALID_QUERY",
},
status=status.HTTP_400_BAD_REQUEST,
)
# Parse and validate entity types
entity_types = []
valid_types = {"park", "ride", "company"}
for entity_type in entity_types_raw:
if entity_type in valid_types:
entity_types.append(EntityType(entity_type))
if not entity_types:
entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
# Perform fuzzy matching
matches, suggestion = entity_fuzzy_matcher.find_entity(
query=query, entity_types=entity_types, user=request.user
)
# Format response
response_data = {
"success": True,
"query": query,
"matches": [match.to_dict() for match in matches],
"user_authenticated": (
request.user.is_authenticated
if hasattr(request.user, "is_authenticated")
else False
),
}
# Include suggestion if requested and available
if include_suggestions and suggestion:
response_data["suggestion"] = {
"suggested_name": suggestion.suggested_name,
"entity_type": suggestion.entity_type.value,
"requires_authentication": suggestion.requires_authentication,
"login_prompt": suggestion.login_prompt,
"signup_prompt": suggestion.signup_prompt,
"creation_hint": suggestion.creation_hint,
}
return Response(response_data, status=status.HTTP_200_OK)
except Exception as e:
return Response(
{
"success": False,
"error": f"Internal server error: {str(e)}",
"code": "INTERNAL_ERROR",
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class EntityNotFoundView(APIView):
"""
Endpoint specifically for handling entity not found scenarios.
This view is called when normal entity lookup fails and provides
fuzzy matching suggestions along with authentication prompts.
Migrated from apps.core.views.entity_search.EntityNotFoundView
"""
permission_classes = [AllowAny]
def post(self, request):
"""
Handle entity not found with suggestions.
Request body:
{
"original_query": "what user searched for",
"attempted_slug": "slug-that-failed", // optional
"entity_type": "park", // optional, inferred from context
"context": { // optional context information
"park_slug": "park-slug-if-searching-for-ride",
"source_page": "page where search originated"
}
}
"""
try:
original_query = request.data.get("original_query", "").strip()
attempted_slug = request.data.get("attempted_slug", "")
entity_type_hint = request.data.get("entity_type")
context = request.data.get("context", {})
if not original_query:
return Response(
{
"success": False,
"error": "original_query is required",
"code": "MISSING_QUERY",
},
status=status.HTTP_400_BAD_REQUEST,
)
# Determine entity types to search based on context
entity_types = []
if entity_type_hint:
try:
entity_types = [EntityType(entity_type_hint)]
except ValueError:
pass
# If we have park context, prioritize ride searches
if context.get("park_slug") and not entity_types:
entity_types = [EntityType.RIDE, EntityType.PARK]
# Default to all types if not specified
if not entity_types:
entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
# Try fuzzy matching on the original query
matches, suggestion = entity_fuzzy_matcher.find_entity(
query=original_query, entity_types=entity_types, user=request.user
)
# If no matches on original query, try the attempted slug
if not matches and attempted_slug:
# Convert slug back to readable name for fuzzy matching
slug_as_name = attempted_slug.replace("-", " ").title()
matches, suggestion = entity_fuzzy_matcher.find_entity(
query=slug_as_name, entity_types=entity_types, user=request.user
)
# Prepare response with detailed context
response_data = {
"success": True,
"original_query": original_query,
"attempted_slug": attempted_slug,
"context": context,
"matches": [match.to_dict() for match in matches],
"user_authenticated": (
request.user.is_authenticated
if hasattr(request.user, "is_authenticated")
else False
),
"has_matches": len(matches) > 0,
}
# Always include suggestion for entity not found scenarios
if suggestion:
response_data["suggestion"] = {
"suggested_name": suggestion.suggested_name,
"entity_type": suggestion.entity_type.value,
"requires_authentication": suggestion.requires_authentication,
"login_prompt": suggestion.login_prompt,
"signup_prompt": suggestion.signup_prompt,
"creation_hint": suggestion.creation_hint,
}
return Response(response_data, status=status.HTTP_200_OK)
except Exception as e:
return Response(
{
"success": False,
"error": f"Internal server error: {str(e)}",
"code": "INTERNAL_ERROR",
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@method_decorator(csrf_exempt, name="dispatch")
class QuickEntitySuggestionView(APIView):
"""
Lightweight endpoint for quick entity suggestions (e.g., autocomplete).
Migrated from apps.core.views.entity_search.QuickEntitySuggestionView
"""
permission_classes = [AllowAny]
def get(self, request):
"""
Get quick entity suggestions.
Query parameters:
- q: query string
- types: comma-separated entity types (park,ride,company)
- limit: max results (default 5)
"""
try:
query = request.GET.get("q", "").strip()
types_param = request.GET.get("types", "park,ride,company")
limit = min(int(request.GET.get("limit", 5)), 10) # Cap at 10
if not query or len(query) < 2:
return Response(
{"suggestions": [], "query": query}, status=status.HTTP_200_OK
)
# Parse entity types
entity_types = []
for type_str in types_param.split(","):
type_str = type_str.strip()
if type_str in ["park", "ride", "company"]:
entity_types.append(EntityType(type_str))
if not entity_types:
entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
# Get fuzzy matches
matches, _ = entity_fuzzy_matcher.find_entity(
query=query, entity_types=entity_types, user=request.user
)
# Format as simple suggestions
suggestions = []
for match in matches[:limit]:
suggestions.append(
{
"name": match.name,
"type": match.entity_type.value,
"slug": match.slug,
"url": match.url,
"score": match.score,
"confidence": match.confidence,
}
)
return Response(
{"suggestions": suggestions, "query": query, "count": len(suggestions)},
status=status.HTTP_200_OK,
)
except Exception as e:
return Response(
{"suggestions": [], "query": request.GET.get("q", ""), "error": str(e)},
status=status.HTTP_200_OK,
) # Return 200 even on errors for autocomplete
# Utility function for other views to use
def get_entity_suggestions(
query: str, entity_types: Optional[List[str]] = None, user=None
):
"""
Utility function for other Django views to get entity suggestions.
Args:
query: Search query
entity_types: List of entity type strings
user: Django user object
Returns:
Tuple of (matches, suggestion)
"""
try:
# Convert string types to EntityType enums
parsed_types = []
if entity_types:
for entity_type in entity_types:
try:
parsed_types.append(EntityType(entity_type))
except ValueError:
continue
if not parsed_types:
parsed_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
return entity_fuzzy_matcher.find_entity(
query=query, entity_types=parsed_types, user=user
)
except Exception:
return [], None

View File

View File

@@ -0,0 +1,11 @@
"""
Email service API URL configuration.
Centralized from apps.email_service.urls
"""
from django.urls import path
from . import views
urlpatterns = [
path("send/", views.SendEmailView.as_view(), name="send_email"),
]

View File

@@ -0,0 +1,71 @@
"""
Centralized email service API views.
Migrated from apps.email_service.views
"""
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
from django.contrib.sites.shortcuts import get_current_site
from apps.email_service.services import EmailService
class SendEmailView(APIView):
"""
API endpoint for sending emails.
Migrated from apps.email_service.views.SendEmailView to centralized API structure.
"""
permission_classes = [AllowAny] # Allow unauthenticated access
def post(self, request):
"""
Send an email via the email service.
Request body:
{
"to": "recipient@example.com",
"subject": "Email subject",
"text": "Email body text",
"from_email": "sender@example.com" // optional
}
"""
data = request.data
to = data.get("to")
subject = data.get("subject")
text = data.get("text")
from_email = data.get("from_email") # Optional
if not all([to, subject, text]):
return Response(
{
"error": "Missing required fields",
"required_fields": ["to", "subject", "text"],
},
status=status.HTTP_400_BAD_REQUEST,
)
try:
# Get the current site
site = get_current_site(request)
# Send email using the site's configuration
response = EmailService.send_email(
to=to,
subject=subject,
text=text,
from_email=from_email, # Will use site's default if None
site=site,
)
return Response(
{"message": "Email sent successfully", "response": response},
status=status.HTTP_200_OK,
)
except Exception as e:
return Response(
{"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

View File

@@ -0,0 +1,6 @@
"""
History API Module
This module provides API endpoints for accessing historical data and change tracking
across all models in the ThrillWiki system.
"""

View File

@@ -0,0 +1,45 @@
"""
History API URLs
URL patterns for history-related API endpoints.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
ParkHistoryViewSet,
RideHistoryViewSet,
UnifiedHistoryViewSet,
)
# Create router for history ViewSets
router = DefaultRouter()
router.register(r"timeline", UnifiedHistoryViewSet, basename="unified-history")
urlpatterns = [
# Park history endpoints
path(
"parks/<str:park_slug>/",
ParkHistoryViewSet.as_view({"get": "list"}),
name="park-history-list",
),
path(
"parks/<str:park_slug>/detail/",
ParkHistoryViewSet.as_view({"get": "retrieve"}),
name="park-history-detail",
),
# Ride history endpoints
path(
"parks/<str:park_slug>/rides/<str:ride_slug>/",
RideHistoryViewSet.as_view({"get": "list"}),
name="ride-history-list",
),
path(
"parks/<str:park_slug>/rides/<str:ride_slug>/detail/",
RideHistoryViewSet.as_view({"get": "retrieve"}),
name="ride-history-detail",
),
# Include router URLs for unified timeline
path("", include(router.urls)),
]

View File

@@ -0,0 +1,580 @@
"""
History API Views
This module provides ViewSets for accessing historical data and change tracking
across all models in the ThrillWiki system using django-pghistory.
"""
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from rest_framework.filters import OrderingFilter
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet
from django.shortcuts import get_object_or_404
from django.db.models import Count
import pghistory.models
# Import models
from apps.parks.models import Park
from apps.rides.models import Ride
# Import serializers
from ..serializers import (
ParkHistoryEventSerializer,
RideHistoryEventSerializer,
ParkHistoryOutputSerializer,
RideHistoryOutputSerializer,
UnifiedHistoryTimelineSerializer,
)
@extend_schema_view(
list=extend_schema(
summary="Get park history",
description="Retrieve history timeline for a specific park including all changes over time.",
parameters=[
OpenApiParameter(
name="limit",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Number of history events to return (default: 50, max: 500)",
),
OpenApiParameter(
name="offset",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Offset for pagination",
),
OpenApiParameter(
name="event_type",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by event type (created, updated, deleted)",
),
OpenApiParameter(
name="start_date",
type=OpenApiTypes.DATE,
location=OpenApiParameter.QUERY,
description="Filter events after this date (YYYY-MM-DD)",
),
OpenApiParameter(
name="end_date",
type=OpenApiTypes.DATE,
location=OpenApiParameter.QUERY,
description="Filter events before this date (YYYY-MM-DD)",
),
],
responses={200: ParkHistoryEventSerializer(many=True)},
tags=["History", "Parks"],
),
retrieve=extend_schema(
summary="Get complete park history",
description="Retrieve complete history for a park including current state and timeline.",
responses={200: ParkHistoryOutputSerializer},
tags=["History", "Parks"],
),
)
class ParkHistoryViewSet(ReadOnlyModelViewSet):
"""
ViewSet for accessing park history data.
Provides read-only access to historical changes for parks,
including version history and real-world changes.
"""
permission_classes = [AllowAny]
lookup_field = "park_slug"
filter_backends = [OrderingFilter]
ordering_fields = ["pgh_created_at"]
ordering = ["-pgh_created_at"]
def get_queryset(self):
"""Get history events for the specified park."""
park_slug = self.kwargs.get("park_slug")
if not park_slug:
return pghistory.models.Events.objects.none()
# Get the park to ensure it exists
park = get_object_or_404(Park, slug=park_slug)
# Get all history events for this park
queryset = (
pghistory.models.Events.objects.filter(
pgh_model__in=["parks.park"], pgh_obj_id=park.id
)
.select_related()
.order_by("-pgh_created_at")
)
# Apply filters
if self.action == "list":
# Filter by event type
event_type = self.request.query_params.get("event_type")
if event_type:
if event_type == "created":
queryset = queryset.filter(pgh_label="created")
elif event_type == "updated":
queryset = queryset.filter(pgh_label="updated")
elif event_type == "deleted":
queryset = queryset.filter(pgh_label="deleted")
# Filter by date range
start_date = self.request.query_params.get("start_date")
if start_date:
try:
from datetime import datetime
start_datetime = datetime.strptime(start_date, "%Y-%m-%d")
queryset = queryset.filter(pgh_created_at__gte=start_datetime)
except ValueError:
pass
end_date = self.request.query_params.get("end_date")
if end_date:
try:
from datetime import datetime
end_datetime = datetime.strptime(end_date, "%Y-%m-%d")
queryset = queryset.filter(pgh_created_at__lte=end_datetime)
except ValueError:
pass
# Apply limit
limit = self.request.query_params.get("limit", "50")
try:
limit = min(int(limit), 500) # Max 500 events
queryset = queryset[:limit]
except (ValueError, TypeError):
queryset = queryset[:50]
return queryset
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "retrieve":
return ParkHistoryOutputSerializer
return ParkHistoryEventSerializer
def retrieve(self, request, park_slug=None):
"""Get complete park history including current state."""
park = get_object_or_404(Park, slug=park_slug)
# Get history events
history_events = self.get_queryset()[:100] # Latest 100 events
# Prepare data for serializer
history_data = {
"park": park,
"current_state": park,
"summary": {
"total_events": self.get_queryset().count(),
"first_recorded": (
history_events.last().pgh_created_at if history_events else None
),
"last_modified": (
history_events.first().pgh_created_at if history_events else None
),
},
"events": history_events,
}
serializer = ParkHistoryOutputSerializer(history_data)
return Response(serializer.data)
@extend_schema_view(
list=extend_schema(
summary="Get ride history",
description="Retrieve history timeline for a specific ride including all changes over time.",
parameters=[
OpenApiParameter(
name="limit",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Number of history events to return (default: 50, max: 500)",
),
OpenApiParameter(
name="offset",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Offset for pagination",
),
OpenApiParameter(
name="event_type",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by event type (created, updated, deleted)",
),
OpenApiParameter(
name="start_date",
type=OpenApiTypes.DATE,
location=OpenApiParameter.QUERY,
description="Filter events after this date (YYYY-MM-DD)",
),
OpenApiParameter(
name="end_date",
type=OpenApiTypes.DATE,
location=OpenApiParameter.QUERY,
description="Filter events before this date (YYYY-MM-DD)",
),
],
responses={200: RideHistoryEventSerializer(many=True)},
tags=["History", "Rides"],
),
retrieve=extend_schema(
summary="Get complete ride history",
description="Retrieve complete history for a ride including current state and timeline.",
responses={200: RideHistoryOutputSerializer},
tags=["History", "Rides"],
),
)
class RideHistoryViewSet(ReadOnlyModelViewSet):
"""
ViewSet for accessing ride history data.
Provides read-only access to historical changes for rides,
including version history and real-world changes.
"""
permission_classes = [AllowAny]
lookup_field = "ride_slug"
filter_backends = [OrderingFilter]
ordering_fields = ["pgh_created_at"]
ordering = ["-pgh_created_at"]
def get_queryset(self):
"""Get history events for the specified ride."""
park_slug = self.kwargs.get("park_slug")
ride_slug = self.kwargs.get("ride_slug")
if not park_slug or not ride_slug:
return pghistory.models.Events.objects.none()
# Get the ride to ensure it exists
ride = get_object_or_404(Ride, slug=ride_slug, park__slug=park_slug)
# Get all history events for this ride
queryset = (
pghistory.models.Events.objects.filter(
pgh_model__in=[
"rides.ride",
"rides.ridemodel",
"rides.rollercoasterstats",
],
pgh_obj_id=ride.id,
)
.select_related()
.order_by("-pgh_created_at")
)
# Apply the same filtering logic as ParkHistoryViewSet
if self.action == "list":
# Filter by event type
event_type = self.request.query_params.get("event_type")
if event_type:
if event_type == "created":
queryset = queryset.filter(pgh_label="created")
elif event_type == "updated":
queryset = queryset.filter(pgh_label="updated")
elif event_type == "deleted":
queryset = queryset.filter(pgh_label="deleted")
# Filter by date range
start_date = self.request.query_params.get("start_date")
if start_date:
try:
from datetime import datetime
start_datetime = datetime.strptime(start_date, "%Y-%m-%d")
queryset = queryset.filter(pgh_created_at__gte=start_datetime)
except ValueError:
pass
end_date = self.request.query_params.get("end_date")
if end_date:
try:
from datetime import datetime
end_datetime = datetime.strptime(end_date, "%Y-%m-%d")
queryset = queryset.filter(pgh_created_at__lte=end_datetime)
except ValueError:
pass
# Apply limit
limit = self.request.query_params.get("limit", "50")
try:
limit = min(int(limit), 500) # Max 500 events
queryset = queryset[:limit]
except (ValueError, TypeError):
queryset = queryset[:50]
return queryset
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "retrieve":
return RideHistoryOutputSerializer
return RideHistoryEventSerializer
def retrieve(self, request, park_slug=None, ride_slug=None):
"""Get complete ride history including current state."""
ride = get_object_or_404(Ride, slug=ride_slug, park__slug=park_slug)
# Get history events
history_events = self.get_queryset()[:100] # Latest 100 events
# Prepare data for serializer
history_data = {
"ride": ride,
"current_state": ride,
"summary": {
"total_events": self.get_queryset().count(),
"first_recorded": (
history_events.last().pgh_created_at if history_events else None
),
"last_modified": (
history_events.first().pgh_created_at if history_events else None
),
},
"events": history_events,
}
serializer = RideHistoryOutputSerializer(history_data)
return Response(serializer.data)
@extend_schema_view(
list=extend_schema(
summary="Unified history timeline",
description="Retrieve a unified timeline of all changes across parks, rides, and companies.",
parameters=[
OpenApiParameter(
name="limit",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Number of history events to return (default: 100, max: 1000)",
),
OpenApiParameter(
name="offset",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Offset for pagination",
),
OpenApiParameter(
name="model_type",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by model type (park, ride, company)",
),
OpenApiParameter(
name="event_type",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by event type (created, updated, deleted)",
),
OpenApiParameter(
name="start_date",
type=OpenApiTypes.DATE,
location=OpenApiParameter.QUERY,
description="Filter events after this date (YYYY-MM-DD)",
),
OpenApiParameter(
name="end_date",
type=OpenApiTypes.DATE,
location=OpenApiParameter.QUERY,
description="Filter events before this date (YYYY-MM-DD)",
),
OpenApiParameter(
name="significance",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by change significance (major, minor, routine)",
),
],
responses={200: UnifiedHistoryTimelineSerializer},
tags=["History"],
),
)
class UnifiedHistoryViewSet(ReadOnlyModelViewSet):
"""
ViewSet for unified history timeline across all models.
Provides a comprehensive view of all changes across
parks, rides, and companies in chronological order.
"""
permission_classes = [AllowAny]
filter_backends = [OrderingFilter]
ordering_fields = ["pgh_created_at"]
ordering = ["-pgh_created_at"]
def get_queryset(self):
"""Get unified history events across all tracked models."""
queryset = (
pghistory.models.Events.objects.filter(
pgh_model__in=[
"parks.park",
"rides.ride",
"rides.ridemodel",
"rides.rollercoasterstats",
"companies.operator",
"companies.propertyowner",
"companies.manufacturer",
"companies.designer",
"accounts.user",
]
)
.select_related()
.order_by("-pgh_created_at")
)
# Apply filters
model_type = self.request.query_params.get("model_type")
if model_type:
if model_type == "park":
queryset = queryset.filter(pgh_model="parks.park")
elif model_type == "ride":
queryset = queryset.filter(
pgh_model__in=[
"rides.ride",
"rides.ridemodel",
"rides.rollercoasterstats",
]
)
elif model_type == "company":
queryset = queryset.filter(
pgh_model__in=[
"companies.operator",
"companies.propertyowner",
"companies.manufacturer",
"companies.designer",
]
)
elif model_type == "user":
queryset = queryset.filter(pgh_model="accounts.user")
# Filter by event type
event_type = self.request.query_params.get("event_type")
if event_type:
if event_type == "created":
queryset = queryset.filter(pgh_label="created")
elif event_type == "updated":
queryset = queryset.filter(pgh_label="updated")
elif event_type == "deleted":
queryset = queryset.filter(pgh_label="deleted")
# Filter by date range
start_date = self.request.query_params.get("start_date")
if start_date:
try:
from datetime import datetime
start_datetime = datetime.strptime(start_date, "%Y-%m-%d")
queryset = queryset.filter(pgh_created_at__gte=start_datetime)
except ValueError:
pass
end_date = self.request.query_params.get("end_date")
if end_date:
try:
from datetime import datetime
end_datetime = datetime.strptime(end_date, "%Y-%m-%d")
queryset = queryset.filter(pgh_created_at__lte=end_datetime)
except ValueError:
pass
# Apply limit
limit = self.request.query_params.get("limit", "100")
try:
limit = min(int(limit), 1000) # Max 1000 events
queryset = queryset[:limit]
except (ValueError, TypeError):
queryset = queryset[:100]
return queryset
def get_serializer_class(self):
"""Return unified history timeline serializer."""
return UnifiedHistoryTimelineSerializer
def list(self, request):
"""Get unified history timeline with summary statistics."""
events = self.get_queryset()
# Calculate summary statistics
total_events = pghistory.models.Events.objects.filter(
pgh_model__in=[
"parks.park",
"rides.ride",
"rides.ridemodel",
"rides.rollercoasterstats",
"companies.operator",
"companies.propertyowner",
"companies.manufacturer",
"companies.designer",
"accounts.user",
]
).count()
# Get event type counts
event_type_counts = (
pghistory.models.Events.objects.filter(
pgh_model__in=[
"parks.park",
"rides.ride",
"rides.ridemodel",
"rides.rollercoasterstats",
"companies.operator",
"companies.propertyowner",
"companies.manufacturer",
"companies.designer",
"accounts.user",
]
)
.values("pgh_label")
.annotate(count=Count("id"))
)
# Get model type counts
model_type_counts = (
pghistory.models.Events.objects.filter(
pgh_model__in=[
"parks.park",
"rides.ride",
"rides.ridemodel",
"rides.rollercoasterstats",
"companies.operator",
"companies.propertyowner",
"companies.manufacturer",
"companies.designer",
"accounts.user",
]
)
.values("pgh_model")
.annotate(count=Count("id"))
)
timeline_data = {
"summary": {
"total_events": total_events,
"events_returned": len(events),
"event_type_breakdown": {
item["pgh_label"]: item["count"] for item in event_type_counts
},
"model_type_breakdown": {
item["pgh_model"]: item["count"] for item in model_type_counts
},
"time_range": {
"earliest": events.last().pgh_created_at if events else None,
"latest": events.first().pgh_created_at if events else None,
},
},
"events": events,
}
serializer = UnifiedHistoryTimelineSerializer(timeline_data)
return Response(serializer.data)

View File

@@ -0,0 +1,4 @@
"""
Maps API module for centralized API structure.
Migrated from apps.core.views.map_views
"""

View File

@@ -0,0 +1,32 @@
"""
URL patterns for the unified map service API.
Migrated from apps.core.urls.map_urls to centralized API structure.
"""
from django.urls import path
from . import views
# Map API endpoints - migrated from apps.core.urls.map_urls
urlpatterns = [
# Main map data endpoint
path("locations/", views.MapLocationsAPIView.as_view(), name="map_locations"),
# Location detail endpoint
path(
"locations/<str:location_type>/<int:location_id>/",
views.MapLocationDetailAPIView.as_view(),
name="map_location_detail",
),
# Search endpoint
path("search/", views.MapSearchAPIView.as_view(), name="map_search"),
# Bounds-based query endpoint
path("bounds/", views.MapBoundsAPIView.as_view(), name="map_bounds"),
# Service statistics endpoint
path("stats/", views.MapStatsAPIView.as_view(), name="map_stats"),
# Cache management endpoints
path("cache/", views.MapCacheAPIView.as_view(), name="map_cache"),
path(
"cache/invalidate/",
views.MapCacheAPIView.as_view(),
name="map_cache_invalidate",
),
]

View File

@@ -0,0 +1,278 @@
"""
Centralized map API views.
Migrated from apps.core.views.map_views
"""
import json
import logging
import time
from typing import Dict, Any, Optional
from django.http import JsonResponse, HttpRequest
from django.views.decorators.cache import cache_page
from django.views.decorators.gzip import gzip_page
from django.utils.decorators import method_decorator
from django.views import View
from django.core.exceptions import ValidationError
from django.conf import settings
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
from drf_spectacular.utils import extend_schema, extend_schema_view
from drf_spectacular.types import OpenApiTypes
logger = logging.getLogger(__name__)
@extend_schema_view(
get=extend_schema(
summary="Get map locations",
description="Get map locations with optional clustering and filtering.",
parameters=[
{"name": "north", "in": "query", "required": False, "schema": {"type": "number"}},
{"name": "south", "in": "query", "required": False, "schema": {"type": "number"}},
{"name": "east", "in": "query", "required": False, "schema": {"type": "number"}},
{"name": "west", "in": "query", "required": False, "schema": {"type": "number"}},
{"name": "zoom", "in": "query", "required": False, "schema": {"type": "integer"}},
{"name": "types", "in": "query", "required": False, "schema": {"type": "string"}},
{"name": "cluster", "in": "query", "required": False,
"schema": {"type": "boolean"}},
{"name": "q", "in": "query", "required": False, "schema": {"type": "string"}},
],
responses={200: OpenApiTypes.OBJECT},
tags=["Maps"],
),
)
class MapLocationsAPIView(APIView):
"""API endpoint for getting map locations with optional clustering."""
permission_classes = [AllowAny]
def get(self, request: HttpRequest) -> Response:
"""Get map locations with optional clustering and filtering."""
try:
# Simple implementation to fix import error
# TODO: Implement full functionality
return Response({
"status": "success",
"message": "Map locations endpoint - implementation needed",
"data": []
})
except Exception as e:
logger.error(f"Error in MapLocationsAPIView: {str(e)}", exc_info=True)
return Response({
"status": "error",
"message": "Failed to retrieve map locations"
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@extend_schema_view(
get=extend_schema(
summary="Get location details",
description="Get detailed information about a specific location.",
parameters=[
{"name": "location_type", "in": "path",
"required": True, "schema": {"type": "string"}},
{"name": "location_id", "in": "path",
"required": True, "schema": {"type": "integer"}},
],
responses={200: OpenApiTypes.OBJECT, 404: OpenApiTypes.OBJECT},
tags=["Maps"],
),
)
class MapLocationDetailAPIView(APIView):
"""API endpoint for getting detailed information about a specific location."""
permission_classes = [AllowAny]
def get(self, request: HttpRequest, location_type: str, location_id: int) -> Response:
"""Get detailed information for a specific location."""
try:
# Simple implementation to fix import error
return Response({
"status": "success",
"message": f"Location detail for {location_type}/{location_id} - implementation needed",
"data": {
"location_type": location_type,
"location_id": location_id
}
})
except Exception as e:
logger.error(f"Error in MapLocationDetailAPIView: {str(e)}", exc_info=True)
return Response({
"status": "error",
"message": "Failed to retrieve location details"
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@extend_schema_view(
get=extend_schema(
summary="Search map locations",
description="Search locations by text query with optional bounds filtering.",
parameters=[
{"name": "q", "in": "query", "required": True, "schema": {"type": "string"}},
],
responses={200: OpenApiTypes.OBJECT, 400: OpenApiTypes.OBJECT},
tags=["Maps"],
),
)
class MapSearchAPIView(APIView):
"""API endpoint for searching locations by text query."""
permission_classes = [AllowAny]
def get(self, request: HttpRequest) -> Response:
"""Search locations by text query with pagination."""
try:
query = request.GET.get("q", "").strip()
if not query:
return Response({
"status": "error",
"message": "Search query 'q' parameter is required"
}, status=status.HTTP_400_BAD_REQUEST)
# Simple implementation to fix import error
return Response({
"status": "success",
"message": f"Search for '{query}' - implementation needed",
"data": []
})
except Exception as e:
logger.error(f"Error in MapSearchAPIView: {str(e)}", exc_info=True)
return Response({
"status": "error",
"message": "Search failed due to internal error"
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@extend_schema_view(
get=extend_schema(
summary="Get locations within bounds",
description="Get locations within specific geographic bounds.",
parameters=[
{"name": "north", "in": "query", "required": True, "schema": {"type": "number"}},
{"name": "south", "in": "query", "required": True, "schema": {"type": "number"}},
{"name": "east", "in": "query", "required": True, "schema": {"type": "number"}},
{"name": "west", "in": "query", "required": True, "schema": {"type": "number"}},
],
responses={200: OpenApiTypes.OBJECT, 400: OpenApiTypes.OBJECT},
tags=["Maps"],
),
)
class MapBoundsAPIView(APIView):
"""API endpoint for getting locations within specific bounds."""
permission_classes = [AllowAny]
def get(self, request: HttpRequest) -> Response:
"""Get locations within specific geographic bounds."""
try:
# Simple implementation to fix import error
return Response({
"status": "success",
"message": "Bounds query - implementation needed",
"data": []
})
except Exception as e:
logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True)
return Response({
"status": "error",
"message": "Failed to retrieve locations within bounds"
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@extend_schema_view(
get=extend_schema(
summary="Get map service statistics",
description="Get map service statistics and performance metrics.",
responses={200: OpenApiTypes.OBJECT},
tags=["Maps"],
),
)
class MapStatsAPIView(APIView):
"""API endpoint for getting map service statistics and health information."""
permission_classes = [AllowAny]
def get(self, request: HttpRequest) -> Response:
"""Get map service statistics and performance metrics."""
try:
# Simple implementation to fix import error
return Response({
"status": "success",
"data": {
"total_locations": 0,
"cache_hits": 0,
"cache_misses": 0
}
})
except Exception as e:
return Response(
{"error": f"Internal server error: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@extend_schema_view(
delete=extend_schema(
summary="Clear map cache",
description="Clear all map cache (admin only).",
responses={200: OpenApiTypes.OBJECT},
tags=["Maps"],
),
post=extend_schema(
summary="Invalidate specific cache entries",
description="Invalidate specific cache entries.",
responses={200: OpenApiTypes.OBJECT},
tags=["Maps"],
),
)
class MapCacheAPIView(APIView):
"""API endpoint for cache management (admin only)."""
permission_classes = [AllowAny] # TODO: Add admin permission check
def delete(self, request: HttpRequest) -> Response:
"""Clear all map cache (admin only)."""
try:
# Simple implementation to fix import error
return Response({
"status": "success",
"message": "Map cache cleared successfully"
})
except Exception as e:
return Response(
{"error": f"Internal server error: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def post(self, request: HttpRequest) -> Response:
"""Invalidate specific cache entries."""
try:
# Simple implementation to fix import error
return Response({
"status": "success",
"message": "Cache invalidated successfully"
})
except Exception as e:
return Response(
{"error": f"Internal server error: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Legacy compatibility aliases
MapLocationsView = MapLocationsAPIView
MapLocationDetailView = MapLocationDetailAPIView
MapSearchView = MapSearchAPIView
MapBoundsView = MapBoundsAPIView
MapStatsView = MapStatsAPIView
MapCacheView = MapCacheAPIView

View File

@@ -0,0 +1,6 @@
"""
Media API module for ThrillWiki API v1.
This module provides API endpoints for media management including
photo uploads, captions, and media operations.
"""

View File

@@ -0,0 +1,113 @@
"""
Media domain serializers for ThrillWiki API v1.
This module contains serializers for photo uploads, media management,
and related media functionality.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
)
# === MEDIA SERIALIZERS ===
class PhotoUploadOutputSerializer(serializers.Serializer):
"""Output serializer for photo uploads."""
id = serializers.IntegerField()
url = serializers.CharField()
caption = serializers.CharField()
alt_text = serializers.CharField()
is_primary = serializers.BooleanField()
message = serializers.CharField()
@extend_schema_serializer(
examples=[
OpenApiExample(
"Photo Detail Example",
summary="Example photo detail response",
description="A photo with full details",
value={
"id": 1,
"url": "https://example.com/media/photos/ride123.jpg",
"thumbnail_url": "https://example.com/media/thumbnails/ride123_thumb.jpg",
"caption": "Amazing view of Steel Vengeance",
"alt_text": "Steel Vengeance roller coaster with blue sky",
"is_primary": True,
"uploaded_at": "2024-08-15T10:30:00Z",
"uploaded_by": {
"id": 1,
"username": "coaster_photographer",
"display_name": "Coaster Photographer",
},
"content_type": "Ride",
"object_id": 123,
},
)
]
)
class PhotoDetailOutputSerializer(serializers.Serializer):
"""Output serializer for photo details."""
id = serializers.IntegerField()
url = serializers.URLField()
thumbnail_url = serializers.URLField(required=False)
caption = serializers.CharField()
alt_text = serializers.CharField()
is_primary = serializers.BooleanField()
uploaded_at = serializers.DateTimeField()
content_type = serializers.CharField()
object_id = serializers.IntegerField()
# File metadata
file_size = serializers.IntegerField()
width = serializers.IntegerField()
height = serializers.IntegerField()
format = serializers.CharField()
# Uploader info
uploaded_by = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_uploaded_by(self, obj) -> dict:
"""Get uploader information."""
return {
"id": obj.uploaded_by.id,
"username": obj.uploaded_by.username,
"display_name": getattr(
obj.uploaded_by, "get_display_name", lambda: obj.uploaded_by.username
)(),
}
class PhotoListOutputSerializer(serializers.Serializer):
"""Output serializer for photo list view."""
id = serializers.IntegerField()
url = serializers.URLField()
thumbnail_url = serializers.URLField(required=False)
caption = serializers.CharField()
is_primary = serializers.BooleanField()
uploaded_at = serializers.DateTimeField()
uploaded_by = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_uploaded_by(self, obj) -> dict:
"""Get uploader information."""
return {
"id": obj.uploaded_by.id,
"username": obj.uploaded_by.username,
}
class PhotoUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating photos."""
caption = serializers.CharField(max_length=500, required=False, allow_blank=True)
alt_text = serializers.CharField(max_length=255, required=False, allow_blank=True)
is_primary = serializers.BooleanField(required=False)

View File

@@ -0,0 +1,19 @@
"""
Media API URL configuration.
Centralized from apps.media.urls
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
# Create router for ViewSets
router = DefaultRouter()
router.register(r"photos", views.PhotoViewSet, basename="photo")
urlpatterns = [
# Photo upload endpoint
path("upload/", views.PhotoUploadAPIView.as_view(), name="photo_upload"),
# Include router URLs for photo management
path("", include(router.urls)),
]

View File

@@ -0,0 +1,233 @@
"""
Media API views for ThrillWiki API v1.
This module provides API endpoints for media management including
photo uploads, captions, and media operations.
Consolidated from apps.media.views
"""
import json
import logging
from typing import Any, Dict
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.http import Http404
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from rest_framework.parsers import MultiPartParser, FormParser
# Import domain-specific models and services instead of generic Photo model
from apps.parks.models import ParkPhoto
from apps.rides.models import RidePhoto
from apps.parks.services import ParkMediaService
from apps.rides.services import RideMediaService
from apps.core.services.media_service import MediaService
from .serializers import (
PhotoUploadInputSerializer,
PhotoUploadOutputSerializer,
PhotoDetailOutputSerializer,
PhotoUpdateInputSerializer,
PhotoListOutputSerializer,
)
from ..parks.serializers import ParkPhotoSerializer
from ..rides.serializers import RidePhotoSerializer
logger = logging.getLogger(__name__)
@extend_schema_view(
post=extend_schema(
summary="Upload photo",
description="Upload a photo and associate it with a content object (park, ride, etc.)",
request=PhotoUploadInputSerializer,
responses={
201: PhotoUploadOutputSerializer,
400: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
},
tags=["Media"],
),
)
class PhotoUploadAPIView(APIView):
"""API endpoint for photo uploads."""
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
def post(self, request: Request) -> Response:
"""Upload a photo and associate it with a content object."""
try:
serializer = PhotoUploadInputSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
validated_data = serializer.validated_data
# Get content object
try:
content_type = ContentType.objects.get(
app_label=validated_data["app_label"], model=validated_data["model"]
)
content_object = content_type.get_object_for_this_type(
pk=validated_data["object_id"]
)
except ContentType.DoesNotExist:
return Response(
{
"error": f"Invalid content type: {validated_data['app_label']}.{validated_data['model']}"
},
status=status.HTTP_400_BAD_REQUEST,
)
except content_type.model_class().DoesNotExist:
return Response(
{"error": "Content object not found"},
status=status.HTTP_404_NOT_FOUND,
)
# Determine which domain service to use based on content object
if hasattr(content_object, '_meta') and content_object._meta.app_label == 'parks':
# Check permissions for park photos
if not request.user.has_perm("parks.add_parkphoto"):
return Response(
{"error": "You do not have permission to upload park photos"},
status=status.HTTP_403_FORBIDDEN,
)
# Create park photo using park media service
photo = ParkMediaService.upload_photo(
park=content_object,
image_file=validated_data["photo"],
user=request.user,
caption=validated_data.get("caption", ""),
alt_text=validated_data.get("alt_text", ""),
is_primary=validated_data.get("is_primary", False),
)
elif hasattr(content_object, '_meta') and content_object._meta.app_label == 'rides':
# Check permissions for ride photos
if not request.user.has_perm("rides.add_ridephoto"):
return Response(
{"error": "You do not have permission to upload ride photos"},
status=status.HTTP_403_FORBIDDEN,
)
# Create ride photo using ride media service
photo = RideMediaService.upload_photo(
ride=content_object,
image_file=validated_data["photo"],
user=request.user,
caption=validated_data.get("caption", ""),
alt_text=validated_data.get("alt_text", ""),
is_primary=validated_data.get("is_primary", False),
photo_type=validated_data.get("photo_type", "general"),
)
else:
return Response(
{"error": f"Unsupported content type for media upload: {content_object._meta.label}"},
status=status.HTTP_400_BAD_REQUEST,
)
response_serializer = PhotoUploadOutputSerializer(
{
"id": photo.id,
"url": photo.image.url,
"caption": photo.caption,
"alt_text": photo.alt_text,
"is_primary": photo.is_primary,
"message": "Photo uploaded successfully",
}
)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
logger.error(f"Error in photo upload: {str(e)}", exc_info=True)
return Response(
{"error": f"An error occurred while uploading the photo: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@extend_schema_view(
list=extend_schema(
summary="List photos",
description="Retrieve a list of photos with optional filtering",
parameters=[
OpenApiParameter(
name="content_type",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by content type (e.g., 'parks.park')",
),
OpenApiParameter(
name="object_id",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Filter by object ID",
),
],
responses={200: PhotoListOutputSerializer(many=True)},
tags=["Media"],
),
retrieve=extend_schema(
summary="Get photo details",
description="Retrieve detailed information about a specific photo",
responses={
200: PhotoDetailOutputSerializer,
404: OpenApiTypes.OBJECT,
},
tags=["Media"],
),
update=extend_schema(
summary="Update photo",
description="Update photo information (caption, alt text, etc.)",
request=PhotoUpdateInputSerializer,
responses={
200: PhotoDetailOutputSerializer,
400: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Media"],
),
destroy=extend_schema(
summary="Delete photo",
description="Delete a photo (only by owner or admin)",
responses={
204: None,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Media"],
),
set_primary=extend_schema(
summary="Set photo as primary",
description="Set this photo as the primary photo for its content object",
responses={
200: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Media"],
),
)
class PhotoViewSet(ModelViewSet):
"""ViewSet for managing photos."""
permission_classes = [IsAuthenticated]
lookup_field = "id"
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "list":
return PhotoListOutputSerializer
elif self.action in ["update", "partial_update"]:
return PhotoUpdateInputSerializer
return PhotoDetailOutputSerializer

View File

@@ -0,0 +1,6 @@
"""
Parks API module for ThrillWiki API v1.
This module provides API endpoints for park-related functionality including
search suggestions, location services, and roadtrip planning.
"""

View File

@@ -0,0 +1,41 @@
"""
Serializers for the parks API.
"""
from rest_framework import serializers
from apps.parks.models import Park, ParkPhoto
class ParkPhotoSerializer(serializers.ModelSerializer):
"""Serializer for the ParkPhoto model."""
class Meta:
model = ParkPhoto
fields = (
"id",
"image",
"caption",
"alt_text",
"is_primary",
"uploaded_at",
"uploaded_by",
)
class ParkSerializer(serializers.ModelSerializer):
"""Serializer for the Park model."""
class Meta:
model = Park
fields = (
"id",
"name",
"slug",
"country",
"continent",
"latitude",
"longitude",
"website",
"status",
)

View File

@@ -0,0 +1,14 @@
"""
Park API URLs for ThrillWiki API v1.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ParkPhotoViewSet
router = DefaultRouter()
router.register(r"photos", ParkPhotoViewSet, basename="park-photo")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -0,0 +1,116 @@
"""
Park API views for ThrillWiki API v1.
"""
import logging
from django.core.exceptions import PermissionDenied
from drf_spectacular.utils import extend_schema_view, extend_schema
from drf_spectacular.types import OpenApiTypes
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.parks.models import ParkPhoto
from apps.parks.services import ParkMediaService
from ..media.serializers import (
PhotoUpdateInputSerializer,
PhotoListOutputSerializer,
)
from .serializers import ParkPhotoSerializer
logger = logging.getLogger(__name__)
@extend_schema_view(
list=extend_schema(
summary="List park photos",
description="Retrieve a list of photos for a specific park.",
responses={200: PhotoListOutputSerializer(many=True)},
tags=["Parks"],
),
retrieve=extend_schema(
summary="Get park photo details",
description="Retrieve detailed information about a specific park photo.",
responses={
200: ParkPhotoSerializer,
404: OpenApiTypes.OBJECT,
},
tags=["Parks"],
),
update=extend_schema(
summary="Update park photo",
description="Update park photo information (caption, alt text, etc.)",
request=PhotoUpdateInputSerializer,
responses={
200: ParkPhotoSerializer,
400: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Parks"],
),
destroy=extend_schema(
summary="Delete park photo",
description="Delete a park photo (only by owner or admin)",
responses={
204: None,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Parks"],
),
)
class ParkPhotoViewSet(ModelViewSet):
"""ViewSet for managing park photos."""
queryset = ParkPhoto.objects.select_related("park", "uploaded_by").all()
permission_classes = [IsAuthenticated]
lookup_field = "id"
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "list":
return PhotoListOutputSerializer
elif self.action in ["update", "partial_update"]:
return PhotoUpdateInputSerializer
return ParkPhotoSerializer
def perform_update(self, serializer):
"""Update photo with permission check."""
photo = self.get_object()
if not (
self.request.user == photo.uploaded_by
or self.request.user.has_perm("parks.change_parkphoto")
):
raise PermissionDenied("You do not have permission to edit this photo.")
serializer.save()
def perform_destroy(self, instance):
"""Delete photo with permission check."""
if not (
self.request.user == instance.uploaded_by
or self.request.user.has_perm("parks.delete_parkphoto")
):
raise PermissionDenied("You do not have permission to delete this photo.")
instance.delete()
@action(detail=True, methods=["post"])
def set_primary(self, request, id=None):
"""Set this photo as the primary photo for its park."""
photo = self.get_object()
if not (
request.user == photo.uploaded_by
or request.user.has_perm("parks.change_parkphoto")
):
return Response(
{"error": "You do not have permission to edit photos for this park."},
status=status.HTTP_403_FORBIDDEN,
)
try:
ParkMediaService.set_primary_photo(photo.park, photo)
return Response({"message": "Photo set as primary successfully."})
except Exception as e:
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)

View File

View File

@@ -0,0 +1,43 @@
"""
Serializers for the rides API.
"""
from rest_framework import serializers
from apps.rides.models import Ride, RidePhoto
class RidePhotoSerializer(serializers.ModelSerializer):
"""Serializer for the RidePhoto model."""
class Meta:
model = RidePhoto
fields = (
"id",
"image",
"caption",
"alt_text",
"is_primary",
"photo_type",
"uploaded_at",
"uploaded_by",
)
class RideSerializer(serializers.ModelSerializer):
"""Serializer for the Ride model."""
class Meta:
model = Ride
fields = (
"id",
"name",
"slug",
"park",
"manufacturer",
"designer",
"type",
"status",
"opening_date",
"closing_date",
)

View File

@@ -0,0 +1,14 @@
"""
Ride API URLs for ThrillWiki API v1.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import RidePhotoViewSet
router = DefaultRouter()
router.register(r"photos", RidePhotoViewSet, basename="ride-photo")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -0,0 +1,116 @@
"""
Ride API views for ThrillWiki API v1.
"""
import logging
from django.core.exceptions import PermissionDenied
from drf_spectacular.utils import extend_schema_view, extend_schema
from drf_spectacular.types import OpenApiTypes
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.rides.models import RidePhoto
from apps.rides.services import RideMediaService
from ..media.serializers import (
PhotoUpdateInputSerializer,
PhotoListOutputSerializer,
)
from .serializers import RidePhotoSerializer
logger = logging.getLogger(__name__)
@extend_schema_view(
list=extend_schema(
summary="List ride photos",
description="Retrieve a list of photos for a specific ride.",
responses={200: PhotoListOutputSerializer(many=True)},
tags=["Rides"],
),
retrieve=extend_schema(
summary="Get ride photo details",
description="Retrieve detailed information about a specific ride photo.",
responses={
200: RidePhotoSerializer,
404: OpenApiTypes.OBJECT,
},
tags=["Rides"],
),
update=extend_schema(
summary="Update ride photo",
description="Update ride photo information (caption, alt text, etc.)",
request=PhotoUpdateInputSerializer,
responses={
200: RidePhotoSerializer,
400: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Rides"],
),
destroy=extend_schema(
summary="Delete ride photo",
description="Delete a ride photo (only by owner or admin)",
responses={
204: None,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Rides"],
),
)
class RidePhotoViewSet(ModelViewSet):
"""ViewSet for managing ride photos."""
queryset = RidePhoto.objects.select_related("ride", "uploaded_by").all()
permission_classes = [IsAuthenticated]
lookup_field = "id"
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "list":
return PhotoListOutputSerializer
elif self.action in ["update", "partial_update"]:
return PhotoUpdateInputSerializer
return RidePhotoSerializer
def perform_update(self, serializer):
"""Update photo with permission check."""
photo = self.get_object()
if not (
self.request.user == photo.uploaded_by
or self.request.user.has_perm("rides.change_ridephoto")
):
raise PermissionDenied("You do not have permission to edit this photo.")
serializer.save()
def perform_destroy(self, instance):
"""Delete photo with permission check."""
if not (
self.request.user == instance.uploaded_by
or self.request.user.has_perm("rides.delete_ridephoto")
):
raise PermissionDenied("You do not have permission to delete this photo.")
instance.delete()
@action(detail=True, methods=["post"])
def set_primary(self, request, id=None):
"""Set this photo as the primary photo for its ride."""
photo = self.get_object()
if not (
request.user == photo.uploaded_by
or request.user.has_perm("rides.change_ridephoto")
):
return Response(
{"error": "You do not have permission to edit photos for this ride."},
status=status.HTTP_403_FORBIDDEN,
)
try:
RideMediaService.set_primary_photo(photo.ride, photo)
return Response({"message": "Photo set as primary successfully."})
except Exception as e:
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,294 @@
"""
ThrillWiki API v1 serializers module.
This module provides a unified interface to all serializers across different domains
while maintaining the modular structure for better organization and maintainability.
"""
# Shared utilities and base classes
from .shared import (
CATEGORY_CHOICES,
ModelChoices,
LocationOutputSerializer,
CompanyOutputSerializer,
UserModel,
)
# Parks domain
from .parks import (
ParkListOutputSerializer,
ParkDetailOutputSerializer,
ParkCreateInputSerializer,
ParkUpdateInputSerializer,
ParkFilterInputSerializer,
ParkAreaDetailOutputSerializer,
ParkAreaCreateInputSerializer,
ParkAreaUpdateInputSerializer,
ParkLocationOutputSerializer,
ParkLocationCreateInputSerializer,
ParkLocationUpdateInputSerializer,
ParkSuggestionSerializer,
ParkSuggestionOutputSerializer,
)
# Companies and ride models domain
from .companies import (
CompanyDetailOutputSerializer,
CompanyCreateInputSerializer,
CompanyUpdateInputSerializer,
RideModelDetailOutputSerializer,
RideModelCreateInputSerializer,
RideModelUpdateInputSerializer,
)
# Rides domain
from .rides import (
RideParkOutputSerializer,
RideModelOutputSerializer,
RideListOutputSerializer,
RideDetailOutputSerializer,
RideCreateInputSerializer,
RideUpdateInputSerializer,
RideFilterInputSerializer,
RollerCoasterStatsOutputSerializer,
RollerCoasterStatsCreateInputSerializer,
RollerCoasterStatsUpdateInputSerializer,
RideLocationOutputSerializer,
RideLocationCreateInputSerializer,
RideLocationUpdateInputSerializer,
RideReviewOutputSerializer,
RideReviewCreateInputSerializer,
RideReviewUpdateInputSerializer,
)
# Accounts domain
from .accounts import (
UserProfileOutputSerializer,
UserProfileCreateInputSerializer,
UserProfileUpdateInputSerializer,
TopListOutputSerializer,
TopListCreateInputSerializer,
TopListUpdateInputSerializer,
TopListItemOutputSerializer,
TopListItemCreateInputSerializer,
TopListItemUpdateInputSerializer,
UserOutputSerializer,
LoginInputSerializer,
LoginOutputSerializer,
SignupInputSerializer,
SignupOutputSerializer,
PasswordResetInputSerializer,
PasswordResetOutputSerializer,
PasswordChangeInputSerializer,
PasswordChangeOutputSerializer,
LogoutOutputSerializer,
SocialProviderOutputSerializer,
AuthStatusOutputSerializer,
)
# Statistics and health checks
from .other import (
ParkStatsOutputSerializer,
RideStatsOutputSerializer,
ParkReviewOutputSerializer,
HealthCheckOutputSerializer,
PerformanceMetricsOutputSerializer,
SimpleHealthOutputSerializer,
)
# Media domain
from .media import (
PhotoUploadInputSerializer,
PhotoDetailOutputSerializer,
PhotoListOutputSerializer,
PhotoUpdateInputSerializer,
)
# Parks media domain
from .parks_media import (
ParkPhotoOutputSerializer,
ParkPhotoCreateInputSerializer,
ParkPhotoUpdateInputSerializer,
ParkPhotoListOutputSerializer,
ParkPhotoApprovalInputSerializer,
ParkPhotoStatsOutputSerializer,
)
# Rides media domain
from .rides_media import (
RidePhotoOutputSerializer,
RidePhotoCreateInputSerializer,
RidePhotoUpdateInputSerializer,
RidePhotoListOutputSerializer,
RidePhotoApprovalInputSerializer,
RidePhotoStatsOutputSerializer,
RidePhotoTypeFilterSerializer,
)
# Search domain
from .search import (
EntitySearchInputSerializer,
EntitySearchResultSerializer,
EntitySearchOutputSerializer,
LocationSearchResultSerializer,
LocationSearchOutputSerializer,
ReverseGeocodeOutputSerializer,
)
# History domain
from .history import (
ParkHistoryEventSerializer,
RideHistoryEventSerializer,
ParkHistoryOutputSerializer,
RideHistoryOutputSerializer,
UnifiedHistoryTimelineSerializer,
HistorySummarySerializer,
)
# Services domain
from .services import (
EmailSendInputSerializer,
EmailTemplateOutputSerializer,
MapDataOutputSerializer,
CoordinateInputSerializer,
HistoryEventSerializer,
HistoryEntryOutputSerializer,
HistoryCreateInputSerializer,
ModerationSubmissionSerializer,
ModerationSubmissionOutputSerializer,
RoadtripParkSerializer,
RoadtripCreateInputSerializer,
RoadtripOutputSerializer,
GeocodeInputSerializer,
GeocodeOutputSerializer,
DistanceCalculationInputSerializer,
DistanceCalculationOutputSerializer,
)
# Re-export everything for backward compatibility
__all__ = [
# Shared
"CATEGORY_CHOICES",
"ModelChoices",
"LocationOutputSerializer",
"CompanyOutputSerializer",
"UserModel",
# Parks
"ParkListOutputSerializer",
"ParkDetailOutputSerializer",
"ParkCreateInputSerializer",
"ParkUpdateInputSerializer",
"ParkFilterInputSerializer",
"ParkAreaDetailOutputSerializer",
"ParkAreaCreateInputSerializer",
"ParkAreaUpdateInputSerializer",
"ParkLocationOutputSerializer",
"ParkLocationCreateInputSerializer",
"ParkLocationUpdateInputSerializer",
"ParkSuggestionSerializer",
"ParkSuggestionOutputSerializer",
# Companies
"CompanyDetailOutputSerializer",
"CompanyCreateInputSerializer",
"CompanyUpdateInputSerializer",
"RideModelDetailOutputSerializer",
"RideModelCreateInputSerializer",
"RideModelUpdateInputSerializer",
# Rides
"RideParkOutputSerializer",
"RideModelOutputSerializer",
"RideListOutputSerializer",
"RideDetailOutputSerializer",
"RideCreateInputSerializer",
"RideUpdateInputSerializer",
"RideFilterInputSerializer",
"RollerCoasterStatsOutputSerializer",
"RollerCoasterStatsCreateInputSerializer",
"RollerCoasterStatsUpdateInputSerializer",
"RideLocationOutputSerializer",
"RideLocationCreateInputSerializer",
"RideLocationUpdateInputSerializer",
"RideReviewOutputSerializer",
"RideReviewCreateInputSerializer",
"RideReviewUpdateInputSerializer",
# Services
"EmailSendInputSerializer",
"EmailTemplateOutputSerializer",
"MapDataOutputSerializer",
"CoordinateInputSerializer",
"HistoryEventSerializer",
"HistoryEntryOutputSerializer",
"HistoryCreateInputSerializer",
"ModerationSubmissionSerializer",
"ModerationSubmissionOutputSerializer",
"RoadtripParkSerializer",
"RoadtripCreateInputSerializer",
"RoadtripOutputSerializer",
"GeocodeInputSerializer",
"GeocodeOutputSerializer",
"DistanceCalculationInputSerializer",
"DistanceCalculationOutputSerializer",
# Media
"PhotoUploadInputSerializer",
"PhotoDetailOutputSerializer",
"PhotoListOutputSerializer",
"PhotoUpdateInputSerializer",
# Parks Media
"ParkPhotoOutputSerializer",
"ParkPhotoCreateInputSerializer",
"ParkPhotoUpdateInputSerializer",
"ParkPhotoListOutputSerializer",
"ParkPhotoApprovalInputSerializer",
"ParkPhotoStatsOutputSerializer",
# Rides Media
"RidePhotoOutputSerializer",
"RidePhotoCreateInputSerializer",
"RidePhotoUpdateInputSerializer",
"RidePhotoListOutputSerializer",
"RidePhotoApprovalInputSerializer",
"RidePhotoStatsOutputSerializer",
"RidePhotoTypeFilterSerializer",
# Search
"EntitySearchInputSerializer",
"EntitySearchResultSerializer",
"EntitySearchOutputSerializer",
"LocationSearchResultSerializer",
"LocationSearchOutputSerializer",
"ReverseGeocodeOutputSerializer",
# History
"ParkHistoryEventSerializer",
"RideHistoryEventSerializer",
"ParkHistoryOutputSerializer",
"RideHistoryOutputSerializer",
"UnifiedHistoryTimelineSerializer",
"HistorySummarySerializer",
# Statistics and health
"ParkStatsOutputSerializer",
"RideStatsOutputSerializer",
"ParkReviewOutputSerializer",
"HealthCheckOutputSerializer",
"PerformanceMetricsOutputSerializer",
"SimpleHealthOutputSerializer",
# Accounts
"UserProfileOutputSerializer",
"UserProfileCreateInputSerializer",
"UserProfileUpdateInputSerializer",
"TopListOutputSerializer",
"TopListCreateInputSerializer",
"TopListUpdateInputSerializer",
"TopListItemOutputSerializer",
"TopListItemCreateInputSerializer",
"TopListItemUpdateInputSerializer",
"UserOutputSerializer",
"LoginInputSerializer",
"LoginOutputSerializer",
"SignupInputSerializer",
"SignupOutputSerializer",
"PasswordResetInputSerializer",
"PasswordResetOutputSerializer",
"PasswordChangeInputSerializer",
"PasswordChangeOutputSerializer",
"LogoutOutputSerializer",
"SocialProviderOutputSerializer",
"AuthStatusOutputSerializer",
]

View File

@@ -0,0 +1,496 @@
"""
Accounts domain serializers for ThrillWiki API v1.
This module contains all serializers related to user accounts, profiles,
authentication, top lists, and user statistics.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
)
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.crypto import get_random_string
from django.utils import timezone
from datetime import timedelta
from django.contrib.sites.shortcuts import get_current_site
from django.template.loader import render_to_string
from .shared import UserModel, ModelChoices
# === USER PROFILE SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"User Profile Example",
summary="Example user profile response",
description="A user's profile information",
value={
"id": 1,
"profile_id": "1234",
"display_name": "Coaster Enthusiast",
"bio": "Love visiting theme parks around the world!",
"pronouns": "they/them",
"avatar_url": "/media/avatars/user1.jpg",
"coaster_credits": 150,
"dark_ride_credits": 45,
"flat_ride_credits": 80,
"water_ride_credits": 25,
"user": {
"username": "coaster_fan",
"date_joined": "2024-01-01T00:00:00Z",
},
},
)
]
)
class UserProfileOutputSerializer(serializers.Serializer):
"""Output serializer for user profiles."""
id = serializers.IntegerField()
profile_id = serializers.CharField()
display_name = serializers.CharField()
bio = serializers.CharField()
pronouns = serializers.CharField()
avatar_url = serializers.SerializerMethodField()
twitter = serializers.URLField()
instagram = serializers.URLField()
youtube = serializers.URLField()
discord = serializers.CharField()
# Ride statistics
coaster_credits = serializers.IntegerField()
dark_ride_credits = serializers.IntegerField()
flat_ride_credits = serializers.IntegerField()
water_ride_credits = serializers.IntegerField()
# User info (limited)
user = serializers.SerializerMethodField()
@extend_schema_field(serializers.URLField(allow_null=True))
def get_avatar_url(self, obj) -> str | None:
return obj.get_avatar()
@extend_schema_field(serializers.DictField())
def get_user(self, obj) -> dict:
return {
"username": obj.user.username,
"date_joined": obj.user.date_joined,
}
class UserProfileCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating user profiles."""
display_name = serializers.CharField(max_length=50)
bio = serializers.CharField(max_length=500, allow_blank=True, default="")
pronouns = serializers.CharField(max_length=50, allow_blank=True, default="")
twitter = serializers.URLField(required=False, allow_blank=True)
instagram = serializers.URLField(required=False, allow_blank=True)
youtube = serializers.URLField(required=False, allow_blank=True)
discord = serializers.CharField(max_length=100, allow_blank=True, default="")
class UserProfileUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating user profiles."""
display_name = serializers.CharField(max_length=50, required=False)
bio = serializers.CharField(max_length=500, allow_blank=True, required=False)
pronouns = serializers.CharField(max_length=50, allow_blank=True, required=False)
twitter = serializers.URLField(required=False, allow_blank=True)
instagram = serializers.URLField(required=False, allow_blank=True)
youtube = serializers.URLField(required=False, allow_blank=True)
discord = serializers.CharField(max_length=100, allow_blank=True, required=False)
coaster_credits = serializers.IntegerField(required=False)
dark_ride_credits = serializers.IntegerField(required=False)
flat_ride_credits = serializers.IntegerField(required=False)
water_ride_credits = serializers.IntegerField(required=False)
# === TOP LIST SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Top List Example",
summary="Example top list response",
description="A user's top list of rides or parks",
value={
"id": 1,
"title": "My Top 10 Roller Coasters",
"category": "RC",
"description": "My favorite roller coasters ranked",
"user": {"username": "coaster_fan", "display_name": "Coaster Fan"},
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-08-15T12:00:00Z",
},
)
]
)
class TopListOutputSerializer(serializers.Serializer):
"""Output serializer for top lists."""
id = serializers.IntegerField()
title = serializers.CharField()
category = serializers.CharField()
description = serializers.CharField()
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
# User info
user = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_user(self, obj) -> dict:
return {
"username": obj.user.username,
"display_name": obj.user.get_display_name(),
}
class TopListCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating top lists."""
title = serializers.CharField(max_length=100)
category = serializers.ChoiceField(choices=ModelChoices.get_top_list_categories())
description = serializers.CharField(allow_blank=True, default="")
class TopListUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating top lists."""
title = serializers.CharField(max_length=100, required=False)
category = serializers.ChoiceField(
choices=ModelChoices.get_top_list_categories(), required=False
)
description = serializers.CharField(allow_blank=True, required=False)
# === TOP LIST ITEM SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Top List Item Example",
summary="Example top list item response",
description="An item in a user's top list",
value={
"id": 1,
"rank": 1,
"notes": "Amazing airtime and smooth ride",
"object_name": "Steel Vengeance",
"object_type": "Ride",
"top_list": {"id": 1, "title": "My Top 10 Roller Coasters"},
},
)
]
)
class TopListItemOutputSerializer(serializers.Serializer):
"""Output serializer for top list items."""
id = serializers.IntegerField()
rank = serializers.IntegerField()
notes = serializers.CharField()
object_name = serializers.SerializerMethodField()
object_type = serializers.SerializerMethodField()
# Top list info
top_list = serializers.SerializerMethodField()
@extend_schema_field(serializers.CharField())
def get_object_name(self, obj) -> str:
"""Get the name of the referenced object."""
# This would need to be implemented based on the generic foreign key
return "Object Name" # Placeholder
@extend_schema_field(serializers.CharField())
def get_object_type(self, obj) -> str:
"""Get the type of the referenced object."""
return obj.content_type.model_class().__name__
@extend_schema_field(serializers.DictField())
def get_top_list(self, obj) -> dict:
return {
"id": obj.top_list.id,
"title": obj.top_list.title,
}
class TopListItemCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating top list items."""
top_list_id = serializers.IntegerField()
content_type_id = serializers.IntegerField()
object_id = serializers.IntegerField()
rank = serializers.IntegerField(min_value=1)
notes = serializers.CharField(allow_blank=True, default="")
class TopListItemUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating top list items."""
rank = serializers.IntegerField(min_value=1, required=False)
notes = serializers.CharField(allow_blank=True, required=False)
# === ACCOUNTS SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"User Example",
summary="Example user response",
description="A typical user object",
value={
"id": 1,
"username": "john_doe",
"email": "john@example.com",
"first_name": "John",
"last_name": "Doe",
"date_joined": "2024-01-01T12:00:00Z",
"is_active": True,
"avatar_url": "https://example.com/avatars/john.jpg",
},
)
]
)
class UserOutputSerializer(serializers.ModelSerializer):
"""User serializer for API responses."""
avatar_url = serializers.SerializerMethodField()
class Meta:
model = UserModel
fields = [
"id",
"username",
"email",
"first_name",
"last_name",
"date_joined",
"is_active",
"avatar_url",
]
read_only_fields = ["id", "date_joined", "is_active"]
@extend_schema_field(serializers.URLField(allow_null=True))
def get_avatar_url(self, obj) -> str | None:
"""Get user avatar URL."""
if hasattr(obj, "profile") and obj.profile.avatar:
return obj.profile.avatar.url
return None
class LoginInputSerializer(serializers.Serializer):
"""Input serializer for user login."""
username = serializers.CharField(
max_length=254, help_text="Username or email address"
)
password = serializers.CharField(
max_length=128, style={"input_type": "password"}, trim_whitespace=False
)
def validate(self, attrs):
username = attrs.get("username")
password = attrs.get("password")
if username and password:
return attrs
raise serializers.ValidationError("Must include username/email and password.")
class LoginOutputSerializer(serializers.Serializer):
"""Output serializer for successful login."""
token = serializers.CharField()
user = UserOutputSerializer()
message = serializers.CharField()
class SignupInputSerializer(serializers.ModelSerializer):
"""Input serializer for user registration."""
password = serializers.CharField(
write_only=True,
validators=[validate_password],
style={"input_type": "password"},
)
password_confirm = serializers.CharField(
write_only=True, style={"input_type": "password"}
)
class Meta:
model = UserModel
fields = [
"username",
"email",
"first_name",
"last_name",
"password",
"password_confirm",
]
extra_kwargs = {
"password": {"write_only": True},
"email": {"required": True},
}
def validate_email(self, value):
"""Validate email is unique."""
if UserModel.objects.filter(email=value).exists():
raise serializers.ValidationError("A user with this email already exists.")
return value
def validate_username(self, value):
"""Validate username is unique."""
if UserModel.objects.filter(username=value).exists():
raise serializers.ValidationError(
"A user with this username already exists."
)
return value
def validate(self, attrs):
"""Validate passwords match."""
password = attrs.get("password")
password_confirm = attrs.get("password_confirm")
if password != password_confirm:
raise serializers.ValidationError(
{"password_confirm": "Passwords do not match."}
)
return attrs
def create(self, validated_data):
"""Create user with validated data."""
validated_data.pop("password_confirm", None)
password = validated_data.pop("password")
# Use type: ignore for Django's create_user method which isn't properly typed
user = UserModel.objects.create_user( # type: ignore[attr-defined]
password=password, **validated_data
)
return user
class SignupOutputSerializer(serializers.Serializer):
"""Output serializer for successful signup."""
token = serializers.CharField()
user = UserOutputSerializer()
message = serializers.CharField()
class PasswordResetInputSerializer(serializers.Serializer):
"""Input serializer for password reset request."""
email = serializers.EmailField()
def validate_email(self, value):
"""Validate email exists."""
try:
user = UserModel.objects.get(email=value)
self.user = user
return value
except UserModel.DoesNotExist:
# Don't reveal if email exists or not for security
return value
def save(self, **kwargs):
"""Send password reset email if user exists."""
if hasattr(self, "user"):
# Create password reset token
token = get_random_string(64)
# Note: PasswordReset model would need to be imported
# PasswordReset.objects.update_or_create(...)
pass
class PasswordResetOutputSerializer(serializers.Serializer):
"""Output serializer for password reset request."""
detail = serializers.CharField()
class PasswordChangeInputSerializer(serializers.Serializer):
"""Input serializer for password change."""
old_password = serializers.CharField(
max_length=128, style={"input_type": "password"}
)
new_password = serializers.CharField(
max_length=128,
validators=[validate_password],
style={"input_type": "password"},
)
new_password_confirm = serializers.CharField(
max_length=128, style={"input_type": "password"}
)
def validate_old_password(self, value):
"""Validate old password is correct."""
user = self.context["request"].user
if not user.check_password(value):
raise serializers.ValidationError("Old password is incorrect.")
return value
def validate(self, attrs):
"""Validate new passwords match."""
new_password = attrs.get("new_password")
new_password_confirm = attrs.get("new_password_confirm")
if new_password != new_password_confirm:
raise serializers.ValidationError(
{"new_password_confirm": "New passwords do not match."}
)
return attrs
def save(self, **kwargs):
"""Change user password."""
user = self.context["request"].user
# validated_data is guaranteed to exist after is_valid() is called
new_password = self.validated_data["new_password"] # type: ignore[index]
user.set_password(new_password)
user.save()
return user
class PasswordChangeOutputSerializer(serializers.Serializer):
"""Output serializer for password change."""
detail = serializers.CharField()
class LogoutOutputSerializer(serializers.Serializer):
"""Output serializer for logout."""
message = serializers.CharField()
class SocialProviderOutputSerializer(serializers.Serializer):
"""Output serializer for social authentication providers."""
id = serializers.CharField()
name = serializers.CharField()
authUrl = serializers.URLField()
class AuthStatusOutputSerializer(serializers.Serializer):
"""Output serializer for authentication status check."""
authenticated = serializers.BooleanField()
user = UserOutputSerializer(allow_null=True)

View File

@@ -0,0 +1,149 @@
"""
Companies and ride models domain serializers for ThrillWiki API v1.
This module contains all serializers related to companies that operate parks
or manufacture rides, as well as ride model serializers.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
)
from .shared import CATEGORY_CHOICES, ModelChoices
# === COMPANY SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Company Example",
summary="Example company response",
description="A company that operates parks or manufactures rides",
value={
"id": 1,
"name": "Cedar Fair",
"slug": "cedar-fair",
"roles": ["OPERATOR", "PROPERTY_OWNER"],
"description": "Theme park operator based in Ohio",
"website": "https://cedarfair.com",
"founded_date": "1983-01-01",
"rides_count": 0,
"coasters_count": 0,
},
)
]
)
class CompanyDetailOutputSerializer(serializers.Serializer):
"""Output serializer for company details."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
roles = serializers.ListField(child=serializers.CharField())
description = serializers.CharField()
website = serializers.URLField()
founded_date = serializers.DateField(allow_null=True)
rides_count = serializers.IntegerField()
coasters_count = serializers.IntegerField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
class CompanyCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating companies."""
name = serializers.CharField(max_length=255)
roles = serializers.ListField(
child=serializers.ChoiceField(choices=ModelChoices.get_company_role_choices()),
allow_empty=False,
)
description = serializers.CharField(allow_blank=True, default="")
website = serializers.URLField(required=False, allow_blank=True)
founded_date = serializers.DateField(required=False, allow_null=True)
class CompanyUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating companies."""
name = serializers.CharField(max_length=255, required=False)
roles = serializers.ListField(
child=serializers.ChoiceField(choices=ModelChoices.get_company_role_choices()),
required=False,
)
description = serializers.CharField(allow_blank=True, required=False)
website = serializers.URLField(required=False, allow_blank=True)
founded_date = serializers.DateField(required=False, allow_null=True)
# === RIDE MODEL SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Ride Model Example",
summary="Example ride model response",
description="A specific model/type of ride manufactured by a company",
value={
"id": 1,
"name": "Dive Coaster",
"description": "A roller coaster featuring a near-vertical drop",
"category": "RC",
"manufacturer": {
"id": 1,
"name": "Bolliger & Mabillard",
"slug": "bolliger-mabillard",
},
},
)
]
)
class RideModelDetailOutputSerializer(serializers.Serializer):
"""Output serializer for ride model details."""
id = serializers.IntegerField()
name = serializers.CharField()
description = serializers.CharField()
category = serializers.CharField()
# Manufacturer info
manufacturer = serializers.SerializerMethodField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_field(serializers.DictField(allow_null=True))
def get_manufacturer(self, obj) -> dict | None:
if obj.manufacturer:
return {
"id": obj.manufacturer.id,
"name": obj.manufacturer.name,
"slug": obj.manufacturer.slug,
}
return None
class RideModelCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating ride models."""
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
category = serializers.ChoiceField(choices=CATEGORY_CHOICES, required=False)
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
class RideModelUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating ride models."""
name = serializers.CharField(max_length=255, required=False)
description = serializers.CharField(allow_blank=True, required=False)
category = serializers.ChoiceField(choices=CATEGORY_CHOICES, required=False)
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)

View File

@@ -0,0 +1,187 @@
"""
History domain serializers for ThrillWiki API v1.
This module contains serializers for history tracking and timeline functionality
using django-pghistory.
"""
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_serializer, extend_schema_field
import pghistory.models
class ParkHistoryEventSerializer(serializers.Serializer):
"""Serializer for park history events."""
pgh_id = serializers.IntegerField(read_only=True)
pgh_created_at = serializers.DateTimeField(read_only=True)
pgh_label = serializers.CharField(read_only=True)
pgh_obj_id = serializers.IntegerField(read_only=True)
pgh_context = serializers.JSONField(read_only=True, allow_null=True)
pgh_data = serializers.JSONField(read_only=True)
event_type = serializers.SerializerMethodField()
changes = serializers.SerializerMethodField()
@extend_schema_field(serializers.CharField())
def get_event_type(self, obj) -> str:
"""Get human-readable event type."""
return obj.pgh_label.replace("_", " ").title()
@extend_schema_field(serializers.DictField())
def get_changes(self, obj) -> dict:
"""Get changes made in this event."""
if hasattr(obj, "pgh_diff") and obj.pgh_diff:
return obj.pgh_diff
return {}
class RideHistoryEventSerializer(serializers.Serializer):
"""Serializer for ride history events."""
pgh_id = serializers.IntegerField(read_only=True)
pgh_created_at = serializers.DateTimeField(read_only=True)
pgh_label = serializers.CharField(read_only=True)
pgh_obj_id = serializers.IntegerField(read_only=True)
pgh_context = serializers.JSONField(read_only=True, allow_null=True)
pgh_data = serializers.JSONField(read_only=True)
event_type = serializers.SerializerMethodField()
changes = serializers.SerializerMethodField()
@extend_schema_field(serializers.CharField())
def get_event_type(self, obj) -> str:
"""Get human-readable event type."""
return obj.pgh_label.replace("_", " ").title()
@extend_schema_field(serializers.DictField())
def get_changes(self, obj) -> dict:
"""Get changes made in this event."""
if hasattr(obj, "pgh_diff") and obj.pgh_diff:
return obj.pgh_diff
return {}
class HistorySummarySerializer(serializers.Serializer):
"""Serializer for history summary information."""
total_events = serializers.IntegerField()
first_recorded = serializers.DateTimeField(allow_null=True)
last_modified = serializers.DateTimeField(allow_null=True)
class ParkHistoryOutputSerializer(serializers.Serializer):
"""Output serializer for complete park history."""
park = serializers.SerializerMethodField()
current_state = serializers.SerializerMethodField()
summary = HistorySummarySerializer()
events = ParkHistoryEventSerializer(many=True)
@extend_schema_field(serializers.DictField())
def get_park(self, obj) -> dict:
"""Get basic park information."""
park = obj.get("park")
if park:
return {
"id": park.id,
"name": park.name,
"slug": park.slug,
"status": park.status,
}
return {}
@extend_schema_field(serializers.DictField())
def get_current_state(self, obj) -> dict:
"""Get current park state."""
park = obj.get("current_state")
if park:
return {
"id": park.id,
"name": park.name,
"slug": park.slug,
"status": park.status,
"opening_date": (
park.opening_date.isoformat()
if hasattr(park, "opening_date") and park.opening_date
else None
),
"coaster_count": getattr(park, "coaster_count", 0),
"ride_count": getattr(park, "ride_count", 0),
}
return {}
class RideHistoryOutputSerializer(serializers.Serializer):
"""Output serializer for complete ride history."""
ride = serializers.SerializerMethodField()
current_state = serializers.SerializerMethodField()
summary = HistorySummarySerializer()
events = RideHistoryEventSerializer(many=True)
@extend_schema_field(serializers.DictField())
def get_ride(self, obj) -> dict:
"""Get basic ride information."""
ride = obj.get("ride")
if ride:
return {
"id": ride.id,
"name": ride.name,
"slug": ride.slug,
"park_name": ride.park.name if hasattr(ride, "park") else None,
"status": getattr(ride, "status", "UNKNOWN"),
}
return {}
@extend_schema_field(serializers.DictField())
def get_current_state(self, obj) -> dict:
"""Get current ride state."""
ride = obj.get("current_state")
if ride:
return {
"id": ride.id,
"name": ride.name,
"slug": ride.slug,
"park_name": ride.park.name if hasattr(ride, "park") else None,
"status": getattr(ride, "status", "UNKNOWN"),
"opening_date": (
ride.opening_date.isoformat()
if hasattr(ride, "opening_date") and ride.opening_date
else None
),
"ride_type": getattr(ride, "ride_type", "Unknown"),
}
return {}
class UnifiedHistoryTimelineSerializer(serializers.Serializer):
"""Serializer for unified history timeline."""
summary = serializers.SerializerMethodField()
events = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_summary(self, obj) -> dict:
"""Get timeline summary."""
return obj.get("summary", {})
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_events(self, obj) -> list:
"""Get timeline events."""
events = obj.get("events", [])
event_data = []
for event in events:
event_data.append(
{
"pgh_id": event.pgh_id,
"pgh_created_at": event.pgh_created_at,
"pgh_label": event.pgh_label,
"pgh_model": event.pgh_model,
"pgh_obj_id": event.pgh_obj_id,
"pgh_context": event.pgh_context,
"event_type": event.pgh_label.replace("_", " ").title(),
"model_type": event.pgh_model.split(".")[-1].title(),
}
)
return event_data

View File

@@ -0,0 +1,124 @@
"""
Media domain serializers for ThrillWiki API v1.
This module contains serializers for photo uploads, media management,
and related media functionality.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
)
# === MEDIA SERIALIZERS ===
class PhotoUploadInputSerializer(serializers.Serializer):
"""Input serializer for photo uploads."""
file = serializers.ImageField()
caption = serializers.CharField(
max_length=500,
required=False,
allow_blank=True,
help_text="Optional caption for the photo",
)
alt_text = serializers.CharField(
max_length=255,
required=False,
allow_blank=True,
help_text="Alt text for accessibility",
)
is_primary = serializers.BooleanField(
default=False, help_text="Whether this should be the primary photo"
)
@extend_schema_serializer(
examples=[
OpenApiExample(
"Photo Detail Example",
summary="Example photo detail response",
description="A photo with full details",
value={
"id": 1,
"url": "https://example.com/media/photos/ride123.jpg",
"thumbnail_url": "https://example.com/media/thumbnails/ride123_thumb.jpg",
"caption": "Amazing view of Steel Vengeance",
"alt_text": "Steel Vengeance roller coaster with blue sky",
"is_primary": True,
"uploaded_at": "2024-08-15T10:30:00Z",
"uploaded_by": {
"id": 1,
"username": "coaster_photographer",
"display_name": "Coaster Photographer",
},
"content_type": "Ride",
"object_id": 123,
},
)
]
)
class PhotoDetailOutputSerializer(serializers.Serializer):
"""Output serializer for photo details."""
id = serializers.IntegerField()
url = serializers.URLField()
thumbnail_url = serializers.URLField(required=False)
caption = serializers.CharField()
alt_text = serializers.CharField()
is_primary = serializers.BooleanField()
uploaded_at = serializers.DateTimeField()
content_type = serializers.CharField()
object_id = serializers.IntegerField()
# File metadata
file_size = serializers.IntegerField()
width = serializers.IntegerField()
height = serializers.IntegerField()
format = serializers.CharField()
# Uploader info
uploaded_by = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_uploaded_by(self, obj) -> dict:
"""Get uploader information."""
return {
"id": obj.uploaded_by.id,
"username": obj.uploaded_by.username,
"display_name": getattr(
obj.uploaded_by, "get_display_name", lambda: obj.uploaded_by.username
)(),
}
class PhotoListOutputSerializer(serializers.Serializer):
"""Output serializer for photo list view."""
id = serializers.IntegerField()
url = serializers.URLField()
thumbnail_url = serializers.URLField(required=False)
caption = serializers.CharField()
is_primary = serializers.BooleanField()
uploaded_at = serializers.DateTimeField()
uploaded_by = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_uploaded_by(self, obj) -> dict:
"""Get uploader information."""
return {
"id": obj.uploaded_by.id,
"username": obj.uploaded_by.username,
}
class PhotoUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating photos."""
caption = serializers.CharField(max_length=500, required=False, allow_blank=True)
alt_text = serializers.CharField(max_length=255, required=False, allow_blank=True)
is_primary = serializers.BooleanField(required=False)

View File

@@ -0,0 +1,118 @@
"""
Statistics, health check, and miscellaneous domain serializers for ThrillWiki API v1.
This module contains serializers for statistics, health checks, and other
miscellaneous functionality.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
)
# === STATISTICS SERIALIZERS ===
class ParkStatsOutputSerializer(serializers.Serializer):
"""Output serializer for park statistics."""
total_parks = serializers.IntegerField()
operating_parks = serializers.IntegerField()
closed_parks = serializers.IntegerField()
under_construction = serializers.IntegerField()
# Averages
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
average_coaster_count = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True
)
# Top countries
top_countries = serializers.ListField(child=serializers.DictField())
# Recently added
recently_added_count = serializers.IntegerField()
class RideStatsOutputSerializer(serializers.Serializer):
"""Output serializer for ride statistics."""
total_rides = serializers.IntegerField()
operating_rides = serializers.IntegerField()
closed_rides = serializers.IntegerField()
under_construction = serializers.IntegerField()
# By category
rides_by_category = serializers.DictField()
# Averages
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
average_capacity = serializers.DecimalField(
max_digits=8, decimal_places=2, allow_null=True
)
# Top manufacturers
top_manufacturers = serializers.ListField(child=serializers.DictField())
# Recently added
recently_added_count = serializers.IntegerField()
class ParkReviewOutputSerializer(serializers.Serializer):
"""Output serializer for park reviews."""
id = serializers.IntegerField()
rating = serializers.IntegerField()
title = serializers.CharField()
content = serializers.CharField()
visit_date = serializers.DateField()
created_at = serializers.DateTimeField()
# User info (limited for privacy)
user = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_user(self, obj) -> dict:
return {
"username": obj.user.username,
"display_name": obj.user.get_full_name() or obj.user.username,
}
# === HEALTH CHECK SERIALIZERS ===
class HealthCheckOutputSerializer(serializers.Serializer):
"""Output serializer for health check responses."""
status = serializers.ChoiceField(choices=["healthy", "unhealthy"])
timestamp = serializers.DateTimeField()
version = serializers.CharField()
environment = serializers.CharField()
response_time_ms = serializers.FloatField()
checks = serializers.DictField()
metrics = serializers.DictField()
class PerformanceMetricsOutputSerializer(serializers.Serializer):
"""Output serializer for performance metrics."""
timestamp = serializers.DateTimeField()
database_analysis = serializers.DictField()
cache_performance = serializers.DictField()
recent_slow_queries = serializers.ListField()
class SimpleHealthOutputSerializer(serializers.Serializer):
"""Output serializer for simple health check."""
status = serializers.ChoiceField(choices=["ok", "error"])
timestamp = serializers.DateTimeField()
error = serializers.CharField(required=False)

View File

@@ -0,0 +1,448 @@
"""
Parks domain serializers for ThrillWiki API v1.
This module contains all serializers related to parks, park areas, park locations,
and park search functionality.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
)
from .shared import LocationOutputSerializer, CompanyOutputSerializer, ModelChoices
# === PARK SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Park List Example",
summary="Example park list response",
description="A typical park in the list view",
value={
"id": 1,
"name": "Cedar Point",
"slug": "cedar-point",
"status": "OPERATING",
"description": "America's Roller Coast",
"average_rating": 4.5,
"coaster_count": 17,
"ride_count": 70,
"location": {
"city": "Sandusky",
"state": "Ohio",
"country": "United States",
},
"operator": {"id": 1, "name": "Cedar Fair", "slug": "cedar-fair"},
},
)
]
)
class ParkListOutputSerializer(serializers.Serializer):
"""Output serializer for park list view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
status = serializers.CharField()
description = serializers.CharField()
# Statistics
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
coaster_count = serializers.IntegerField(allow_null=True)
ride_count = serializers.IntegerField(allow_null=True)
# Location (simplified for list view)
location = LocationOutputSerializer(allow_null=True)
# Operator info
operator = CompanyOutputSerializer()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_serializer(
examples=[
OpenApiExample(
"Park Detail Example",
summary="Example park detail response",
description="A complete park detail response",
value={
"id": 1,
"name": "Cedar Point",
"slug": "cedar-point",
"status": "OPERATING",
"description": "America's Roller Coast",
"opening_date": "1870-01-01",
"website": "https://cedarpoint.com",
"size_acres": 364.0,
"average_rating": 4.5,
"coaster_count": 17,
"ride_count": 70,
"location": {
"latitude": 41.4793,
"longitude": -82.6833,
"city": "Sandusky",
"state": "Ohio",
"country": "United States",
},
"operator": {"id": 1, "name": "Cedar Fair", "slug": "cedar-fair"},
},
)
]
)
class ParkDetailOutputSerializer(serializers.Serializer):
"""Output serializer for park detail view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
status = serializers.CharField()
description = serializers.CharField()
# Details
opening_date = serializers.DateField(allow_null=True)
closing_date = serializers.DateField(allow_null=True)
operating_season = serializers.CharField()
size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, allow_null=True
)
website = serializers.URLField()
# Statistics
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
coaster_count = serializers.IntegerField(allow_null=True)
ride_count = serializers.IntegerField(allow_null=True)
# Location (full details)
location = LocationOutputSerializer(allow_null=True)
# Companies
operator = CompanyOutputSerializer()
property_owner = CompanyOutputSerializer(allow_null=True)
# Areas
areas = serializers.SerializerMethodField()
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_areas(self, obj):
"""Get simplified area information."""
if hasattr(obj, "areas"):
return [
{
"id": area.id,
"name": area.name,
"slug": area.slug,
"description": area.description,
}
for area in obj.areas.all()
]
return []
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
class ParkCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating parks."""
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
status = serializers.ChoiceField(
choices=ModelChoices.get_park_status_choices(), default="OPERATING"
)
# Optional details
opening_date = serializers.DateField(required=False, allow_null=True)
closing_date = serializers.DateField(required=False, allow_null=True)
operating_season = serializers.CharField(
max_length=255, required=False, allow_blank=True
)
size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, required=False, allow_null=True
)
website = serializers.URLField(required=False, allow_blank=True)
# Required operator
operator_id = serializers.IntegerField()
# Optional property owner
property_owner_id = serializers.IntegerField(required=False, allow_null=True)
def validate(self, attrs):
"""Cross-field validation."""
opening_date = attrs.get("opening_date")
closing_date = attrs.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
return attrs
class ParkUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating parks."""
name = serializers.CharField(max_length=255, required=False)
description = serializers.CharField(allow_blank=True, required=False)
status = serializers.ChoiceField(
choices=ModelChoices.get_park_status_choices(), required=False
)
# Optional details
opening_date = serializers.DateField(required=False, allow_null=True)
closing_date = serializers.DateField(required=False, allow_null=True)
operating_season = serializers.CharField(
max_length=255, required=False, allow_blank=True
)
size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, required=False, allow_null=True
)
website = serializers.URLField(required=False, allow_blank=True)
# Companies
operator_id = serializers.IntegerField(required=False)
property_owner_id = serializers.IntegerField(required=False, allow_null=True)
def validate(self, attrs):
"""Cross-field validation."""
opening_date = attrs.get("opening_date")
closing_date = attrs.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
return attrs
class ParkFilterInputSerializer(serializers.Serializer):
"""Input serializer for park filtering and search."""
# Search
search = serializers.CharField(required=False, allow_blank=True)
# Status filter
status = serializers.MultipleChoiceField(
choices=[], required=False # Choices set dynamically
)
# Location filters
country = serializers.CharField(required=False, allow_blank=True)
state = serializers.CharField(required=False, allow_blank=True)
city = serializers.CharField(required=False, allow_blank=True)
# Rating filter
min_rating = serializers.DecimalField(
max_digits=3,
decimal_places=2,
required=False,
min_value=1,
max_value=10,
)
# Size filter
min_size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, required=False, min_value=0
)
max_size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, required=False, min_value=0
)
# Company filters
operator_id = serializers.IntegerField(required=False)
property_owner_id = serializers.IntegerField(required=False)
# Ordering
ordering = serializers.ChoiceField(
choices=[
"name",
"-name",
"opening_date",
"-opening_date",
"average_rating",
"-average_rating",
"coaster_count",
"-coaster_count",
"created_at",
"-created_at",
],
required=False,
default="name",
)
# === PARK AREA SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Park Area Example",
summary="Example park area response",
description="A themed area within a park",
value={
"id": 1,
"name": "Tomorrowland",
"slug": "tomorrowland",
"description": "A futuristic themed area",
"park": {"id": 1, "name": "Magic Kingdom", "slug": "magic-kingdom"},
"opening_date": "1971-10-01",
"closing_date": None,
},
)
]
)
class ParkAreaDetailOutputSerializer(serializers.Serializer):
"""Output serializer for park areas."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
description = serializers.CharField()
opening_date = serializers.DateField(allow_null=True)
closing_date = serializers.DateField(allow_null=True)
# Park info
park = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_park(self, obj) -> dict:
return {
"id": obj.park.id,
"name": obj.park.name,
"slug": obj.park.slug,
}
class ParkAreaCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating park areas."""
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
park_id = serializers.IntegerField()
opening_date = serializers.DateField(required=False, allow_null=True)
closing_date = serializers.DateField(required=False, allow_null=True)
def validate(self, attrs):
"""Cross-field validation."""
opening_date = attrs.get("opening_date")
closing_date = attrs.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
return attrs
class ParkAreaUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating park areas."""
name = serializers.CharField(max_length=255, required=False)
description = serializers.CharField(allow_blank=True, required=False)
opening_date = serializers.DateField(required=False, allow_null=True)
closing_date = serializers.DateField(required=False, allow_null=True)
def validate(self, attrs):
"""Cross-field validation."""
opening_date = attrs.get("opening_date")
closing_date = attrs.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
return attrs
# === PARK LOCATION SERIALIZERS ===
class ParkLocationOutputSerializer(serializers.Serializer):
"""Output serializer for park locations."""
id = serializers.IntegerField()
latitude = serializers.FloatField(allow_null=True)
longitude = serializers.FloatField(allow_null=True)
address = serializers.CharField()
city = serializers.CharField()
state = serializers.CharField()
country = serializers.CharField()
postal_code = serializers.CharField()
formatted_address = serializers.CharField()
# Park info
park = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_park(self, obj) -> dict:
return {
"id": obj.park.id,
"name": obj.park.name,
"slug": obj.park.slug,
}
class ParkLocationCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating park locations."""
park_id = serializers.IntegerField()
latitude = serializers.FloatField(required=False, allow_null=True)
longitude = serializers.FloatField(required=False, allow_null=True)
address = serializers.CharField(max_length=255, allow_blank=True, default="")
city = serializers.CharField(max_length=100)
state = serializers.CharField(max_length=100)
country = serializers.CharField(max_length=100)
postal_code = serializers.CharField(max_length=20, allow_blank=True, default="")
class ParkLocationUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating park locations."""
latitude = serializers.FloatField(required=False, allow_null=True)
longitude = serializers.FloatField(required=False, allow_null=True)
address = serializers.CharField(max_length=255, allow_blank=True, required=False)
city = serializers.CharField(max_length=100, required=False)
state = serializers.CharField(max_length=100, required=False)
country = serializers.CharField(max_length=100, required=False)
postal_code = serializers.CharField(max_length=20, allow_blank=True, required=False)
# === PARKS SEARCH SERIALIZERS ===
class ParkSuggestionSerializer(serializers.Serializer):
"""Serializer for park search suggestions."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
location = serializers.CharField()
status = serializers.CharField()
coaster_count = serializers.IntegerField()
class ParkSuggestionOutputSerializer(serializers.Serializer):
"""Output serializer for park suggestions."""
results = ParkSuggestionSerializer(many=True)
query = serializers.CharField()
count = serializers.IntegerField()

View File

@@ -0,0 +1,116 @@
"""
Park media serializers for ThrillWiki API.
This module contains serializers for park-specific media functionality.
"""
from rest_framework import serializers
from apps.parks.models import ParkPhoto
class ParkPhotoOutputSerializer(serializers.ModelSerializer):
"""Output serializer for park photos."""
uploaded_by_username = serializers.CharField(
source='uploaded_by.username', read_only=True)
file_size = serializers.ReadOnlyField()
dimensions = serializers.ReadOnlyField()
park_slug = serializers.CharField(source='park.slug', read_only=True)
park_name = serializers.CharField(source='park.name', read_only=True)
class Meta:
model = ParkPhoto
fields = [
'id',
'image',
'caption',
'alt_text',
'is_primary',
'is_approved',
'created_at',
'updated_at',
'date_taken',
'uploaded_by_username',
'file_size',
'dimensions',
'park_slug',
'park_name',
]
read_only_fields = [
'id',
'created_at',
'updated_at',
'uploaded_by_username',
'file_size',
'dimensions',
'park_slug',
'park_name',
]
class ParkPhotoCreateInputSerializer(serializers.ModelSerializer):
"""Input serializer for creating park photos."""
class Meta:
model = ParkPhoto
fields = [
'image',
'caption',
'alt_text',
'is_primary',
]
class ParkPhotoUpdateInputSerializer(serializers.ModelSerializer):
"""Input serializer for updating park photos."""
class Meta:
model = ParkPhoto
fields = [
'caption',
'alt_text',
'is_primary',
]
class ParkPhotoListOutputSerializer(serializers.ModelSerializer):
"""Simplified output serializer for park photo lists."""
uploaded_by_username = serializers.CharField(
source='uploaded_by.username', read_only=True)
class Meta:
model = ParkPhoto
fields = [
'id',
'image',
'caption',
'is_primary',
'is_approved',
'created_at',
'uploaded_by_username',
]
read_only_fields = fields
class ParkPhotoApprovalInputSerializer(serializers.Serializer):
"""Input serializer for photo approval operations."""
photo_ids = serializers.ListField(
child=serializers.IntegerField(),
help_text="List of photo IDs to approve"
)
approve = serializers.BooleanField(
default=True,
help_text="Whether to approve (True) or reject (False) the photos"
)
class ParkPhotoStatsOutputSerializer(serializers.Serializer):
"""Output serializer for park photo statistics."""
total_photos = serializers.IntegerField()
approved_photos = serializers.IntegerField()
pending_photos = serializers.IntegerField()
has_primary = serializers.BooleanField()
recent_uploads = serializers.IntegerField()

View File

@@ -0,0 +1,651 @@
"""
Rides domain serializers for ThrillWiki API v1.
This module contains all serializers related to rides, roller coaster statistics,
ride locations, and ride reviews.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
)
from .shared import ModelChoices
# === RIDE SERIALIZERS ===
class RideParkOutputSerializer(serializers.Serializer):
"""Output serializer for ride's park data."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
class RideModelOutputSerializer(serializers.Serializer):
"""Output serializer for ride model data."""
id = serializers.IntegerField()
name = serializers.CharField()
description = serializers.CharField()
category = serializers.CharField()
manufacturer = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField(allow_null=True))
def get_manufacturer(self, obj) -> dict | None:
if obj.manufacturer:
return {
"id": obj.manufacturer.id,
"name": obj.manufacturer.name,
"slug": obj.manufacturer.slug,
}
return None
@extend_schema_serializer(
examples=[
OpenApiExample(
"Ride List Example",
summary="Example ride list response",
description="A typical ride in the list view",
value={
"id": 1,
"name": "Steel Vengeance",
"slug": "steel-vengeance",
"category": "ROLLER_COASTER",
"status": "OPERATING",
"description": "Hybrid roller coaster",
"park": {"id": 1, "name": "Cedar Point", "slug": "cedar-point"},
"average_rating": 4.8,
"capacity_per_hour": 1200,
"opening_date": "2018-05-05",
},
)
]
)
class RideListOutputSerializer(serializers.Serializer):
"""Output serializer for ride list view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = serializers.CharField()
status = serializers.CharField()
description = serializers.CharField()
# Park info
park = RideParkOutputSerializer()
# Statistics
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
capacity_per_hour = serializers.IntegerField(allow_null=True)
# Dates
opening_date = serializers.DateField(allow_null=True)
closing_date = serializers.DateField(allow_null=True)
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_serializer(
examples=[
OpenApiExample(
"Ride Detail Example",
summary="Example ride detail response",
description="A complete ride detail response",
value={
"id": 1,
"name": "Steel Vengeance",
"slug": "steel-vengeance",
"category": "ROLLER_COASTER",
"status": "OPERATING",
"description": "Hybrid roller coaster featuring RMC I-Box track",
"park": {"id": 1, "name": "Cedar Point", "slug": "cedar-point"},
"opening_date": "2018-05-05",
"min_height_in": 48,
"capacity_per_hour": 1200,
"ride_duration_seconds": 150,
"average_rating": 4.8,
"manufacturer": {
"id": 1,
"name": "Rocky Mountain Construction",
"slug": "rocky-mountain-construction",
},
},
)
]
)
class RideDetailOutputSerializer(serializers.Serializer):
"""Output serializer for ride detail view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = serializers.CharField()
status = serializers.CharField()
post_closing_status = serializers.CharField(allow_null=True)
description = serializers.CharField()
# Park info
park = RideParkOutputSerializer()
park_area = serializers.SerializerMethodField()
# Dates
opening_date = serializers.DateField(allow_null=True)
closing_date = serializers.DateField(allow_null=True)
status_since = serializers.DateField(allow_null=True)
# Physical specs
min_height_in = serializers.IntegerField(allow_null=True)
max_height_in = serializers.IntegerField(allow_null=True)
capacity_per_hour = serializers.IntegerField(allow_null=True)
ride_duration_seconds = serializers.IntegerField(allow_null=True)
# Statistics
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
# Companies
manufacturer = serializers.SerializerMethodField()
designer = serializers.SerializerMethodField()
# Model
ride_model = RideModelOutputSerializer(allow_null=True)
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_field(serializers.DictField(allow_null=True))
def get_park_area(self, obj) -> dict | None:
if obj.park_area:
return {
"id": obj.park_area.id,
"name": obj.park_area.name,
"slug": obj.park_area.slug,
}
return None
@extend_schema_field(serializers.DictField(allow_null=True))
def get_manufacturer(self, obj) -> dict | None:
if obj.manufacturer:
return {
"id": obj.manufacturer.id,
"name": obj.manufacturer.name,
"slug": obj.manufacturer.slug,
}
return None
@extend_schema_field(serializers.DictField(allow_null=True))
def get_designer(self, obj) -> dict | None:
if obj.designer:
return {
"id": obj.designer.id,
"name": obj.designer.name,
"slug": obj.designer.slug,
}
return None
class RideCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating rides."""
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
category = serializers.ChoiceField(choices=[]) # Choices set dynamically
status = serializers.ChoiceField(
choices=[], default="OPERATING"
) # Choices set dynamically
# Required park
park_id = serializers.IntegerField()
# Optional area
park_area_id = serializers.IntegerField(required=False, allow_null=True)
# Optional dates
opening_date = serializers.DateField(required=False, allow_null=True)
closing_date = serializers.DateField(required=False, allow_null=True)
status_since = serializers.DateField(required=False, allow_null=True)
# Optional specs
min_height_in = serializers.IntegerField(
required=False, allow_null=True, min_value=30, max_value=90
)
max_height_in = serializers.IntegerField(
required=False, allow_null=True, min_value=30, max_value=90
)
capacity_per_hour = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
ride_duration_seconds = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
# Optional companies
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
designer_id = serializers.IntegerField(required=False, allow_null=True)
# Optional model
ride_model_id = serializers.IntegerField(required=False, allow_null=True)
def validate(self, attrs):
"""Cross-field validation."""
# Date validation
opening_date = attrs.get("opening_date")
closing_date = attrs.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
# Height validation
min_height = attrs.get("min_height_in")
max_height = attrs.get("max_height_in")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
return attrs
class RideUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating rides."""
name = serializers.CharField(max_length=255, required=False)
description = serializers.CharField(allow_blank=True, required=False)
category = serializers.ChoiceField(
choices=[], required=False
) # Choices set dynamically
status = serializers.ChoiceField(
choices=[], required=False
) # Choices set dynamically
post_closing_status = serializers.ChoiceField(
choices=ModelChoices.get_ride_post_closing_choices(),
required=False,
allow_null=True,
)
# Park and area
park_id = serializers.IntegerField(required=False)
park_area_id = serializers.IntegerField(required=False, allow_null=True)
# Dates
opening_date = serializers.DateField(required=False, allow_null=True)
closing_date = serializers.DateField(required=False, allow_null=True)
status_since = serializers.DateField(required=False, allow_null=True)
# Specs
min_height_in = serializers.IntegerField(
required=False, allow_null=True, min_value=30, max_value=90
)
max_height_in = serializers.IntegerField(
required=False, allow_null=True, min_value=30, max_value=90
)
capacity_per_hour = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
ride_duration_seconds = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
# Companies
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
designer_id = serializers.IntegerField(required=False, allow_null=True)
# Model
ride_model_id = serializers.IntegerField(required=False, allow_null=True)
def validate(self, attrs):
"""Cross-field validation."""
# Date validation
opening_date = attrs.get("opening_date")
closing_date = attrs.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
# Height validation
min_height = attrs.get("min_height_in")
max_height = attrs.get("max_height_in")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
return attrs
class RideFilterInputSerializer(serializers.Serializer):
"""Input serializer for ride filtering and search."""
# Search
search = serializers.CharField(required=False, allow_blank=True)
# Category filter
category = serializers.MultipleChoiceField(
choices=[], required=False
) # Choices set dynamically
# Status filter
status = serializers.MultipleChoiceField(
choices=[], required=False # Choices set dynamically
)
# Park filter
park_id = serializers.IntegerField(required=False)
park_slug = serializers.CharField(required=False, allow_blank=True)
# Company filters
manufacturer_id = serializers.IntegerField(required=False)
designer_id = serializers.IntegerField(required=False)
# Rating filter
min_rating = serializers.DecimalField(
max_digits=3,
decimal_places=2,
required=False,
min_value=1,
max_value=10,
)
# Height filters
min_height_requirement = serializers.IntegerField(required=False)
max_height_requirement = serializers.IntegerField(required=False)
# Capacity filter
min_capacity = serializers.IntegerField(required=False)
# Ordering
ordering = serializers.ChoiceField(
choices=[
"name",
"-name",
"opening_date",
"-opening_date",
"average_rating",
"-average_rating",
"capacity_per_hour",
"-capacity_per_hour",
"created_at",
"-created_at",
],
required=False,
default="name",
)
# === ROLLER COASTER STATS SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Roller Coaster Stats Example",
summary="Example roller coaster statistics",
description="Detailed statistics for a roller coaster",
value={
"id": 1,
"ride": {"id": 1, "name": "Steel Vengeance", "slug": "steel-vengeance"},
"height_ft": 205.0,
"length_ft": 5740.0,
"speed_mph": 74.0,
"inversions": 4,
"ride_time_seconds": 150,
"track_material": "HYBRID",
"roller_coaster_type": "SITDOWN",
"launch_type": "CHAIN",
},
)
]
)
class RollerCoasterStatsOutputSerializer(serializers.Serializer):
"""Output serializer for roller coaster statistics."""
id = serializers.IntegerField()
height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True
)
length_ft = serializers.DecimalField(
max_digits=7, decimal_places=2, allow_null=True
)
speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True
)
inversions = serializers.IntegerField()
ride_time_seconds = serializers.IntegerField(allow_null=True)
track_type = serializers.CharField()
track_material = serializers.CharField()
roller_coaster_type = serializers.CharField()
max_drop_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True
)
launch_type = serializers.CharField()
train_style = serializers.CharField()
trains_count = serializers.IntegerField(allow_null=True)
cars_per_train = serializers.IntegerField(allow_null=True)
seats_per_car = serializers.IntegerField(allow_null=True)
# Ride info
ride = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_ride(self, obj) -> dict:
return {
"id": obj.ride.id,
"name": obj.ride.name,
"slug": obj.ride.slug,
}
class RollerCoasterStatsCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating roller coaster statistics."""
ride_id = serializers.IntegerField()
height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
length_ft = serializers.DecimalField(
max_digits=7, decimal_places=2, required=False, allow_null=True
)
speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
inversions = serializers.IntegerField(default=0)
ride_time_seconds = serializers.IntegerField(required=False, allow_null=True)
track_type = serializers.CharField(max_length=255, allow_blank=True, default="")
track_material = serializers.ChoiceField(
choices=ModelChoices.get_coaster_track_choices(), default="STEEL"
)
roller_coaster_type = serializers.ChoiceField(
choices=ModelChoices.get_coaster_type_choices(), default="SITDOWN"
)
max_drop_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
launch_type = serializers.ChoiceField(
choices=ModelChoices.get_launch_choices(), default="CHAIN"
)
train_style = serializers.CharField(max_length=255, allow_blank=True, default="")
trains_count = serializers.IntegerField(required=False, allow_null=True)
cars_per_train = serializers.IntegerField(required=False, allow_null=True)
seats_per_car = serializers.IntegerField(required=False, allow_null=True)
class RollerCoasterStatsUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating roller coaster statistics."""
height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
length_ft = serializers.DecimalField(
max_digits=7, decimal_places=2, required=False, allow_null=True
)
speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
inversions = serializers.IntegerField(required=False)
ride_time_seconds = serializers.IntegerField(required=False, allow_null=True)
track_type = serializers.CharField(max_length=255, allow_blank=True, required=False)
track_material = serializers.ChoiceField(
choices=ModelChoices.get_coaster_track_choices(), required=False
)
roller_coaster_type = serializers.ChoiceField(
choices=ModelChoices.get_coaster_type_choices(), required=False
)
max_drop_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
launch_type = serializers.ChoiceField(
choices=ModelChoices.get_launch_choices(), required=False
)
train_style = serializers.CharField(
max_length=255, allow_blank=True, required=False
)
trains_count = serializers.IntegerField(required=False, allow_null=True)
cars_per_train = serializers.IntegerField(required=False, allow_null=True)
seats_per_car = serializers.IntegerField(required=False, allow_null=True)
# === RIDE LOCATION SERIALIZERS ===
class RideLocationOutputSerializer(serializers.Serializer):
"""Output serializer for ride locations."""
id = serializers.IntegerField()
latitude = serializers.FloatField(allow_null=True)
longitude = serializers.FloatField(allow_null=True)
coordinates = serializers.CharField()
# Ride info
ride = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_ride(self, obj) -> dict:
return {
"id": obj.ride.id,
"name": obj.ride.name,
"slug": obj.ride.slug,
}
class RideLocationCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating ride locations."""
ride_id = serializers.IntegerField()
latitude = serializers.FloatField(required=False, allow_null=True)
longitude = serializers.FloatField(required=False, allow_null=True)
class RideLocationUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating ride locations."""
latitude = serializers.FloatField(required=False, allow_null=True)
longitude = serializers.FloatField(required=False, allow_null=True)
# === RIDE REVIEW SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Ride Review Example",
summary="Example ride review response",
description="A user review of a ride",
value={
"id": 1,
"rating": 9,
"title": "Amazing coaster!",
"content": "This ride was incredible, the airtime was fantastic.",
"visit_date": "2024-08-15",
"ride": {"id": 1, "name": "Steel Vengeance", "slug": "steel-vengeance"},
"user": {"username": "coaster_fan", "display_name": "Coaster Fan"},
"created_at": "2024-08-16T10:30:00Z",
"is_published": True,
},
)
]
)
class RideReviewOutputSerializer(serializers.Serializer):
"""Output serializer for ride reviews."""
id = serializers.IntegerField()
rating = serializers.IntegerField()
title = serializers.CharField()
content = serializers.CharField()
visit_date = serializers.DateField()
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
is_published = serializers.BooleanField()
# Ride info
ride = serializers.SerializerMethodField()
# User info (limited for privacy)
user = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_ride(self, obj) -> dict:
return {
"id": obj.ride.id,
"name": obj.ride.name,
"slug": obj.ride.slug,
}
@extend_schema_field(serializers.DictField())
def get_user(self, obj) -> dict:
return {
"username": obj.user.username,
"display_name": obj.user.get_display_name(),
}
class RideReviewCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating ride reviews."""
ride_id = serializers.IntegerField()
rating = serializers.IntegerField(min_value=1, max_value=10)
title = serializers.CharField(max_length=200)
content = serializers.CharField()
visit_date = serializers.DateField()
def validate_visit_date(self, value):
"""Validate visit date is not in the future."""
from django.utils import timezone
if value > timezone.now().date():
raise serializers.ValidationError("Visit date cannot be in the future")
return value
class RideReviewUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating ride reviews."""
rating = serializers.IntegerField(min_value=1, max_value=10, required=False)
title = serializers.CharField(max_length=200, required=False)
content = serializers.CharField(required=False)
visit_date = serializers.DateField(required=False)
def validate_visit_date(self, value):
"""Validate visit date is not in the future."""
from django.utils import timezone
if value and value > timezone.now().date():
raise serializers.ValidationError("Visit date cannot be in the future")
return value

View File

@@ -0,0 +1,147 @@
"""
Ride media serializers for ThrillWiki API.
This module contains serializers for ride-specific media functionality.
"""
from rest_framework import serializers
from apps.rides.models import RidePhoto
class RidePhotoOutputSerializer(serializers.ModelSerializer):
"""Output serializer for ride photos."""
uploaded_by_username = serializers.CharField(
source='uploaded_by.username', read_only=True)
file_size = serializers.ReadOnlyField()
dimensions = serializers.ReadOnlyField()
ride_slug = serializers.CharField(source='ride.slug', read_only=True)
ride_name = serializers.CharField(source='ride.name', read_only=True)
park_slug = serializers.CharField(source='ride.park.slug', read_only=True)
park_name = serializers.CharField(source='ride.park.name', read_only=True)
class Meta:
model = RidePhoto
fields = [
'id',
'image',
'caption',
'alt_text',
'is_primary',
'is_approved',
'photo_type',
'created_at',
'updated_at',
'date_taken',
'uploaded_by_username',
'file_size',
'dimensions',
'ride_slug',
'ride_name',
'park_slug',
'park_name',
]
read_only_fields = [
'id',
'created_at',
'updated_at',
'uploaded_by_username',
'file_size',
'dimensions',
'ride_slug',
'ride_name',
'park_slug',
'park_name',
]
class RidePhotoCreateInputSerializer(serializers.ModelSerializer):
"""Input serializer for creating ride photos."""
class Meta:
model = RidePhoto
fields = [
'image',
'caption',
'alt_text',
'photo_type',
'is_primary',
]
class RidePhotoUpdateInputSerializer(serializers.ModelSerializer):
"""Input serializer for updating ride photos."""
class Meta:
model = RidePhoto
fields = [
'caption',
'alt_text',
'photo_type',
'is_primary',
]
class RidePhotoListOutputSerializer(serializers.ModelSerializer):
"""Simplified output serializer for ride photo lists."""
uploaded_by_username = serializers.CharField(
source='uploaded_by.username', read_only=True)
class Meta:
model = RidePhoto
fields = [
'id',
'image',
'caption',
'photo_type',
'is_primary',
'is_approved',
'created_at',
'uploaded_by_username',
]
read_only_fields = fields
class RidePhotoApprovalInputSerializer(serializers.Serializer):
"""Input serializer for photo approval operations."""
photo_ids = serializers.ListField(
child=serializers.IntegerField(),
help_text="List of photo IDs to approve"
)
approve = serializers.BooleanField(
default=True,
help_text="Whether to approve (True) or reject (False) the photos"
)
class RidePhotoStatsOutputSerializer(serializers.Serializer):
"""Output serializer for ride photo statistics."""
total_photos = serializers.IntegerField()
approved_photos = serializers.IntegerField()
pending_photos = serializers.IntegerField()
has_primary = serializers.BooleanField()
recent_uploads = serializers.IntegerField()
by_type = serializers.DictField(
child=serializers.IntegerField(),
help_text="Photo counts by type"
)
class RidePhotoTypeFilterSerializer(serializers.Serializer):
"""Serializer for filtering photos by type."""
photo_type = serializers.ChoiceField(
choices=[
('exterior', 'Exterior View'),
('queue', 'Queue Area'),
('station', 'Station'),
('onride', 'On-Ride'),
('construction', 'Construction'),
('other', 'Other'),
],
required=False,
help_text="Filter photos by type"
)

View File

@@ -0,0 +1,88 @@
"""
Search domain serializers for ThrillWiki API v1.
This module contains serializers for entity search, location search,
and other search functionality.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
)
# === CORE ENTITY SEARCH SERIALIZERS ===
class EntitySearchInputSerializer(serializers.Serializer):
"""Input serializer for entity search requests."""
query = serializers.CharField(max_length=255, help_text="Search query string")
entity_types = serializers.ListField(
child=serializers.ChoiceField(choices=["park", "ride", "company", "user"]),
required=False,
help_text="Types of entities to search for",
)
limit = serializers.IntegerField(
default=10,
min_value=1,
max_value=50,
help_text="Maximum number of results to return",
)
class EntitySearchResultSerializer(serializers.Serializer):
"""Serializer for individual entity search results."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
type = serializers.CharField()
description = serializers.CharField()
relevance_score = serializers.FloatField()
# Context-specific info
context = serializers.JSONField(help_text="Additional context based on entity type")
class EntitySearchOutputSerializer(serializers.Serializer):
"""Output serializer for entity search results."""
query = serializers.CharField()
total_results = serializers.IntegerField()
results = EntitySearchResultSerializer(many=True)
search_time_ms = serializers.FloatField()
# === LOCATION SEARCH SERIALIZERS ===
class LocationSearchResultSerializer(serializers.Serializer):
"""Serializer for location search results."""
display_name = serializers.CharField()
lat = serializers.FloatField()
lon = serializers.FloatField()
type = serializers.CharField()
importance = serializers.FloatField()
address = serializers.JSONField()
class LocationSearchOutputSerializer(serializers.Serializer):
"""Output serializer for location search."""
results = LocationSearchResultSerializer(many=True)
query = serializers.CharField()
count = serializers.IntegerField()
class ReverseGeocodeOutputSerializer(serializers.Serializer):
"""Output serializer for reverse geocoding."""
display_name = serializers.CharField()
lat = serializers.FloatField()
lon = serializers.FloatField()
address = serializers.JSONField()
type = serializers.CharField()

View File

@@ -0,0 +1,229 @@
"""
Services domain serializers for ThrillWiki API v1.
This module contains serializers for various services like email, maps,
history tracking, moderation, and roadtrip planning.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
)
# === EMAIL SERVICE SERIALIZERS ===
class EmailSendInputSerializer(serializers.Serializer):
"""Input serializer for sending emails."""
to = serializers.EmailField()
subject = serializers.CharField(max_length=255)
text = serializers.CharField()
html = serializers.CharField(required=False)
template = serializers.CharField(required=False)
context = serializers.JSONField(required=False)
class EmailTemplateOutputSerializer(serializers.Serializer):
"""Output serializer for email templates."""
id = serializers.CharField()
name = serializers.CharField()
subject = serializers.CharField()
text_template = serializers.CharField()
html_template = serializers.CharField(required=False)
# === MAP SERVICE SERIALIZERS ===
class MapDataOutputSerializer(serializers.Serializer):
"""Output serializer for map data."""
parks = serializers.ListField(child=serializers.DictField())
rides = serializers.ListField(child=serializers.DictField())
bounds = serializers.DictField()
zoom_level = serializers.IntegerField()
class CoordinateInputSerializer(serializers.Serializer):
"""Input serializer for coordinate-based requests."""
latitude = serializers.FloatField(min_value=-90, max_value=90)
longitude = serializers.FloatField(min_value=-180, max_value=180)
radius_km = serializers.FloatField(min_value=0, max_value=1000, default=10)
# === HISTORY SERIALIZERS ===
class HistoryEventSerializer(serializers.Serializer):
"""Base serializer for history events from pghistory."""
pgh_id = serializers.IntegerField(read_only=True)
pgh_created_at = serializers.DateTimeField(read_only=True)
pgh_label = serializers.CharField(read_only=True)
pgh_obj_id = serializers.IntegerField(read_only=True)
pgh_context = serializers.JSONField(read_only=True, allow_null=True)
pgh_diff = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_pgh_diff(self, obj) -> dict:
"""Get diff from previous version if available."""
if hasattr(obj, "diff_against_previous"):
return obj.diff_against_previous()
return {}
class HistoryEntryOutputSerializer(serializers.Serializer):
"""Output serializer for history entries."""
id = serializers.IntegerField()
model_type = serializers.CharField()
object_id = serializers.IntegerField()
object_name = serializers.CharField()
action = serializers.CharField()
changes = serializers.JSONField()
timestamp = serializers.DateTimeField()
user = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField(allow_null=True))
def get_user(self, obj) -> dict | None:
if hasattr(obj, "user") and obj.user:
return {
"id": obj.user.id,
"username": obj.user.username,
}
return None
class HistoryCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating history entries."""
action = serializers.CharField(max_length=50)
description = serializers.CharField(max_length=500)
metadata = serializers.JSONField(required=False)
# === MODERATION SERIALIZERS ===
class ModerationSubmissionSerializer(serializers.Serializer):
"""Serializer for moderation submissions."""
submission_type = serializers.ChoiceField(
choices=["EDIT", "PHOTO", "REVIEW"], help_text="Type of submission"
)
content_type = serializers.CharField(help_text="Content type being modified")
object_id = serializers.IntegerField(help_text="ID of object being modified")
changes = serializers.JSONField(help_text="Changes being submitted")
reason = serializers.CharField(
max_length=500,
required=False,
allow_blank=True,
help_text="Reason for the changes",
)
class ModerationSubmissionOutputSerializer(serializers.Serializer):
"""Output serializer for moderation submission responses."""
status = serializers.CharField()
message = serializers.CharField()
submission_id = serializers.IntegerField(required=False)
auto_approved = serializers.BooleanField(required=False)
# === ROADTRIP SERIALIZERS ===
class RoadtripParkSerializer(serializers.Serializer):
"""Serializer for parks in roadtrip planning."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
latitude = serializers.FloatField()
longitude = serializers.FloatField()
coaster_count = serializers.IntegerField()
status = serializers.CharField()
class RoadtripCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating roadtrips."""
name = serializers.CharField(max_length=255)
park_ids = serializers.ListField(
child=serializers.IntegerField(),
min_length=2,
max_length=10,
help_text="List of park IDs (2-10 parks)",
)
start_date = serializers.DateField(required=False)
end_date = serializers.DateField(required=False)
notes = serializers.CharField(max_length=1000, required=False, allow_blank=True)
def validate_park_ids(self, value):
"""Validate park IDs."""
if len(value) < 2:
raise serializers.ValidationError("At least 2 parks are required")
if len(value) > 10:
raise serializers.ValidationError("Maximum 10 parks allowed")
if len(set(value)) != len(value):
raise serializers.ValidationError("Duplicate park IDs not allowed")
return value
class RoadtripOutputSerializer(serializers.Serializer):
"""Output serializer for roadtrip responses."""
id = serializers.CharField()
name = serializers.CharField()
parks = RoadtripParkSerializer(many=True)
total_distance_miles = serializers.FloatField()
estimated_drive_time_hours = serializers.FloatField()
route_coordinates = serializers.ListField(
child=serializers.ListField(child=serializers.FloatField())
)
created_at = serializers.DateTimeField()
class GeocodeInputSerializer(serializers.Serializer):
"""Input serializer for geocoding requests."""
address = serializers.CharField(max_length=500, help_text="Address to geocode")
class GeocodeOutputSerializer(serializers.Serializer):
"""Output serializer for geocoding responses."""
status = serializers.CharField()
coordinates = serializers.JSONField(required=False)
formatted_address = serializers.CharField(required=False)
# === DISTANCE CALCULATION SERIALIZERS ===
class DistanceCalculationInputSerializer(serializers.Serializer):
"""Input serializer for distance calculation requests."""
park1_id = serializers.IntegerField(help_text="ID of first park")
park2_id = serializers.IntegerField(help_text="ID of second park")
def validate(self, data):
"""Validate that park IDs are different."""
if data["park1_id"] == data["park2_id"]:
raise serializers.ValidationError("Park IDs must be different")
return data
class DistanceCalculationOutputSerializer(serializers.Serializer):
"""Output serializer for distance calculation responses."""
status = serializers.CharField()
distance_miles = serializers.FloatField(required=False)
distance_km = serializers.FloatField(required=False)
drive_time_hours = serializers.FloatField(required=False)
message = serializers.CharField(required=False)

View File

@@ -0,0 +1,159 @@
"""
Shared serializers and utilities for ThrillWiki API v1.
This module contains common serializers and helper classes used across multiple domains
to avoid code duplication and maintain consistency.
"""
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from django.contrib.auth import get_user_model
# Import models inside class methods to avoid Django initialization issues
UserModel = get_user_model()
# Define constants to avoid import-time model loading
CATEGORY_CHOICES = [
("RC", "Roller Coaster"),
("FL", "Flat Ride"),
("DR", "Dark Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
]
# Placeholder for dynamic model choices - will be populated at runtime
class ModelChoices:
@staticmethod
def get_ride_status_choices():
try:
from apps.rides.models import Ride
return Ride.STATUS_CHOICES
except ImportError:
return [("OPERATING", "Operating"), ("CLOSED", "Closed")]
@staticmethod
def get_park_status_choices():
try:
from apps.parks.models import Park
return Park.STATUS_CHOICES
except ImportError:
return [("OPERATING", "Operating"), ("CLOSED", "Closed")]
@staticmethod
def get_company_role_choices():
try:
from apps.parks.models import Company
return Company.CompanyRole.choices
except ImportError:
return [("OPERATOR", "Operator"), ("MANUFACTURER", "Manufacturer")]
@staticmethod
def get_coaster_track_choices():
try:
from apps.rides.models import RollerCoasterStats
return RollerCoasterStats.TRACK_MATERIAL_CHOICES
except ImportError:
return [("STEEL", "Steel"), ("WOOD", "Wood")]
@staticmethod
def get_coaster_type_choices():
try:
from apps.rides.models import RollerCoasterStats
return RollerCoasterStats.COASTER_TYPE_CHOICES
except ImportError:
return [("SITDOWN", "Sit Down"), ("INVERTED", "Inverted")]
@staticmethod
def get_launch_choices():
try:
from apps.rides.models import RollerCoasterStats
return RollerCoasterStats.LAUNCH_CHOICES
except ImportError:
return [("CHAIN", "Chain Lift"), ("LAUNCH", "Launch")]
@staticmethod
def get_top_list_categories():
try:
from apps.accounts.models import TopList
return TopList.Categories.choices
except ImportError:
return [("RC", "Roller Coasters"), ("PARKS", "Parks")]
@staticmethod
def get_ride_post_closing_choices():
try:
from apps.rides.models import Ride
return Ride.POST_CLOSING_STATUS_CHOICES
except ImportError:
return [
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
("SBNO", "Standing But Not Operating"),
]
class LocationOutputSerializer(serializers.Serializer):
"""Shared serializer for location data."""
latitude = serializers.SerializerMethodField()
longitude = serializers.SerializerMethodField()
city = serializers.SerializerMethodField()
state = serializers.SerializerMethodField()
country = serializers.SerializerMethodField()
formatted_address = serializers.SerializerMethodField()
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_latitude(self, obj) -> float | None:
if hasattr(obj, "location") and obj.location:
return obj.location.latitude
return None
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_longitude(self, obj) -> float | None:
if hasattr(obj, "location") and obj.location:
return obj.location.longitude
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_city(self, obj) -> str | None:
if hasattr(obj, "location") and obj.location:
return obj.location.city
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_state(self, obj) -> str | None:
if hasattr(obj, "location") and obj.location:
return obj.location.state
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_country(self, obj) -> str | None:
if hasattr(obj, "location") and obj.location:
return obj.location.country
return None
@extend_schema_field(serializers.CharField())
def get_formatted_address(self, obj) -> str:
if hasattr(obj, "location") and obj.location:
return obj.location.formatted_address
return ""
class CompanyOutputSerializer(serializers.Serializer):
"""Shared serializer for company data."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
roles = serializers.ListField(child=serializers.CharField(), required=False)

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,7 @@ API serializers for the ride ranking system.
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_serializer, OpenApiExample
from apps.rides.models import RideRanking, RidePairComparison, RankingSnapshot
from django.utils.functional import cached_property
@extend_schema_serializer(
@@ -45,8 +44,19 @@ class RideRankingSerializer(serializers.ModelSerializer):
rank_change = serializers.SerializerMethodField()
previous_rank = serializers.SerializerMethodField()
@cached_property
def _model(self):
from apps.rides.models import RideRanking
return RideRanking
class Meta:
model = RideRanking
@property
def model(self):
from apps.rides.models import RideRanking
return RideRanking
fields = [
"id",
"rank",
@@ -79,6 +89,8 @@ class RideRankingSerializer(serializers.ModelSerializer):
def get_rank_change(self, obj):
"""Calculate rank change from previous snapshot."""
from apps.rides.models import RankingSnapshot
latest_snapshots = RankingSnapshot.objects.filter(ride=obj.ride).order_by(
"-snapshot_date"
)[:2]
@@ -89,6 +101,8 @@ class RideRankingSerializer(serializers.ModelSerializer):
def get_previous_rank(self, obj):
"""Get previous rank."""
from apps.rides.models import RankingSnapshot
latest_snapshots = RankingSnapshot.objects.filter(ride=obj.ride).order_by(
"-snapshot_date"
)[:2]
@@ -106,7 +120,7 @@ class RideRankingDetailSerializer(serializers.ModelSerializer):
ranking_history = serializers.SerializerMethodField()
class Meta:
model = RideRanking
model = "rides.RideRanking"
fields = [
"id",
"rank",
@@ -167,6 +181,7 @@ class RideRankingDetailSerializer(serializers.ModelSerializer):
def get_head_to_head_comparisons(self, obj):
"""Get top head-to-head comparisons."""
from django.db.models import Q
from apps.rides.models import RidePairComparison
comparisons = (
RidePairComparison.objects.filter(Q(ride_a=obj.ride) | Q(ride_b=obj.ride))
@@ -207,6 +222,8 @@ class RideRankingDetailSerializer(serializers.ModelSerializer):
def get_ranking_history(self, obj):
"""Get recent ranking history."""
from apps.rides.models import RankingSnapshot
history = RankingSnapshot.objects.filter(ride=obj.ride).order_by(
"-snapshot_date"
)[:30]
@@ -228,7 +245,7 @@ class RankingSnapshotSerializer(serializers.ModelSerializer):
park_name = serializers.CharField(source="ride.park.name", read_only=True)
class Meta:
model = RankingSnapshot
model = "rides.RankingSnapshot"
fields = [
"id",
"ride",

View File

@@ -5,19 +5,8 @@ This module provides unified API routing following RESTful conventions
and DRF Router patterns for automatic URL generation.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularSwaggerView,
SpectacularRedocView,
)
from .viewsets import (
ParkViewSet,
RideViewSet,
ParkReadOnlyViewSet,
RideReadOnlyViewSet,
from .viewsets_rankings import RideRankingViewSet, TriggerRankingCalculationView
from .views import (
LoginAPIView,
SignupAPIView,
LogoutAPIView,
@@ -29,62 +18,21 @@ from .viewsets import (
HealthCheckAPIView,
PerformanceMetricsAPIView,
SimpleHealthAPIView,
# History viewsets
ParkHistoryViewSet,
RideHistoryViewSet,
UnifiedHistoryViewSet,
# New comprehensive viewsets
ParkAreaViewSet,
ParkLocationViewSet,
CompanyViewSet,
RideModelViewSet,
RollerCoasterStatsViewSet,
RideLocationViewSet,
RideReviewViewSet,
UserProfileViewSet,
TopListViewSet,
TopListItemViewSet,
# Trending system views
TrendingAPIView,
NewContentAPIView,
)
# Import ranking viewsets
from .viewsets_rankings import RideRankingViewSet, TriggerRankingCalculationView
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularSwaggerView,
SpectacularRedocView,
)
# Create the main API router
router = DefaultRouter()
# Register ViewSets with descriptive prefixes
# Core models
router.register(r"parks", ParkViewSet, basename="park")
# Note: rides registered below with list-only actions to enforce nested-only detail access
# Park-related models
router.register(r"park-areas", ParkAreaViewSet, basename="park-area")
router.register(r"park-locations", ParkLocationViewSet, basename="park-location")
# Company models
router.register(r"companies", CompanyViewSet, basename="company")
# Ride-related models
router.register(r"ride-models", RideModelViewSet, basename="ride-model")
router.register(
r"roller-coaster-stats", RollerCoasterStatsViewSet, basename="roller-coaster-stats"
)
router.register(r"ride-locations", RideLocationViewSet, basename="ride-location")
router.register(r"ride-reviews", RideReviewViewSet, basename="ride-review")
# User-related models
router.register(r"user-profiles", UserProfileViewSet, basename="user-profile")
router.register(r"top-lists", TopListViewSet, basename="top-list")
router.register(r"top-list-items", TopListItemViewSet, basename="top-list-item")
# Register read-only endpoints for reference data
router.register(r"ref/parks", ParkReadOnlyViewSet, basename="park-ref")
router.register(r"ref/rides", RideReadOnlyViewSet, basename="ride-ref")
# Register ranking endpoints
router.register(r"rankings", RideRankingViewSet, basename="ranking")
@@ -120,50 +68,6 @@ urlpatterns = [
PerformanceMetricsAPIView.as_view(),
name="performance-metrics",
),
# History endpoints
path(
"history/timeline/",
UnifiedHistoryViewSet.as_view({"get": "list"}),
name="unified-history-timeline",
),
path(
"parks/<str:park_slug>/history/",
ParkHistoryViewSet.as_view({"get": "list"}),
name="park-history-list",
),
path(
"parks/<str:park_slug>/history/detail/",
ParkHistoryViewSet.as_view({"get": "retrieve"}),
name="park-history-detail",
),
path(
"parks/<str:park_slug>/rides/<str:ride_slug>/history/",
RideHistoryViewSet.as_view({"get": "list"}),
name="ride-history-list",
),
path(
"parks/<str:park_slug>/rides/<str:ride_slug>/history/detail/",
RideHistoryViewSet.as_view({"get": "retrieve"}),
name="ride-history-detail",
),
# Nested park-scoped ride endpoints
path(
"parks/<str:park_slug>/rides/",
RideViewSet.as_view({"get": "list", "post": "create"}),
name="park-rides-list",
),
path(
"parks/<str:park_slug>/rides/<str:ride_slug>/",
RideViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="park-rides-detail",
),
# Trending system endpoints
path("trending/content/", TrendingAPIView.as_view(), name="trending"),
path("trending/new/", NewContentAPIView.as_view(), name="new-content"),
@@ -173,12 +77,14 @@ urlpatterns = [
TriggerRankingCalculationView.as_view(),
name="trigger-ranking-calculation",
),
# Global rides list endpoint (detail access only via nested park routes)
path(
"rides/",
RideViewSet.as_view({"get": "list"}),
name="ride-list",
),
# Include all router-generated URLs
# Domain-specific API endpoints
path("parks/", include("apps.api.v1.parks.urls")),
path("rides/", include("apps.api.v1.rides.urls")),
path("accounts/", include("apps.api.v1.accounts.urls")),
path("history/", include("apps.api.v1.history.urls")),
path("email/", include("apps.api.v1.email.urls")),
path("core/", include("apps.api.v1.core.urls")),
path("maps/", include("apps.api.v1.maps.urls")),
# Include router URLs (for rankings and any other router-registered endpoints)
path("", include(router.urls)),
]

View File

@@ -0,0 +1,51 @@
"""
API v1 Views Package
This package contains all API view classes organized by functionality:
- auth.py: Authentication and user management views
- health.py: Health check and monitoring views
- trending.py: Trending and new content discovery views
"""
# Import all view classes for easy access
from .auth import (
LoginAPIView,
SignupAPIView,
LogoutAPIView,
CurrentUserAPIView,
PasswordResetAPIView,
PasswordChangeAPIView,
SocialProvidersAPIView,
AuthStatusAPIView,
)
from .health import (
HealthCheckAPIView,
PerformanceMetricsAPIView,
SimpleHealthAPIView,
)
from .trending import (
TrendingAPIView,
NewContentAPIView,
)
# Export all views for import convenience
__all__ = [
# Authentication views
"LoginAPIView",
"SignupAPIView",
"LogoutAPIView",
"CurrentUserAPIView",
"PasswordResetAPIView",
"PasswordChangeAPIView",
"SocialProvidersAPIView",
"AuthStatusAPIView",
# Health check views
"HealthCheckAPIView",
"PerformanceMetricsAPIView",
"SimpleHealthAPIView",
# Trending views
"TrendingAPIView",
"NewContentAPIView",
]

View File

@@ -0,0 +1,468 @@
"""
Authentication API views for ThrillWiki API v1.
This module contains all authentication-related API endpoints including
login, signup, logout, password management, and social authentication.
"""
import time
from django.contrib.auth import authenticate, login, logout, get_user_model
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.conf import settings
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated
from allauth.socialaccount import providers
from drf_spectacular.utils import extend_schema, extend_schema_view
# Import serializers inside methods to avoid Django initialization issues
# Placeholder classes for schema decorators
class LoginInputSerializer:
pass
class LoginOutputSerializer:
pass
class SignupInputSerializer:
pass
class SignupOutputSerializer:
pass
class LogoutOutputSerializer:
pass
class UserOutputSerializer:
pass
class PasswordResetInputSerializer:
pass
class PasswordResetOutputSerializer:
pass
class PasswordChangeInputSerializer:
pass
class PasswordChangeOutputSerializer:
pass
class SocialProviderOutputSerializer:
pass
class AuthStatusOutputSerializer:
pass
# Handle optional dependencies with fallback classes
class FallbackTurnstileMixin:
"""Fallback mixin if TurnstileMixin is not available."""
def validate_turnstile(self, request):
pass
# Try to import the real class, use fallback if not available
try:
from apps.accounts.mixins import TurnstileMixin
except ImportError:
TurnstileMixin = FallbackTurnstileMixin
UserModel = get_user_model()
@extend_schema_view(
post=extend_schema(
summary="User login",
description="Authenticate user with username/email and password.",
request=LoginInputSerializer,
responses={
200: LoginOutputSerializer,
400: "Bad Request",
},
tags=["Authentication"],
),
)
class LoginAPIView(TurnstileMixin, APIView):
"""API endpoint for user login."""
permission_classes = [AllowAny]
authentication_classes = []
serializer_class = LoginInputSerializer
def post(self, request: Request) -> Response:
from ..serializers import LoginInputSerializer, LoginOutputSerializer
try:
# Validate Turnstile if configured
self.validate_turnstile(request)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
serializer = LoginInputSerializer(data=request.data)
if serializer.is_valid():
# type: ignore[index]
email_or_username = serializer.validated_data["username"]
password = serializer.validated_data["password"] # type: ignore[index]
# Optimized user lookup: single query using Q objects
from django.db.models import Q
from django.contrib.auth import get_user_model
User = get_user_model()
user = None
# Single query to find user by email OR username
try:
if "@" in email_or_username:
# Email-like input: try email first, then username as fallback
user_obj = (
User.objects.select_related()
.filter(
Q(email=email_or_username) | Q(username=email_or_username)
)
.first()
)
else:
# Username-like input: try username first, then email as fallback
user_obj = (
User.objects.select_related()
.filter(
Q(username=email_or_username) | Q(email=email_or_username)
)
.first()
)
if user_obj:
user = authenticate(
# type: ignore[attr-defined]
request._request,
username=user_obj.username,
password=password,
)
except Exception:
# Fallback to original behavior
user = authenticate(
# type: ignore[attr-defined]
request._request,
username=email_or_username,
password=password,
)
if user:
if user.is_active:
login(request._request, user) # type: ignore[attr-defined]
# Optimized token creation - get_or_create is atomic
from rest_framework.authtoken.models import Token
token, created = Token.objects.get_or_create(user=user)
response_serializer = LoginOutputSerializer(
{
"token": token.key,
"user": user,
"message": "Login successful",
}
)
return Response(response_serializer.data)
else:
return Response(
{"error": "Account is disabled"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return Response(
{"error": "Invalid credentials"},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@extend_schema_view(
post=extend_schema(
summary="User registration",
description="Register a new user account.",
request=SignupInputSerializer,
responses={
201: SignupOutputSerializer,
400: "Bad Request",
},
tags=["Authentication"],
),
)
class SignupAPIView(TurnstileMixin, APIView):
"""API endpoint for user registration."""
permission_classes = [AllowAny]
authentication_classes = []
serializer_class = SignupInputSerializer
def post(self, request: Request) -> Response:
try:
# Validate Turnstile if configured
self.validate_turnstile(request)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
serializer = SignupInputSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
login(request._request, user) # type: ignore[attr-defined]
from rest_framework.authtoken.models import Token
token, created = Token.objects.get_or_create(user=user)
response_serializer = SignupOutputSerializer(
{
"token": token.key,
"user": user,
"message": "Registration successful",
}
)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@extend_schema_view(
post=extend_schema(
summary="User logout",
description="Logout the current user and invalidate their token.",
responses={
200: LogoutOutputSerializer,
401: "Unauthorized",
},
tags=["Authentication"],
),
)
class LogoutAPIView(APIView):
"""API endpoint for user logout."""
permission_classes = [IsAuthenticated]
serializer_class = LogoutOutputSerializer
def post(self, request: Request) -> Response:
try:
# Delete the token for token-based auth
if hasattr(request.user, "auth_token"):
request.user.auth_token.delete()
# Logout from session
logout(request._request) # type: ignore[attr-defined]
response_serializer = LogoutOutputSerializer(
{"message": "Logout successful"}
)
return Response(response_serializer.data)
except Exception as e:
return Response(
{"error": "Logout failed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@extend_schema_view(
get=extend_schema(
summary="Get current user",
description="Retrieve information about the currently authenticated user.",
responses={
200: UserOutputSerializer,
401: "Unauthorized",
},
tags=["Authentication"],
),
)
class CurrentUserAPIView(APIView):
"""API endpoint to get current user information."""
permission_classes = [IsAuthenticated]
serializer_class = UserOutputSerializer
def get(self, request: Request) -> Response:
serializer = UserOutputSerializer(request.user)
return Response(serializer.data)
@extend_schema_view(
post=extend_schema(
summary="Request password reset",
description="Send a password reset email to the user.",
request=PasswordResetInputSerializer,
responses={
200: PasswordResetOutputSerializer,
400: "Bad Request",
},
tags=["Authentication"],
),
)
class PasswordResetAPIView(APIView):
"""API endpoint to request password reset."""
permission_classes = [AllowAny]
serializer_class = PasswordResetInputSerializer
def post(self, request: Request) -> Response:
serializer = PasswordResetInputSerializer(
data=request.data, context={"request": request}
)
if serializer.is_valid():
serializer.save()
response_serializer = PasswordResetOutputSerializer(
{"detail": "Password reset email sent"}
)
return Response(response_serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@extend_schema_view(
post=extend_schema(
summary="Change password",
description="Change the current user's password.",
request=PasswordChangeInputSerializer,
responses={
200: PasswordChangeOutputSerializer,
400: "Bad Request",
401: "Unauthorized",
},
tags=["Authentication"],
),
)
class PasswordChangeAPIView(APIView):
"""API endpoint to change password."""
permission_classes = [IsAuthenticated]
serializer_class = PasswordChangeInputSerializer
def post(self, request: Request) -> Response:
serializer = PasswordChangeInputSerializer(
data=request.data, context={"request": request}
)
if serializer.is_valid():
serializer.save()
response_serializer = PasswordChangeOutputSerializer(
{"detail": "Password changed successfully"}
)
return Response(response_serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@extend_schema_view(
get=extend_schema(
summary="Get social providers",
description="Retrieve available social authentication providers.",
responses={200: "List of social providers"},
tags=["Authentication"],
),
)
class SocialProvidersAPIView(APIView):
"""API endpoint to get available social authentication providers."""
permission_classes = [AllowAny]
serializer_class = SocialProviderOutputSerializer
def get(self, request: Request) -> Response:
from django.core.cache import cache
from django.contrib.sites.shortcuts import get_current_site
site = get_current_site(request._request) # type: ignore[attr-defined]
# Cache key based on site and request host
cache_key = (
f"social_providers:{getattr(site, 'id', site.pk)}:{request.get_host()}"
)
# Try to get from cache first (cache for 15 minutes)
cached_providers = cache.get(cache_key)
if cached_providers is not None:
return Response(cached_providers)
providers_list = []
# Optimized query: filter by site and order by provider name
from allauth.socialaccount.models import SocialApp
social_apps = SocialApp.objects.filter(sites=site).order_by("provider")
for social_app in social_apps:
try:
# Simplified provider name resolution - avoid expensive provider class loading
provider_name = social_app.name or social_app.provider.title()
# Build auth URL efficiently
auth_url = request.build_absolute_uri(
f"/accounts/{social_app.provider}/login/"
)
providers_list.append(
{
"id": social_app.provider,
"name": provider_name,
"authUrl": auth_url,
}
)
except Exception:
# Skip if provider can't be loaded
continue
# Serialize and cache the result
serializer = SocialProviderOutputSerializer(providers_list, many=True)
response_data = serializer.data
# Cache for 15 minutes (900 seconds)
cache.set(cache_key, response_data, 900)
return Response(response_data)
@extend_schema_view(
post=extend_schema(
summary="Check authentication status",
description="Check if user is authenticated and return user data.",
responses={200: AuthStatusOutputSerializer},
tags=["Authentication"],
),
)
class AuthStatusAPIView(APIView):
"""API endpoint to check authentication status."""
permission_classes = [AllowAny]
serializer_class = AuthStatusOutputSerializer
def post(self, request: Request) -> Response:
if request.user.is_authenticated:
response_data = {
"authenticated": True,
"user": request.user,
}
else:
response_data = {
"authenticated": False,
"user": None,
}
serializer = AuthStatusOutputSerializer(response_data)
return Response(serializer.data)

View File

@@ -0,0 +1,351 @@
"""
Health check API views for ThrillWiki API v1.
This module contains health check and monitoring endpoints for system status,
performance metrics, and database analysis.
"""
import time
from django.utils import timezone
from django.conf import settings
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from health_check.views import MainView
from drf_spectacular.utils import extend_schema, extend_schema_view
# Import serializers
from ..serializers import (
HealthCheckOutputSerializer,
PerformanceMetricsOutputSerializer,
SimpleHealthOutputSerializer,
)
# Handle optional dependencies with fallback classes
class FallbackCacheMonitor:
"""Fallback class if CacheMonitor is not available."""
def get_cache_stats(self):
return {"error": "Cache monitoring not available"}
class FallbackIndexAnalyzer:
"""Fallback class if IndexAnalyzer is not available."""
@staticmethod
def analyze_slow_queries(threshold):
return {"error": "Query analysis not available"}
# Try to import the real classes, use fallbacks if not available
try:
from apps.core.services.enhanced_cache_service import CacheMonitor
except ImportError:
CacheMonitor = FallbackCacheMonitor
try:
from apps.core.utils.query_optimization import IndexAnalyzer
except ImportError:
IndexAnalyzer = FallbackIndexAnalyzer
@extend_schema_view(
get=extend_schema(
summary="Health check",
description="Get comprehensive health check information including system metrics.",
responses={
200: HealthCheckOutputSerializer,
503: HealthCheckOutputSerializer,
},
tags=["Health"],
),
)
class HealthCheckAPIView(APIView):
"""Enhanced API endpoint for health checks with detailed JSON response."""
permission_classes = [AllowAny]
serializer_class = HealthCheckOutputSerializer
def get(self, request: Request) -> Response:
"""Return comprehensive health check information."""
start_time = time.time()
# Get basic health check results
main_view = MainView()
main_view.request = request._request # type: ignore[attr-defined]
plugins = main_view.plugins
errors = main_view.errors
# Collect additional performance metrics
try:
cache_monitor = CacheMonitor()
cache_stats = cache_monitor.get_cache_stats()
except Exception:
cache_stats = {"error": "Cache monitoring unavailable"}
# Build comprehensive health data
health_data = {
"status": "healthy" if not errors else "unhealthy",
"timestamp": timezone.now(),
"version": getattr(settings, "VERSION", "1.0.0"),
"environment": getattr(settings, "ENVIRONMENT", "development"),
"response_time_ms": 0, # Will be calculated at the end
"checks": {},
"metrics": {
"cache": cache_stats,
"database": self._get_database_metrics(),
"system": self._get_system_metrics(),
},
}
# Process individual health checks
for plugin in plugins:
plugin_name = plugin.identifier()
plugin_errors = (
errors.get(plugin.__class__.__name__, [])
if isinstance(errors, dict)
else []
)
health_data["checks"][plugin_name] = {
"status": "healthy" if not plugin_errors else "unhealthy",
"critical": getattr(plugin, "critical_service", False),
"errors": [str(error) for error in plugin_errors],
"response_time_ms": getattr(plugin, "_response_time", None),
}
# Calculate total response time
health_data["response_time_ms"] = round((time.time() - start_time) * 1000, 2)
# Determine HTTP status code
status_code = 200
if errors:
# Check if any critical services are failing
critical_errors = any(
getattr(plugin, "critical_service", False)
for plugin in plugins
if isinstance(errors, dict) and errors.get(plugin.__class__.__name__)
)
status_code = 503 if critical_errors else 200
serializer = HealthCheckOutputSerializer(health_data)
return Response(serializer.data, status=status_code)
def _get_database_metrics(self):
"""Get database performance metrics."""
try:
from django.db import connection
# Get basic connection info
metrics = {
"vendor": connection.vendor,
"connection_status": "connected",
}
# Test query performance
start_time = time.time()
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
query_time = (time.time() - start_time) * 1000
metrics["test_query_time_ms"] = round(query_time, 2)
# PostgreSQL specific metrics
if connection.vendor == "postgresql":
try:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT
numbackends as active_connections,
xact_commit as transactions_committed,
xact_rollback as transactions_rolled_back,
blks_read as blocks_read,
blks_hit as blocks_hit
FROM pg_stat_database
WHERE datname = current_database()
"""
)
row = cursor.fetchone()
if row:
metrics.update(
{ # type: ignore[arg-type]
"active_connections": row[0],
"transactions_committed": row[1],
"transactions_rolled_back": row[2],
"cache_hit_ratio": (
round((row[4] / (row[3] + row[4])) * 100, 2)
if (row[3] + row[4]) > 0
else 0
),
}
)
except Exception:
pass # Skip advanced metrics if not available
return metrics
except Exception as e:
return {"connection_status": "error", "error": str(e)}
def _get_system_metrics(self):
"""Get system performance metrics."""
metrics = {
"debug_mode": settings.DEBUG,
"allowed_hosts": (settings.ALLOWED_HOSTS if settings.DEBUG else ["hidden"]),
}
try:
import psutil
# Memory metrics
memory = psutil.virtual_memory()
metrics["memory"] = {
"total_mb": round(memory.total / 1024 / 1024, 2),
"available_mb": round(memory.available / 1024 / 1024, 2),
"percent_used": memory.percent,
}
# CPU metrics
metrics["cpu"] = {
"percent_used": psutil.cpu_percent(interval=0.1),
"core_count": psutil.cpu_count(),
}
# Disk metrics
disk = psutil.disk_usage("/")
metrics["disk"] = {
"total_gb": round(disk.total / 1024 / 1024 / 1024, 2),
"free_gb": round(disk.free / 1024 / 1024 / 1024, 2),
"percent_used": round((disk.used / disk.total) * 100, 2),
}
except ImportError:
metrics["system_monitoring"] = "psutil not available"
except Exception as e:
metrics["system_error"] = str(e)
return metrics
@extend_schema_view(
get=extend_schema(
summary="Performance metrics",
description="Get performance metrics and database analysis (debug mode only).",
responses={
200: PerformanceMetricsOutputSerializer,
403: "Forbidden",
},
tags=["Health"],
),
)
class PerformanceMetricsAPIView(APIView):
"""API view for performance metrics and database analysis."""
permission_classes = [AllowAny] if settings.DEBUG else []
serializer_class = PerformanceMetricsOutputSerializer
def get(self, request: Request) -> Response:
"""Return performance metrics and analysis."""
if not settings.DEBUG:
return Response({"error": "Only available in debug mode"}, status=403)
metrics = {
"timestamp": timezone.now(),
"database_analysis": self._get_database_analysis(),
"cache_performance": self._get_cache_performance(),
"recent_slow_queries": self._get_slow_queries(),
}
serializer = PerformanceMetricsOutputSerializer(metrics)
return Response(serializer.data)
def _get_database_analysis(self):
"""Analyze database performance."""
try:
from django.db import connection
analysis = {
"total_queries": len(connection.queries),
"query_analysis": IndexAnalyzer.analyze_slow_queries(0.05),
}
if connection.queries:
query_times = [float(q.get("time", 0)) for q in connection.queries]
analysis.update(
{
"total_query_time": sum(query_times),
"average_query_time": sum(query_times) / len(query_times),
"slowest_query_time": max(query_times),
"fastest_query_time": min(query_times),
}
)
return analysis
except Exception as e:
return {"error": str(e)}
def _get_cache_performance(self):
"""Get cache performance metrics."""
try:
cache_monitor = CacheMonitor()
return cache_monitor.get_cache_stats()
except Exception as e:
return {"error": str(e)}
def _get_slow_queries(self):
"""Get recent slow queries."""
try:
return IndexAnalyzer.analyze_slow_queries(0.1) # 100ms threshold
except Exception as e:
return {"error": str(e)}
@extend_schema_view(
get=extend_schema(
summary="Simple health check",
description="Simple health check endpoint for load balancers.",
responses={
200: SimpleHealthOutputSerializer,
503: SimpleHealthOutputSerializer,
},
tags=["Health"],
),
)
class SimpleHealthAPIView(APIView):
"""Simple health check endpoint for load balancers."""
permission_classes = [AllowAny]
serializer_class = SimpleHealthOutputSerializer
def get(self, request: Request) -> Response:
"""Return simple OK status."""
try:
# Basic database connectivity test
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
response_data = {
"status": "ok",
"timestamp": timezone.now(),
}
serializer = SimpleHealthOutputSerializer(response_data)
return Response(serializer.data)
except Exception as e:
response_data = {
"status": "error",
"error": str(e),
"timestamp": timezone.now(),
}
serializer = SimpleHealthOutputSerializer(response_data)
return Response(serializer.data, status=503)

View File

@@ -0,0 +1,364 @@
"""
Trending content API views for ThrillWiki API v1.
This module contains endpoints for trending and new content discovery
including trending parks, rides, and recently added content.
"""
from datetime import datetime, date
from django.utils import timezone
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from drf_spectacular.utils import extend_schema, extend_schema_view
from drf_spectacular.types import OpenApiTypes
@extend_schema_view(
get=extend_schema(
summary="Get trending content",
description="Retrieve trending parks and rides based on view counts, ratings, and recency.",
parameters=[
{
"name": "limit",
"in": "query",
"description": "Number of trending items to return (default: 20, max: 100)",
"required": False,
"schema": {"type": "integer", "default": 20, "maximum": 100},
},
{
"name": "timeframe",
"in": "query",
"description": "Timeframe for trending calculation (day, week, month) - default: week",
"required": False,
"schema": {
"type": "string",
"enum": ["day", "week", "month"],
"default": "week",
},
},
],
responses={200: OpenApiTypes.OBJECT},
tags=["Trending"],
),
)
class TrendingAPIView(APIView):
"""API endpoint for trending content."""
permission_classes = [AllowAny]
def get(self, request: Request) -> Response:
"""Get trending parks and rides."""
try:
from apps.core.services.trending_service import TrendingService
except ImportError:
# Fallback if trending service is not available
return self._get_fallback_trending_content(request)
# Parse parameters
limit = min(int(request.query_params.get("limit", 20)), 100)
# Get trending content
trending_service = TrendingService()
all_trending = trending_service.get_trending_content(limit=limit * 2)
# Separate by content type
trending_rides = []
trending_parks = []
for item in all_trending:
if item.get("category") == "ride":
trending_rides.append(item)
elif item.get("category") == "park":
trending_parks.append(item)
# Limit each category
trending_rides = trending_rides[: limit // 3] if trending_rides else []
trending_parks = trending_parks[: limit // 3] if trending_parks else []
# Create mock latest reviews (since not implemented yet)
latest_reviews = [
{
"id": 1,
"name": "Steel Vengeance Review",
"location": "Cedar Point",
"category": "Roller Coaster",
"rating": 5.0,
"rank": 1,
"views": 1234,
"views_change": "+45%",
"slug": "steel-vengeance-review",
}
][: limit // 3]
# Return in expected frontend format
response_data = {
"trending_rides": trending_rides,
"trending_parks": trending_parks,
"latest_reviews": latest_reviews,
}
return Response(response_data)
def _get_fallback_trending_content(self, request: Request) -> Response:
"""Fallback method when trending service is not available."""
limit = min(int(request.query_params.get("limit", 20)), 100)
# Mock trending data
trending_rides = [
{
"id": 1,
"name": "Steel Vengeance",
"location": "Cedar Point",
"category": "Roller Coaster",
"rating": 4.8,
"rank": 1,
"views": 15234,
"views_change": "+25%",
"slug": "steel-vengeance",
},
{
"id": 2,
"name": "Lightning Rod",
"location": "Dollywood",
"category": "Roller Coaster",
"rating": 4.7,
"rank": 2,
"views": 12456,
"views_change": "+18%",
"slug": "lightning-rod",
},
][: limit // 3]
trending_parks = [
{
"id": 1,
"name": "Cedar Point",
"location": "Sandusky, OH",
"category": "Theme Park",
"rating": 4.6,
"rank": 1,
"views": 45678,
"views_change": "+12%",
"slug": "cedar-point",
},
{
"id": 2,
"name": "Magic Kingdom",
"location": "Orlando, FL",
"category": "Theme Park",
"rating": 4.5,
"rank": 2,
"views": 67890,
"views_change": "+8%",
"slug": "magic-kingdom",
},
][: limit // 3]
latest_reviews = [
{
"id": 1,
"name": "Steel Vengeance Review",
"location": "Cedar Point",
"category": "Roller Coaster",
"rating": 5.0,
"rank": 1,
"views": 1234,
"views_change": "+45%",
"slug": "steel-vengeance-review",
}
][: limit // 3]
response_data = {
"trending_rides": trending_rides,
"trending_parks": trending_parks,
"latest_reviews": latest_reviews,
}
return Response(response_data)
@extend_schema_view(
get=extend_schema(
summary="Get new content",
description="Retrieve recently added parks and rides.",
parameters=[
{
"name": "limit",
"in": "query",
"description": "Number of new items to return (default: 20, max: 100)",
"required": False,
"schema": {"type": "integer", "default": 20, "maximum": 100},
},
{
"name": "days",
"in": "query",
"description": "Number of days to look back for new content (default: 30, max: 365)",
"required": False,
"schema": {"type": "integer", "default": 30, "maximum": 365},
},
],
responses={200: OpenApiTypes.OBJECT},
tags=["Trending"],
),
)
class NewContentAPIView(APIView):
"""API endpoint for new content."""
permission_classes = [AllowAny]
def get(self, request: Request) -> Response:
"""Get new parks and rides."""
try:
from apps.core.services.trending_service import TrendingService
except ImportError:
# Fallback if trending service is not available
return self._get_fallback_new_content(request)
# Parse parameters
limit = min(int(request.query_params.get("limit", 20)), 100)
# Get new content with longer timeframe to get more data
trending_service = TrendingService()
all_new_content = trending_service.get_new_content(
limit=limit * 2, days_back=60
)
recently_added = []
newly_opened = []
upcoming = []
# Categorize items based on date
today = date.today()
for item in all_new_content:
date_added = item.get("date_added", "")
if date_added:
try:
# Parse the date string
if isinstance(date_added, str):
item_date = datetime.fromisoformat(date_added).date()
else:
item_date = date_added
# Calculate days difference
days_diff = (today - item_date).days
if days_diff <= 30: # Recently added (last 30 days)
recently_added.append(item)
elif days_diff <= 365: # Newly opened (last year)
newly_opened.append(item)
else: # Older items
newly_opened.append(item)
except (ValueError, TypeError):
# If date parsing fails, add to recently added
recently_added.append(item)
else:
recently_added.append(item)
# Create mock upcoming items
upcoming = [
{
"id": 1,
"name": "Epic Universe",
"location": "Universal Orlando",
"category": "Theme Park",
"date_added": "Opening 2025",
"slug": "epic-universe",
},
{
"id": 2,
"name": "New Fantasyland Expansion",
"location": "Magic Kingdom",
"category": "Land Expansion",
"date_added": "Opening 2026",
"slug": "fantasyland-expansion",
},
]
# Limit each category
recently_added = recently_added[: limit // 3] if recently_added else []
newly_opened = newly_opened[: limit // 3] if newly_opened else []
upcoming = upcoming[: limit // 3] if upcoming else []
# Return in expected frontend format
response_data = {
"recently_added": recently_added,
"newly_opened": newly_opened,
"upcoming": upcoming,
}
return Response(response_data)
def _get_fallback_new_content(self, request: Request) -> Response:
"""Fallback method when trending service is not available."""
limit = min(int(request.query_params.get("limit", 20)), 100)
# Mock new content data
recently_added = [
{
"id": 1,
"name": "Iron Gwazi",
"location": "Busch Gardens Tampa",
"category": "Roller Coaster",
"date_added": "2024-12-01",
"slug": "iron-gwazi",
},
{
"id": 2,
"name": "VelociCoaster",
"location": "Universal's Islands of Adventure",
"category": "Roller Coaster",
"date_added": "2024-11-15",
"slug": "velocicoaster",
},
][: limit // 3]
newly_opened = [
{
"id": 3,
"name": "Guardians of the Galaxy",
"location": "EPCOT",
"category": "Roller Coaster",
"date_added": "2024-10-01",
"slug": "guardians-galaxy",
},
{
"id": 4,
"name": "TRON Lightcycle Run",
"location": "Magic Kingdom",
"category": "Roller Coaster",
"date_added": "2024-09-15",
"slug": "tron-lightcycle",
},
][: limit // 3]
upcoming = [
{
"id": 5,
"name": "Epic Universe",
"location": "Universal Orlando",
"category": "Theme Park",
"date_added": "Opening 2025",
"slug": "epic-universe",
},
{
"id": 6,
"name": "New Fantasyland Expansion",
"location": "Magic Kingdom",
"category": "Land Expansion",
"date_added": "Opening 2026",
"slug": "fantasyland-expansion",
},
][: limit // 3]
response_data = {
"recently_added": recently_added,
"newly_opened": newly_opened,
"upcoming": upcoming,
}
return Response(response_data)

File diff suppressed because it is too large Load Diff

View File

@@ -15,8 +15,7 @@ from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet
from rest_framework.views import APIView
from apps.rides.models import RideRanking, RidePairComparison, RankingSnapshot
from apps.rides.services import RideRankingService
# Import models inside methods to avoid Django initialization issues
from .serializers_rankings import (
RideRankingSerializer,
RideRankingDetailSerializer,
@@ -104,6 +103,8 @@ class RideRankingViewSet(ReadOnlyModelViewSet):
def get_queryset(self):
"""Get rankings with optimized queries."""
from apps.rides.models import RideRanking
queryset = RideRanking.objects.select_related(
"ride", "ride__park", "ride__park__location", "ride__manufacturer"
)
@@ -141,6 +142,8 @@ class RideRankingViewSet(ReadOnlyModelViewSet):
@action(detail=True, methods=["get"])
def history(self, request, ride_slug=None):
"""Get ranking history for a specific ride."""
from apps.rides.models import RankingSnapshot
ranking = self.get_object()
history = RankingSnapshot.objects.filter(ride=ranking.ride).order_by(
"-snapshot_date"
@@ -154,6 +157,8 @@ class RideRankingViewSet(ReadOnlyModelViewSet):
@action(detail=False, methods=["get"])
def statistics(self, request):
"""Get overall ranking system statistics."""
from apps.rides.models import RideRanking, RidePairComparison, RankingSnapshot
total_rankings = RideRanking.objects.count()
total_comparisons = RidePairComparison.objects.count()
@@ -246,6 +251,8 @@ class RideRankingViewSet(ReadOnlyModelViewSet):
@action(detail=True, methods=["get"])
def comparisons(self, request, ride_slug=None):
"""Get head-to-head comparisons for a specific ride."""
from apps.rides.models import RidePairComparison
ranking = self.get_object()
comparisons = (
@@ -326,6 +333,8 @@ class TriggerRankingCalculationView(APIView):
{"error": "Admin access required"}, status=status.HTTP_403_FORBIDDEN
)
from apps.rides.services import RideRankingService
category = request.data.get("category")
service = RideRankingService()

View File

@@ -15,7 +15,6 @@ from .data_structures import (
)
from apps.parks.models import ParkLocation, CompanyHeadquarters
from apps.rides.models import RideLocation
from apps.location.models import Location
class BaseLocationAdapter:
@@ -320,81 +319,8 @@ class CompanyLocationAdapter(BaseLocationAdapter):
return queryset.order_by("company__name")
class GenericLocationAdapter(BaseLocationAdapter):
"""Converts generic Location model to UnifiedLocation."""
def to_unified_location(self, location: Location) -> Optional[UnifiedLocation]:
"""Convert generic Location to UnifiedLocation."""
if not location.point and not (location.latitude and location.longitude):
return None
# Use point coordinates if available, fall back to lat/lng fields
if location.point:
coordinates = (location.point.y, location.point.x)
else:
coordinates = (float(location.latitude), float(location.longitude))
return UnifiedLocation(
id=f"generic_{location.id}",
type=LocationType.GENERIC,
name=location.name,
coordinates=coordinates,
address=location.get_formatted_address(),
metadata={
"location_type": location.location_type,
"content_type": (
location.content_type.model if location.content_type else None
),
"object_id": location.object_id,
"city": location.city,
"state": location.state,
"country": location.country,
},
type_data={
"created_at": (
location.created_at.isoformat() if location.created_at else None
),
"updated_at": (
location.updated_at.isoformat() if location.updated_at else None
),
},
cluster_weight=1,
cluster_category="generic",
)
def get_queryset(
self,
bounds: Optional[GeoBounds] = None,
filters: Optional[MapFilters] = None,
) -> QuerySet:
"""Get optimized queryset for generic locations."""
queryset = Location.objects.select_related("content_type").filter(
models.Q(point__isnull=False)
| models.Q(latitude__isnull=False, longitude__isnull=False)
)
# Spatial filtering
if bounds:
queryset = queryset.filter(
models.Q(point__within=bounds.to_polygon())
| models.Q(
latitude__gte=bounds.south,
latitude__lte=bounds.north,
longitude__gte=bounds.west,
longitude__lte=bounds.east,
)
)
# Generic filters
if filters:
if filters.search_query:
queryset = queryset.filter(name__icontains=filters.search_query)
if filters.country:
queryset = queryset.filter(country=filters.country)
if filters.city:
queryset = queryset.filter(city=filters.city)
return queryset.order_by("name")
# GenericLocationAdapter removed - generic location app is being deprecated
# All location functionality moved to domain-specific models (ParkLocation, RideLocation, etc.)
class LocationAbstractionLayer:
@@ -408,7 +334,7 @@ class LocationAbstractionLayer:
LocationType.PARK: ParkLocationAdapter(),
LocationType.RIDE: RideLocationAdapter(),
LocationType.COMPANY: CompanyLocationAdapter(),
LocationType.GENERIC: GenericLocationAdapter(),
# LocationType.GENERIC: Removed - generic location app deprecated
}
def get_all_locations(
@@ -464,10 +390,7 @@ class LocationAbstractionLayer:
obj = CompanyHeadquarters.objects.select_related("company").get(
company_id=location_id
)
elif location_type == LocationType.GENERIC:
obj = Location.objects.select_related("content_type").get(
id=location_id
)
# LocationType.GENERIC removed - generic location app deprecated
else:
return None

View File

@@ -0,0 +1,192 @@
"""
Shared media service for ThrillWiki.
This module provides shared functionality for media upload, storage, and processing
that can be used across all domain-specific media implementations.
"""
import logging
from typing import Any, Optional, Dict, Tuple
from datetime import datetime
from django.core.files.uploadedfile import UploadedFile
from django.conf import settings
from PIL import Image, ExifTags
import os
logger = logging.getLogger(__name__)
class MediaService:
"""Shared service for media upload and processing operations."""
@staticmethod
def generate_upload_path(
domain: str,
identifier: str,
filename: str,
subdirectory: Optional[str] = None
) -> str:
"""
Generate standardized upload path for media files.
Args:
domain: Domain type (e.g., 'park', 'ride')
identifier: Object identifier (slug or id)
filename: Original filename
subdirectory: Optional subdirectory for organization
Returns:
Standardized upload path
"""
# Always use .jpg extension for consistency
base_filename = f"{identifier}.jpg"
if subdirectory:
return f"{domain}/{subdirectory}/{identifier}/{base_filename}"
else:
return f"{domain}/{identifier}/{base_filename}"
@staticmethod
def extract_exif_date(image_file: UploadedFile) -> Optional[datetime]:
"""
Extract the date taken from image EXIF data.
Args:
image_file: Uploaded image file
Returns:
DateTime when photo was taken, or None if not available
"""
try:
with Image.open(image_file) as img:
exif = img.getexif()
if exif:
# Find the DateTime tag ID
for tag_id in ExifTags.TAGS:
if ExifTags.TAGS[tag_id] == "DateTimeOriginal":
if tag_id in exif:
# EXIF dates are typically in format: '2024:02:15 14:30:00'
date_str = exif[tag_id]
return datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
return None
except Exception as e:
logger.warning(f"Failed to extract EXIF date: {str(e)}")
return None
@staticmethod
def validate_image_file(image_file: UploadedFile) -> Tuple[bool, Optional[str]]:
"""
Validate uploaded image file.
Args:
image_file: Uploaded image file
Returns:
Tuple of (is_valid, error_message)
"""
try:
# Check file size
max_size = getattr(settings, 'MAX_PHOTO_SIZE',
10 * 1024 * 1024) # 10MB default
if image_file.size > max_size:
return False, f"File size too large. Maximum size is {max_size // (1024 * 1024)}MB"
# Check file type
allowed_types = getattr(settings, 'ALLOWED_PHOTO_TYPES', [
'image/jpeg', 'image/png', 'image/webp'])
if image_file.content_type not in allowed_types:
return False, f"File type not allowed. Allowed types: {', '.join(allowed_types)}"
# Try to open with PIL to validate it's a real image
with Image.open(image_file) as img:
img.verify()
return True, None
except Exception as e:
return False, f"Invalid image file: {str(e)}"
@staticmethod
def process_image(
image_file: UploadedFile,
max_width: int = 1920,
max_height: int = 1080,
quality: int = 85
) -> UploadedFile:
"""
Process and optimize image file.
Args:
image_file: Original uploaded file
max_width: Maximum width for resizing
max_height: Maximum height for resizing
quality: JPEG quality (1-100)
Returns:
Processed image file
"""
try:
with Image.open(image_file) as img:
# Convert to RGB if necessary
if img.mode in ('RGBA', 'LA', 'P'):
img = img.convert('RGB')
# Resize if necessary
if img.width > max_width or img.height > max_height:
img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
# Save processed image
from io import BytesIO
from django.core.files.uploadedfile import InMemoryUploadedFile
output = BytesIO()
img.save(output, format='JPEG', quality=quality, optimize=True)
output.seek(0)
return InMemoryUploadedFile(
output,
'ImageField',
f"{os.path.splitext(image_file.name)[0]}.jpg",
'image/jpeg',
output.getbuffer().nbytes,
None
)
except Exception as e:
logger.warning(f"Failed to process image, using original: {str(e)}")
return image_file
@staticmethod
def generate_default_caption(username: str) -> str:
"""
Generate default caption for uploaded photos.
Args:
username: Username of uploader
Returns:
Default caption string
"""
from django.utils import timezone
current_time = timezone.now()
return f"Uploaded by {username} on {current_time.strftime('%B %d, %Y at %I:%M %p')}"
@staticmethod
def get_storage_stats() -> Dict[str, Any]:
"""
Get media storage statistics.
Returns:
Dictionary with storage statistics
"""
try:
# This would need to be implemented based on your storage backend
return {
"total_files": 0,
"total_size_bytes": 0,
"storage_backend": "default",
"available_space": "unknown"
}
except Exception as e:
logger.error(f"Failed to get storage stats: {str(e)}")
return {"error": str(e)}

View File

@@ -1,67 +0,0 @@
from django.contrib import admin
from .models import Location
# DEPRECATED: This admin interface is deprecated.
# Location data has been migrated to domain-specific models:
# - ParkLocation in parks.models.location
# - RideLocation in rides.models.location
# - CompanyHeadquarters in parks.models.companies
#
# This admin interface is kept for data migration and cleanup purposes only.
@admin.register(Location)
class LocationAdmin(admin.ModelAdmin):
list_display = (
"name",
"location_type",
"city",
"state",
"country",
"created_at",
)
list_filter = ("location_type", "country", "state", "city")
search_fields = ("name", "street_address", "city", "state", "country")
readonly_fields = ("created_at", "updated_at", "content_type", "object_id")
fieldsets = (
(
"⚠️ DEPRECATED MODEL",
{
"description": "This model is deprecated. Use domain-specific location models instead.",
"fields": (),
},
),
("Basic Information", {"fields": ("name", "location_type")}),
("Geographic Coordinates", {"fields": ("latitude", "longitude")}),
(
"Address",
{
"fields": (
"street_address",
"city",
"state",
"country",
"postal_code",
)
},
),
(
"Content Type (Read Only)",
{
"fields": ("content_type", "object_id"),
"classes": ("collapse",),
},
),
(
"Metadata",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
def get_queryset(self, request):
return super().get_queryset(request).select_related("content_type")
def has_add_permission(self, request):
# Prevent creating new generic Location objects
return False

View File

@@ -1,8 +0,0 @@
from django.apps import AppConfig
import os
class LocationConfig(AppConfig):
path = os.path.dirname(os.path.abspath(__file__))
default_auto_field = "django.db.models.BigAutoField"
name = "apps.location"

View File

@@ -1,42 +0,0 @@
# DEPRECATED: These forms are deprecated and no longer used.
#
# Domain-specific location models now have their own forms:
# - ParkLocationForm in parks.forms (for ParkLocation)
# - RideLocationForm in rides.forms (for RideLocation)
# - CompanyHeadquartersForm in parks.forms (for CompanyHeadquarters)
#
# This file is kept for reference during migration cleanup only.
from django import forms
from .models import Location
# NOTE: All classes below are DEPRECATED
# Use domain-specific location forms instead
class LocationForm(forms.ModelForm):
"""DEPRECATED: Use domain-specific location forms instead"""
class Meta:
model = Location
fields = [
"name",
"location_type",
"latitude",
"longitude",
"street_address",
"city",
"state",
"country",
"postal_code",
]
class LocationSearchForm(forms.Form):
"""DEPRECATED: Location search functionality has been moved to parks app"""
query = forms.CharField(
max_length=255,
required=True,
help_text="This form is deprecated. Use location search in the parks app.",
)

View File

@@ -1,293 +0,0 @@
# Generated by Django 5.1.4 on 2025-08-13 21:35
import django.contrib.gis.db.models.fields
import django.core.validators
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("pghistory", "0006_delete_aggregateevent"),
]
operations = [
migrations.CreateModel(
name="Location",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("object_id", models.PositiveIntegerField()),
(
"name",
models.CharField(
help_text="Name of the location (e.g. business name, landmark)",
max_length=255,
),
),
(
"location_type",
models.CharField(
help_text="Type of location (e.g. business, landmark, address)",
max_length=50,
),
),
(
"latitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Latitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-90),
django.core.validators.MaxValueValidator(90),
],
),
),
(
"longitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Longitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-180),
django.core.validators.MaxValueValidator(180),
],
),
),
(
"point",
django.contrib.gis.db.models.fields.PointField(
blank=True,
help_text="Geographic coordinates as a Point",
null=True,
srid=4326,
),
),
(
"street_address",
models.CharField(blank=True, max_length=255, null=True),
),
(
"city",
models.CharField(blank=True, max_length=100, null=True),
),
(
"state",
models.CharField(
blank=True,
help_text="State/Region/Province",
max_length=100,
null=True,
),
),
(
"country",
models.CharField(blank=True, max_length=100, null=True),
),
(
"postal_code",
models.CharField(blank=True, max_length=20, null=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
],
options={
"ordering": ["name"],
},
),
migrations.CreateModel(
name="LocationEvent",
fields=[
(
"pgh_id",
models.AutoField(primary_key=True, serialize=False),
),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("object_id", models.PositiveIntegerField()),
(
"name",
models.CharField(
help_text="Name of the location (e.g. business name, landmark)",
max_length=255,
),
),
(
"location_type",
models.CharField(
help_text="Type of location (e.g. business, landmark, address)",
max_length=50,
),
),
(
"latitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Latitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-90),
django.core.validators.MaxValueValidator(90),
],
),
),
(
"longitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Longitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-180),
django.core.validators.MaxValueValidator(180),
],
),
),
(
"point",
django.contrib.gis.db.models.fields.PointField(
blank=True,
help_text="Geographic coordinates as a Point",
null=True,
srid=4326,
),
),
(
"street_address",
models.CharField(blank=True, max_length=255, null=True),
),
(
"city",
models.CharField(blank=True, max_length=100, null=True),
),
(
"state",
models.CharField(
blank=True,
help_text="State/Region/Province",
max_length=100,
null=True,
),
),
(
"country",
models.CharField(blank=True, max_length=100, null=True),
),
(
"postal_code",
models.CharField(blank=True, max_length=20, null=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"content_type",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="location.location",
),
),
],
options={
"abstract": False,
},
),
migrations.AddIndex(
model_name="location",
index=models.Index(
fields=["content_type", "object_id"],
name="location_lo_content_9ee1bd_idx",
),
),
migrations.AddIndex(
model_name="location",
index=models.Index(fields=["city"], name="location_lo_city_99f908_idx"),
),
migrations.AddIndex(
model_name="location",
index=models.Index(
fields=["country"], name="location_lo_country_b75eba_idx"
),
),
pgtrigger.migrations.AddTrigger(
model_name="location",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_98cd4",
table="location_location",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="location",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_471d2",
table="location_location",
when="AFTER",
),
),
),
]

View File

@@ -1,53 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-16 17:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("location", "0001_initial"),
]
operations = [
migrations.AddConstraint(
model_name="location",
constraint=models.CheckConstraint(
condition=models.Q(
("latitude__isnull", True),
models.Q(("latitude__gte", -90), ("latitude__lte", 90)),
_connector="OR",
),
name="location_latitude_range",
violation_error_message="Latitude must be between -90 and 90 degrees",
),
),
migrations.AddConstraint(
model_name="location",
constraint=models.CheckConstraint(
condition=models.Q(
("longitude__isnull", True),
models.Q(("longitude__gte", -180), ("longitude__lte", 180)),
_connector="OR",
),
name="location_longitude_range",
violation_error_message="Longitude must be between -180 and 180 degrees",
),
),
migrations.AddConstraint(
model_name="location",
constraint=models.CheckConstraint(
condition=models.Q(
models.Q(("latitude__isnull", True), ("longitude__isnull", True)),
models.Q(
("latitude__isnull", False),
("longitude__isnull", False),
),
_connector="OR",
),
name="location_coordinates_complete",
violation_error_message="Both latitude and longitude must be provided together",
),
),
]

View File

@@ -1,52 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-24 18:23
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("location", "0002_add_business_constraints"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="location",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="location",
name="update_update",
),
pgtrigger.migrations.AddTrigger(
model_name="location",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
hash="8a8f00869cfcaa1a23ab29b3d855e83602172c67",
operation="INSERT",
pgid="pgtrigger_insert_insert_98cd4",
table="location_location",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="location",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
hash="f3378cb26a5d88aa82c8fae016d46037b530de90",
operation="UPDATE",
pgid="pgtrigger_update_update_471d2",
table="location_location",
when="AFTER",
),
),
),
]

View File

@@ -1,175 +0,0 @@
from django.contrib.gis.db import models as gis_models
from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MinValueValidator, MaxValueValidator
from django.contrib.gis.geos import Point
import pghistory
from apps.core.history import TrackedModel
@pghistory.track()
class Location(TrackedModel):
"""
A generic location model that can be associated with any model
using GenericForeignKey. Stores detailed location information
including coordinates and address components.
"""
# Generic relation fields
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey("content_type", "object_id")
# Location name and type
name = models.CharField(
max_length=255,
help_text="Name of the location (e.g. business name, landmark)",
)
location_type = models.CharField(
max_length=50,
help_text="Type of location (e.g. business, landmark, address)",
)
# Geographic coordinates
latitude = models.DecimalField(
max_digits=9,
decimal_places=6,
validators=[MinValueValidator(-90), MaxValueValidator(90)],
help_text="Latitude coordinate (legacy field)",
null=True,
blank=True,
)
longitude = models.DecimalField(
max_digits=9,
decimal_places=6,
validators=[MinValueValidator(-180), MaxValueValidator(180)],
help_text="Longitude coordinate (legacy field)",
null=True,
blank=True,
)
# GeoDjango point field
point = gis_models.PointField(
srid=4326, # WGS84 coordinate system
null=True,
blank=True,
help_text="Geographic coordinates as a Point",
)
# Address components
street_address = models.CharField(max_length=255, blank=True, null=True)
city = models.CharField(max_length=100, blank=True, null=True)
state = models.CharField(
max_length=100,
blank=True,
null=True,
help_text="State/Region/Province",
)
country = models.CharField(max_length=100, blank=True, null=True)
postal_code = models.CharField(max_length=20, blank=True, null=True)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
indexes = [
models.Index(fields=["content_type", "object_id"]),
models.Index(fields=["city"]),
models.Index(fields=["country"]),
]
ordering = ["name"]
constraints = [
# Business rule: Latitude must be within valid range (-90 to 90)
models.CheckConstraint(
name="location_latitude_range",
check=models.Q(latitude__isnull=True)
| (models.Q(latitude__gte=-90) & models.Q(latitude__lte=90)),
violation_error_message="Latitude must be between -90 and 90 degrees",
),
# Business rule: Longitude must be within valid range (-180 to 180)
models.CheckConstraint(
name="location_longitude_range",
check=models.Q(longitude__isnull=True)
| (models.Q(longitude__gte=-180) & models.Q(longitude__lte=180)),
violation_error_message="Longitude must be between -180 and 180 degrees",
),
# Business rule: If coordinates are provided, both lat and lng must
# be present
models.CheckConstraint(
name="location_coordinates_complete",
check=models.Q(latitude__isnull=True, longitude__isnull=True)
| models.Q(latitude__isnull=False, longitude__isnull=False),
violation_error_message="Both latitude and longitude must be provided together",
),
]
def __str__(self):
location_parts = []
if self.city:
location_parts.append(self.city)
if self.country:
location_parts.append(self.country)
location_str = (
", ".join(location_parts) if location_parts else "Unknown location"
)
return f"{self.name} ({location_str})"
def save(self, *args, **kwargs):
# Sync point field with lat/lon fields for backward compatibility
if self.latitude is not None and self.longitude is not None and not self.point:
self.point = Point(float(self.longitude), float(self.latitude))
elif self.point and (self.latitude is None or self.longitude is None):
self.longitude = self.point.x
self.latitude = self.point.y
super().save(*args, **kwargs)
def get_formatted_address(self):
"""Returns a formatted address string"""
components = []
if self.street_address:
components.append(self.street_address)
if self.city:
components.append(self.city)
if self.state:
components.append(self.state)
if self.postal_code:
components.append(self.postal_code)
if self.country:
components.append(self.country)
return ", ".join(components) if components else ""
@property
def coordinates(self):
"""Returns coordinates as a tuple"""
if self.point:
# Returns (latitude, longitude)
return (self.point.y, self.point.x)
elif self.latitude is not None and self.longitude is not None:
return (float(self.latitude), float(self.longitude))
return None
def distance_to(self, other_location):
"""
Calculate the distance to another location in meters.
Returns None if either location is missing coordinates.
"""
if not self.point or not other_location.point:
return None
return self.point.distance(other_location.point) * 100000 # Convert to meters
def nearby_locations(self, distance_km=10):
"""
Find locations within specified distance in kilometers.
Returns a queryset of nearby Location objects.
"""
if not self.point:
return Location.objects.none()
return Location.objects.filter(
point__distance_lte=(
self.point,
distance_km * 1000,
) # Convert km to meters
).exclude(pk=self.pk)

View File

@@ -1,181 +0,0 @@
from django.test import TestCase
from django.contrib.contenttypes.models import ContentType
from django.contrib.gis.geos import Point
from .models import Location
from apps.parks.models import Park, Company as Operator
class LocationModelTests(TestCase):
def setUp(self):
# Create test company
self.operator = Operator.objects.create(
name="Test Operator", website="http://example.com"
)
# Create test park
self.park = Park.objects.create(
name="Test Park", owner=self.operator, status="OPERATING"
)
# Create test location for company
self.operator_location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Operator),
object_id=self.operator.pk,
name="Test Operator HQ",
location_type="business",
street_address="123 Operator St",
city="Operator City",
state="CS",
country="Test Country",
postal_code="12345",
point=Point(-118.2437, 34.0522), # Los Angeles coordinates
)
# Create test location for park
self.park_location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Park),
object_id=self.park.pk,
name="Test Park Location",
location_type="park",
street_address="456 Park Ave",
city="Park City",
state="PC",
country="Test Country",
postal_code="67890",
point=Point(-111.8910, 40.7608), # Park City coordinates
)
def test_location_creation(self):
"""Test location instance creation and field values"""
# Test company location
self.assertEqual(self.operator_location.name, "Test Operator HQ")
self.assertEqual(self.operator_location.location_type, "business")
self.assertEqual(self.operator_location.street_address, "123 Operator St")
self.assertEqual(self.operator_location.city, "Operator City")
self.assertEqual(self.operator_location.state, "CS")
self.assertEqual(self.operator_location.country, "Test Country")
self.assertEqual(self.operator_location.postal_code, "12345")
self.assertIsNotNone(self.operator_location.point)
# Test park location
self.assertEqual(self.park_location.name, "Test Park Location")
self.assertEqual(self.park_location.location_type, "park")
self.assertEqual(self.park_location.street_address, "456 Park Ave")
self.assertEqual(self.park_location.city, "Park City")
self.assertEqual(self.park_location.state, "PC")
self.assertEqual(self.park_location.country, "Test Country")
self.assertEqual(self.park_location.postal_code, "67890")
self.assertIsNotNone(self.park_location.point)
def test_location_str_representation(self):
"""Test string representation of location"""
expected_company_str = "Test Operator HQ (Operator City, Test Country)"
self.assertEqual(str(self.operator_location), expected_company_str)
expected_park_str = "Test Park Location (Park City, Test Country)"
self.assertEqual(str(self.park_location), expected_park_str)
def test_get_formatted_address(self):
"""Test get_formatted_address method"""
expected_address = "123 Operator St, Operator City, CS, 12345, Test Country"
self.assertEqual(
self.operator_location.get_formatted_address(), expected_address
)
def test_point_coordinates(self):
"""Test point coordinates"""
# Test company location point
self.assertIsNotNone(self.operator_location.point)
self.assertAlmostEqual(
self.operator_location.point.y, 34.0522, places=4
) # latitude
self.assertAlmostEqual(
self.operator_location.point.x, -118.2437, places=4
) # longitude
# Test park location point
self.assertIsNotNone(self.park_location.point)
self.assertAlmostEqual(
self.park_location.point.y, 40.7608, places=4
) # latitude
self.assertAlmostEqual(
self.park_location.point.x, -111.8910, places=4
) # longitude
def test_coordinates_property(self):
"""Test coordinates property"""
company_coords = self.operator_location.coordinates
self.assertIsNotNone(company_coords)
self.assertAlmostEqual(company_coords[0], 34.0522, places=4) # latitude
self.assertAlmostEqual(company_coords[1], -118.2437, places=4) # longitude
park_coords = self.park_location.coordinates
self.assertIsNotNone(park_coords)
self.assertAlmostEqual(park_coords[0], 40.7608, places=4) # latitude
self.assertAlmostEqual(park_coords[1], -111.8910, places=4) # longitude
def test_distance_calculation(self):
"""Test distance_to method"""
distance = self.operator_location.distance_to(self.park_location)
self.assertIsNotNone(distance)
self.assertGreater(distance, 0)
def test_nearby_locations(self):
"""Test nearby_locations method"""
# Create another location near the company location
nearby_location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Operator),
object_id=self.operator.pk,
name="Nearby Location",
location_type="business",
street_address="789 Nearby St",
city="Operator City",
country="Test Country",
point=Point(-118.2438, 34.0523), # Very close to company location
)
nearby = self.operator_location.nearby_locations(distance_km=1)
self.assertEqual(nearby.count(), 1)
self.assertEqual(nearby.first(), nearby_location)
def test_content_type_relations(self):
"""Test generic relations work correctly"""
# Test company location relation
company_location = Location.objects.get(
content_type=ContentType.objects.get_for_model(Operator),
object_id=self.operator.pk,
)
self.assertEqual(company_location, self.operator_location)
# Test park location relation
park_location = Location.objects.get(
content_type=ContentType.objects.get_for_model(Park),
object_id=self.park.pk,
)
self.assertEqual(park_location, self.park_location)
def test_location_updates(self):
"""Test location updates"""
# Update company location
self.operator_location.street_address = "Updated Address"
self.operator_location.city = "Updated City"
self.operator_location.save()
updated_location = Location.objects.get(pk=self.operator_location.pk)
self.assertEqual(updated_location.street_address, "Updated Address")
self.assertEqual(updated_location.city, "Updated City")
def test_point_sync_with_lat_lon(self):
"""Test point synchronization with latitude/longitude fields"""
location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Operator),
object_id=self.operator.pk,
name="Test Sync Location",
location_type="business",
latitude=34.0522,
longitude=-118.2437,
)
self.assertIsNotNone(location.point)
self.assertAlmostEqual(location.point.y, 34.0522, places=4)
self.assertAlmostEqual(location.point.x, -118.2437, places=4)

View File

@@ -1,31 +0,0 @@
# DEPRECATED: These URLs are deprecated and no longer used.
#
# Location search functionality has been moved to the parks app:
# - /parks/search/location/ (replaces /location/search/)
# - /parks/search/reverse-geocode/ (replaces /location/reverse-geocode/)
#
# Domain-specific location models are managed through their respective apps:
# - Parks app for ParkLocation
# - Rides app for RideLocation
# - Parks app for CompanyHeadquarters
#
# This file is kept for reference during migration cleanup only.
from django.urls import path
from . import views
app_name = "location"
# NOTE: All URLs below are DEPRECATED
# The location app URLs should not be included in the main URLconf
urlpatterns = [
# DEPRECATED: Use /parks/search/location/ instead
path("search/", views.LocationSearchView.as_view(), name="search"),
# DEPRECATED: Use /parks/search/reverse-geocode/ instead
path("reverse-geocode/", views.reverse_geocode, name="reverse_geocode"),
# DEPRECATED: Use domain-specific location models instead
path("create/", views.LocationCreateView.as_view(), name="create"),
path("<int:pk>/update/", views.LocationUpdateView.as_view(), name="update"),
path("<int:pk>/delete/", views.LocationDeleteView.as_view(), name="delete"),
]

View File

@@ -1,48 +0,0 @@
# DEPRECATED: These views are deprecated and no longer used.
#
# Location search functionality has been moved to the parks app:
# - parks.views.location_search
# - parks.views.reverse_geocode
#
# Domain-specific location models are now used instead of the generic Location model:
# - ParkLocation in parks.models.location
# - RideLocation in rides.models.location
# - CompanyHeadquarters in parks.models.companies
#
# This file is kept for reference during migration cleanup only.
from django.views.generic import View
from django.http import JsonResponse
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.decorators.http import require_http_methods
# NOTE: All classes and functions below are DEPRECATED
# Use the equivalent functionality in the parks app instead
class LocationSearchView(View):
"""DEPRECATED: Use parks.views.location_search instead"""
class LocationCreateView(LoginRequiredMixin, View):
"""DEPRECATED: Use domain-specific location models instead"""
class LocationUpdateView(LoginRequiredMixin, View):
"""DEPRECATED: Use domain-specific location models instead"""
class LocationDeleteView(LoginRequiredMixin, View):
"""DEPRECATED: Use domain-specific location models instead"""
@require_http_methods(["GET"])
def reverse_geocode(request):
"""DEPRECATED: Use parks.views.reverse_geocode instead"""
return JsonResponse(
{
"error": "This endpoint is deprecated. Use /parks/search/reverse-geocode/ instead"
},
status=410,
)

View File

@@ -1,28 +0,0 @@
from django.contrib import admin
from django.utils.html import format_html
from .models import Photo
@admin.register(Photo)
class PhotoAdmin(admin.ModelAdmin):
list_display = (
"thumbnail_preview",
"content_type",
"content_object",
"caption",
"is_primary",
"created_at",
)
list_filter = ("content_type", "is_primary", "created_at")
search_fields = ("caption", "alt_text")
readonly_fields = ("thumbnail_preview",)
def thumbnail_preview(self, obj):
if obj.image:
return format_html(
'<img src="{}" style="max-height: 50px; max-width: 100px;" />',
obj.image.url,
)
return "No image"
thumbnail_preview.short_description = "Thumbnail"

View File

@@ -3,26 +3,46 @@ from django.db.models.signals import post_migrate
def create_photo_permissions(sender, **kwargs):
"""Create custom permissions for photos"""
"""Create custom permissions for domain-specific photo models"""
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from apps.media.models import Photo
from apps.parks.models import ParkPhoto
from apps.rides.models import RidePhoto
content_type = ContentType.objects.get_for_model(Photo)
# Create permissions for ParkPhoto
park_photo_content_type = ContentType.objects.get_for_model(ParkPhoto)
Permission.objects.get_or_create(
codename="add_photo",
name="Can add photo",
content_type=content_type,
codename="add_parkphoto",
name="Can add park photo",
content_type=park_photo_content_type,
)
Permission.objects.get_or_create(
codename="change_photo",
name="Can change photo",
content_type=content_type,
codename="change_parkphoto",
name="Can change park photo",
content_type=park_photo_content_type,
)
Permission.objects.get_or_create(
codename="delete_photo",
name="Can delete photo",
content_type=content_type,
codename="delete_parkphoto",
name="Can delete park photo",
content_type=park_photo_content_type,
)
# Create permissions for RidePhoto
ride_photo_content_type = ContentType.objects.get_for_model(RidePhoto)
Permission.objects.get_or_create(
codename="add_ridephoto",
name="Can add ride photo",
content_type=ride_photo_content_type,
)
Permission.objects.get_or_create(
codename="change_ridephoto",
name="Can change ride photo",
content_type=ride_photo_content_type,
)
Permission.objects.get_or_create(
codename="delete_ridephoto",
name="Can delete ride photo",
content_type=ride_photo_content_type,
)

View File

@@ -1,9 +1,7 @@
import requests
from django.core.management.base import BaseCommand
from apps.media.models import Photo
from apps.parks.models import Park
from apps.rides.models import Ride
from django.contrib.contenttypes.models import ContentType
from apps.parks.models import Park, ParkPhoto
from apps.rides.models import Ride, RidePhoto
import json
from django.core.files.base import ContentFile
@@ -18,9 +16,6 @@ class Command(BaseCommand):
with open("parks/management/commands/seed_data.json", "r") as f:
seed_data = json.load(f)
park_content_type = ContentType.objects.get_for_model(Park)
ride_content_type = ContentType.objects.get_for_model(Ride)
# Process parks and their photos
for park_data in seed_data["parks"]:
try:
@@ -34,15 +29,11 @@ class Command(BaseCommand):
response = requests.get(photo_url, timeout=60)
if response.status_code == 200:
# Delete any existing photos for this park
Photo.objects.filter(
content_type=park_content_type,
object_id=park.id,
).delete()
ParkPhoto.objects.filter(park=park).delete()
# Create new photo record
photo = Photo(
content_type=park_content_type,
object_id=park.id,
photo = ParkPhoto(
park=park,
is_primary=idx == 1,
)
@@ -87,15 +78,11 @@ class Command(BaseCommand):
response = requests.get(photo_url, timeout=60)
if response.status_code == 200:
# Delete any existing photos for this ride
Photo.objects.filter(
content_type=ride_content_type,
object_id=ride.id,
).delete()
RidePhoto.objects.filter(ride=ride).delete()
# Create new photo record
photo = Photo(
content_type=ride_content_type,
object_id=ride.id,
photo = RidePhoto(
ride=ride,
is_primary=idx == 1,
)

View File

@@ -1,6 +1,7 @@
import os
from django.core.management.base import BaseCommand
from apps.media.models import Photo
from apps.parks.models import ParkPhoto
from apps.rides.models import RidePhoto
from django.db import transaction
@@ -11,9 +12,11 @@ class Command(BaseCommand):
self.stdout.write("Fixing photo paths in database...")
# Get all photos
photos = Photo.objects.all()
park_photos = ParkPhoto.objects.all()
ride_photos = RidePhoto.objects.all()
for photo in photos:
# Process park photos
for photo in park_photos:
try:
with transaction.atomic():
# Get current file path
@@ -27,8 +30,8 @@ class Command(BaseCommand):
parts = current_name.split("/")
if len(parts) >= 2:
content_type = parts[0] # 'park' or 'ride'
identifier = parts[1] # e.g., 'alton-towers'
content_type = "park"
identifier = photo.park.slug
# Look for files in the media directory
media_dir = os.path.join("media", content_type, identifier)
@@ -51,27 +54,89 @@ class Command(BaseCommand):
photo.image.name = file_path
photo.save()
self.stdout.write(
f"Updated path for photo {
f"Updated path for park photo {
photo.id} to {file_path}"
)
else:
self.stdout.write(
f"File not found for photo {
f"File not found for park photo {
photo.id}: {file_path}"
)
else:
self.stdout.write(
f"No files found in directory for photo {
f"No files found in directory for park photo {
photo.id}: {media_dir}"
)
else:
self.stdout.write(
f"Directory not found for photo {
f"Directory not found for park photo {
photo.id}: {media_dir}"
)
except Exception as e:
self.stdout.write(f"Error updating photo {photo.id}: {str(e)}")
self.stdout.write(f"Error updating park photo {photo.id}: {str(e)}")
continue
# Process ride photos
for photo in ride_photos:
try:
with transaction.atomic():
# Get current file path
current_name = photo.image.name
# Remove any 'media/' prefix if it exists
if current_name.startswith("media/"):
# Remove 'media/' prefix
current_name = current_name[6:]
parts = current_name.split("/")
if len(parts) >= 2:
content_type = "ride"
identifier = photo.ride.slug
# Look for files in the media directory
media_dir = os.path.join("media", content_type, identifier)
if os.path.exists(media_dir):
files = [
f
for f in os.listdir(media_dir)
if not f.startswith(".") # Skip hidden files
and not f.startswith("tmp") # Skip temp files
and os.path.isfile(os.path.join(media_dir, f))
]
if files:
# Get the first file and update the database
# record
file_path = os.path.join(
content_type, identifier, files[0]
)
if os.path.exists(os.path.join("media", file_path)):
photo.image.name = file_path
photo.save()
self.stdout.write(
f"Updated path for ride photo {
photo.id} to {file_path}"
)
else:
self.stdout.write(
f"File not found for ride photo {
photo.id}: {file_path}"
)
else:
self.stdout.write(
f"No files found in directory for ride photo {
photo.id}: {media_dir}"
)
else:
self.stdout.write(
f"Directory not found for ride photo {
photo.id}: {media_dir}"
)
except Exception as e:
self.stdout.write(f"Error updating ride photo {photo.id}: {str(e)}")
continue
self.stdout.write("Finished fixing photo paths")

View File

@@ -1,6 +1,7 @@
import os
from django.core.management.base import BaseCommand
from apps.media.models import Photo
from apps.parks.models import ParkPhoto
from apps.rides.models import RidePhoto
from django.conf import settings
import shutil
@@ -12,12 +13,93 @@ class Command(BaseCommand):
self.stdout.write("Moving photo files to normalized locations...")
# Get all photos
photos = Photo.objects.all()
park_photos = ParkPhoto.objects.all()
ride_photos = RidePhoto.objects.all()
# Track processed files to clean up later
processed_files = set()
for photo in photos:
# Process park photos
for photo in park_photos:
try:
# Get current file path
current_name = photo.image.name
current_path = os.path.join(settings.MEDIA_ROOT, current_name)
# Try to find the actual file
if not os.path.exists(current_path):
# Check if file exists in the old location structure
parts = current_name.split("/")
if len(parts) >= 2:
content_type = "park"
identifier = photo.park.slug
# Look for any files in that directory
old_dir = os.path.join(
settings.MEDIA_ROOT, content_type, identifier
)
if os.path.exists(old_dir):
files = [
f
for f in os.listdir(old_dir)
if not f.startswith(".") # Skip hidden files
and not f.startswith("tmp") # Skip temp files
and os.path.isfile(os.path.join(old_dir, f))
]
if files:
current_path = os.path.join(old_dir, files[0])
# Skip if file still not found
if not os.path.exists(current_path):
self.stdout.write(f"Skipping {current_name} - file not found")
continue
# Get content type and object
content_type_model = "park"
obj = photo.park
identifier = getattr(obj, "slug", obj.id)
# Get photo number
photo_number = ParkPhoto.objects.filter(
park=photo.park,
created_at__lte=photo.created_at,
).count()
# Create new filename
_, ext = os.path.splitext(current_path)
if not ext:
ext = ".jpg"
ext = ext.lower()
new_filename = f"{identifier}_{photo_number}{ext}"
# Create new path
new_relative_path = f"{content_type_model}/{identifier}/{new_filename}"
new_full_path = os.path.join(settings.MEDIA_ROOT, new_relative_path)
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(new_full_path), exist_ok=True)
# Move the file
if current_path != new_full_path:
shutil.copy2(
current_path, new_full_path
) # Use copy2 to preserve metadata
processed_files.add(current_path)
else:
processed_files.add(current_path)
# Update database
photo.image.name = new_relative_path
photo.save()
self.stdout.write(f"Moved {current_name} to {new_relative_path}")
except Exception as e:
self.stdout.write(f"Error moving park photo {photo.id}: {str(e)}")
continue
# Process ride photos
for photo in ride_photos:
try:
# Get current file path
current_name = photo.image.name
@@ -52,14 +134,13 @@ class Command(BaseCommand):
continue
# Get content type and object
content_type_model = photo.content_type.model
obj = photo.content_object
content_type_model = "ride"
obj = photo.ride
identifier = getattr(obj, "slug", obj.id)
# Get photo number
photo_number = Photo.objects.filter(
content_type=photo.content_type,
object_id=photo.object_id,
photo_number = RidePhoto.objects.filter(
ride=photo.ride,
created_at__lte=photo.created_at,
).count()
@@ -93,7 +174,7 @@ class Command(BaseCommand):
self.stdout.write(f"Moved {current_name} to {new_relative_path}")
except Exception as e:
self.stdout.write(f"Error moving photo {photo.id}: {str(e)}")
self.stdout.write(f"Error moving ride photo {photo.id}: {str(e)}")
continue
# Clean up old files

View File

@@ -1,21 +0,0 @@
from django import template
from django.core.serializers.json import DjangoJSONEncoder
import json
register = template.Library()
@register.filter
def serialize_photos(photos):
"""Serialize photos queryset to JSON for AlpineJS"""
photo_data = []
for photo in photos:
photo_data.append(
{
"id": photo.id,
"url": photo.image.url,
"caption": photo.caption or "",
"is_primary": photo.is_primary,
}
)
return json.dumps(photo_data, cls=DjangoJSONEncoder)

View File

@@ -1,120 +0,0 @@
from typing import Any, Optional, cast
from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.conf import settings
from PIL import Image, ExifTags
from datetime import datetime
from .storage import MediaStorage
from apps.rides.models import Ride
from django.utils import timezone
from apps.core.history import TrackedModel
import pghistory
def photo_upload_path(instance: models.Model, filename: str) -> str:
"""Generate upload path for photos using normalized filenames"""
# Get the content type and object
photo = cast(Photo, instance)
content_type = photo.content_type.model
obj = photo.content_object
if obj is None:
raise ValueError("Content object cannot be None")
# Get object identifier (slug or id)
identifier = getattr(obj, "slug", None)
if identifier is None:
identifier = obj.pk # Use pk instead of id as it's guaranteed to exist
# Create normalized filename - always use .jpg extension
base_filename = f"{identifier}.jpg"
# If it's a ride photo, store it under the park's directory
if content_type == "ride":
ride = cast(Ride, obj)
return f"park/{ride.park.slug}/{identifier}/{base_filename}"
# For park photos, store directly in park directory
return f"park/{identifier}/{base_filename}"
@pghistory.track()
class Photo(TrackedModel):
"""Generic photo model that can be attached to any model"""
image = models.ImageField(
upload_to=photo_upload_path, # type: ignore[arg-type]
max_length=255,
storage=MediaStorage(),
)
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) # New field for approval status
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
date_taken = models.DateTimeField(null=True, blank=True)
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name="uploaded_photos",
)
# Generic foreign key fields
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey("content_type", "object_id")
class Meta:
app_label = "media"
ordering = ["-is_primary", "-created_at"]
indexes = [
models.Index(fields=["content_type", "object_id"]),
]
def __str__(self) -> str:
return f"{self.content_type} - {self.content_object} - {self.caption or 'No caption'}"
def extract_exif_date(self) -> Optional[datetime]:
"""Extract the date taken from image EXIF data"""
try:
with Image.open(self.image) as img:
exif = img.getexif()
if exif:
# Find the DateTime tag ID
for tag_id in ExifTags.TAGS:
if ExifTags.TAGS[tag_id] == "DateTimeOriginal":
if tag_id in exif:
# EXIF dates are typically in format:
# '2024:02:15 14:30:00'
date_str = exif[tag_id]
return datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
return None
except Exception:
return None
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:
self.date_taken = self.extract_exif_date()
# Set default caption if not provided
if not self.caption and self.uploaded_by:
current_time = timezone.now()
self.caption = f"Uploaded by {
self.uploaded_by.username} on {
current_time.strftime('%B %d, %Y at %I:%M %p')}"
# If this is marked as primary, unmark other primary photos
if self.is_primary:
Photo.objects.filter(
content_type=self.content_type,
object_id=self.object_id,
is_primary=True,
).exclude(pk=self.pk).update(
is_primary=False
) # Use pk instead of id
super().save(*args, **kwargs)

View File

@@ -1,82 +0,0 @@
from django.core.files.storage import FileSystemStorage
from django.conf import settings
from django.core.files.base import File
from django.core.files.move import file_move_safe
from django.core.files.uploadedfile import UploadedFile, TemporaryUploadedFile
import os
from typing import Optional, Any, Union
class MediaStorage(FileSystemStorage):
_instance = None
_counters = {}
def __init__(self, *args: Any, **kwargs: Any) -> None:
kwargs["location"] = settings.MEDIA_ROOT
kwargs["base_url"] = settings.MEDIA_URL
super().__init__(*args, **kwargs)
@classmethod
def reset_counters(cls):
"""Reset all counters - useful for testing"""
cls._counters = {}
def get_available_name(self, name: str, max_length: Optional[int] = None) -> str:
"""
Returns a filename that's free on the target storage system.
Ensures proper normalization and uniqueness.
"""
# Get the directory and filename
directory = os.path.dirname(name)
filename = os.path.basename(name)
# Create directory if it doesn't exist
full_dir = os.path.join(self.location, directory)
os.makedirs(full_dir, exist_ok=True)
# Split filename into root and extension
file_root, file_ext = os.path.splitext(filename)
# Extract base name without any existing numbers
base_root = file_root.rsplit("_", 1)[0]
# Use counter for this directory
dir_key = os.path.join(directory, base_root)
if dir_key not in self._counters:
self._counters[dir_key] = 0
self._counters[dir_key] += 1
counter = self._counters[dir_key]
new_name = f"{base_root}_{counter}{file_ext}"
return os.path.join(directory, new_name)
def _save(self, name: str, content: Union[File, UploadedFile]) -> str:
"""
Save the file and set proper permissions
"""
# Get the full path where the file will be saved
full_path = self.path(name)
directory = os.path.dirname(full_path)
# Create the directory if it doesn't exist
os.makedirs(directory, exist_ok=True)
# Save the file using Django's file handling
if isinstance(content, TemporaryUploadedFile):
# This is a TemporaryUploadedFile
file_move_safe(content.temporary_file_path(), full_path)
else:
# This is an InMemoryUploadedFile or similar
with open(full_path, "wb") as destination:
if hasattr(content, "chunks"):
for chunk in content.chunks():
destination.write(chunk)
else:
destination.write(content.read())
# Set proper permissions
os.chmod(full_path, 0o644)
os.chmod(directory, 0o755)
return name

View File

@@ -1,270 +0,0 @@
from django.test import TestCase, override_settings
from django.core.files.uploadedfile import SimpleUploadedFile
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django.conf import settings
from django.db import models
from datetime import datetime
from PIL import Image
import piexif # type: ignore
import io
import shutil
import tempfile
import os
import logging
from typing import Optional, Any, Generator, cast
from contextlib import contextmanager
from .models import Photo
from .storage import MediaStorage
from apps.parks.models import Park, Company as Operator
User = get_user_model()
logger = logging.getLogger(__name__)
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
class PhotoModelTests(TestCase):
test_media_root: str
user: models.Model
park: Park
content_type: ContentType
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.test_media_root = settings.MEDIA_ROOT
@classmethod
def tearDownClass(cls) -> None:
try:
shutil.rmtree(cls.test_media_root, ignore_errors=True)
except Exception as e:
logger.warning(f"Failed to clean up test media directory: {e}")
super().tearDownClass()
def setUp(self) -> None:
self.user = self._create_test_user()
self.park = self._create_test_park()
self.content_type = ContentType.objects.get_for_model(Park)
self._setup_test_directory()
def tearDown(self) -> None:
self._cleanup_test_directory()
Photo.objects.all().delete()
with self._reset_storage_state():
pass
def _create_test_user(self) -> models.Model:
"""Create a test user for the tests"""
return User.objects.create_user(username="testuser", password="testpass123")
def _create_test_park(self) -> Park:
"""Create a test park for the tests"""
operator = Operator.objects.create(name="Test Operator")
return Park.objects.create(
name="Test Park", slug="test-park", operator=operator
)
def _setup_test_directory(self) -> None:
"""Set up test directory and clean any existing test files"""
try:
# Clean up any existing test park directory
test_park_dir = os.path.join(settings.MEDIA_ROOT, "park", "test-park")
if os.path.exists(test_park_dir):
shutil.rmtree(test_park_dir, ignore_errors=True)
# Create necessary directories
os.makedirs(test_park_dir, exist_ok=True)
except Exception as e:
logger.warning(f"Failed to set up test directory: {e}")
raise
def _cleanup_test_directory(self) -> None:
"""Clean up test directories and files"""
try:
test_park_dir = os.path.join(settings.MEDIA_ROOT, "park", "test-park")
if os.path.exists(test_park_dir):
shutil.rmtree(test_park_dir, ignore_errors=True)
except Exception as e:
logger.warning(f"Failed to clean up test directory: {e}")
@contextmanager
def _reset_storage_state(self) -> Generator[None, None, None]:
"""Safely reset storage state"""
try:
MediaStorage.reset_counters()
yield
finally:
MediaStorage.reset_counters()
def create_test_image_with_exif(
self, date_taken: Optional[datetime] = None, filename: str = "test.jpg"
) -> SimpleUploadedFile:
"""Helper method to create a test image with EXIF data"""
image = Image.new("RGB", (100, 100), color="red")
image_io = io.BytesIO()
# Save image first without EXIF
image.save(image_io, "JPEG")
image_io.seek(0)
if date_taken:
# Create EXIF data
exif_dict = {
"0th": {},
"Exif": {
piexif.ExifIFD.DateTimeOriginal: date_taken.strftime(
"%Y:%m:%d %H:%M:%S"
).encode()
},
}
exif_bytes = piexif.dump(exif_dict)
# Insert EXIF into image
image_with_exif = io.BytesIO()
piexif.insert(exif_bytes, image_io.getvalue(), image_with_exif)
image_with_exif.seek(0)
image_data = image_with_exif.getvalue()
else:
image_data = image_io.getvalue()
return SimpleUploadedFile(filename, image_data, content_type="image/jpeg")
def test_filename_normalization(self) -> None:
"""Test that filenames are properly normalized"""
with self._reset_storage_state():
# Test with various problematic filenames
test_cases = [
("test with spaces.jpg", "test-park_1.jpg"),
("TEST_UPPER.JPG", "test-park_2.jpg"),
("special@#chars.jpeg", "test-park_3.jpg"),
("no-extension", "test-park_4.jpg"),
("multiple...dots.jpg", "test-park_5.jpg"),
("très_açaí.jpg", "test-park_6.jpg"), # Unicode characters
]
for input_name, expected_suffix in test_cases:
photo = Photo.objects.create(
image=self.create_test_image_with_exif(filename=input_name),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
)
# Check that the filename follows the normalized pattern
self.assertTrue(
photo.image.name.endswith(expected_suffix),
f"Expected filename to end with {expected_suffix}, got {
photo.image.name}",
)
# Verify the path structure
expected_path = f"park/{self.park.slug}/"
self.assertTrue(
photo.image.name.startswith(expected_path),
f"Expected path to start with {expected_path}, got {
photo.image.name}",
)
def test_sequential_filename_numbering(self) -> None:
"""Test that sequential files get proper numbering"""
with self._reset_storage_state():
# Create multiple photos and verify numbering
for i in range(1, 4):
photo = Photo.objects.create(
image=self.create_test_image_with_exif(),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
)
expected_name = f"park/{self.park.slug}/test-park_{i}.jpg"
self.assertEqual(
photo.image.name,
expected_name,
f"Expected {expected_name}, got {photo.image.name}",
)
def test_exif_date_extraction(self) -> None:
"""Test EXIF date extraction from uploaded photos"""
test_date = datetime(2024, 1, 1, 12, 0, 0)
image_file = self.create_test_image_with_exif(test_date)
photo = Photo.objects.create(
image=image_file,
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
)
if photo.date_taken:
self.assertEqual(
photo.date_taken.strftime("%Y-%m-%d %H:%M:%S"),
test_date.strftime("%Y-%m-%d %H:%M:%S"),
)
else:
self.skipTest("EXIF data extraction not supported in test environment")
def test_photo_without_exif(self) -> None:
"""Test photo upload without EXIF data"""
image_file = self.create_test_image_with_exif()
photo = Photo.objects.create(
image=image_file,
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
)
self.assertIsNone(photo.date_taken)
def test_default_caption(self) -> None:
"""Test default caption generation"""
photo = Photo.objects.create(
image=self.create_test_image_with_exif(),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
)
expected_prefix = f"Uploaded by {cast(Any, self.user).username} on"
self.assertTrue(photo.caption.startswith(expected_prefix))
def test_primary_photo_toggle(self) -> None:
"""Test primary photo functionality"""
photo1 = Photo.objects.create(
image=self.create_test_image_with_exif(),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
is_primary=True,
)
photo2 = Photo.objects.create(
image=self.create_test_image_with_exif(),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
is_primary=True,
)
photo1.refresh_from_db()
photo2.refresh_from_db()
self.assertFalse(photo1.is_primary)
self.assertTrue(photo2.is_primary)
def test_date_taken_field(self) -> None:
"""Test date_taken field functionality"""
test_date = timezone.now()
photo = Photo.objects.create(
image=self.create_test_image_with_exif(),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
date_taken=test_date,
)
self.assertEqual(photo.date_taken, test_date)

View File

@@ -1,21 +0,0 @@
from django.urls import path
from . import views
app_name = "photos"
urlpatterns = [
path("upload/", views.upload_photo, name="upload"),
path(
"upload/<int:photo_id>/", views.delete_photo, name="delete"
), # Updated to match frontend
path(
"upload/<int:photo_id>/primary/",
views.set_primary_photo,
name="set_primary",
),
path(
"upload/<int:photo_id>/caption/",
views.update_caption,
name="update_caption",
),
]

View File

@@ -1,189 +0,0 @@
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import get_object_or_404
import json
import logging
from .models import Photo
logger = logging.getLogger(__name__)
@login_required
@require_http_methods(["POST"])
def upload_photo(request):
"""Handle photo upload for any model"""
try:
# Get app label, model, and object ID
app_label = request.POST.get("app_label")
model = request.POST.get("model")
object_id = request.POST.get("object_id")
# Log received data
logger.debug(
f"Received upload request - app_label: {app_label}, model: {model}, object_id: {object_id}"
)
logger.debug(f"Files in request: {request.FILES}")
# Validate required fields
missing_fields = []
if not app_label:
missing_fields.append("app_label")
if not model:
missing_fields.append("model")
if not object_id:
missing_fields.append("object_id")
if "image" not in request.FILES:
missing_fields.append("image")
if missing_fields:
return JsonResponse(
{"error": f'Missing required fields: {", ".join(missing_fields)}'},
status=400,
)
# Get content type
try:
content_type = ContentType.objects.get(
app_label=app_label.lower(), model=model.lower()
)
except ContentType.DoesNotExist:
return JsonResponse(
{"error": f"Invalid content type: {app_label}.{model}"},
status=400,
)
# Get the object instance
try:
obj = content_type.get_object_for_this_type(pk=object_id)
except Exception as e:
return JsonResponse(
{
"error": f"Object not found: {app_label}.{model} with id {object_id}. Error: {
str(e)}"
},
status=404,
)
# Check if user has permission to add photos
if not request.user.has_perm("media.add_photo"):
logger.warning(
f"User {
request.user} attempted to upload photo without permission"
)
return JsonResponse(
{"error": "You do not have permission to upload photos"},
status=403,
)
# Determine if the photo should be auto-approved
is_approved = (
request.user.is_superuser
or request.user.is_staff
or request.user.groups.filter(name="Moderators").exists()
)
# Create the photo
photo = Photo.objects.create(
image=request.FILES["image"],
content_type=content_type,
object_id=obj.pk,
uploaded_by=request.user, # Add the user who uploaded the photo
is_primary=not Photo.objects.filter(
content_type=content_type, object_id=obj.pk
).exists(),
is_approved=is_approved,
# Auto-approve if the user is a moderator, admin, or superuser
)
return JsonResponse(
{
"id": photo.pk,
"url": photo.image.url,
"caption": photo.caption,
"is_primary": photo.is_primary,
"is_approved": photo.is_approved,
}
)
except Exception as e:
logger.error(f"Error in upload_photo: {str(e)}", exc_info=True)
return JsonResponse(
{"error": f"An error occurred while uploading the photo: {str(e)}"},
status=400,
)
@login_required
@require_http_methods(["POST"])
def set_primary_photo(request, photo_id):
"""Set a photo as primary"""
try:
photo = get_object_or_404(Photo, pk=photo_id)
# Check if user has permission to edit photos
if not request.user.has_perm("media.change_photo"):
return JsonResponse(
{"error": "You do not have permission to edit photos"},
status=403,
)
# Set this photo as primary
photo.is_primary = True
photo.save() # This will automatically unset other primary photos
return JsonResponse({"status": "success"})
except Exception as e:
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
return JsonResponse({"error": str(e)}, status=400)
@login_required
@require_http_methods(["POST"])
def update_caption(request, photo_id):
"""Update a photo's caption"""
try:
photo = get_object_or_404(Photo, pk=photo_id)
# Check if user has permission to edit photos
if not request.user.has_perm("media.change_photo"):
return JsonResponse(
{"error": "You do not have permission to edit photos"},
status=403,
)
# Update caption
data = json.loads(request.body)
photo.caption = data.get("caption", "")
photo.save()
return JsonResponse({"id": photo.pk, "caption": photo.caption})
except Exception as e:
logger.error(f"Error in update_caption: {str(e)}", exc_info=True)
return JsonResponse({"error": str(e)}, status=400)
@login_required
@require_http_methods(["DELETE"])
def delete_photo(request, photo_id):
"""Delete a photo"""
try:
photo = get_object_or_404(Photo, pk=photo_id)
# Check if user has permission to delete photos
if not request.user.has_perm("media.delete_photo"):
return JsonResponse(
{"error": "You do not have permission to delete photos"},
status=403,
)
photo.delete()
return JsonResponse({"status": "success"})
except Exception as e:
logger.error(f"Error in delete_photo: {str(e)}", exc_info=True)
return JsonResponse({"error": str(e)}, status=400)

View File

@@ -109,7 +109,7 @@ class EditSubmission(TrackedModel):
and value is not None
):
if related_model := field.related_model:
resolved_data[field_name] = related_model.objects.get(id=value)
resolved_data[field_name] = related_model.objects.get(pk=value)
except (FieldDoesNotExist, ObjectDoesNotExist):
continue
@@ -141,7 +141,9 @@ class EditSubmission(TrackedModel):
"""Check if an object with the same name already exists"""
try:
return model_class.objects.filter(name=name).first()
except BaseException:
except BaseException as e:
print(f"Error checking for duplicate name '{name}': {e}")
raise e
return None
def approve(self, user: UserType) -> Optional[models.Model]:
@@ -172,7 +174,7 @@ class EditSubmission(TrackedModel):
self.notes = f"A {
model_class.__name__} with the name '{
prepared_data['name']}' already exists (ID: {
existing_obj.id})"
existing_obj.pk})"
self.save()
raise ValueError(self.notes)
@@ -283,18 +285,27 @@ class PhotoSubmission(TrackedModel):
def approve(self, moderator: UserType, notes: str = "") -> None:
"""Approve the photo submission"""
from apps.media.models import Photo
from apps.parks.models.media import ParkPhoto
from apps.rides.models.media import RidePhoto
self.status = "APPROVED"
self.handled_by = moderator # type: ignore
self.handled_at = timezone.now()
self.notes = notes
# Determine the correct photo model based on the content type
model_class = self.content_type.model_class()
if model_class.__name__ == "Park":
PhotoModel = ParkPhoto
elif model_class.__name__ == "Ride":
PhotoModel = RidePhoto
else:
raise ValueError(f"Unsupported content type: {model_class.__name__}")
# Create the approved photo
Photo.objects.create(
PhotoModel.objects.create(
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.object_id,
content_object=self.content_object,
image=self.photo,
caption=self.caption,
is_approved=True,

View File

@@ -13,7 +13,7 @@ from .areas import ParkArea
from .location import ParkLocation
from .reviews import ParkReview
from .companies import Company, CompanyHeadquarters
from .media import ParkPhoto
# Alias Company as Operator for clarity
Operator = Company
@@ -23,6 +23,7 @@ __all__ = [
"ParkArea",
"ParkLocation",
"ParkReview",
"ParkPhoto",
# Company models with clear naming
"Operator",
"CompanyHeadquarters",

View File

@@ -0,0 +1,122 @@
"""
Park-specific media models for ThrillWiki.
This module contains media models specific to parks 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 park_photo_upload_path(instance: models.Model, filename: str) -> str:
"""Generate upload path for park photos."""
photo = cast('ParkPhoto', instance)
park = photo.park
if park is None:
raise ValueError("Park cannot be None")
return MediaService.generate_upload_path(
domain="park",
identifier=park.slug,
filename=filename
)
@pghistory.track()
class ParkPhoto(TrackedModel):
"""Photo model specific to parks."""
park = models.ForeignKey(
'parks.Park',
on_delete=models.CASCADE,
related_name='photos'
)
image = models.ImageField(
upload_to=park_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)
# 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_park_photos",
)
class Meta:
app_label = "parks"
ordering = ["-is_primary", "-created_at"]
indexes = [
models.Index(fields=["park", "is_primary"]),
models.Index(fields=["park", "is_approved"]),
models.Index(fields=["created_at"]),
]
constraints = [
# Only one primary photo per park
models.UniqueConstraint(
fields=['park'],
condition=models.Q(is_primary=True),
name='unique_primary_park_photo'
)
]
def __str__(self) -> str:
return f"Photo of {self.park.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 park
if self.is_primary:
ParkPhoto.objects.filter(
park=self.park,
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.park.slug}/photos/{self.pk}/"

View File

@@ -1,11 +1,9 @@
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from typing import Tuple, Optional, Any, TYPE_CHECKING
import pghistory
from apps.media.models import Photo
from apps.core.history import TrackedModel
if TYPE_CHECKING:
@@ -74,7 +72,6 @@ class Park(TrackedModel):
help_text="Company that owns the property (if different from operator)",
limit_choices_to={"roles__contains": ["PROPERTY_OWNER"]},
)
photos = GenericRelation(Photo, related_query_name="park")
areas: models.Manager["ParkArea"] # Type hint for reverse relation
# Type hint for reverse relation from rides app
rides: models.Manager["Ride"]

View File

@@ -10,7 +10,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractBaseUser
from .models import Park, ParkArea
from apps.location.models import Location
from .services.location_service import ParkLocationService
# Use AbstractBaseUser for type hinting
UserType = AbstractBaseUser
@@ -89,7 +89,7 @@ class ParkService:
# Handle location if provided
if location_data:
LocationService.create_park_location(park=park, **location_data)
ParkLocationService.create_park_location(park=park, **location_data)
return park
@@ -227,97 +227,3 @@ class ParkService:
park.save()
return park
class LocationService:
"""Service for managing location operations."""
@staticmethod
def create_park_location(
*,
park: Park,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
street_address: str = "",
city: str = "",
state: str = "",
country: str = "",
postal_code: str = "",
) -> Location:
"""
Create a location for a park.
Args:
park: Park instance
latitude: Latitude coordinate
longitude: Longitude coordinate
street_address: Street address
city: City name
state: State/region name
country: Country name
postal_code: Postal/ZIP code
Returns:
Created Location instance
Raises:
ValidationError: If location data is invalid
"""
location = Location(
content_object=park,
name=park.name,
location_type="park",
latitude=latitude,
longitude=longitude,
street_address=street_address,
city=city,
state=state,
country=country,
postal_code=postal_code,
)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
location.full_clean()
location.save()
return location
@staticmethod
def update_park_location(
*, park_id: int, location_updates: Dict[str, Any]
) -> Location:
"""
Update location information for a park.
Args:
park_id: ID of the park
location_updates: Dictionary of location field updates
Returns:
Updated Location instance
Raises:
Location.DoesNotExist: If location doesn't exist
ValidationError: If location data is invalid
"""
with transaction.atomic():
park = Park.objects.get(id=park_id)
try:
location = park.location
except Location.DoesNotExist:
# Create location if it doesn't exist
return LocationService.create_park_location(
park=park, **location_updates
)
# Apply updates
for field, value in location_updates.items():
if hasattr(location, field):
setattr(location, field, value)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
location.full_clean()
location.save()
return location

View File

@@ -1,5 +1,7 @@
from .roadtrip import RoadTripService
from .park_management import ParkService, LocationService
from .park_management import ParkService
from .location_service import ParkLocationService
from .filter_service import ParkFilterService
__all__ = ["RoadTripService", "ParkService", "LocationService", "ParkFilterService"]
from .media_service import ParkMediaService
__all__ = ["RoadTripService", "ParkService",
"ParkLocationService", "ParkFilterService", "ParkMediaService"]

View File

@@ -0,0 +1,492 @@
"""
Parks-specific location services with OpenStreetMap integration.
Handles geocoding, reverse geocoding, and location search for 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 ParkLocation
logger = logging.getLogger(__name__)
class ParkLocationService:
"""
Location service specifically for parks using OpenStreetMap Nominatim API.
"""
NOMINATIM_BASE_URL = "https://nominatim.openstreetmap.org"
USER_AGENT = "ThrillWiki/1.0 (https://thrillwiki.com)"
@classmethod
def search_locations(cls, query: str, limit: int = 10) -> Dict[str, Any]:
"""
Search for locations using OpenStreetMap Nominatim API.
Optimized for finding theme parks and amusement parks.
Args:
query: Search query string
limit: Maximum number of results (default: 10, max: 25)
Returns:
Dictionary with search results
"""
if not query.strip():
return {"count": 0, "results": [], "query": query}
# Limit the number of results
limit = min(limit, 25)
# Check cache first
cache_key = f"park_location_search:{query.lower()}:{limit}"
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
try:
params = {
"q": query,
"format": "json",
"limit": limit,
"addressdetails": 1,
"extratags": 1,
"namedetails": 1,
"accept-language": "en",
# Prioritize places that might be parks or entertainment venues
"featuretype": "settlement,leisure,tourism",
}
headers = {
"User-Agent": cls.USER_AGENT,
}
response = requests.get(
f"{cls.NOMINATIM_BASE_URL}/search",
params=params,
headers=headers,
timeout=10,
)
response.raise_for_status()
osm_results = response.json()
# Transform OSM results to our format
results = []
for item in osm_results:
result = cls._transform_osm_result(item)
if result:
results.append(result)
result_data = {"count": len(results), "results": results, "query": query}
# Cache for 1 hour
cache.set(cache_key, result_data, 3600)
return result_data
except requests.RequestException as e:
logger.error(f"Error searching park locations: {str(e)}")
return {
"count": 0,
"results": [],
"query": query,
"error": "Location search service temporarily unavailable",
}
@classmethod
def reverse_geocode(cls, latitude: float, longitude: float) -> Dict[str, Any]:
"""
Reverse geocode coordinates to get location information using OSM.
Args:
latitude: Latitude coordinate
longitude: Longitude coordinate
Returns:
Dictionary with location information
"""
# Validate coordinates
if not (-90 <= latitude <= 90) or not (-180 <= longitude <= 180):
return {"error": "Invalid coordinates"}
# Check cache first
cache_key = f"park_reverse_geocode:{latitude:.6f}:{longitude:.6f}"
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
try:
params = {
"lat": latitude,
"lon": longitude,
"format": "json",
"addressdetails": 1,
"extratags": 1,
"namedetails": 1,
"accept-language": "en",
}
headers = {
"User-Agent": cls.USER_AGENT,
}
response = requests.get(
f"{cls.NOMINATIM_BASE_URL}/reverse",
params=params,
headers=headers,
timeout=10,
)
response.raise_for_status()
osm_result = response.json()
if "error" in osm_result:
return {"error": "Location not found"}
result = cls._transform_osm_reverse_result(osm_result)
# Cache for 24 hours
cache.set(cache_key, result, 86400)
return result
except requests.RequestException as e:
logger.error(f"Error reverse geocoding park location: {str(e)}")
return {"error": "Reverse geocoding service temporarily unavailable"}
@classmethod
def geocode_address(cls, address: str) -> Dict[str, Any]:
"""
Geocode an address to get coordinates using OSM.
Args:
address: Address string to geocode
Returns:
Dictionary with coordinates and location information
"""
if not address.strip():
return {"error": "Address is required"}
# Use search_locations for geocoding
results = cls.search_locations(address, limit=1)
if results["count"] > 0:
return results["results"][0]
else:
return {"error": "Address not found"}
@classmethod
def create_park_location(
cls,
*,
park,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
street_address: str = "",
city: str = "",
state: str = "",
country: str = "USA",
postal_code: str = "",
highway_exit: str = "",
parking_notes: str = "",
seasonal_notes: str = "",
osm_id: Optional[int] = None,
osm_type: str = "",
) -> ParkLocation:
"""
Create a location for a park with OSM integration.
Args:
park: Park instance
latitude: Latitude coordinate
longitude: Longitude coordinate
street_address: Street address
city: City name
state: State/region name
country: Country name (default: USA)
postal_code: Postal/ZIP code
highway_exit: Highway exit information
parking_notes: Parking information
seasonal_notes: Seasonal access notes
osm_id: OpenStreetMap ID
osm_type: OpenStreetMap type (node, way, relation)
Returns:
Created ParkLocation instance
"""
with transaction.atomic():
park_location = ParkLocation(
park=park,
street_address=street_address,
city=city,
state=state,
country=country,
postal_code=postal_code,
highway_exit=highway_exit,
parking_notes=parking_notes,
seasonal_notes=seasonal_notes,
osm_id=osm_id,
osm_type=osm_type,
)
# Set coordinates if provided
if latitude is not None and longitude is not None:
park_location.set_coordinates(latitude, longitude)
park_location.full_clean()
park_location.save()
return park_location
@classmethod
def update_park_location(
cls, park_location: ParkLocation, **updates
) -> ParkLocation:
"""
Update park location with validation.
Args:
park_location: ParkLocation instance to update
**updates: Fields to update
Returns:
Updated ParkLocation 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(park_location, field):
setattr(park_location, field, value)
# Update coordinates if provided
if latitude is not None and longitude is not None:
park_location.set_coordinates(latitude, longitude)
park_location.full_clean()
park_location.save()
return park_location
@classmethod
def find_nearby_parks(
cls, latitude: float, longitude: float, radius_km: float = 50
) -> List[ParkLocation]:
"""
Find parks near given coordinates using PostGIS.
Args:
latitude: Center latitude
longitude: Center longitude
radius_km: Search radius in kilometers
Returns:
List of nearby ParkLocation instances
"""
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import Distance
center_point = Point(longitude, latitude, srid=4326)
return list(
ParkLocation.objects.filter(
point__distance_lte=(center_point, Distance(km=radius_km))
)
.select_related("park", "park__operator")
.order_by("point__distance")
)
@classmethod
def enrich_location_from_osm(cls, park_location: ParkLocation) -> ParkLocation:
"""
Enrich park location data using OSM reverse geocoding.
Args:
park_location: ParkLocation instance to enrich
Returns:
Updated ParkLocation instance
"""
if not park_location.point:
return park_location
# Get detailed location info from OSM
osm_data = cls.reverse_geocode(park_location.latitude, park_location.longitude)
if "error" not in osm_data:
updates = {}
# Update missing address components
if not park_location.street_address and osm_data.get("street_address"):
updates["street_address"] = osm_data["street_address"]
if not park_location.city and osm_data.get("city"):
updates["city"] = osm_data["city"]
if not park_location.state and osm_data.get("state"):
updates["state"] = osm_data["state"]
if not park_location.country and osm_data.get("country"):
updates["country"] = osm_data["country"]
if not park_location.postal_code and osm_data.get("postal_code"):
updates["postal_code"] = osm_data["postal_code"]
# Update OSM metadata
if osm_data.get("osm_id"):
updates["osm_id"] = osm_data["osm_id"]
if osm_data.get("osm_type"):
updates["osm_type"] = osm_data["osm_type"]
if updates:
return cls.update_park_location(park_location, **updates)
return park_location
@classmethod
def _transform_osm_result(
cls, osm_item: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""Transform OSM search result to our standard format."""
try:
address = osm_item.get("address", {})
# Extract address components
street_number = address.get("house_number", "")
street_name = address.get("road", "")
street_address = f"{street_number} {street_name}".strip()
city = (
address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality")
or ""
)
state = (
address.get("state")
or address.get("province")
or address.get("region")
or ""
)
country = address.get("country", "")
postal_code = address.get("postcode", "")
# Build formatted address
address_parts = []
if street_address:
address_parts.append(street_address)
if city:
address_parts.append(city)
if state:
address_parts.append(state)
if postal_code:
address_parts.append(postal_code)
if country:
address_parts.append(country)
formatted_address = ", ".join(address_parts)
# Check if this might be a theme park or entertainment venue
place_type = osm_item.get("type", "").lower()
extratags = osm_item.get("extratags", {})
is_park_related = any(
[
"park" in place_type,
"theme" in place_type,
"amusement" in place_type,
"attraction" in place_type,
extratags.get("tourism") == "theme_park",
extratags.get("leisure") == "amusement_arcade",
extratags.get("amenity") == "amusement_arcade",
]
)
return {
"name": osm_item.get("display_name", ""),
"latitude": float(osm_item["lat"]),
"longitude": float(osm_item["lon"]),
"formatted_address": formatted_address,
"street_address": street_address,
"city": city,
"state": state,
"country": country,
"postal_code": postal_code,
"osm_id": osm_item.get("osm_id"),
"osm_type": osm_item.get("osm_type"),
"place_type": place_type,
"importance": osm_item.get("importance", 0),
"is_park_related": is_park_related,
}
except (KeyError, ValueError, TypeError) as e:
logger.warning(f"Error transforming OSM result: {str(e)}")
return None
@classmethod
def _transform_osm_reverse_result(
cls, osm_result: Dict[str, Any]
) -> Dict[str, Any]:
"""Transform OSM reverse geocoding result to our standard format."""
address = osm_result.get("address", {})
# Extract address components
street_number = address.get("house_number", "")
street_name = address.get("road", "")
street_address = f"{street_number} {street_name}".strip()
city = (
address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality")
or ""
)
state = (
address.get("state")
or address.get("province")
or address.get("region")
or ""
)
country = address.get("country", "")
postal_code = address.get("postcode", "")
# Build formatted address
address_parts = []
if street_address:
address_parts.append(street_address)
if city:
address_parts.append(city)
if state:
address_parts.append(state)
if postal_code:
address_parts.append(postal_code)
if country:
address_parts.append(country)
formatted_address = ", ".join(address_parts)
return {
"name": osm_result.get("display_name", ""),
"latitude": float(osm_result["lat"]),
"longitude": float(osm_result["lon"]),
"formatted_address": formatted_address,
"street_address": street_address,
"city": city,
"state": state,
"country": country,
"postal_code": postal_code,
"osm_id": osm_result.get("osm_id"),
"osm_type": osm_result.get("osm_type"),
"place_type": osm_result.get("type", ""),
}

View File

@@ -0,0 +1,241 @@
"""
Park-specific media service for ThrillWiki.
This module provides media management functionality specific to parks.
"""
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 Park, ParkPhoto
User = get_user_model()
logger = logging.getLogger(__name__)
class ParkMediaService:
"""Service for managing park-specific media operations."""
@staticmethod
def upload_photo(
park: Park,
image_file: UploadedFile,
user: User,
caption: str = "",
alt_text: str = "",
is_primary: bool = False,
auto_approve: bool = False
) -> ParkPhoto:
"""
Upload a photo for a park.
Args:
park: Park instance
image_file: Uploaded image file
user: User uploading the photo
caption: Photo caption
alt_text: Alt text for accessibility
is_primary: Whether this should be the primary photo
auto_approve: Whether to auto-approve the photo
Returns:
Created ParkPhoto 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 = ParkPhoto(
park=park,
image=processed_image,
caption=caption or MediaService.generate_default_caption(user.username),
alt_text=alt_text,
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 park {park.slug} by user {user.username}")
return photo
@staticmethod
def get_park_photos(
park: Park,
approved_only: bool = True,
primary_first: bool = True
) -> List[ParkPhoto]:
"""
Get photos for a park.
Args:
park: Park instance
approved_only: Whether to only return approved photos
primary_first: Whether to order primary photos first
Returns:
List of ParkPhoto instances
"""
queryset = park.photos.all()
if approved_only:
queryset = queryset.filter(is_approved=True)
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(park: Park) -> Optional[ParkPhoto]:
"""
Get the primary photo for a park.
Args:
park: Park instance
Returns:
Primary ParkPhoto instance or None
"""
try:
return park.photos.filter(is_primary=True, is_approved=True).first()
except ParkPhoto.DoesNotExist:
return None
@staticmethod
def set_primary_photo(park: Park, photo: ParkPhoto) -> bool:
"""
Set a photo as the primary photo for a park.
Args:
park: Park instance
photo: ParkPhoto to set as primary
Returns:
True if successful, False otherwise
"""
if photo.park != park:
return False
with transaction.atomic():
# Unset current primary
park.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 park {park.slug}")
return True
@staticmethod
def approve_photo(photo: ParkPhoto, approved_by: User) -> bool:
"""
Approve a park photo.
Args:
photo: ParkPhoto 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: ParkPhoto, deleted_by: User) -> bool:
"""
Delete a park photo.
Args:
photo: ParkPhoto to delete
deleted_by: User deleting the photo
Returns:
True if successful, False otherwise
"""
try:
park_slug = photo.park.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 park {park_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(park: Park) -> Dict[str, Any]:
"""
Get photo statistics for a park.
Args:
park: Park instance
Returns:
Dictionary with photo statistics
"""
photos = park.photos.all()
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()
}
@staticmethod
def bulk_approve_photos(photos: List[ParkPhoto], approved_by: User) -> int:
"""
Bulk approve multiple photos.
Args:
photos: List of ParkPhoto 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 ParkMediaService.approve_photo(photo, approved_by):
approved_count += 1
logger.info(
f"Bulk approved {approved_count} photos by user {approved_by.username}")
return approved_count

View File

@@ -11,7 +11,7 @@ if TYPE_CHECKING:
from django.contrib.auth.models import AbstractUser
from ..models import Park, ParkArea
from apps.location.models import Location
from .location_service import ParkLocationService
class ParkService:
@@ -86,7 +86,7 @@ class ParkService:
# Handle location if provided
if location_data:
LocationService.create_park_location(park=park, **location_data)
ParkLocationService.create_park_location(park=park, **location_data)
return park
@@ -226,97 +226,3 @@ class ParkService:
park.save()
return park
class LocationService:
"""Service for managing location operations."""
@staticmethod
def create_park_location(
*,
park: Park,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
street_address: str = "",
city: str = "",
state: str = "",
country: str = "",
postal_code: str = "",
) -> Location:
"""
Create a location for a park.
Args:
park: Park instance
latitude: Latitude coordinate
longitude: Longitude coordinate
street_address: Street address
city: City name
state: State/region name
country: Country name
postal_code: Postal/ZIP code
Returns:
Created Location instance
Raises:
ValidationError: If location data is invalid
"""
location = Location(
content_object=park,
name=park.name,
location_type="park",
latitude=latitude,
longitude=longitude,
street_address=street_address,
city=city,
state=state,
country=country,
postal_code=postal_code,
)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
location.full_clean()
location.save()
return location
@staticmethod
def update_park_location(
*, park_id: int, location_updates: Dict[str, Any]
) -> Location:
"""
Update location information for a park.
Args:
park_id: ID of the park
location_updates: Dictionary of location field updates
Returns:
Updated Location instance
Raises:
Location.DoesNotExist: If location doesn't exist
ValidationError: If location data is invalid
"""
with transaction.atomic():
park = Park.objects.get(id=park_id)
try:
location = park.location
except Location.DoesNotExist:
# Create location if it doesn't exist
return LocationService.create_park_location(
park=park, **location_updates
)
# Apply updates
for field, value in location_updates.items():
if hasattr(location, field):
setattr(location, field, value)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
location.full_clean()
location.save()
return location

View File

@@ -1,7 +1,7 @@
from .querysets import get_base_park_queryset
from apps.core.mixins import HTMXFilterableMixin
from .models.location import ParkLocation
from apps.media.models import Photo
from .models.media import ParkPhoto
from apps.moderation.models import EditSubmission
from apps.moderation.mixins import (
EditSubmissionMixin,
@@ -547,12 +547,11 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
uploaded_count = 0
for photo_file in photos:
try:
Photo.objects.create(
ParkPhoto.objects.create(
image=photo_file,
uploaded_by=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
object_id=self.object.id,
)
park=self.object,
) )
uploaded_count += 1
except Exception as e:
messages.error(
@@ -718,7 +717,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
uploaded_count = 0
for photo_file in photos:
try:
Photo.objects.create(
ParkPhoto.objects.create(
image=photo_file,
uploaded_by=self.request.user,
content_type=ContentType.objects.get_for_model(Park),

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

Some files were not shown because too many files have changed in this diff Show More