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

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