mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-27 12:07:04 -05:00
feat: Add blog, media, and support apps, implement ride credits and image API, and remove toplist feature.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from apps.accounts.models import UserProfile, TopList, TopListItem
|
||||
from apps.accounts.models import UserProfile
|
||||
from apps.accounts.serializers import UserSerializer # existing shared user serializer
|
||||
|
||||
|
||||
@@ -11,10 +11,21 @@ class UserProfileCreateInputSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
|
||||
cloudflare_image_id = serializers.CharField(write_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = "__all__"
|
||||
extra_kwargs = {"user": {"read_only": True}}
|
||||
extra_kwargs = {"user": {"read_only": True}, "avatar": {"read_only": True}}
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
cloudflare_id = validated_data.pop("cloudflare_image_id", None)
|
||||
if cloudflare_id:
|
||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||
image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id)
|
||||
instance.avatar = image
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class UserProfileOutputSerializer(serializers.ModelSerializer):
|
||||
@@ -38,49 +49,3 @@ class UserProfileOutputSerializer(serializers.ModelSerializer):
|
||||
if avatar:
|
||||
return getattr(avatar, "url", None)
|
||||
return None
|
||||
|
||||
|
||||
class TopListItemCreateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TopListItem
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class TopListItemUpdateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TopListItem
|
||||
fields = "__all__"
|
||||
# allow updates, adjust as needed
|
||||
extra_kwargs = {"top_list": {"read_only": False}}
|
||||
|
||||
|
||||
class TopListItemOutputSerializer(serializers.ModelSerializer):
|
||||
# Remove the ride field since it doesn't exist on the model
|
||||
# The model likely uses a generic foreign key or different field name
|
||||
|
||||
class Meta:
|
||||
model = TopListItem
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class TopListCreateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TopList
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class TopListUpdateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TopList
|
||||
fields = "__all__"
|
||||
# user is set by view's perform_create
|
||||
extra_kwargs = {"user": {"read_only": True}}
|
||||
|
||||
|
||||
class TopListOutputSerializer(serializers.ModelSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
items = TopListItemOutputSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TopList
|
||||
fields = "__all__"
|
||||
|
||||
@@ -33,6 +33,8 @@ urlpatterns = [
|
||||
views.cancel_account_deletion,
|
||||
name="cancel_account_deletion",
|
||||
),
|
||||
# Data Export endpoint
|
||||
path("data-export/", views.export_user_data, name="export_user_data"),
|
||||
# User profile endpoints
|
||||
path("profile/", views.get_user_profile, name="get_user_profile"),
|
||||
path("profile/account/", views.update_user_account, name="update_user_account"),
|
||||
@@ -106,4 +108,19 @@ urlpatterns = [
|
||||
path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"),
|
||||
path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"),
|
||||
path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"),
|
||||
|
||||
# Public Profile
|
||||
path("profiles/<str:username>/", views.get_public_user_profile, name="get_public_user_profile"),
|
||||
]
|
||||
|
||||
# Register ViewSets
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from . import views_credits
|
||||
from django.urls import include
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"credits", views_credits.RideCreditViewSet, basename="ride-credit")
|
||||
|
||||
urlpatterns += [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ preferences, privacy, notifications, and security.
|
||||
|
||||
from apps.api.v1.serializers.accounts import (
|
||||
CompleteUserSerializer,
|
||||
PublicUserSerializer,
|
||||
UserPreferencesSerializer,
|
||||
NotificationSettingsSerializer,
|
||||
PrivacySettingsSerializer,
|
||||
@@ -23,6 +24,7 @@ from apps.api.v1.serializers.accounts import (
|
||||
AvatarUploadSerializer,
|
||||
)
|
||||
from apps.accounts.services import UserDeletionService
|
||||
from apps.accounts.export_service import UserExportService
|
||||
from apps.accounts.models import (
|
||||
User,
|
||||
UserProfile,
|
||||
@@ -1583,6 +1585,57 @@ def upload_avatar(request):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="export_user_data",
|
||||
summary="Export all user data",
|
||||
description="Generate a JSON dump of all user data including profile, reviews, and lists.",
|
||||
responses={
|
||||
200: {
|
||||
"description": "User data export",
|
||||
"example": {
|
||||
"account": {"username": "user", "email": "user@example.com"},
|
||||
"profile": {"display_name": "User"},
|
||||
"content": {"park_reviews": [], "lists": []}
|
||||
}
|
||||
},
|
||||
401: {"description": "Authentication required"},
|
||||
},
|
||||
tags=["Self-Service Account Management"],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def export_user_data(request):
|
||||
"""Export all user data as JSON."""
|
||||
try:
|
||||
export_data = UserExportService.export_user_data(request.user)
|
||||
return Response(export_data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting data for user {request.user.id}: {e}", exc_info=True)
|
||||
return Response(
|
||||
{"error": "Failed to generate data export"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_public_user_profile",
|
||||
summary="Get public user profile",
|
||||
description="Get the public profile of a user by username.",
|
||||
responses={
|
||||
200: PublicUserSerializer,
|
||||
404: {"description": "User not found"},
|
||||
},
|
||||
tags=["User Profile"],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([AllowAny])
|
||||
def get_public_user_profile(request, username):
|
||||
"""Get public user profile by username."""
|
||||
user = get_object_or_404(User, username=username)
|
||||
serializer = PublicUserSerializer(user)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
# === MISSING FUNCTION IMPLEMENTATIONS ===
|
||||
|
||||
|
||||
|
||||
51
backend/apps/api/v1/accounts/views_credits.py
Normal file
51
backend/apps/api/v1/accounts/views_credits.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from rest_framework import viewsets, permissions, filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from apps.rides.models.credits import RideCredit
|
||||
from apps.api.v1.serializers.ride_credits import RideCreditSerializer
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
class RideCreditViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing Ride Credits.
|
||||
Allows users to track rides they have ridden.
|
||||
"""
|
||||
serializer_class = RideCreditSerializer
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||
filterset_fields = ['user__username', 'ride__park__slug', 'ride__manufacturer__slug']
|
||||
ordering_fields = ['first_ridden_at', 'last_ridden_at', 'created_at', 'count', 'rating']
|
||||
ordering = ['-last_ridden_at']
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Return ride credits.
|
||||
Optionally filter by user via query param ?user=username
|
||||
"""
|
||||
queryset = RideCredit.objects.all().select_related('ride', 'ride__park', 'user')
|
||||
|
||||
# Filter by user if provided
|
||||
username = self.request.query_params.get('user')
|
||||
if username:
|
||||
queryset = queryset.filter(user__username=username)
|
||||
|
||||
return queryset
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Associate the current user with the ride credit."""
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
@extend_schema(
|
||||
summary="List ride credits",
|
||||
description="List ride credits. filter by user username.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="user",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by username",
|
||||
),
|
||||
]
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
@@ -37,16 +37,7 @@ def _normalize_email(value: str) -> str:
|
||||
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 ===
|
||||
@@ -480,129 +471,4 @@ class UserProfileUpdateInputSerializer(serializers.Serializer):
|
||||
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[str, Any]:
|
||||
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[str, Any]:
|
||||
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)
|
||||
|
||||
6
backend/apps/api/v1/images/urls.py
Normal file
6
backend/apps/api/v1/images/urls.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.urls import path
|
||||
from .views import GenerateUploadURLView
|
||||
|
||||
urlpatterns = [
|
||||
path("generate-upload-url/", GenerateUploadURLView.as_view(), name="generate-upload-url"),
|
||||
]
|
||||
37
backend/apps/api/v1/images/views.py
Normal file
37
backend/apps/api/v1/images/views.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework import status
|
||||
from apps.core.utils.cloudflare import get_direct_upload_url
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
import requests
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class GenerateUploadURLView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
# Pass user_id for metadata if needed
|
||||
result = get_direct_upload_url(user_id=str(request.user.id))
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
except ImproperlyConfigured as e:
|
||||
logger.error(f"Configuration Error: {e}")
|
||||
return Response(
|
||||
{"detail": "Server configuration error."},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Cloudflare API Error: {e}")
|
||||
return Response(
|
||||
{"detail": "Failed to generate upload URL."},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Unexpected error generating upload URL")
|
||||
return Response(
|
||||
{"detail": "An unexpected error occurred."},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
88
backend/apps/api/v1/parks/history_views.py
Normal file
88
backend/apps/api/v1/parks/history_views.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Park history API views.
|
||||
"""
|
||||
|
||||
from rest_framework import viewsets, mixins
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
from apps.api.v1.serializers.history import ParkHistoryOutputSerializer, RideHistoryOutputSerializer
|
||||
|
||||
class ParkHistoryViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
ViewSet for retrieving park history.
|
||||
"""
|
||||
permission_classes = [AllowAny]
|
||||
lookup_field = "slug"
|
||||
lookup_url_kwarg = "park_slug"
|
||||
|
||||
@extend_schema(
|
||||
summary="Get park history",
|
||||
description="Retrieve history events for a park.",
|
||||
responses={200: ParkHistoryOutputSerializer},
|
||||
tags=["Park History"],
|
||||
)
|
||||
def list(self, request, park_slug=None):
|
||||
park = get_object_or_404(Park, slug=park_slug)
|
||||
|
||||
events = []
|
||||
if hasattr(park, "events"):
|
||||
events = park.events.all().order_by("-pgh_created_at")
|
||||
|
||||
summary = {
|
||||
"total_events": len(events),
|
||||
"first_recorded": events.last().pgh_created_at if len(events) else None,
|
||||
"last_modified": events.first().pgh_created_at if len(events) else None,
|
||||
}
|
||||
|
||||
data = {
|
||||
"park": park,
|
||||
"current_state": park,
|
||||
"summary": summary,
|
||||
"events": events
|
||||
}
|
||||
|
||||
serializer = ParkHistoryOutputSerializer(data)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class RideHistoryViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
ViewSet for retrieving ride history.
|
||||
"""
|
||||
permission_classes = [AllowAny]
|
||||
lookup_field = "slug"
|
||||
lookup_url_kwarg = "ride_slug"
|
||||
|
||||
@extend_schema(
|
||||
summary="Get ride history",
|
||||
description="Retrieve history events for a ride.",
|
||||
responses={200: RideHistoryOutputSerializer},
|
||||
tags=["Ride History"],
|
||||
)
|
||||
def list(self, request, park_slug=None, ride_slug=None):
|
||||
park = get_object_or_404(Park, slug=park_slug)
|
||||
ride = get_object_or_404(Ride, slug=ride_slug, park=park)
|
||||
|
||||
events = []
|
||||
if hasattr(ride, "events"):
|
||||
events = ride.events.all().order_by("-pgh_created_at")
|
||||
|
||||
summary = {
|
||||
"total_events": len(events),
|
||||
"first_recorded": events.last().pgh_created_at if len(events) else None,
|
||||
"last_modified": events.first().pgh_created_at if len(events) else None,
|
||||
}
|
||||
|
||||
data = {
|
||||
"ride": ride,
|
||||
"current_state": ride,
|
||||
"summary": summary,
|
||||
"events": events
|
||||
}
|
||||
|
||||
serializer = RideHistoryOutputSerializer(data)
|
||||
return Response(serializer.data)
|
||||
162
backend/apps/api/v1/parks/park_reviews_views.py
Normal file
162
backend/apps/api/v1/parks/park_reviews_views.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Park review API views for ThrillWiki API v1.
|
||||
|
||||
This module contains park review ViewSet following the reviews pattern.
|
||||
Provides CRUD operations for park reviews nested under parks/{slug}/reviews/
|
||||
"""
|
||||
|
||||
import logging
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Avg
|
||||
from django.utils import timezone
|
||||
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, NotFound
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.parks.models import Park, ParkReview
|
||||
from apps.api.v1.serializers.park_reviews import (
|
||||
ParkReviewOutputSerializer,
|
||||
ParkReviewCreateInputSerializer,
|
||||
ParkReviewUpdateInputSerializer,
|
||||
ParkReviewListOutputSerializer,
|
||||
ParkReviewStatsOutputSerializer,
|
||||
ParkReviewModerationInputSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List park reviews",
|
||||
tags=["Park Reviews"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Create park review",
|
||||
tags=["Park Reviews"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get park review details",
|
||||
tags=["Park Reviews"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update park review",
|
||||
tags=["Park Reviews"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partially update park review",
|
||||
tags=["Park Reviews"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete park review",
|
||||
tags=["Park Reviews"],
|
||||
),
|
||||
)
|
||||
class ParkReviewViewSet(ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing park reviews with full CRUD operations.
|
||||
"""
|
||||
|
||||
lookup_field = "id"
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set permissions based on action."""
|
||||
if self.action in ['list', 'retrieve', 'stats']:
|
||||
permission_classes = [AllowAny]
|
||||
else:
|
||||
permission_classes = [IsAuthenticated]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get reviews for the current park."""
|
||||
queryset = ParkReview.objects.select_related(
|
||||
"park", "user", "user__profile"
|
||||
)
|
||||
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
if park_slug:
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
queryset = queryset.filter(park=park)
|
||||
except Park.DoesNotExist:
|
||||
return queryset.none()
|
||||
|
||||
if not (hasattr(self.request, 'user') and getattr(self.request.user, 'is_staff', False)):
|
||||
queryset = queryset.filter(is_published=True)
|
||||
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return ParkReviewListOutputSerializer
|
||||
elif self.action == "create":
|
||||
return ParkReviewCreateInputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return ParkReviewUpdateInputSerializer
|
||||
else:
|
||||
return ParkReviewOutputSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
except Park.DoesNotExist:
|
||||
raise NotFound("Park not found")
|
||||
|
||||
if ParkReview.objects.filter(park=park, user=self.request.user).exists():
|
||||
raise ValidationError("You have already reviewed this park")
|
||||
|
||||
serializer.save(
|
||||
park=park,
|
||||
user=self.request.user,
|
||||
is_published=True
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
instance = self.get_object()
|
||||
if not (self.request.user == instance.user or getattr(self.request.user, "is_staff", False)):
|
||||
raise PermissionDenied("You can only edit your own reviews.")
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if not (self.request.user == instance.user or getattr(self.request.user, "is_staff", False)):
|
||||
raise PermissionDenied("You can only delete your own reviews.")
|
||||
instance.delete()
|
||||
|
||||
@extend_schema(
|
||||
summary="Get park review statistics",
|
||||
responses={200: ParkReviewStatsOutputSerializer},
|
||||
tags=["Park Reviews"],
|
||||
)
|
||||
@action(detail=False, methods=["get"])
|
||||
def stats(self, request, park_slug=None):
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
except Park.DoesNotExist:
|
||||
return Response({"error": "Park not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
reviews = ParkReview.objects.filter(park=park, is_published=True)
|
||||
total_reviews = reviews.count()
|
||||
avg_rating = reviews.aggregate(avg=Avg('rating'))['avg']
|
||||
|
||||
rating_distribution = {}
|
||||
for i in range(1, 11):
|
||||
rating_distribution[str(i)] = reviews.filter(rating=i).count()
|
||||
|
||||
from datetime import timedelta
|
||||
recent_reviews = reviews.filter(created_at__gte=timezone.now() - timedelta(days=30)).count()
|
||||
|
||||
stats = {
|
||||
"total_reviews": total_reviews,
|
||||
"published_reviews": total_reviews,
|
||||
"pending_reviews": ParkReview.objects.filter(park=park, is_published=False).count(),
|
||||
"average_rating": avg_rating,
|
||||
"rating_distribution": rating_distribution,
|
||||
"recent_reviews": recent_reviews,
|
||||
}
|
||||
return Response(ParkReviewStatsOutputSerializer(stats).data)
|
||||
@@ -42,10 +42,19 @@ router.register(r"", ParkPhotoViewSet, basename="park-photo")
|
||||
# Create routers for nested ride endpoints
|
||||
ride_photos_router = DefaultRouter()
|
||||
ride_photos_router.register(r"", RidePhotoViewSet, basename="ride-photo")
|
||||
from .ride_reviews_views import RideReviewViewSet
|
||||
|
||||
ride_reviews_router = DefaultRouter()
|
||||
ride_reviews_router.register(r"", RideReviewViewSet, basename="ride-review")
|
||||
|
||||
from .park_reviews_views import ParkReviewViewSet
|
||||
from .history_views import ParkHistoryViewSet, RideHistoryViewSet
|
||||
|
||||
# Create routers for nested park endpoints
|
||||
reviews_router = DefaultRouter()
|
||||
reviews_router.register(r"", ParkReviewViewSet, basename="park-review")
|
||||
|
||||
|
||||
app_name = "api_v1_parks"
|
||||
|
||||
urlpatterns = [
|
||||
@@ -86,7 +95,7 @@ urlpatterns = [
|
||||
name="park-image-settings",
|
||||
),
|
||||
# Park photo endpoints - domain-specific photo management
|
||||
path("<int:park_pk>/photos/", include(router.urls)),
|
||||
path("<str:park_pk>/photos/", include(router.urls)),
|
||||
|
||||
# Nested ride photo endpoints - photos for specific rides within parks
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/photos/", include(ride_photos_router.urls)),
|
||||
@@ -95,6 +104,15 @@ urlpatterns = [
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
|
||||
# Nested ride review endpoints - reviews for specific rides within parks
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
|
||||
|
||||
# Ride History
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/history/", RideHistoryViewSet.as_view({'get': 'list'}), name="ride-history"),
|
||||
|
||||
# Park Reviews
|
||||
path("<str:park_slug>/reviews/", include(reviews_router.urls)),
|
||||
|
||||
# Park History
|
||||
path("<str:park_slug>/history/", ParkHistoryViewSet.as_view({'get': 'list'}), name="park-history"),
|
||||
|
||||
# Roadtrip API endpoints
|
||||
path("roadtrip/create/", CreateTripView.as_view(), name="roadtrip-create"),
|
||||
|
||||
@@ -142,10 +142,14 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
"park", "park__operator", "uploaded_by"
|
||||
)
|
||||
|
||||
# If park_pk is provided in URL kwargs, filter by park
|
||||
# If park_pk is provided in URL kwargs, filter by park
|
||||
park_pk = self.kwargs.get("park_pk")
|
||||
if park_pk:
|
||||
queryset = queryset.filter(park_id=park_pk)
|
||||
if str(park_pk).isdigit():
|
||||
queryset = queryset.filter(park_id=park_pk)
|
||||
else:
|
||||
queryset = queryset.filter(park__slug=park_pk)
|
||||
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
@@ -164,10 +168,16 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
"""Create a new park photo using ParkMediaService."""
|
||||
park_id = self.kwargs.get("park_pk")
|
||||
if not park_id:
|
||||
raise ValidationError("Park ID is required")
|
||||
raise ValidationError("Park ID/Slug is required")
|
||||
|
||||
try:
|
||||
Park.objects.get(pk=park_id)
|
||||
if str(park_id).isdigit():
|
||||
park = Park.objects.get(pk=park_id)
|
||||
else:
|
||||
park = Park.objects.get(slug=park_id)
|
||||
|
||||
# Use real park ID
|
||||
park_id = park.id
|
||||
except Park.DoesNotExist:
|
||||
raise ValidationError("Park not found")
|
||||
|
||||
@@ -342,7 +352,10 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
# Filter photos to only those belonging to this park (if park_pk provided)
|
||||
photos_queryset = ParkPhoto.objects.filter(id__in=photo_ids)
|
||||
if park_id:
|
||||
photos_queryset = photos_queryset.filter(park_id=park_id)
|
||||
if str(park_id).isdigit():
|
||||
photos_queryset = photos_queryset.filter(park_id=park_id)
|
||||
else:
|
||||
photos_queryset = photos_queryset.filter(park__slug=park_id)
|
||||
|
||||
updated_count = photos_queryset.update(is_approved=approve)
|
||||
|
||||
@@ -385,10 +398,13 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
park = None
|
||||
if park_pk:
|
||||
try:
|
||||
park = Park.objects.get(pk=park_pk)
|
||||
if str(park_pk).isdigit():
|
||||
park = Park.objects.get(pk=park_pk)
|
||||
else:
|
||||
park = Park.objects.get(slug=park_pk)
|
||||
except Park.DoesNotExist:
|
||||
return ErrorHandler.handle_api_error(
|
||||
NotFoundError(f"Park with id {park_pk} not found"),
|
||||
NotFoundError(f"Park with id/slug {park_pk} not found"),
|
||||
user_message="Park not found",
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
@@ -474,7 +490,10 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
)
|
||||
|
||||
try:
|
||||
park = Park.objects.get(pk=park_pk)
|
||||
if str(park_pk).isdigit():
|
||||
park = Park.objects.get(pk=park_pk)
|
||||
else:
|
||||
park = Park.objects.get(slug=park_pk)
|
||||
except Park.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Park not found"},
|
||||
|
||||
@@ -30,12 +30,7 @@ AuthStatusOutputSerializer: Any = None
|
||||
UserProfileCreateInputSerializer: Any = None
|
||||
UserProfileUpdateInputSerializer: Any = None
|
||||
UserProfileOutputSerializer: Any = None
|
||||
TopListCreateInputSerializer: Any = None
|
||||
TopListUpdateInputSerializer: Any = None
|
||||
TopListOutputSerializer: Any = None
|
||||
TopListItemCreateInputSerializer: Any = None
|
||||
TopListItemUpdateInputSerializer: Any = None
|
||||
TopListItemOutputSerializer: Any = None
|
||||
|
||||
|
||||
# Explicit __all__ for static analysis — update this list if new serializers are added.
|
||||
__all__ = (
|
||||
@@ -54,10 +49,5 @@ __all__ = (
|
||||
"UserProfileCreateInputSerializer",
|
||||
"UserProfileUpdateInputSerializer",
|
||||
"UserProfileOutputSerializer",
|
||||
"TopListCreateInputSerializer",
|
||||
"TopListUpdateInputSerializer",
|
||||
"TopListOutputSerializer",
|
||||
"TopListItemCreateInputSerializer",
|
||||
"TopListItemUpdateInputSerializer",
|
||||
"TopListItemOutputSerializer",
|
||||
|
||||
)
|
||||
|
||||
@@ -90,12 +90,7 @@ _ACCOUNTS_SYMBOLS: List[str] = [
|
||||
"UserProfileOutputSerializer",
|
||||
"UserProfileCreateInputSerializer",
|
||||
"UserProfileUpdateInputSerializer",
|
||||
"TopListOutputSerializer",
|
||||
"TopListCreateInputSerializer",
|
||||
"TopListUpdateInputSerializer",
|
||||
"TopListItemOutputSerializer",
|
||||
"TopListItemCreateInputSerializer",
|
||||
"TopListItemUpdateInputSerializer",
|
||||
|
||||
"UserOutputSerializer",
|
||||
"LoginInputSerializer",
|
||||
"LoginOutputSerializer",
|
||||
|
||||
@@ -18,6 +18,7 @@ from apps.accounts.models import (
|
||||
NotificationPreference,
|
||||
)
|
||||
from apps.lists.models import UserList
|
||||
from apps.rides.models.credits import RideCredit
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
UserModel = get_user_model()
|
||||
@@ -66,6 +67,8 @@ class UserProfileSerializer(serializers.ModelSerializer):
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
avatar_variants = serializers.SerializerMethodField()
|
||||
total_credits = serializers.SerializerMethodField()
|
||||
unique_parks = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
@@ -87,8 +90,19 @@ class UserProfileSerializer(serializers.ModelSerializer):
|
||||
"water_ride_credits",
|
||||
"unit_system",
|
||||
"location",
|
||||
"total_credits",
|
||||
"unique_parks",
|
||||
]
|
||||
read_only_fields = ["profile_id", "avatar_url", "avatar_variants"]
|
||||
read_only_fields = ["profile_id", "avatar_url", "avatar_variants", "total_credits", "unique_parks"]
|
||||
|
||||
def get_total_credits(self, obj):
|
||||
"""Get the total number of ride credits."""
|
||||
return RideCredit.objects.filter(user=obj.user).count()
|
||||
|
||||
def get_unique_parks(self, obj):
|
||||
"""Get the number of unique parks visited."""
|
||||
# This assumes RideCredit -> Ride -> Park relationship
|
||||
return RideCredit.objects.filter(user=obj.user).values("ride__park").distinct().count()
|
||||
|
||||
def get_avatar_url(self, obj):
|
||||
"""Get the avatar URL with fallback to default letter-based avatar."""
|
||||
@@ -167,6 +181,25 @@ class CompleteUserSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ["user_id", "date_joined", "role"]
|
||||
|
||||
|
||||
class PublicUserSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Public user serializer for viewing other users' profiles.
|
||||
Only exposes public information.
|
||||
"""
|
||||
profile = UserProfileSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"user_id",
|
||||
"username",
|
||||
"date_joined",
|
||||
"role",
|
||||
"profile",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
# === USER SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
|
||||
171
backend/apps/api/v1/serializers/park_reviews.py
Normal file
171
backend/apps/api/v1/serializers/park_reviews.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Serializers for park review API endpoints.
|
||||
|
||||
This module contains serializers for park review CRUD operations.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
|
||||
from apps.parks.models.reviews import ParkReview
|
||||
from apps.api.v1.serializers.reviews import ReviewUserSerializer
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Complete Park Review",
|
||||
summary="Full park review response",
|
||||
description="Example response showing all fields for a park review",
|
||||
value={
|
||||
"id": 123,
|
||||
"title": "Great family park!",
|
||||
"content": "We had a wonderful time. The atmosphere is charming.",
|
||||
"rating": 9,
|
||||
"visit_date": "2023-06-15",
|
||||
"created_at": "2023-06-16T10:30:00Z",
|
||||
"updated_at": "2023-06-16T10:30:00Z",
|
||||
"is_published": True,
|
||||
"user": {
|
||||
"username": "park_fan",
|
||||
"display_name": "Park Fan",
|
||||
"avatar_url": "https://example.com/avatar.jpg"
|
||||
},
|
||||
"park": {
|
||||
"id": 101,
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point"
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
)
|
||||
class ParkReviewOutputSerializer(serializers.ModelSerializer):
|
||||
"""Output serializer for park reviews."""
|
||||
|
||||
user = ReviewUserSerializer(read_only=True)
|
||||
park = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ParkReview
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"content",
|
||||
"rating",
|
||||
"visit_date",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"is_published",
|
||||
"user",
|
||||
"park",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"user",
|
||||
"park",
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_park(self, obj):
|
||||
"""Get park information."""
|
||||
return {
|
||||
"id": obj.park.id,
|
||||
"name": obj.park.name,
|
||||
"slug": obj.park.slug,
|
||||
}
|
||||
|
||||
|
||||
class ParkReviewCreateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for creating park reviews."""
|
||||
|
||||
class Meta:
|
||||
model = ParkReview
|
||||
fields = [
|
||||
"title",
|
||||
"content",
|
||||
"rating",
|
||||
"visit_date",
|
||||
]
|
||||
|
||||
def validate_rating(self, value):
|
||||
"""Validate rating is between 1 and 10."""
|
||||
if not (1 <= value <= 10):
|
||||
raise serializers.ValidationError("Rating must be between 1 and 10.")
|
||||
return value
|
||||
|
||||
|
||||
class ParkReviewUpdateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for updating park reviews."""
|
||||
|
||||
class Meta:
|
||||
model = ParkReview
|
||||
fields = [
|
||||
"title",
|
||||
"content",
|
||||
"rating",
|
||||
"visit_date",
|
||||
]
|
||||
|
||||
def validate_rating(self, value):
|
||||
"""Validate rating is between 1 and 10."""
|
||||
if not (1 <= value <= 10):
|
||||
raise serializers.ValidationError("Rating must be between 1 and 10.")
|
||||
return value
|
||||
|
||||
|
||||
class ParkReviewListOutputSerializer(serializers.ModelSerializer):
|
||||
"""Simplified output serializer for park review lists."""
|
||||
|
||||
user = ReviewUserSerializer(read_only=True)
|
||||
park_name = serializers.CharField(source="park.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ParkReview
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"rating",
|
||||
"visit_date",
|
||||
"created_at",
|
||||
"is_published",
|
||||
"user",
|
||||
"park_name",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class ParkReviewStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park review statistics."""
|
||||
|
||||
total_reviews = serializers.IntegerField()
|
||||
published_reviews = serializers.IntegerField()
|
||||
pending_reviews = serializers.IntegerField()
|
||||
average_rating = serializers.FloatField(allow_null=True)
|
||||
rating_distribution = serializers.DictField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="Count of reviews by rating (1-10)"
|
||||
)
|
||||
recent_reviews = serializers.IntegerField()
|
||||
|
||||
|
||||
class ParkReviewModerationInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for review moderation operations."""
|
||||
|
||||
review_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="List of review IDs to moderate"
|
||||
)
|
||||
action = serializers.ChoiceField(
|
||||
choices=[
|
||||
("publish", "Publish"),
|
||||
("unpublish", "Unpublish"),
|
||||
("delete", "Delete"),
|
||||
],
|
||||
help_text="Moderation action to perform"
|
||||
)
|
||||
moderation_notes = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="Optional notes about the moderation action"
|
||||
)
|
||||
47
backend/apps/api/v1/serializers/ride_credits.py
Normal file
47
backend/apps/api/v1/serializers/ride_credits.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from apps.rides.models.credits import RideCredit
|
||||
from apps.rides.models import Ride
|
||||
from apps.api.v1.serializers.rides import RideListOutputSerializer
|
||||
|
||||
class RideCreditSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user ride credits."""
|
||||
|
||||
ride_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Ride.objects.all(), source='ride', write_only=True
|
||||
)
|
||||
ride = RideListOutputSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RideCredit
|
||||
fields = [
|
||||
'id',
|
||||
'ride',
|
||||
'ride_id',
|
||||
'count',
|
||||
'rating',
|
||||
'first_ridden_at',
|
||||
'last_ridden_at',
|
||||
'notes',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
def validate(self, attrs):
|
||||
"""
|
||||
Validate data.
|
||||
"""
|
||||
# Ensure dates make sense
|
||||
first = attrs.get('first_ridden_at')
|
||||
last = attrs.get('last_ridden_at')
|
||||
if first and last and last < first:
|
||||
raise serializers.ValidationError("Last ridden date cannot be before first ridden date.")
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create a new ride credit."""
|
||||
user = self.context['request'].user
|
||||
validated_data['user'] = user
|
||||
return super().create(validated_data)
|
||||
@@ -16,6 +16,7 @@ from .views import (
|
||||
NewContentAPIView,
|
||||
TriggerTrendingCalculationAPIView,
|
||||
)
|
||||
from .views.discovery import DiscoveryAPIView
|
||||
from .views.stats import StatsAPIView, StatsRecalculateAPIView
|
||||
from .views.reviews import LatestReviewsAPIView
|
||||
from django.urls import path, include
|
||||
@@ -44,6 +45,7 @@ urlpatterns = [
|
||||
),
|
||||
# Trending system endpoints
|
||||
path("trending/", TrendingAPIView.as_view(), name="trending"),
|
||||
path("discovery/", DiscoveryAPIView.as_view(), name="discovery"),
|
||||
path("new-content/", NewContentAPIView.as_view(), name="new-content"),
|
||||
path(
|
||||
"trending/calculate/",
|
||||
@@ -75,6 +77,11 @@ urlpatterns = [
|
||||
path("maps/", include("apps.api.v1.maps.urls")),
|
||||
path("lists/", include("apps.lists.urls")),
|
||||
path("moderation/", include("apps.moderation.urls")),
|
||||
path("reviews/", include("apps.reviews.urls")),
|
||||
path("media/", include("apps.media.urls")),
|
||||
path("blog/", include("apps.blog.urls")),
|
||||
path("support/", include("apps.support.urls")),
|
||||
path("images/", include("apps.api.v1.images.urls")),
|
||||
# Cloudflare Images Toolkit API endpoints
|
||||
path("cloudflare-images/", include("django_cloudflareimages_toolkit.urls")),
|
||||
# Include router URLs (for rankings and any other router-registered endpoints)
|
||||
|
||||
96
backend/apps/api/v1/views/discovery.py
Normal file
96
backend/apps/api/v1/views/discovery.py
Normal file
@@ -0,0 +1,96 @@
|
||||
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.db.models import F
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from datetime import timedelta
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
|
||||
class DiscoveryAPIView(APIView):
|
||||
"""
|
||||
API endpoint for discovery content (Top Lists, Opening/Closing Soon).
|
||||
"""
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="Get discovery content",
|
||||
description="Retrieve curated lists for discovery tabs (Top, Opening, Closing).",
|
||||
responses={200: "object"},
|
||||
tags=["Discovery"],
|
||||
)
|
||||
def get(self, request):
|
||||
today = timezone.now().date()
|
||||
limit = 10
|
||||
|
||||
# --- TOP LISTS ---
|
||||
# Top Parks by average rating
|
||||
top_parks = Park.objects.filter(average_rating__isnull=False).order_by("-average_rating")[:limit]
|
||||
|
||||
# Top Rides by average rating (fallback to RideRanking in future)
|
||||
top_rides = Ride.objects.filter(average_rating__isnull=False).order_by("-average_rating")[:limit]
|
||||
|
||||
# --- OPENING ---
|
||||
# Opening Soon (Future opening date)
|
||||
opening_soon_parks = Park.objects.filter(opening_date__gt=today).order_by("opening_date")[:limit]
|
||||
opening_soon_rides = Ride.objects.filter(opening_date__gt=today).order_by("opening_date")[:limit]
|
||||
|
||||
# Recently Opened (Past opening date, descending)
|
||||
recently_opened_parks = Park.objects.filter(opening_date__lte=today).order_by("-opening_date")[:limit]
|
||||
recently_opened_rides = Ride.objects.filter(opening_date__lte=today).order_by("-opening_date")[:limit]
|
||||
|
||||
# --- CLOSING ---
|
||||
# Closing Soon (Future closing date)
|
||||
closing_soon_parks = Park.objects.filter(closing_date__gt=today).order_by("closing_date")[:limit]
|
||||
closing_soon_rides = Ride.objects.filter(closing_date__gt=today).order_by("closing_date")[:limit]
|
||||
|
||||
# Recently Closed (Past closing date, descending)
|
||||
recently_closed_parks = Park.objects.filter(closing_date__lte=today).order_by("-closing_date")[:limit]
|
||||
recently_closed_rides = Ride.objects.filter(closing_date__lte=today).order_by("-closing_date")[:limit]
|
||||
|
||||
data = {
|
||||
"top_parks": self._serialize(top_parks, "park"),
|
||||
"top_rides": self._serialize(top_rides, "ride"),
|
||||
"opening_soon": {
|
||||
"parks": self._serialize(opening_soon_parks, "park"),
|
||||
"rides": self._serialize(opening_soon_rides, "ride"),
|
||||
},
|
||||
"recently_opened": {
|
||||
"parks": self._serialize(recently_opened_parks, "park"),
|
||||
"rides": self._serialize(recently_opened_rides, "ride"),
|
||||
},
|
||||
"closing_soon": {
|
||||
"parks": self._serialize(closing_soon_parks, "park"),
|
||||
"rides": self._serialize(closing_soon_rides, "ride"),
|
||||
},
|
||||
"recently_closed": {
|
||||
"parks": self._serialize(recently_closed_parks, "park"),
|
||||
"rides": self._serialize(recently_closed_rides, "ride"),
|
||||
}
|
||||
}
|
||||
|
||||
return Response(data)
|
||||
|
||||
def _serialize(self, queryset, type_):
|
||||
results = []
|
||||
for item in queryset:
|
||||
data = {
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"slug": item.slug,
|
||||
"average_rating": item.average_rating,
|
||||
}
|
||||
if type_ == "park":
|
||||
data.update({
|
||||
"city": item.location.city if item.location else None,
|
||||
"state": item.location.state if item.location else None,
|
||||
})
|
||||
elif type_ == "ride":
|
||||
data.update({
|
||||
"park_name": item.park.name,
|
||||
"park_slug": item.park.slug
|
||||
})
|
||||
results.append(data)
|
||||
return results
|
||||
Reference in New Issue
Block a user