This commit is contained in:
pacnpal
2025-08-28 23:20:09 -04:00
parent 02ac587216
commit ac745cc541
30 changed files with 2835 additions and 4689 deletions

View File

@@ -11,6 +11,7 @@ from drf_spectacular.utils import (
extend_schema_field,
OpenApiExample,
)
from config.django import base as settings
from .shared import LocationOutputSerializer, CompanyOutputSerializer, ModelChoices
@@ -65,10 +66,18 @@ class ParkListOutputSerializer(serializers.Serializer):
# Operator info
operator = CompanyOutputSerializer()
# URL
url = serializers.SerializerMethodField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this park."""
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.slug}/"
@extend_schema_serializer(
examples=[
@@ -166,6 +175,14 @@ class ParkDetailOutputSerializer(serializers.Serializer):
banner_image = serializers.SerializerMethodField()
card_image = serializers.SerializerMethodField()
# URL
url = serializers.SerializerMethodField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this park."""
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.slug}/"
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_areas(self, obj):
"""Get simplified area information."""

View File

@@ -0,0 +1,98 @@
"""
Serializers for review-related API endpoints.
"""
from rest_framework import serializers
from apps.parks.models.reviews import ParkReview
from apps.rides.models.reviews import RideReview
from apps.accounts.models import User
class ReviewUserSerializer(serializers.ModelSerializer):
"""Serializer for user information in reviews."""
avatar_url = serializers.SerializerMethodField()
display_name = serializers.SerializerMethodField()
class Meta:
model = User
fields = ['username', 'display_name', 'avatar_url']
def get_avatar_url(self, obj):
"""Get the user's avatar URL."""
if hasattr(obj, 'profile') and obj.profile:
return obj.profile.get_avatar()
return "/static/images/default-avatar.png"
def get_display_name(self, obj):
"""Get the user's display name."""
return obj.get_display_name()
class LatestReviewSerializer(serializers.Serializer):
"""Serializer for latest reviews combining park and ride reviews."""
id = serializers.IntegerField()
type = serializers.CharField() # 'park' or 'ride'
title = serializers.CharField()
content_snippet = serializers.CharField()
rating = serializers.IntegerField()
created_at = serializers.DateTimeField()
user = ReviewUserSerializer()
# Subject information (park or ride)
subject_name = serializers.CharField()
subject_slug = serializers.CharField()
subject_url = serializers.CharField()
# Park information (for ride reviews)
park_name = serializers.CharField(allow_null=True)
park_slug = serializers.CharField(allow_null=True)
park_url = serializers.CharField(allow_null=True)
def to_representation(self, instance):
"""Convert review instance to serialized representation."""
if isinstance(instance, ParkReview):
return {
'id': instance.pk,
'type': 'park',
'title': instance.title,
'content_snippet': self._get_content_snippet(instance.content),
'rating': instance.rating,
'created_at': instance.created_at,
'user': ReviewUserSerializer(instance.user).data,
'subject_name': instance.park.name,
'subject_slug': instance.park.slug,
'subject_url': f"/parks/{instance.park.slug}/",
'park_name': None,
'park_slug': None,
'park_url': None,
}
elif isinstance(instance, RideReview):
return {
'id': instance.pk,
'type': 'ride',
'title': instance.title,
'content_snippet': self._get_content_snippet(instance.content),
'rating': instance.rating,
'created_at': instance.created_at,
'user': ReviewUserSerializer(instance.user).data,
'subject_name': instance.ride.name,
'subject_slug': instance.ride.slug,
'subject_url': f"/parks/{instance.ride.park.slug}/rides/{instance.ride.slug}/",
'park_name': instance.ride.park.name,
'park_slug': instance.ride.park.slug,
'park_url': f"/parks/{instance.ride.park.slug}/",
}
return {}
def _get_content_snippet(self, content, max_length=150):
"""Get a snippet of the review content."""
if len(content) <= max_length:
return content
# Find the last complete word within the limit
snippet = content[:max_length]
last_space = snippet.rfind(' ')
if last_space > 0:
snippet = snippet[:last_space]
return snippet + "..."

