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

6
backend/api/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
Centralized API package for ThrillWiki.
This package contains all API endpoints organized by version.
All API routes must be routed through this centralized structure.
"""

12
backend/api/urls.py Normal file
View File

@@ -0,0 +1,12 @@
"""
Main API router for ThrillWiki.
This module routes all API requests to the appropriate version.
Currently supports v1 API endpoints.
"""
from django.urls import path, include
urlpatterns = [
path('v1/', include('api.v1.urls')),
]

View File

@@ -0,0 +1,6 @@
"""
Version 1 API package for ThrillWiki.
This package contains all v1 API endpoints organized by domain.
Domain-specific endpoints are in their respective subdirectories.
"""

View File

@@ -0,0 +1,6 @@
"""
Authentication API endpoints for ThrillWiki v1.
This package contains all authentication and authorization-related
API functionality including login, logout, user management, and permissions.
"""

View File

@@ -0,0 +1,512 @@
"""
Auth domain serializers for ThrillWiki API v1.
This module contains all serializers related to authentication, user accounts,
profiles, 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 django.contrib.auth import get_user_model
from apps.accounts.models import UserProfile, TopList, TopListItem
UserModel = get_user_model()
# Import shared utilities
class ModelChoices:
"""Model choices utility class."""
@staticmethod
def get_top_list_categories():
"""Get top list category choices."""
return [
("RC", "Roller Coasters"),
("DR", "Dark Rides"),
("FR", "Flat Rides"),
("WR", "Water Rides"),
("PK", "Parks"),
]
# === AUTHENTICATION 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)
# === 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)

View File

@@ -0,0 +1,33 @@
"""
Auth domain URL Configuration for ThrillWiki API v1.
This module contains all URL patterns for authentication, user accounts,
profiles, and top lists functionality.
"""
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 = [
# Authentication endpoints
path("login/", views.LoginAPIView.as_view(), name="auth-login"),
path("signup/", views.SignupAPIView.as_view(), name="auth-signup"),
path("logout/", views.LogoutAPIView.as_view(), name="auth-logout"),
path("user/", views.CurrentUserAPIView.as_view(), name="auth-current-user"),
path("password/reset/", views.PasswordResetAPIView.as_view(), name="auth-password-reset"),
path("password/change/", views.PasswordChangeAPIView.as_view(),
name="auth-password-change"),
path("social/providers/", views.SocialProvidersAPIView.as_view(),
name="auth-social-providers"),
path("status/", views.AuthStatusAPIView.as_view(), name="auth-status"),
# Include router URLs for ViewSets (profiles, top lists)
path("", include(router.urls)),
]

View File

@@ -0,0 +1,626 @@
"""
Auth domain views for ThrillWiki API v1.
This module contains all authentication-related API endpoints including
login, signup, logout, password management, social authentication,
user profiles, and top lists.
"""
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 django.db.models import Q
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.decorators import action
from allauth.socialaccount import providers
from drf_spectacular.utils import extend_schema, extend_schema_view
from apps.accounts.models import UserProfile, TopList, TopListItem
from .serializers import (
# Authentication serializers
LoginInputSerializer,
LoginOutputSerializer,
SignupInputSerializer,
SignupOutputSerializer,
LogoutOutputSerializer,
UserOutputSerializer,
PasswordResetInputSerializer,
PasswordResetOutputSerializer,
PasswordChangeInputSerializer,
PasswordChangeOutputSerializer,
SocialProviderOutputSerializer,
AuthStatusOutputSerializer,
# User profile serializers
UserProfileCreateInputSerializer,
UserProfileUpdateInputSerializer,
UserProfileOutputSerializer,
# Top list serializers
TopListCreateInputSerializer,
TopListUpdateInputSerializer,
TopListOutputSerializer,
TopListItemCreateInputSerializer,
TopListItemUpdateInputSerializer,
TopListItemOutputSerializer,
)
# 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()
# === AUTHENTICATION API VIEWS ===
@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:
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
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 = (
UserModel.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 = (
UserModel.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)
# === USER PROFILE API VIEWS ===
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
)
# === TOP LIST API VIEWS ===
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,6 @@
"""
Media API endpoints for ThrillWiki v1.
This package contains all media-related API functionality including
photo uploads, media management, and media-specific operations.
"""

