feat: Add blog, media, and support apps, implement ride credits and image API, and remove toplist feature.

This commit is contained in:
pacnpal
2025-12-26 15:15:28 -05:00
parent cd8868a591
commit 00699d53b4
77 changed files with 7274 additions and 538 deletions

View File

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

View File

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

View File

@@ -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 ===

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -90,12 +90,7 @@ _ACCOUNTS_SYMBOLS: List[str] = [
"UserProfileOutputSerializer",
"UserProfileCreateInputSerializer",
"UserProfileUpdateInputSerializer",
"TopListOutputSerializer",
"TopListCreateInputSerializer",
"TopListUpdateInputSerializer",
"TopListItemOutputSerializer",
"TopListItemCreateInputSerializer",
"TopListItemUpdateInputSerializer",
"UserOutputSerializer",
"LoginInputSerializer",
"LoginOutputSerializer",

View File

@@ -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 ===

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

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

View File

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

View 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