View File

@@ -11,6 +11,7 @@ from drf_spectacular.utils import (
extend_schema_field,
OpenApiExample,
)
from config.django import base as settings
from .shared import ModelChoices
@@ -142,10 +143,18 @@ class RideModelListOutputSerializer(serializers.Serializer):
# Primary image
primary_image = RideModelPhotoOutputSerializer(allow_null=True)
# URL
url = serializers.SerializerMethodField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this ride model."""
return f"{settings.FRONTEND_DOMAIN}/rides/manufacturers/{obj.manufacturer.slug}/{obj.slug}/"
@extend_schema_serializer(
examples=[
@@ -277,10 +286,18 @@ class RideModelDetailOutputSerializer(serializers.Serializer):
technical_specs = RideModelTechnicalSpecOutputSerializer(many=True)
installations = serializers.SerializerMethodField()
# URL
url = serializers.SerializerMethodField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this ride model."""
return f"{settings.FRONTEND_DOMAIN}/rides/manufacturers/{obj.manufacturer.slug}/{obj.slug}/"
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_installations(self, obj):
"""Get ride installations using this model."""

View File

@@ -11,7 +11,7 @@ from drf_spectacular.utils import (
extend_schema_field,
OpenApiExample,
)
from config.django import base as settings
from .shared import ModelChoices
@@ -90,10 +90,18 @@ class RideListOutputSerializer(serializers.Serializer):
opening_date = serializers.DateField(allow_null=True)
closing_date = serializers.DateField(allow_null=True)
# URL
url = serializers.SerializerMethodField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this ride."""
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.park.slug}/rides/{obj.slug}/"
@extend_schema_serializer(
examples=[
@@ -194,10 +202,18 @@ class RideDetailOutputSerializer(serializers.Serializer):
banner_image = serializers.SerializerMethodField()
card_image = serializers.SerializerMethodField()
# URL
url = serializers.SerializerMethodField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this ride."""
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.park.slug}/rides/{obj.slug}/"
@extend_schema_field(serializers.DictField(allow_null=True))
def get_park_area(self, obj) -> dict | None:
if obj.park_area:

View File