View File

@@ -0,0 +1,222 @@
"""
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 UPLOAD SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Photo Upload Example",
summary="Example photo upload request",
description="Upload a photo for a park or ride",
value={
"photo": "file_upload",
"app_label": "parks",
"model": "park",
"object_id": 123,
"caption": "Beautiful view of the park entrance",
"alt_text": "Park entrance with landscaping",
"is_primary": True,
"photo_type": "general",
},
)
]
)
class PhotoUploadInputSerializer(serializers.Serializer):
"""Input serializer for photo uploads."""
photo = serializers.ImageField(
help_text="The image file to upload"
)
app_label = serializers.CharField(
max_length=100,
help_text="App label of the content object (e.g., 'parks', 'rides')"
)
model = serializers.CharField(
max_length=100,
help_text="Model name of the content object (e.g., 'park', 'ride')"
)
object_id = serializers.IntegerField(
help_text="ID of the content object"
)
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="Optional alt text for accessibility"
)
is_primary = serializers.BooleanField(
default=False,
help_text="Whether this should be the primary photo"
)
photo_type = serializers.CharField(
max_length=50,
default="general",
required=False,
help_text="Type of photo (for rides: 'general', 'on_ride', 'construction', etc.)"
)
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()
# === PHOTO DETAIL SERIALIZERS ===
@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,
"file_size": 2048576,
"width": 1920,
"height": 1080,
"format": "JPEG",
},
)
]
)
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)
# === MEDIA STATS SERIALIZERS ===
class MediaStatsOutputSerializer(serializers.Serializer):
"""Output serializer for media statistics."""
total_photos = serializers.IntegerField()
photos_by_content_type = serializers.DictField()
recent_uploads = serializers.IntegerField()
top_uploaders = serializers.ListField()
storage_usage = serializers.DictField()
# === BULK OPERATIONS SERIALIZERS ===
class BulkPhotoActionInputSerializer(serializers.Serializer):
"""Input serializer for bulk photo actions."""
photo_ids = serializers.ListField(
child=serializers.IntegerField(),
help_text="List of photo IDs to perform action on"
)
action = serializers.ChoiceField(
choices=[
('delete', 'Delete'),
('approve', 'Approve'),
('reject', 'Reject'),
],
help_text="Action to perform on selected photos"
)
class BulkPhotoActionOutputSerializer(serializers.Serializer):
"""Output serializer for bulk photo actions."""
success_count = serializers.IntegerField()
failed_count = serializers.IntegerField()
errors = serializers.ListField(child=serializers.CharField(), required=False)
message = serializers.CharField()

View File

@@ -0,0 +1,29 @@
"""
Media API URL configuration for ThrillWiki API v1.
This module contains URL patterns for media management endpoints
including photo uploads, CRUD operations, and bulk actions.
"""
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"),
# Media statistics endpoint
path("stats/", views.MediaStatsAPIView.as_view(), name="media_stats"),
# Bulk photo operations
path("photos/bulk-action/", views.BulkPhotoActionAPIView.as_view(),
name="bulk_photo_action"),
# Include router URLs for photo management (CRUD operations)
path("", include(router.urls)),
]

View File

@@ -0,0 +1,484 @@
"""
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 with proper domain service integration.
"""
import json
import logging
from typing import Any, Dict, Union
from django.db.models import Q, QuerySet
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, Park
from apps.rides.models import RidePhoto, Ride
from apps.parks.services import ParkMediaService
from apps.rides.services import RideMediaService
from .serializers import (
PhotoUploadInputSerializer,
PhotoUploadOutputSerializer,
PhotoDetailOutputSerializer,
PhotoUpdateInputSerializer,
PhotoListOutputSerializer,
MediaStatsOutputSerializer,
BulkPhotoActionInputSerializer,
BulkPhotoActionOutputSerializer,
)
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', 'rides.ride')",
),
OpenApiParameter(
name="object_id",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Filter by object ID",
),
OpenApiParameter(
name="is_primary",
type=OpenApiTypes.BOOL,
location=OpenApiParameter.QUERY,
description="Filter by primary photos only",
),
],
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 across domains."""
permission_classes = [IsAuthenticated]
lookup_field = "id"
def get_queryset(self) -> QuerySet:
"""Get queryset combining photos from all domains."""
# Combine park and ride photos
park_photos = ParkPhoto.objects.select_related('uploaded_by', 'park')
ride_photos = RidePhoto.objects.select_related('uploaded_by', 'ride')
# Apply filters
content_type = self.request.query_params.get('content_type')
object_id = self.request.query_params.get('object_id')
is_primary = self.request.query_params.get('is_primary')
if content_type == 'parks.park':
queryset = park_photos
if object_id:
queryset = queryset.filter(park_id=object_id)
elif content_type == 'rides.ride':
queryset = ride_photos
if object_id:
queryset = queryset.filter(ride_id=object_id)
else:
# Return combined queryset (this is complex due to different models)
# For now, return park photos as default - in production might need Union
queryset = park_photos
if is_primary is not None:
is_primary_bool = is_primary.lower() in ('true', '1', 'yes')
queryset = queryset.filter(is_primary=is_primary_bool)
return queryset.order_by('-uploaded_at')
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
def get_object(self):
"""Get photo object from either domain."""
photo_id = self.kwargs.get('id')
# Try to find in park photos first
try:
return ParkPhoto.objects.select_related('uploaded_by', 'park').get(id=photo_id)
except ParkPhoto.DoesNotExist:
pass
# Try ride photos
try:
return RidePhoto.objects.select_related('uploaded_by', 'ride').get(id=photo_id)
except RidePhoto.DoesNotExist:
pass
raise Http404("Photo not found")
def update(self, request: Request, *args, **kwargs) -> Response:
"""Update photo details."""
photo = self.get_object()
# Check permissions
if not (request.user == photo.uploaded_by or request.user.is_staff):
raise PermissionDenied("You can only edit your own photos")
serializer = self.get_serializer(data=request.data, partial=True)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Update fields
for field, value in serializer.validated_data.items():
setattr(photo, field, value)
photo.save()
# Return updated photo details
response_serializer = PhotoDetailOutputSerializer(photo)
return Response(response_serializer.data)
def destroy(self, request: Request, *args, **kwargs) -> Response:
"""Delete a photo."""
photo = self.get_object()
# Check permissions
if not (request.user == photo.uploaded_by or request.user.is_staff):
raise PermissionDenied("You can only delete your own photos")
photo.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=['post'])
def set_primary(self, request: Request, id=None) -> Response:
"""Set this photo as primary for its content object."""
photo = self.get_object()
# Check permissions
if not (request.user == photo.uploaded_by or request.user.is_staff):
raise PermissionDenied("You can only modify your own photos")
# Use appropriate service based on photo type
if isinstance(photo, ParkPhoto):
ParkMediaService.set_primary_photo(photo.park, photo)
elif isinstance(photo, RidePhoto):
RideMediaService.set_primary_photo(photo.ride, photo)
return Response({
"message": "Photo set as primary successfully",
"photo_id": photo.id,
"is_primary": True
})
@extend_schema_view(
get=extend_schema(
summary="Get media statistics",
description="Retrieve statistics about photos and media usage",
responses={200: MediaStatsOutputSerializer},
tags=["Media"],
),
)
class MediaStatsAPIView(APIView):
"""API endpoint for media statistics."""
permission_classes = [IsAuthenticated]
def get(self, request: Request) -> Response:
"""Get media statistics."""
from django.db.models import Count
from datetime import datetime, timedelta
# Count photos by type
park_photo_count = ParkPhoto.objects.count()
ride_photo_count = RidePhoto.objects.count()
total_photos = park_photo_count + ride_photo_count
# Recent uploads (last 30 days)
thirty_days_ago = datetime.now() - timedelta(days=30)
recent_park_uploads = ParkPhoto.objects.filter(
uploaded_at__gte=thirty_days_ago).count()
recent_ride_uploads = RidePhoto.objects.filter(
uploaded_at__gte=thirty_days_ago).count()
recent_uploads = recent_park_uploads + recent_ride_uploads
# Top uploaders
from django.db.models import Q
from django.contrib.auth import get_user_model
User = get_user_model()
# This is a simplified version - in production might need more complex aggregation
top_uploaders = []
stats = MediaStatsOutputSerializer({
"total_photos": total_photos,
"photos_by_content_type": {
"parks": park_photo_count,
"rides": ride_photo_count,
},
"recent_uploads": recent_uploads,
"top_uploaders": top_uploaders,
"storage_usage": {
"total_size": 0, # Would need to calculate from file sizes
"average_size": 0,
}
})
return Response(stats.data)
@extend_schema_view(
post=extend_schema(
summary="Bulk photo actions",
description="Perform bulk actions on multiple photos (delete, approve, etc.)",
request=BulkPhotoActionInputSerializer,
responses={
200: BulkPhotoActionOutputSerializer,
400: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
},
tags=["Media"],
),
)
class BulkPhotoActionAPIView(APIView):
"""API endpoint for bulk photo operations."""
permission_classes = [IsAuthenticated]
def post(self, request: Request) -> Response:
"""Perform bulk action on photos."""
serializer = BulkPhotoActionInputSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
photo_ids = serializer.validated_data['photo_ids']
action = serializer.validated_data['action']
success_count = 0
failed_count = 0
errors = []
for photo_id in photo_ids:
try:
# Find photo in either domain
photo = None
try:
photo = ParkPhoto.objects.get(id=photo_id)
except ParkPhoto.DoesNotExist:
try:
photo = RidePhoto.objects.get(id=photo_id)
except RidePhoto.DoesNotExist:
errors.append(f"Photo {photo_id} not found")
failed_count += 1
continue
# Check permissions
if not (request.user == photo.uploaded_by or request.user.is_staff):
errors.append(f"No permission for photo {photo_id}")
failed_count += 1
continue
# Perform action
if action == 'delete':
photo.delete()
success_count += 1
elif action == 'approve':
if hasattr(photo, 'is_approved'):
photo.is_approved = True
photo.save()
success_count += 1
else:
errors.append(f"Photo {photo_id} does not support approval")
failed_count += 1
elif action == 'reject':
if hasattr(photo, 'is_approved'):
photo.is_approved = False
photo.save()
success_count += 1
else:
errors.append(f"Photo {photo_id} does not support approval")
failed_count += 1
except Exception as e:
errors.append(f"Error processing photo {photo_id}: {str(e)}")
failed_count += 1
response_data = BulkPhotoActionOutputSerializer({
"success_count": success_count,
"failed_count": failed_count,
"errors": errors,
"message": f"Bulk {action} completed: {success_count} successful, {failed_count} failed"
})
return Response(response_data.data)