@@ -8,6 +8,7 @@ to avoid code duplication and maintain consistency.
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from django.contrib.auth import get_user_model
from django.conf import settings
# Import models inside class methods to avoid Django initialization issues
@@ -173,3 +174,31 @@ class CompanyOutputSerializer(serializers.Serializer):
name = serializers.CharField()
slug = serializers.CharField()
roles = serializers.ListField(child=serializers.CharField(), required=False)
url = serializers.SerializerMethodField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this company based on their primary role.
CRITICAL DOMAIN SEPARATION:
- OPERATOR and PROPERTY_OWNER are for parks domain
- MANUFACTURER and DESIGNER are for rides domain
"""
# Use the URL field from the model if it exists (auto-generated on save)
if hasattr(obj, 'url') and obj.url:
return obj.url
# Fallback URL generation (should not be needed if model save works correctly)
if hasattr(obj, 'roles') and obj.roles:
frontend_domain = getattr(
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
primary_role = obj.roles[0] if obj.roles else None
# Only generate URLs for rides domain roles here
if primary_role == 'MANUFACTURER':
return f"{frontend_domain}/rides/manufacturers/{obj.slug}/"
elif primary_role == 'DESIGNER':
return f"{frontend_domain}/rides/designers/{obj.slug}/"
# OPERATOR and PROPERTY_OWNER URLs are handled by parks domain
return ""

View File

@@ -21,8 +21,10 @@ from .views import (
# Trending system views
TrendingAPIView,
NewContentAPIView,
TriggerTrendingCalculationAPIView,
)
from .views.stats import StatsAPIView, StatsRecalculateAPIView
from .views.reviews import LatestReviewsAPIView
from django.urls import path, include
from rest_framework.routers import DefaultRouter
@@ -57,11 +59,15 @@ urlpatterns = [
name="performance-metrics",
),
# Trending system endpoints
path("trending/content/", TrendingAPIView.as_view(), name="trending"),
path("trending/new/", NewContentAPIView.as_view(), name="new-content"),
path("trending/", TrendingAPIView.as_view(), name="trending"),
path("new-content/", NewContentAPIView.as_view(), name="new-content"),
path("trending/calculate/", TriggerTrendingCalculationAPIView.as_view(),
name="trigger-trending-calculation"),
# Statistics endpoints
path("stats/", StatsAPIView.as_view(), name="stats"),
path("stats/recalculate/", StatsRecalculateAPIView.as_view(), name="stats-recalculate"),
# Reviews endpoints
path("reviews/latest/", LatestReviewsAPIView.as_view(), name="latest-reviews"),
# Ranking system endpoints
path(
"rankings/calculate/",

View File

@@ -28,6 +28,7 @@ from .health import (
from .trending import (
TrendingAPIView,
NewContentAPIView,
TriggerTrendingCalculationAPIView,
)
# Export all views for import convenience
@@ -48,4 +49,5 @@ __all__ = [
# Trending views
"TrendingAPIView",
"NewContentAPIView",
"TriggerTrendingCalculationAPIView",
]

View File

@@ -0,0 +1,85 @@
"""
Views for review-related API endpoints.
"""
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework import status
from django.db.models import Q
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from itertools import chain
from operator import attrgetter
from apps.parks.models.reviews import ParkReview
from apps.rides.models.reviews import RideReview
from ..serializers.reviews import LatestReviewSerializer
class LatestReviewsAPIView(APIView):
"""
API endpoint to get the latest reviews from both parks and rides.
Returns a combined list of the most recent reviews across the platform,
including username, user avatar, date, score, and review snippet.
"""
permission_classes = [AllowAny]
@extend_schema(
summary="Get Latest Reviews",
description=(
"Retrieve the latest reviews from both parks and rides. "
"Returns a combined list sorted by creation date, including "
"user information, ratings, and content snippets."
),
parameters=[
OpenApiParameter(
name="limit",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Number of reviews to return (default: 20, max: 100)",
default=20,
),
],
responses={
200: LatestReviewSerializer(many=True),
},
tags=["Reviews"],
)
def get(self, request):
"""Get the latest reviews from both parks and rides."""
# Get limit parameter with validation
try:
limit = int(request.query_params.get('limit', 20))
limit = min(max(limit, 1), 100) # Clamp between 1 and 100
except (ValueError, TypeError):
limit = 20
# Get published reviews from both models
park_reviews = ParkReview.objects.filter(
is_published=True
).select_related(
'user', 'user__profile', 'park'
).order_by('-created_at')[:limit]
ride_reviews = RideReview.objects.filter(
is_published=True
).select_related(
'user', 'user__profile', 'ride', 'ride__park'
).order_by('-created_at')[:limit]
# Combine and sort by created_at
all_reviews = sorted(
chain(park_reviews, ride_reviews),
key=attrgetter('created_at'),
reverse=True
)[:limit]
# Serialize the combined results
serializer = LatestReviewSerializer(all_reviews, many=True)
return Response({
'count': len(all_reviews),
'results': serializer.data
}, status=status.HTTP_200_OK)

View File

@@ -9,7 +9,8 @@ from datetime import datetime, date
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework.permissions import AllowAny, IsAdminUser
from rest_framework import status
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
@@ -48,17 +49,12 @@ class TrendingAPIView(APIView):
def get(self, request: Request) -> Response:
"""Get trending parks and rides."""
try:
from apps.core.services.trending_service import TrendingService
except ImportError:
# Fallback if trending service is not available
return self._get_fallback_trending_content(request)
from apps.core.services.trending_service import trending_service
# Parse parameters
limit = min(int(request.query_params.get("limit", 20)), 100)
# Get trending content
trending_service = TrendingService()
# Get trending content using direct calculation service
all_trending = trending_service.get_trending_content(limit=limit * 2)
# Separate by content type
@@ -75,20 +71,8 @@ class TrendingAPIView(APIView):
trending_rides = trending_rides[: limit // 3] if trending_rides else []
trending_parks = trending_parks[: limit // 3] if trending_parks else []
# Create mock latest reviews (since not implemented yet)
latest_reviews = [
{
"id": 1,
"name": "Steel Vengeance Review",
"location": "Cedar Point",
"category": "Roller Coaster",
"rating": 5.0,
"rank": 1,
"views": 1234,
"views_change": "+45%",
"slug": "steel-vengeance-review",
}
][: limit // 3]
# Latest reviews will be empty until review system is implemented
latest_reviews = []
# Return in expected frontend format
response_data = {
@@ -99,82 +83,85 @@ class TrendingAPIView(APIView):
return Response(response_data)
def _get_fallback_trending_content(self, request: Request) -> Response:
"""Fallback method when trending service is not available."""
limit = min(int(request.query_params.get("limit", 20)), 100)
# Mock trending data
trending_rides = [
{
"id": 1,
"name": "Steel Vengeance",
"location": "Cedar Point",
"category": "Roller Coaster",
"rating": 4.8,
"rank": 1,
"views": 15234,
"views_change": "+25%",
"slug": "steel-vengeance",
@extend_schema_view(
post=extend_schema(
summary="Trigger trending content calculation",
description="Manually trigger the calculation of trending content using Django management commands. Admin access required.",
responses={
202: {
"type": "object",
"properties": {
"message": {"type": "string"},
"trending_completed": {"type": "boolean"},
"new_content_completed": {"type": "boolean"},
"completion_time": {"type": "string"},
},
},
{
"id": 2,
"name": "Lightning Rod",
"location": "Dollywood",
"category": "Roller Coaster",
"rating": 4.7,
"rank": 2,
"views": 12456,
"views_change": "+18%",
"slug": "lightning-rod",
},
][: limit // 3]
403: {"description": "Admin access required"},
},
tags=["Trending"],
),
)
class TriggerTrendingCalculationAPIView(APIView):
"""API endpoint to manually trigger trending content calculation."""
trending_parks = [
{
"id": 1,
"name": "Cedar Point",
"location": "Sandusky, OH",
"category": "Theme Park",
"rating": 4.6,
"rank": 1,
"views": 45678,
"views_change": "+12%",
"slug": "cedar-point",
},
{
"id": 2,
"name": "Magic Kingdom",
"location": "Orlando, FL",
"category": "Theme Park",
"rating": 4.5,
"rank": 2,
"views": 67890,
"views_change": "+8%",
"slug": "magic-kingdom",
},
][: limit // 3]
permission_classes = [IsAdminUser]
latest_reviews = [
{
"id": 1,
"name": "Steel Vengeance Review",
"location": "Cedar Point",
"category": "Roller Coaster",
"rating": 5.0,
"rank": 1,
"views": 1234,
"views_change": "+45%",
"slug": "steel-vengeance-review",
}
][: limit // 3]
def post(self, request: Request) -> Response:
"""Trigger trending content calculation using management commands."""
try:
from django.core.management import call_command
import io
from contextlib import redirect_stdout, redirect_stderr
response_data = {
"trending_rides": trending_rides,
"trending_parks": trending_parks,
"latest_reviews": latest_reviews,
}
# Capture command output
trending_output = io.StringIO()
new_content_output = io.StringIO()
return Response(response_data)
trending_completed = False
new_content_completed = False
try:
# Run trending calculation command
with redirect_stdout(trending_output), redirect_stderr(trending_output):
call_command('calculate_trending',
'--content-type=all', '--limit=50')
trending_completed = True
except Exception as e:
trending_output.write(f"Error: {str(e)}")
try:
# Run new content calculation command
with redirect_stdout(new_content_output), redirect_stderr(new_content_output):
call_command('calculate_new_content',
'--content-type=all', '--days-back=30', '--limit=50')
new_content_completed = True
except Exception as e:
new_content_output.write(f"Error: {str(e)}")
completion_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return Response(
{
"message": "Trending content calculation completed",
"trending_completed": trending_completed,
"new_content_completed": new_content_completed,
"completion_time": completion_time,
"trending_output": trending_output.getvalue(),
"new_content_output": new_content_output.getvalue(),
},
status=status.HTTP_202_ACCEPTED,
)
except Exception as e:
return Response(
{
"error": "Failed to trigger trending content calculation",
"details": str(e),
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@extend_schema_view(
@@ -210,19 +197,15 @@ class NewContentAPIView(APIView):
def get(self, request: Request) -> Response:
"""Get new parks and rides."""
try:
from apps.core.services.trending_service import TrendingService
except ImportError:
# Fallback if trending service is not available
return self._get_fallback_new_content(request)
from apps.core.services.trending_service import trending_service
# Parse parameters
limit = min(int(request.query_params.get("limit", 20)), 100)
days_back = min(int(request.query_params.get("days", 30)), 365)
# Get new content with longer timeframe to get more data
trending_service = TrendingService()
# Get new content using direct calculation service
all_new_content = trending_service.get_new_content(
limit=limit * 2, days_back=60
limit=limit * 2, days_back=days_back
)
recently_added = []
@@ -258,30 +241,12 @@ class NewContentAPIView(APIView):
else:
recently_added.append(item)
# Create mock upcoming items
upcoming = [
{
"id": 1,
"name": "Epic Universe",
"location": "Universal Orlando",
"category": "Theme Park",
"date_added": "Opening 2025",
"slug": "epic-universe",
},
{
"id": 2,
"name": "New Fantasyland Expansion",
"location": "Magic Kingdom",
"category": "Land Expansion",
"date_added": "Opening 2026",
"slug": "fantasyland-expansion",
},
]
# Upcoming items will be empty until future content system is implemented
upcoming = []
# Limit each category
recently_added = recently_added[: limit // 3] if recently_added else []
newly_opened = newly_opened[: limit // 3] if newly_opened else []
upcoming = upcoming[: limit // 3] if upcoming else []
# Return in expected frontend format
response_data = {
@@ -291,73 +256,3 @@ class NewContentAPIView(APIView):
}
return Response(response_data)
def _get_fallback_new_content(self, request: Request) -> Response:
"""Fallback method when trending service is not available."""
limit = min(int(request.query_params.get("limit", 20)), 100)
# Mock new content data
recently_added = [
{
"id": 1,
"name": "Iron Gwazi",
"location": "Busch Gardens Tampa",
"category": "Roller Coaster",
"date_added": "2024-12-01",
"slug": "iron-gwazi",
},
{
"id": 2,
"name": "VelociCoaster",
"location": "Universal's Islands of Adventure",
"category": "Roller Coaster",
"date_added": "2024-11-15",
"slug": "velocicoaster",
},
][: limit // 3]
newly_opened = [
{
"id": 3,
"name": "Guardians of the Galaxy",
"location": "EPCOT",
"category": "Roller Coaster",
"date_added": "2024-10-01",
"slug": "guardians-galaxy",
},
{
"id": 4,
"name": "TRON Lightcycle Run",
"location": "Magic Kingdom",
"category": "Roller Coaster",
"date_added": "2024-09-15",
"slug": "tron-lightcycle",
},
][: limit // 3]
upcoming = [
{
"id": 5,
"name": "Epic Universe",
"location": "Universal Orlando",
"category": "Theme Park",
"date_added": "Opening 2025",
"slug": "epic-universe",
},
{
"id": 6,
"name": "New Fantasyland Expansion",
"location": "Magic Kingdom",
"category": "Land Expansion",
"date_added": "Opening 2026",
"slug": "fantasyland-expansion",
},
][: limit // 3]
response_data = {
"recently_added": recently_added,
"newly_opened": newly_opened,
"upcoming": upcoming,
}
return Response(response_data)