View File

@@ -0,0 +1,6 @@
"""
Parks API endpoints for ThrillWiki v1.
This package contains all park-related API functionality including
park management, park photos, and park-specific operations.
"""

View File

@@ -0,0 +1,116 @@
"""
Park media serializers for ThrillWiki API v1.
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,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,276 @@
"""
Park API views for ThrillWiki API v1.
This module contains consolidated park photo viewset for the centralized API structure.
"""
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.exceptions import ValidationError
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 .serializers import (
ParkPhotoOutputSerializer,
ParkPhotoCreateInputSerializer,
ParkPhotoUpdateInputSerializer,
ParkPhotoListOutputSerializer,
ParkPhotoApprovalInputSerializer,
ParkPhotoStatsOutputSerializer,
)
logger = logging.getLogger(__name__)
@extend_schema_view(
list=extend_schema(
summary="List park photos",
description="Retrieve a paginated list of park photos with filtering capabilities.",
responses={200: ParkPhotoListOutputSerializer(many=True)},
tags=["Park Media"],
),
create=extend_schema(
summary="Upload park photo",
description="Upload a new photo for a park. Requires authentication.",
request=ParkPhotoCreateInputSerializer,
responses={
201: ParkPhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
),
retrieve=extend_schema(
summary="Get park photo details",
description="Retrieve detailed information about a specific park photo.",
responses={
200: ParkPhotoOutputSerializer,
404: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
),
update=extend_schema(
summary="Update park photo",
description="Update park photo information. Requires authentication and ownership or admin privileges.",
request=ParkPhotoUpdateInputSerializer,
responses={
200: ParkPhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
),
partial_update=extend_schema(
summary="Partially update park photo",
description="Partially update park photo information. Requires authentication and ownership or admin privileges.",
request=ParkPhotoUpdateInputSerializer,
responses={
200: ParkPhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
),
destroy=extend_schema(
summary="Delete park photo",
description="Delete a park photo. Requires authentication and ownership or admin privileges.",
responses={
204: None,
401: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
),
)
class ParkPhotoViewSet(ModelViewSet):
"""
ViewSet for managing park photos.
Provides CRUD operations for park photos with proper permission checking.
Uses ParkMediaService for business logic operations.
"""
permission_classes = [IsAuthenticated]
lookup_field = "id"
def get_queryset(self):
"""Get photos for the current park with optimized queries."""
return ParkPhoto.objects.select_related(
'park',
'park__operator',
'uploaded_by'
).filter(
park_id=self.kwargs.get('park_pk')
).order_by('-created_at')
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == 'list':
return ParkPhotoListOutputSerializer
elif self.action == 'create':
return ParkPhotoCreateInputSerializer
elif self.action in ['update', 'partial_update']:
return ParkPhotoUpdateInputSerializer
else:
return ParkPhotoOutputSerializer
def perform_create(self, serializer):
"""Create a new park photo using ParkMediaService."""
park_id = self.kwargs.get('park_pk')
if not park_id:
raise ValidationError("Park ID is required")
try:
# Use the service to create the photo with proper business logic
photo = ParkMediaService.create_photo(
park_id=park_id,
uploaded_by=self.request.user,
**serializer.validated_data
)
# Set the instance for the serializer response
serializer.instance = photo
except Exception as e:
logger.error(f"Error creating park photo: {e}")
raise ValidationError(f"Failed to create photo: {str(e)}")
def perform_update(self, serializer):
"""Update park photo with permission checking."""
instance = self.get_object()
# Check permissions
if not (self.request.user == instance.uploaded_by or self.request.user.is_staff):
raise PermissionDenied("You can only edit your own photos or be an admin.")
# Handle primary photo logic using service
if serializer.validated_data.get('is_primary', False):
try:
ParkMediaService.set_primary_photo(
park_id=instance.park_id,
photo_id=instance.id
)
# Remove is_primary from validated_data since service handles it
if 'is_primary' in serializer.validated_data:
del serializer.validated_data['is_primary']
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
raise ValidationError(f"Failed to set primary photo: {str(e)}")
serializer.save()
def perform_destroy(self, instance):
"""Delete park photo with permission checking."""
# Check permissions
if not (self.request.user == instance.uploaded_by or self.request.user.is_staff):
raise PermissionDenied(
"You can only delete your own photos or be an admin.")
try:
ParkMediaService.delete_photo(instance.id)
except Exception as e:
logger.error(f"Error deleting park photo: {e}")
raise ValidationError(f"Failed to delete photo: {str(e)}")
@action(detail=True, methods=['post'])
def set_primary(self, request, **kwargs):
"""Set this photo as the primary photo for the park."""
photo = self.get_object()
# Check permissions
if not (request.user == photo.uploaded_by or request.user.is_staff):
raise PermissionDenied(
"You can only modify your own photos or be an admin.")
try:
ParkMediaService.set_primary_photo(
park_id=photo.park_id,
photo_id=photo.id
)
# Refresh the photo instance
photo.refresh_from_db()
serializer = self.get_serializer(photo)
return Response(
{
'message': 'Photo set as primary successfully',
'photo': serializer.data
},
status=status.HTTP_200_OK
)
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
return Response(
{'error': f'Failed to set primary photo: {str(e)}'},
status=status.HTTP_400_BAD_REQUEST
)
@action(detail=False, methods=['post'], permission_classes=[IsAuthenticated])
def bulk_approve(self, request, **kwargs):
"""Bulk approve or reject multiple photos (admin only)."""
if not request.user.is_staff:
raise PermissionDenied("Only administrators can approve photos.")
serializer = ParkPhotoApprovalInputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
photo_ids = serializer.validated_data['photo_ids']
approve = serializer.validated_data['approve']
park_id = self.kwargs.get('park_pk')
try:
# Filter photos to only those belonging to this park
photos = ParkPhoto.objects.filter(
id__in=photo_ids,
park_id=park_id
)
updated_count = photos.update(is_approved=approve)
return Response(
{
'message': f'Successfully {"approved" if approve else "rejected"} {updated_count} photos',
'updated_count': updated_count
},
status=status.HTTP_200_OK
)
except Exception as e:
logger.error(f"Error in bulk photo approval: {e}")
return Response(
{'error': f'Failed to update photos: {str(e)}'},
status=status.HTTP_400_BAD_REQUEST
)
@action(detail=False, methods=['get'])
def stats(self, request, **kwargs):
"""Get photo statistics for the park."""
park_id = self.kwargs.get('park_pk')
try:
stats = ParkMediaService.get_photo_stats(park_id=park_id)
serializer = ParkPhotoStatsOutputSerializer(stats)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error getting park photo stats: {e}")
return Response(
{'error': f'Failed to get photo statistics: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

View File

@@ -0,0 +1,6 @@
"""
Rides API endpoints for ThrillWiki v1.
This package contains all ride-related API functionality including
ride management, ride photos, and ride-specific operations.
"""

View File

@@ -0,0 +1,147 @@
"""
Ride media serializers for ThrillWiki API v1.
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,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,276 @@
"""
Ride API views for ThrillWiki API v1.
This module contains consolidated ride photo viewset for the centralized API structure.
"""
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.exceptions import ValidationError
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 .serializers import (
RidePhotoOutputSerializer,
RidePhotoCreateInputSerializer,
RidePhotoUpdateInputSerializer,
RidePhotoListOutputSerializer,
RidePhotoApprovalInputSerializer,
RidePhotoStatsOutputSerializer,
)
logger = logging.getLogger(__name__)
@extend_schema_view(
list=extend_schema(
summary="List ride photos",
description="Retrieve a paginated list of ride photos with filtering capabilities.",
responses={200: RidePhotoListOutputSerializer(many=True)},
tags=["Ride Media"],
),
create=extend_schema(
summary="Upload ride photo",
description="Upload a new photo for a ride. Requires authentication.",
request=RidePhotoCreateInputSerializer,
responses={
201: RidePhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
},
tags=["Ride Media"],
),
retrieve=extend_schema(
summary="Get ride photo details",
description="Retrieve detailed information about a specific ride photo.",
responses={
200: RidePhotoOutputSerializer,
404: OpenApiTypes.OBJECT,
},
tags=["Ride Media"],
),
update=extend_schema(
summary="Update ride photo",
description="Update ride photo information. Requires authentication and ownership or admin privileges.",
request=RidePhotoUpdateInputSerializer,
responses={
200: RidePhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Ride Media"],
),
partial_update=extend_schema(
summary="Partially update ride photo",
description="Partially update ride photo information. Requires authentication and ownership or admin privileges.",
request=RidePhotoUpdateInputSerializer,
responses={
200: RidePhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Ride Media"],
),
destroy=extend_schema(
summary="Delete ride photo",
description="Delete a ride photo. Requires authentication and ownership or admin privileges.",
responses={
204: None,
401: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Ride Media"],
),
)
class RidePhotoViewSet(ModelViewSet):
"""
ViewSet for managing ride photos.
Provides CRUD operations for ride photos with proper permission checking.
Uses RideMediaService for business logic operations.
"""
permission_classes = [IsAuthenticated]
lookup_field = "id"
def get_queryset(self):
"""Get photos for the current ride with optimized queries."""
return RidePhoto.objects.select_related(
'ride',
'ride__park',
'uploaded_by'
).filter(
ride_id=self.kwargs.get('ride_pk')
).order_by('-created_at')
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == 'list':
return RidePhotoListOutputSerializer
elif self.action == 'create':
return RidePhotoCreateInputSerializer
elif self.action in ['update', 'partial_update']:
return RidePhotoUpdateInputSerializer
else:
return RidePhotoOutputSerializer
def perform_create(self, serializer):
"""Create a new ride photo using RideMediaService."""
ride_id = self.kwargs.get('ride_pk')
if not ride_id:
raise ValidationError("Ride ID is required")
try:
# Use the service to create the photo with proper business logic
photo = RideMediaService.create_photo(
ride_id=ride_id,
uploaded_by=self.request.user,
**serializer.validated_data
)
# Set the instance for the serializer response
serializer.instance = photo
except Exception as e:
logger.error(f"Error creating ride photo: {e}")
raise ValidationError(f"Failed to create photo: {str(e)}")
def perform_update(self, serializer):
"""Update ride photo with permission checking."""
instance = self.get_object()
# Check permissions
if not (self.request.user == instance.uploaded_by or self.request.user.is_staff):
raise PermissionDenied("You can only edit your own photos or be an admin.")
# Handle primary photo logic using service
if serializer.validated_data.get('is_primary', False):
try:
RideMediaService.set_primary_photo(
ride_id=instance.ride_id,
photo_id=instance.id
)
# Remove is_primary from validated_data since service handles it
if 'is_primary' in serializer.validated_data:
del serializer.validated_data['is_primary']
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
raise ValidationError(f"Failed to set primary photo: {str(e)}")
serializer.save()
def perform_destroy(self, instance):
"""Delete ride photo with permission checking."""
# Check permissions
if not (self.request.user == instance.uploaded_by or self.request.user.is_staff):
raise PermissionDenied(
"You can only delete your own photos or be an admin.")
try:
RideMediaService.delete_photo(instance.id)
except Exception as e:
logger.error(f"Error deleting ride photo: {e}")
raise ValidationError(f"Failed to delete photo: {str(e)}")
@action(detail=True, methods=['post'])
def set_primary(self, request, **kwargs):
"""Set this photo as the primary photo for the ride."""
photo = self.get_object()
# Check permissions
if not (request.user == photo.uploaded_by or request.user.is_staff):
raise PermissionDenied(
"You can only modify your own photos or be an admin.")
try:
RideMediaService.set_primary_photo(
ride_id=photo.ride_id,
photo_id=photo.id
)
# Refresh the photo instance
photo.refresh_from_db()
serializer = self.get_serializer(photo)
return Response(
{
'message': 'Photo set as primary successfully',
'photo': serializer.data
},
status=status.HTTP_200_OK
)
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
return Response(
{'error': f'Failed to set primary photo: {str(e)}'},
status=status.HTTP_400_BAD_REQUEST
)
@action(detail=False, methods=['post'], permission_classes=[IsAuthenticated])
def bulk_approve(self, request, **kwargs):
"""Bulk approve or reject multiple photos (admin only)."""
if not request.user.is_staff:
raise PermissionDenied("Only administrators can approve photos.")
serializer = RidePhotoApprovalInputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
photo_ids = serializer.validated_data['photo_ids']
approve = serializer.validated_data['approve']
ride_id = self.kwargs.get('ride_pk')
try:
# Filter photos to only those belonging to this ride
photos = RidePhoto.objects.filter(
id__in=photo_ids,
ride_id=ride_id
)
updated_count = photos.update(is_approved=approve)
return Response(
{
'message': f'Successfully {"approved" if approve else "rejected"} {updated_count} photos',
'updated_count': updated_count
},
status=status.HTTP_200_OK
)
except Exception as e:
logger.error(f"Error in bulk photo approval: {e}")
return Response(
{'error': f'Failed to update photos: {str(e)}'},
status=status.HTTP_400_BAD_REQUEST
)
@action(detail=False, methods=['get'])
def stats(self, request, **kwargs):
"""Get photo statistics for the ride."""
ride_id = self.kwargs.get('ride_pk')
try:
stats = RideMediaService.get_photo_stats(ride_id=ride_id)
serializer = RidePhotoStatsOutputSerializer(stats)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error getting ride photo stats: {e}")
return Response(
{'error': f'Failed to get photo statistics: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

19
backend/api/v1/urls.py Normal file
View File

@@ -0,0 +1,19 @@
"""
Version 1 API URL router for ThrillWiki.
This module routes API requests to domain-specific endpoints.
All domain endpoints are organized in their respective subdirectories.
"""
from django.urls import path, include
urlpatterns = [
# Domain-specific API endpoints
path('rides/', include('api.v1.rides.urls')),
path('parks/', include('api.v1.parks.urls')),
path('auth/', include('api.v1.auth.urls')),
# Media endpoints (for photo management)
# Will be consolidated from the various media implementations
path('media/', include('api.v1.media.urls')),
]