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,94 @@
import json
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from .models import User
class UserExportService:
"""Service for exporting all user data."""
@staticmethod
def export_user_data(user: User) -> dict:
"""
Export all data associated with a user or an object containing counts/metadata and actual data.
Args:
user: The user to export data for
Returns:
dict: The complete user data export
"""
# Import models locally to avoid circular imports
from apps.parks.models import ParkReview
from apps.rides.models import RideReview
from apps.lists.models import UserList
# User account and profile
user_data = {
"username": user.username,
"email": user.email,
"date_joined": user.date_joined,
"first_name": user.first_name,
"last_name": user.last_name,
"is_active": user.is_active,
"role": user.role,
}
profile_data = {}
if hasattr(user, "profile"):
profile = user.profile
profile_data = {
"display_name": profile.display_name,
"bio": profile.bio,
"location": profile.location,
"pronouns": profile.pronouns,
"unit_system": profile.unit_system,
"social_media": {
"twitter": profile.twitter,
"instagram": profile.instagram,
"youtube": profile.youtube,
"discord": profile.discord,
},
"ride_credits": {
"coaster": profile.coaster_credits,
"dark_ride": profile.dark_ride_credits,
"flat_ride": profile.flat_ride_credits,
"water_ride": profile.water_ride_credits,
}
}
# Reviews
park_reviews = list(ParkReview.objects.filter(user=user).values(
"park__name", "rating", "review", "created_at", "updated_at", "is_published"
))
ride_reviews = list(RideReview.objects.filter(user=user).values(
"ride__name", "rating", "review", "created_at", "updated_at", "is_published"
))
# Lists
user_lists = []
for user_list in UserList.objects.filter(user=user):
items = list(user_list.items.values("order", "content_type__model", "object_id", "comment"))
user_lists.append({
"title": user_list.title,
"description": user_list.description,
"created_at": user_list.created_at,
"items": items
})
export_data = {
"account": user_data,
"profile": profile_data,
"preferences": getattr(user, "notification_preferences", {}),
"content": {
"park_reviews": park_reviews,
"ride_reviews": ride_reviews,
"lists": user_lists,
},
"export_info": {
"generated_at": timezone.now(),
"version": "1.0"
}
}
return export_data

View File

@@ -13,29 +13,11 @@ class Migration(migrations.Migration):
dependencies = [
("accounts", "0013_add_user_query_indexes"),
("contenttypes", "0002_remove_content_type_name"),
(
"django_cloudflareimages_toolkit",
"0002_rename_cloudflare_i_user_id_b8c8a5_idx_cloudflare__user_id_a3ad50_idx_and_more",
),
("django_cloudflareimages_toolkit", "0001_initial"),
]
operations = [
migrations.RemoveField(
model_name="toplist",
name="user",
),
migrations.RemoveField(
model_name="toplistitem",
name="top_list",
),
migrations.AlterUniqueTogether(
name="toplistitem",
unique_together=None,
),
migrations.RemoveField(
model_name="toplistitem",
name="content_type",
),
migrations.AlterModelOptions(
name="user",
options={"verbose_name": "User", "verbose_name_plural": "Users"},

View File

@@ -15,15 +15,12 @@ from apps.accounts.admin import (
CustomUserAdmin,
EmailVerificationAdmin,
PasswordResetAdmin,
TopListAdmin,
TopListItemAdmin,
UserProfileAdmin,
)
from apps.accounts.models import (
EmailVerification,
PasswordReset,
TopList,
TopListItem,
User,
UserProfile,
)
@@ -157,51 +154,4 @@ class TestPasswordResetAdmin(TestCase):
assert "cleanup_old_tokens" in actions
class TestTopListAdmin(TestCase):
"""Tests for TopListAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = TopListAdmin(model=TopList, admin_site=self.site)
def test_list_select_related(self):
"""Verify select_related for user."""
assert "user" in self.admin.list_select_related
def test_list_prefetch_related(self):
"""Verify prefetch_related for items."""
assert "items" in self.admin.list_prefetch_related
def test_publish_actions(self):
"""Verify publish actions exist."""
request = self.factory.get("/admin/")
request.user = UserModel(is_superuser=True)
actions = self.admin.get_actions(request)
assert "publish_lists" in actions
assert "unpublish_lists" in actions
class TestTopListItemAdmin(TestCase):
"""Tests for TopListItemAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = TopListItemAdmin(model=TopListItem, admin_site=self.site)
def test_list_select_related(self):
"""Verify select_related for top_list and user."""
assert "top_list" in self.admin.list_select_related
assert "top_list__user" in self.admin.list_select_related
assert "content_type" in self.admin.list_select_related
def test_reorder_actions(self):
"""Verify reorder actions exist."""
request = self.factory.get("/admin/")
request.user = UserModel(is_superuser=True)
actions = self.admin.get_actions(request)
assert "move_up" in actions
assert "move_down" in actions

View File

@@ -24,7 +24,7 @@ from django.utils.text import slugify
# Import all models
from apps.accounts.models import (
User, UserProfile, TopList, TopListItem, UserNotification,
User, UserProfile, UserNotification,
NotificationPreference, UserDeletionRequest
)
from apps.parks.models import (
@@ -128,7 +128,7 @@ class Command(BaseCommand):
# Create content and interactions
self.create_reviews(options['reviews'], users, parks, rides)
self.create_top_lists(users, parks, rides)
self.create_notifications(users)
self.create_moderation_data(users, parks, rides)
@@ -149,7 +149,7 @@ class Command(BaseCommand):
models_to_clear = [
# Content and interactions (clear first)
TopListItem, TopList, UserNotification, NotificationPreference,
UserNotification, NotificationPreference,
ParkReview, RideReview, ModerationAction, ModerationQueue,
# Media
@@ -1042,65 +1042,7 @@ class Command(BaseCommand):
self.stdout.write(f' ✅ Created {count} reviews')
def create_top_lists(self, users: List[User], parks: List[Park], rides: List[Ride]) -> None:
"""Create user top lists"""
self.stdout.write('📋 Creating top lists...')
if not users:
self.stdout.write(' ⚠️ No users found, skipping top lists')
return
list_count = 0
# Create top lists for some users
for user in random.sample(users, min(len(users), 10)):
# Create roller coaster top list
if rides:
coasters = [r for r in rides if r.category == 'RC']
if coasters:
top_list = TopList.objects.create(
user=user,
title=f"{user.get_display_name()}'s Top Roller Coasters",
category="RC",
description="My favorite roller coasters ranked by thrill and experience",
)
# Add items to the list
for rank, coaster in enumerate(random.sample(coasters, min(len(coasters), 10)), 1):
from django.contrib.contenttypes.models import ContentType
content_type = ContentType.objects.get_for_model(coaster)
TopListItem.objects.create(
top_list=top_list,
content_type=content_type,
object_id=coaster.pk,
rank=rank,
notes=f"Incredible {coaster.category} experience at {coaster.park.name}",
)
list_count += 1
# Create park top list
if parks and random.random() < 0.5:
top_list = TopList.objects.create(
user=user,
title=f"{user.get_display_name()}'s Favorite Parks",
category="PK",
description="Theme parks that provide the best overall experience",
)
# Add items to the list
for rank, park in enumerate(random.sample(parks, min(len(parks), 5)), 1):
from django.contrib.contenttypes.models import ContentType
content_type = ContentType.objects.get_for_model(park)
TopListItem.objects.create(
top_list=top_list,
content_type=content_type,
object_id=park.pk,
rank=rank,
notes=f"Amazing park with great {park.park_type.lower().replace('_', ' ')} atmosphere",
)
list_count += 1
self.stdout.write(f' ✅ Created {list_count} top lists')
def create_notifications(self, users: List[User]) -> None:
"""Create sample notifications for users"""
@@ -1198,7 +1140,7 @@ class Command(BaseCommand):
'Ride Models': RideModel.objects.count(),
'Park Reviews': ParkReview.objects.count(),
'Ride Reviews': RideReview.objects.count(),
'Top Lists': TopList.objects.count(),
'Notifications': UserNotification.objects.count(),
'Park Photos': ParkPhoto.objects.count(),
'Ride Photos': RidePhoto.objects.count(),

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

View File

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.blog"
verbose_name = "Blog"

View File

@@ -0,0 +1,69 @@
# Generated by Django 5.1.6 on 2025-12-26 14:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("django_cloudflareimages_toolkit", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Tag",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("slug", models.SlugField(help_text="URL-friendly identifier", max_length=200, unique=True)),
("name", models.CharField(max_length=50, unique=True)),
],
options={
"ordering": ["name"],
},
),
migrations.CreateModel(
name="Post",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(help_text="Name of the object", max_length=200)),
("slug", models.SlugField(help_text="URL-friendly identifier", max_length=200, unique=True)),
("title", models.CharField(max_length=255)),
("content", models.TextField(help_text="Markdown content supported")),
("excerpt", models.TextField(blank=True, help_text="Short summary for lists")),
("published_at", models.DateTimeField(blank=True, db_index=True, null=True)),
("is_published", models.BooleanField(db_index=True, default=False)),
(
"author",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="blog_posts",
to=settings.AUTH_USER_MODEL,
),
),
(
"image",
models.ForeignKey(
blank=True,
help_text="Featured image",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="blog_posts",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
("tags", models.ManyToManyField(blank=True, related_name="posts", to="blog.tag")),
],
options={
"ordering": ["-published_at", "-created_at"],
},
),
]

View File

View File

@@ -0,0 +1,45 @@
from django.db import models
from django.conf import settings
from apps.core.models import SluggedModel
# Using string reference for CloudflareImage
from django_cloudflareimages_toolkit.models import CloudflareImage
class Tag(SluggedModel):
name = models.CharField(max_length=50, unique=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class Post(SluggedModel):
title = models.CharField(max_length=255)
content = models.TextField(help_text="Markdown content supported")
excerpt = models.TextField(blank=True, help_text="Short summary for lists")
image = models.ForeignKey(
CloudflareImage,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="blog_posts",
help_text="Featured image"
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="blog_posts"
)
published_at = models.DateTimeField(null=True, blank=True, db_index=True)
is_published = models.BooleanField(default=False, db_index=True)
tags = models.ManyToManyField(Tag, blank=True, related_name="posts")
class Meta:
ordering = ["-published_at", "-created_at"]
def __str__(self):
return self.title

View File

@@ -0,0 +1,60 @@
from rest_framework import serializers
from .models import Post, Tag
from apps.accounts.serializers import UserSerializer
from apps.media.serializers import CloudflareImageSerializer
from django_cloudflareimages_toolkit.models import CloudflareImage
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ["id", "name", "slug"]
class PostListSerializer(serializers.ModelSerializer):
"""Lighter serializer for lists"""
author = UserSerializer(read_only=True)
tags = TagSerializer(many=True, read_only=True)
image = CloudflareImageSerializer(read_only=True)
class Meta:
model = Post
fields = [
"id",
"title",
"slug",
"excerpt",
"image",
"author",
"published_at",
"tags",
]
class PostDetailSerializer(serializers.ModelSerializer):
author = UserSerializer(read_only=True)
tags = TagSerializer(many=True, read_only=True)
image = CloudflareImageSerializer(read_only=True)
image_id = serializers.PrimaryKeyRelatedField(
queryset=CloudflareImage.objects.all(),
source='image',
write_only=True,
required=False,
allow_null=True
)
class Meta:
model = Post
fields = [
"id",
"title",
"slug",
"content",
"excerpt",
"image",
"image_id",
"author",
"published_at",
"is_published",
"tags",
"created_at",
"updated_at",
]
read_only_fields = ["id", "slug", "created_at", "updated_at", "author"]

11
backend/apps/blog/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import PostViewSet, TagViewSet
router = DefaultRouter()
router.register(r"posts", PostViewSet, basename="post")
router.register(r"tags", TagViewSet, basename="tag")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -0,0 +1,42 @@
from rest_framework import viewsets, permissions, filters
from django_filters.rest_framework import DjangoFilterBackend
from django.utils import timezone
from .models import Post, Tag
from .serializers import PostListSerializer, PostDetailSerializer, TagSerializer
from apps.core.permissions import IsStaffOrReadOnly
class TagViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Tag.objects.all()
serializer_class = TagSerializer
permission_classes = [permissions.AllowAny]
filter_backends = [filters.SearchFilter]
search_fields = ["name"]
pagination_class = None # Tags are usually few
class PostViewSet(viewsets.ModelViewSet):
"""
Public API: Read Only (unless staff).
Only published posts unless staff.
"""
permission_classes = [IsStaffOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
search_fields = ["title", "excerpt", "content"]
filterset_fields = ["tags__slug", "is_published"]
ordering_fields = ["published_at", "created_at"]
ordering = ["-published_at"]
lookup_field = "slug"
def get_queryset(self):
qs = Post.objects.all()
# If not staff, filter only published and past posts
if not self.request.user.is_staff:
qs = qs.filter(is_published=True, published_at__lte=timezone.now())
return qs.select_related("author", "image").prefetch_related("tags")
def get_serializer_class(self):
if self.action == "list":
return PostListSerializer
return PostDetailSerializer
def perform_create(self, serializer):
serializer.save(author=self.request.user)

View File

@@ -11,6 +11,7 @@ from django.http import HttpRequest, HttpResponseBase
from django.utils.decorators import method_decorator
from django.views.decorators.vary import vary_on_headers
from django.views import View
from rest_framework.response import Response as DRFResponse
from apps.core.services.enhanced_cache_service import EnhancedCacheService
import logging
@@ -81,6 +82,14 @@ def cache_api_response(
"cache_hit": True,
},
)
# If cached data is our dict format for DRF responses, reconstruct it
if isinstance(cached_response, dict) and '__drf_data__' in cached_response:
return DRFResponse(
data=cached_response['__drf_data__'],
status=cached_response.get('status', 200)
)
return cached_response
# Execute view and cache result
@@ -90,8 +99,18 @@ def cache_api_response(
# Only cache successful responses
if hasattr(response, "status_code") and response.status_code == 200:
# For DRF responses, we must cache the data, not the response object
# because the response object is not rendered yet and cannot be pickled
if hasattr(response, 'data'):
cache_payload = {
'__drf_data__': response.data,
'status': response.status_code
}
else:
cache_payload = response
getattr(cache_service, cache_backend + "_cache").set(
cache_key, response, timeout
cache_key, cache_payload, timeout
)
logger.debug(
f"Cached API response for view {view_func.__name__}",

View File

@@ -16,3 +16,13 @@ class IsOwnerOrReadOnly(permissions.BasePermission):
if hasattr(obj, 'user'):
return obj.user == request.user
return False
class IsStaffOrReadOnly(permissions.BasePermission):
"""
Custom permission to only allow staff to edit it.
"""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return request.user and request.user.is_staff

View File

@@ -229,7 +229,6 @@ class EntityFuzzyMatcher:
parks = Park.objects.filter(
Q(name__icontains=query)
| Q(slug__icontains=query.lower().replace(" ", "-"))
| Q(former_names__icontains=query)
)[: self.MAX_CANDIDATES]
for park in parks:
@@ -249,7 +248,6 @@ class EntityFuzzyMatcher:
rides = Ride.objects.select_related("park").filter(
Q(name__icontains=query)
| Q(slug__icontains=query.lower().replace(" ", "-"))
| Q(former_names__icontains=query)
| Q(park__name__icontains=query)
)[: self.MAX_CANDIDATES]

View File

@@ -0,0 +1,54 @@
import pytest
from django.contrib.auth import get_user_model
from apps.parks.models import Park, Company
import pghistory
User = get_user_model()
@pytest.mark.django_db
class TestTrackedModel:
"""
Tests for the TrackedModel base class and pghistory integration.
"""
def test_create_history_tracking(self):
"""Test that creating a model instance creates a history event."""
user = User.objects.create_user(username="testuser", password="password")
company = Company.objects.create(name="Test Operator", roles=["OPERATOR"])
with pghistory.context(user=user.id):
park = Park.objects.create(
name="History Test Park",
description="Testing history",
operating_season="Summer",
operator=company
)
# Verify history using the helper method from TrackedModel
events = park.get_history()
assert events.count() == 1
event = events.first()
assert event.pgh_obj_id == park.pk
# Verify context was captured
# The middleware isn't running here, so we used pghistory.context explicitly
# But pghistory.context stores data in pgh_context field if configured?
# Let's check if the event has pgh_context
assert event.pgh_context["user"] == user.id
def test_update_tracking(self):
company = Company.objects.create(name="Test Operator 2", roles=["OPERATOR"])
park = Park.objects.create(name="Original", operator=company)
# Initial create event
assert park.get_history().count() == 1
# Update
park.name = "Updated"
park.save()
assert park.get_history().count() == 2
latest = park.get_history().first() # Ordered by -pgh_created_at
assert latest.name == "Updated"

View File

@@ -0,0 +1,53 @@
import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import logging
logger = logging.getLogger(__name__)
def get_direct_upload_url(user_id=None):
"""
Generates a direct upload URL for Cloudflare Images.
Args:
user_id (str, optional): The user ID to associate with the upload.
Returns:
dict: A dictionary containing 'id' and 'uploadURL'.
Raises:
ImproperlyConfigured: If Cloudflare settings are missing.
requests.RequestException: If the Cloudflare API request fails.
"""
account_id = getattr(settings, 'CLOUDFLARE_IMAGES_ACCOUNT_ID', None)
api_token = getattr(settings, 'CLOUDFLARE_IMAGES_API_TOKEN', None)
if not account_id or not api_token:
raise ImproperlyConfigured(
"CLOUDFLARE_IMAGES_ACCOUNT_ID and CLOUDFLARE_IMAGES_API_TOKEN must be set."
)
url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v2/direct_upload"
headers = {
"Authorization": f"Bearer {api_token}",
}
data = {
"requireSignedURLs": "false",
}
if user_id:
data["metadata"] = f'{{"user_id": "{user_id}"}}'
response = requests.post(url, headers=headers, data=data)
response.raise_for_status()
result = response.json()
if not result.get("success"):
error_msg = result.get("errors", [{"message": "Unknown error"}])[0].get("message")
logger.error(f"Cloudflare Direct Upload Error: {error_msg}")
raise requests.RequestException(f"Cloudflare Error: {error_msg}")
return result.get("result", {})

View File

@@ -1,8 +1,6 @@
# Generated by Django 5.1.4 on 2025-08-13 21:35
# Generated by Django 5.1.6 on 2025-12-26 14:30
import django.db.models.deletion
import apps.media.models
import apps.media.storage
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
@@ -15,6 +13,7 @@ class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("django_cloudflareimages_toolkit", "0001_initial"),
("pghistory", "0006_delete_aggregateevent"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@@ -23,88 +22,82 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="Photo",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"image",
models.ImageField(
max_length=255,
storage=apps.media.storage.MediaStorage(),
upload_to=apps.media.models.photo_upload_path,
),
),
("caption", models.CharField(blank=True, max_length=255)),
("alt_text", models.CharField(blank=True, max_length=255)),
("is_primary", models.BooleanField(default=False)),
("is_approved", models.BooleanField(default=False)),
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("date_taken", models.DateTimeField(blank=True, null=True)),
("object_id", models.PositiveIntegerField()),
("object_id", models.PositiveIntegerField(help_text="ID of the item")),
("caption", models.CharField(blank=True, help_text="Photo caption", max_length=255)),
("is_public", models.BooleanField(default=True, help_text="Whether this photo is visible to others")),
("source", models.CharField(blank=True, help_text="Source/Credit if applicable", max_length=100)),
(
"content_type",
models.ForeignKey(
help_text="Type of item this photo belongs to",
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"uploaded_by",
"image",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="uploaded_photos",
help_text="Cloudflare Image reference",
on_delete=django.db.models.deletion.CASCADE,
related_name="photos_usage",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
(
"user",
models.ForeignKey(
help_text="User who uploaded this photo",
on_delete=django.db.models.deletion.CASCADE,
related_name="photos",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-is_primary", "-created_at"],
"verbose_name": "Photo",
"verbose_name_plural": "Photos",
"ordering": ["-created_at"],
"abstract": False,
},
),
migrations.CreateModel(
name="PhotoEvent",
fields=[
(
"pgh_id",
models.AutoField(primary_key=True, serialize=False),
),
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
(
"image",
models.ImageField(
max_length=255,
storage=apps.media.storage.MediaStorage(),
upload_to=apps.media.models.photo_upload_path,
),
),
("caption", models.CharField(blank=True, max_length=255)),
("alt_text", models.CharField(blank=True, max_length=255)),
("is_primary", models.BooleanField(default=False)),
("is_approved", models.BooleanField(default=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("date_taken", models.DateTimeField(blank=True, null=True)),
("object_id", models.PositiveIntegerField()),
("object_id", models.PositiveIntegerField(help_text="ID of the item")),
("caption", models.CharField(blank=True, help_text="Photo caption", max_length=255)),
("is_public", models.BooleanField(default=True, help_text="Whether this photo is visible to others")),
("source", models.CharField(blank=True, help_text="Source/Credit if applicable", max_length=100)),
(
"content_type",
models.ForeignKey(
db_constraint=False,
help_text="Type of item this photo belongs to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
(
"image",
models.ForeignKey(
db_constraint=False,
help_text="Cloudflare Image reference",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
(
"pgh_context",
models.ForeignKey(
@@ -125,10 +118,10 @@ class Migration(migrations.Migration):
),
),
(
"uploaded_by",
"user",
models.ForeignKey(
db_constraint=False,
null=True,
help_text="User who uploaded this photo",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
@@ -142,18 +135,15 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="photo",
index=models.Index(
fields=["content_type", "object_id"],
name="media_photo_content_0187f5_idx",
),
index=models.Index(fields=["content_type", "object_id"], name="media_photo_content_0187f5_idx"),
),
pgtrigger.migrations.AddTrigger(
model_name="photo",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
func='INSERT INTO "media_photoevent" ("caption", "content_type_id", "created_at", "id", "image_id", "is_public", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "source", "updated_at", "user_id") VALUES (NEW."caption", NEW."content_type_id", NEW."created_at", NEW."id", NEW."image_id", NEW."is_public", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."source", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="05c2d557f631f80ebd4b37ffb1ba9a539fa54244",
operation="INSERT",
pgid="pgtrigger_insert_insert_e1ca0",
table="media_photo",
@@ -167,8 +157,8 @@ class Migration(migrations.Migration):
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
func='INSERT INTO "media_photoevent" ("caption", "content_type_id", "created_at", "id", "image_id", "is_public", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "source", "updated_at", "user_id") VALUES (NEW."caption", NEW."content_type_id", NEW."created_at", NEW."id", NEW."image_id", NEW."is_public", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."source", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="9a4caabe540c0fd782b9c148444c364e385327f4",
operation="UPDATE",
pgid="pgtrigger_update_update_6ff7d",
table="media_photo",

View File

@@ -0,0 +1,57 @@
from django.db import models
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from apps.core.history import TrackedModel
import pghistory
# Using string reference for CloudflareImage to avoid circular imports if possible,
# or direct import if safe. django-cloudflare-images-toolkit usually provides a field or model.
# Checking installed apps... it's "django_cloudflareimages_toolkit".
from django_cloudflareimages_toolkit.models import CloudflareImage
@pghistory.track()
class Photo(TrackedModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="photos",
help_text="User who uploaded this photo",
)
# The actual image
image = models.ForeignKey(
CloudflareImage,
on_delete=models.CASCADE,
related_name="photos_usage",
help_text="Cloudflare Image reference"
)
# Generic relation to target object (Park, Ride, etc.)
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
help_text="Type of item this photo belongs to",
)
object_id = models.PositiveIntegerField(help_text="ID of the item")
content_object = GenericForeignKey("content_type", "object_id")
# Metadata
caption = models.CharField(max_length=255, blank=True, help_text="Photo caption")
is_public = models.BooleanField(
default=True,
help_text="Whether this photo is visible to others"
)
# We might want credit/source info if not taken by user
source = models.CharField(max_length=100, blank=True, help_text="Source/Credit if applicable")
class Meta(TrackedModel.Meta):
verbose_name = "Photo"
verbose_name_plural = "Photos"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["content_type", "object_id"]),
]
def __str__(self):
return f"Photo by {self.user.username} for {self.content_object}"

View File

@@ -0,0 +1,62 @@
from rest_framework import serializers
from .models import Photo
from apps.accounts.serializers import UserSerializer
from django_cloudflareimages_toolkit.models import CloudflareImage
# We need a serializer for the CloudflareImage model too if we want to show variants
class CloudflareImageSerializer(serializers.ModelSerializer):
variants = serializers.JSONField(read_only=True)
class Meta:
model = CloudflareImage
fields = ["id", "cloudflare_id", "variants"]
class PhotoSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
image = CloudflareImageSerializer(read_only=True)
cloudflare_image_id = serializers.CharField(write_only=True)
# Helper for frontend to get URLs easily
url = serializers.SerializerMethodField()
thumbnail = serializers.SerializerMethodField()
class Meta:
model = Photo
fields = [
"id",
"user",
"image",
"cloudflare_image_id",
"content_type",
"object_id",
"caption",
"source",
"is_public",
"created_at",
"updated_at",
"url",
"thumbnail",
]
read_only_fields = ["id", "user", "created_at", "updated_at"]
def create(self, validated_data):
cloudflare_id = validated_data.pop("cloudflare_image_id", None)
if cloudflare_id:
# Get or create the CloudflareImage wrapper
# We assume it exists on CF side. We just need the DB record.
image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id)
validated_data["image"] = image
return super().create(validated_data)
def get_url(self, obj):
# Return public variant or default
if obj.image:
# Check if get_url method exists or we construct strictly
return getattr(obj.image, 'get_url', lambda x: None)('public')
return None
def get_thumbnail(self, obj):
if obj.image:
return getattr(obj.image, 'get_url', lambda x: None)('thumbnail')
return None

View File

@@ -0,0 +1,10 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import PhotoViewSet
router = DefaultRouter()
router.register(r"photos", PhotoViewSet, basename="photo")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -0,0 +1,23 @@
from rest_framework import viewsets, permissions, filters
from django_filters.rest_framework import DjangoFilterBackend
from .models import Photo
from .serializers import PhotoSerializer
from apps.core.permissions import IsOwnerOrReadOnly
class PhotoViewSet(viewsets.ModelViewSet):
queryset = Photo.objects.filter(is_public=True)
serializer_class = PhotoSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["content_type", "object_id", "user"]
ordering_fields = ["created_at"]
ordering = ["-created_at"]
def get_queryset(self):
qs = Photo.objects.filter(is_public=True)
if self.request.user.is_authenticated:
qs = qs | Photo.objects.filter(user=self.request.user)
return qs.distinct()
def perform_create(self, serializer):
serializer.save(user=self.request.user)

View File

@@ -80,51 +80,51 @@ class ModerationConfig(AppConfig):
PhotoSubmission,
)
# EditSubmission callbacks
# EditSubmission callbacks (transitions from CLAIMED state)
register_callback(
EditSubmission, 'status', 'PENDING', 'APPROVED',
EditSubmission, 'status', 'CLAIMED', 'APPROVED',
SubmissionApprovedNotification()
)
register_callback(
EditSubmission, 'status', 'PENDING', 'APPROVED',
EditSubmission, 'status', 'CLAIMED', 'APPROVED',
ModerationCacheInvalidation()
)
register_callback(
EditSubmission, 'status', 'PENDING', 'REJECTED',
EditSubmission, 'status', 'CLAIMED', 'REJECTED',
SubmissionRejectedNotification()
)
register_callback(
EditSubmission, 'status', 'PENDING', 'REJECTED',
EditSubmission, 'status', 'CLAIMED', 'REJECTED',
ModerationCacheInvalidation()
)
register_callback(
EditSubmission, 'status', 'PENDING', 'ESCALATED',
EditSubmission, 'status', 'CLAIMED', 'ESCALATED',
SubmissionEscalatedNotification()
)
register_callback(
EditSubmission, 'status', 'PENDING', 'ESCALATED',
EditSubmission, 'status', 'CLAIMED', 'ESCALATED',
ModerationCacheInvalidation()
)
# PhotoSubmission callbacks
# PhotoSubmission callbacks (transitions from CLAIMED state)
register_callback(
PhotoSubmission, 'status', 'PENDING', 'APPROVED',
PhotoSubmission, 'status', 'CLAIMED', 'APPROVED',
SubmissionApprovedNotification()
)
register_callback(
PhotoSubmission, 'status', 'PENDING', 'APPROVED',
PhotoSubmission, 'status', 'CLAIMED', 'APPROVED',
ModerationCacheInvalidation()
)
register_callback(
PhotoSubmission, 'status', 'PENDING', 'REJECTED',
PhotoSubmission, 'status', 'CLAIMED', 'REJECTED',
SubmissionRejectedNotification()
)
register_callback(
PhotoSubmission, 'status', 'PENDING', 'REJECTED',
PhotoSubmission, 'status', 'CLAIMED', 'REJECTED',
ModerationCacheInvalidation()
)
register_callback(
PhotoSubmission, 'status', 'PENDING', 'ESCALATED',
PhotoSubmission, 'status', 'CLAIMED', 'ESCALATED',
SubmissionEscalatedNotification()
)

View File

@@ -22,12 +22,29 @@ EDIT_SUBMISSION_STATUSES = [
'icon': 'clock',
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
'sort_order': 1,
'can_transition_to': ['APPROVED', 'REJECTED', 'ESCALATED'],
'can_transition_to': ['CLAIMED'], # Must be claimed before any action
'requires_moderator': True,
'is_actionable': True
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="CLAIMED",
label="Claimed",
description="Submission has been claimed by a moderator for review",
metadata={
'color': 'blue',
'icon': 'user-check',
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
'sort_order': 2,
# Note: PENDING not included to avoid cycle - unclaim uses direct status update
'can_transition_to': ['APPROVED', 'REJECTED', 'ESCALATED'],
'requires_moderator': True,
'is_actionable': True,
'is_locked': True # Indicates this submission is locked for editing by others
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="APPROVED",
label="Approved",
@@ -36,7 +53,7 @@ EDIT_SUBMISSION_STATUSES = [
'color': 'green',
'icon': 'check-circle',
'css_class': 'bg-green-100 text-green-800 border-green-200',
'sort_order': 2,
'sort_order': 3,
'can_transition_to': [],
'requires_moderator': True,
'is_actionable': False,
@@ -52,7 +69,7 @@ EDIT_SUBMISSION_STATUSES = [
'color': 'red',
'icon': 'x-circle',
'css_class': 'bg-red-100 text-red-800 border-red-200',
'sort_order': 3,
'sort_order': 4,
'can_transition_to': [],
'requires_moderator': True,
'is_actionable': False,
@@ -68,7 +85,7 @@ EDIT_SUBMISSION_STATUSES = [
'color': 'purple',
'icon': 'arrow-up',
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
'sort_order': 4,
'sort_order': 5,
'can_transition_to': ['APPROVED', 'REJECTED'],
'requires_moderator': True,
'is_actionable': True,

View File

@@ -0,0 +1,201 @@
# Generated by Django 5.1.6 on 2025-12-26 20:01
import apps.core.state_machine.fields
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("moderation", "0008_alter_bulkoperation_options_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="editsubmission",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="editsubmission",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="photosubmission",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="photosubmission",
name="update_update",
),
migrations.AddField(
model_name="editsubmission",
name="claimed_at",
field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True),
),
migrations.AddField(
model_name="editsubmission",
name="claimed_by",
field=models.ForeignKey(
blank=True,
help_text="Moderator who has claimed this submission for review",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="claimed_edit_submissions",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="editsubmissionevent",
name="claimed_at",
field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True),
),
migrations.AddField(
model_name="editsubmissionevent",
name="claimed_by",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Moderator who has claimed this submission for review",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="photosubmission",
name="claimed_at",
field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True),
),
migrations.AddField(
model_name="photosubmission",
name="claimed_by",
field=models.ForeignKey(
blank=True,
help_text="Moderator who has claimed this submission for review",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="claimed_photo_submissions",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="photosubmissionevent",
name="claimed_at",
field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True),
),
migrations.AddField(
model_name="photosubmissionevent",
name="claimed_by",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Moderator who has claimed this submission for review",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="editsubmissionevent",
name="status",
field=apps.core.state_machine.fields.RichFSMField(
allow_deprecated=False,
choice_group="edit_submission_statuses",
choices=[
("PENDING", "Pending"),
("CLAIMED", "Claimed"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("ESCALATED", "Escalated"),
],
default="PENDING",
domain="moderation",
max_length=20,
),
),
migrations.AlterField(
model_name="photosubmissionevent",
name="status",
field=apps.core.state_machine.fields.RichFSMField(
allow_deprecated=False,
choice_group="photo_submission_statuses",
choices=[
("PENDING", "Pending"),
("CLAIMED", "Claimed"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("ESCALATED", "Escalated"),
],
default="PENDING",
domain="moderation",
max_length=20,
),
),
pgtrigger.migrations.AddTrigger(
model_name="editsubmission",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "moderation_editsubmissionevent" ("changes", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."changes", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="947e1d596310a6e4aad4f30724fbd2e2294d977b",
operation="INSERT",
pgid="pgtrigger_insert_insert_2c796",
table="moderation_editsubmission",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="editsubmission",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "moderation_editsubmissionevent" ("changes", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."changes", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="568618c5161ed78a9c72d751f1c312c64dea3994",
operation="UPDATE",
pgid="pgtrigger_update_update_ab38f",
table="moderation_editsubmission",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="photosubmission",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "moderation_photosubmissionevent" ("caption", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_id", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_id", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="483cca8949361fe83eb0a964f9f454c5d2c1ac22",
operation="INSERT",
pgid="pgtrigger_insert_insert_62865",
table="moderation_photosubmission",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="photosubmission",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "moderation_photosubmissionevent" ("caption", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_id", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_id", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="82c7edd7b108f50aed0b6b06e44786617792171c",
operation="UPDATE",
pgid="pgtrigger_update_update_9c311",
table="moderation_photosubmission",
when="AFTER",
),
),
),
]

View File

@@ -143,6 +143,19 @@ class EditSubmission(StateMachineMixin, TrackedModel):
blank=True, help_text="Notes from the moderator about this submission"
)
# Claim tracking for concurrency control
claimed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="claimed_edit_submissions",
help_text="Moderator who has claimed this submission for review",
)
claimed_at = models.DateTimeField(
null=True, blank=True, help_text="When this submission was claimed"
)
class Meta(TrackedModel.Meta):
verbose_name = "Edit Submission"
verbose_name_plural = "Edit Submissions"
@@ -188,6 +201,54 @@ class EditSubmission(StateMachineMixin, TrackedModel):
"""Get the final changes to apply (moderator changes if available, otherwise original changes)"""
return self.moderator_changes or self.changes
def claim(self, user: UserType) -> None:
"""
Claim this submission for review.
Transition: PENDING -> CLAIMED
Args:
user: The moderator claiming this submission
Raises:
ValidationError: If submission is not in PENDING state
"""
from django.core.exceptions import ValidationError
if self.status != "PENDING":
raise ValidationError(
f"Cannot claim submission: current status is {self.status}, expected PENDING"
)
self.transition_to_claimed(user=user)
self.claimed_by = user
self.claimed_at = timezone.now()
self.save()
def unclaim(self, user: UserType = None) -> None:
"""
Release claim on this submission.
Transition: CLAIMED -> PENDING
Args:
user: The user initiating the unclaim (for audit)
Raises:
ValidationError: If submission is not in CLAIMED state
"""
from django.core.exceptions import ValidationError
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED"
)
# Set status directly (not via FSM transition to avoid cycle)
# This is intentional - the unclaim action is a special "rollback" operation
self.status = "PENDING"
self.claimed_by = None
self.claimed_at = None
self.save()
def approve(self, moderator: UserType, user=None) -> Optional[models.Model]:
"""
Approve this submission and apply the changes.
@@ -204,9 +265,17 @@ class EditSubmission(StateMachineMixin, TrackedModel):
ValueError: If submission cannot be approved
ValidationError: If the data is invalid
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
approver = user or moderator
# Validate state - must be CLAIMED before approval
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot approve submission: must be CLAIMED first (current status: {self.status})"
)
model_class = self.content_type.model_class()
if not model_class:
raise ValueError("Could not resolve model class")
@@ -263,9 +332,17 @@ class EditSubmission(StateMachineMixin, TrackedModel):
reason: Reason for rejection
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
rejecter = user or moderator
# Validate state - must be CLAIMED before rejection
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot reject submission: must be CLAIMED first (current status: {self.status})"
)
# Use FSM transition to update status
self.transition_to_rejected(user=rejecter)
self.handled_by = rejecter
@@ -283,9 +360,17 @@ class EditSubmission(StateMachineMixin, TrackedModel):
reason: Reason for escalation
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
escalator = user or moderator
# Validate state - must be CLAIMED before escalation
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot escalate submission: must be CLAIMED first (current status: {self.status})"
)
# Use FSM transition to update status
self.transition_to_escalated(user=escalator)
self.handled_by = escalator
@@ -747,6 +832,19 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
help_text="Notes from the moderator about this photo submission",
)
# Claim tracking for concurrency control
claimed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="claimed_photo_submissions",
help_text="Moderator who has claimed this submission for review",
)
claimed_at = models.DateTimeField(
null=True, blank=True, help_text="When this submission was claimed"
)
class Meta(TrackedModel.Meta):
verbose_name = "Photo Submission"
verbose_name_plural = "Photo Submissions"
@@ -759,6 +857,54 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
def __str__(self) -> str:
return f"Photo submission by {self.user.username} for {self.content_object}"
def claim(self, user: UserType) -> None:
"""
Claim this photo submission for review.
Transition: PENDING -> CLAIMED
Args:
user: The moderator claiming this submission
Raises:
ValidationError: If submission is not in PENDING state
"""
from django.core.exceptions import ValidationError
if self.status != "PENDING":
raise ValidationError(
f"Cannot claim submission: current status is {self.status}, expected PENDING"
)
self.transition_to_claimed(user=user)
self.claimed_by = user
self.claimed_at = timezone.now()
self.save()
def unclaim(self, user: UserType = None) -> None:
"""
Release claim on this photo submission.
Transition: CLAIMED -> PENDING
Args:
user: The user initiating the unclaim (for audit)
Raises:
ValidationError: If submission is not in CLAIMED state
"""
from django.core.exceptions import ValidationError
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED"
)
# Set status directly (not via FSM transition to avoid cycle)
# This is intentional - the unclaim action is a special "rollback" operation
self.status = "PENDING"
self.claimed_by = None
self.claimed_at = None
self.save()
def approve(self, moderator: UserType = None, notes: str = "", user=None) -> None:
"""
Approve the photo submission.
@@ -771,10 +917,17 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
"""
from apps.parks.models.media import ParkPhoto
from apps.rides.models.media import RidePhoto
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
approver = user or moderator
# Validate state - must be CLAIMED before approval
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot approve photo submission: must be CLAIMED first (current status: {self.status})"
)
# Determine the correct photo model based on the content type
model_class = self.content_type.model_class()
if model_class.__name__ == "Park":
@@ -810,9 +963,17 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
notes: Rejection reason
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
rejecter = user or moderator
# Validate state - must be CLAIMED before rejection
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot reject photo submission: must be CLAIMED first (current status: {self.status})"
)
# Use FSM transition to update status
self.transition_to_rejected(user=rejecter)
self.handled_by = rejecter # type: ignore
@@ -839,9 +1000,17 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
notes: Escalation reason
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
escalator = user or moderator
# Validate state - must be CLAIMED before escalation
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot escalate photo submission: must be CLAIMED first (current status: {self.status})"
)
# Use FSM transition to update status
self.transition_to_escalated(user=escalator)
self.handled_by = escalator # type: ignore

View File

@@ -22,6 +22,7 @@ from .models import (
ModerationAction,
BulkOperation,
EditSubmission,
PhotoSubmission,
)
User = get_user_model()
@@ -65,6 +66,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"""Serializer for EditSubmission with UI metadata for Nuxt frontend."""
submitted_by = UserBasicSerializer(source="user", read_only=True)
claimed_by = UserBasicSerializer(read_only=True)
content_type_name = serializers.CharField(
source="content_type.model", read_only=True
)
@@ -91,6 +93,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"rejection_reason",
"submitted_by",
"reviewed_by",
"claimed_by",
"claimed_at",
"created_at",
"updated_at",
"time_since_created",
@@ -100,6 +104,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"created_at",
"updated_at",
"submitted_by",
"claimed_by",
"claimed_at",
"status_color",
"status_icon",
"status_display",
@@ -111,6 +117,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"""Return hex color based on status for UI badges."""
colors = {
"PENDING": "#f59e0b", # Amber
"CLAIMED": "#3b82f6", # Blue
"APPROVED": "#10b981", # Emerald
"REJECTED": "#ef4444", # Red
"ESCALATED": "#8b5cf6", # Violet
@@ -121,6 +128,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"""Return Heroicons icon name based on status."""
icons = {
"PENDING": "heroicons:clock",
"CLAIMED": "heroicons:user-circle",
"APPROVED": "heroicons:check-circle",
"REJECTED": "heroicons:x-circle",
"ESCALATED": "heroicons:arrow-up-circle",
@@ -148,6 +156,9 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
submitted_by_username = serializers.CharField(
source="user.username", read_only=True
)
claimed_by_username = serializers.CharField(
source="claimed_by.username", read_only=True, allow_null=True
)
content_type_name = serializers.CharField(
source="content_type.model", read_only=True
)
@@ -162,6 +173,8 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
"content_type_name",
"object_id",
"submitted_by_username",
"claimed_by_username",
"claimed_at",
"status_color",
"status_icon",
"created_at",
@@ -171,6 +184,7 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
def get_status_color(self, obj) -> str:
colors = {
"PENDING": "#f59e0b",
"CLAIMED": "#3b82f6",
"APPROVED": "#10b981",
"REJECTED": "#ef4444",
"ESCALATED": "#8b5cf6",
@@ -180,6 +194,7 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
def get_status_icon(self, obj) -> str:
icons = {
"PENDING": "heroicons:clock",
"CLAIMED": "heroicons:user-circle",
"APPROVED": "heroicons:check-circle",
"REJECTED": "heroicons:x-circle",
"ESCALATED": "heroicons:arrow-up-circle",
@@ -911,3 +926,90 @@ class StateLogSerializer(serializers.ModelSerializer):
]
read_only_fields = fields
class PhotoSubmissionSerializer(serializers.ModelSerializer):
"""Serializer for PhotoSubmission."""
submitted_by = UserBasicSerializer(source="user", read_only=True)
content_type_name = serializers.CharField(
source="content_type.model", read_only=True
)
photo_url = serializers.SerializerMethodField()
# UI Metadata
status_display = serializers.CharField(source="get_status_display", read_only=True)
status_color = serializers.SerializerMethodField()
status_icon = serializers.SerializerMethodField()
time_since_created = serializers.SerializerMethodField()
class Meta:
model = PhotoSubmission
fields = [
"id",
"status",
"status_display",
"status_color",
"status_icon",
"content_type",
"content_type_name",
"object_id",
"photo",
"photo_url",
"caption",
"date_taken",
"submitted_by",
"handled_by",
"handled_at",
"notes",
"created_at",
"time_since_created",
]
read_only_fields = [
"id",
"created_at",
"submitted_by",
"handled_by",
"handled_at",
"status_display",
"status_color",
"status_icon",
"content_type_name",
"photo_url",
"time_since_created",
]
def get_photo_url(self, obj) -> str | None:
if obj.photo:
return obj.photo.image_url
return None
def get_status_color(self, obj) -> str:
colors = {
"PENDING": "#f59e0b",
"APPROVED": "#10b981",
"REJECTED": "#ef4444",
}
return colors.get(obj.status, "#6b7280")
def get_status_icon(self, obj) -> str:
icons = {
"PENDING": "heroicons:clock",
"APPROVED": "heroicons:check-circle",
"REJECTED": "heroicons:x-circle",
}
return icons.get(obj.status, "heroicons:question-mark-circle")
def get_time_since_created(self, obj) -> str:
"""Human-readable time since creation."""
now = timezone.now()
diff = now - obj.created_at
if diff.days > 0:
return f"{diff.days} days ago"
elif diff.seconds > 3600:
hours = diff.seconds // 3600
return f"{hours} hours ago"
else:
minutes = diff.seconds // 60
return f"{minutes} minutes ago"

View File

@@ -4,12 +4,17 @@ Signal handlers for moderation-related FSM state transitions.
This module provides signal handlers that execute when moderation
models (EditSubmission, PhotoSubmission, ModerationReport, etc.)
undergo state transitions.
Includes:
- Transition handlers for approval, rejection, escalation
- Real-time broadcasting signal for dashboard updates
- Claim/unclaim tracking for concurrency control
"""
import logging
from django.conf import settings
from django.dispatch import receiver
from django.dispatch import receiver, Signal
from apps.core.state_machine.signals import (
post_state_transition,
@@ -20,6 +25,71 @@ from apps.core.state_machine.signals import (
logger = logging.getLogger(__name__)
# ============================================================================
# Custom Signals for Real-Time Broadcasting
# ============================================================================
# Signal emitted when a submission status changes - for real-time UI updates
# Arguments:
# - sender: The model class (EditSubmission or PhotoSubmission)
# - submission_id: The ID of the submission
# - submission_type: "edit" or "photo"
# - new_status: The new status value
# - previous_status: The previous status value
# - locked_by: Username of the moderator who claimed it (or None)
# - payload: Full payload dictionary for broadcasting
submission_status_changed = Signal()
def handle_submission_claimed(instance, source, target, user, context=None, **kwargs):
"""
Handle submission claim transitions.
Called when an EditSubmission or PhotoSubmission is claimed by a moderator.
Broadcasts the status change for real-time dashboard updates.
Args:
instance: The submission instance.
source: The source state.
target: The target state.
user: The user who claimed.
context: Optional TransitionContext.
"""
if target != 'CLAIMED':
return
logger.info(
f"Submission {instance.pk} claimed by {user.username if user else 'system'}"
)
# Broadcast for real-time dashboard updates
_broadcast_submission_status_change(instance, source, target, user)
def handle_submission_unclaimed(instance, source, target, user, context=None, **kwargs):
"""
Handle submission unclaim transitions (CLAIMED -> PENDING).
Called when a moderator releases their claim on a submission.
Args:
instance: The submission instance.
source: The source state.
target: The target state.
user: The user who unclaimed.
context: Optional TransitionContext.
"""
if source != 'CLAIMED' or target != 'PENDING':
return
logger.info(
f"Submission {instance.pk} unclaimed by {user.username if user else 'system'}"
)
# Broadcast for real-time dashboard updates
_broadcast_submission_status_change(instance, source, target, user)
def handle_submission_approved(instance, source, target, user, context=None, **kwargs):
"""
Handle submission approval transitions.
@@ -255,6 +325,66 @@ def _finalize_bulk_operation(instance, success):
logger.warning(f"Failed to finalize bulk operation: {e}")
def _broadcast_submission_status_change(instance, source, target, user):
"""
Broadcast submission status change for real-time UI updates.
Emits the submission_status_changed signal with a structured payload
that can be consumed by notification systems (Novu, SSE, WebSocket, etc.).
Payload format:
{
"submission_id": 123,
"submission_type": "edit" | "photo",
"new_status": "CLAIMED",
"previous_status": "PENDING",
"locked_by": "moderator_username" | None,
"locked_at": "2024-01-01T12:00:00Z" | None,
"changed_by": "username" | None,
}
"""
try:
from .models import EditSubmission, PhotoSubmission
# Determine submission type
submission_type = "edit" if isinstance(instance, EditSubmission) else "photo"
# Build the broadcast payload
payload = {
"submission_id": instance.pk,
"submission_type": submission_type,
"new_status": target,
"previous_status": source,
"locked_by": None,
"locked_at": None,
"changed_by": user.username if user else None,
}
# Add claim information if available
if hasattr(instance, 'claimed_by') and instance.claimed_by:
payload["locked_by"] = instance.claimed_by.username
if hasattr(instance, 'claimed_at') and instance.claimed_at:
payload["locked_at"] = instance.claimed_at.isoformat()
# Emit the signal for downstream notification handlers
submission_status_changed.send(
sender=type(instance),
submission_id=instance.pk,
submission_type=submission_type,
new_status=target,
previous_status=source,
locked_by=payload["locked_by"],
payload=payload,
)
logger.debug(
f"Broadcast status change: {submission_type}#{instance.pk} "
f"{source} -> {target}"
)
except Exception as e:
logger.warning(f"Failed to broadcast submission status change: {e}")
# Signal handler registration
def register_moderation_signal_handlers():
@@ -320,7 +450,41 @@ def register_moderation_signal_handlers():
handle_bulk_operation_status, stage='post'
)
# Claim/Unclaim handlers for EditSubmission
register_transition_handler(
EditSubmission, 'PENDING', 'CLAIMED',
handle_submission_claimed, stage='post'
)
register_transition_handler(
EditSubmission, 'CLAIMED', 'PENDING',
handle_submission_unclaimed, stage='post'
)
# Claim/Unclaim handlers for PhotoSubmission
register_transition_handler(
PhotoSubmission, 'PENDING', 'CLAIMED',
handle_submission_claimed, stage='post'
)
register_transition_handler(
PhotoSubmission, 'CLAIMED', 'PENDING',
handle_submission_unclaimed, stage='post'
)
logger.info("Registered moderation signal handlers")
except ImportError as e:
logger.warning(f"Could not register moderation signal handlers: {e}")
__all__ = [
'submission_status_changed',
'register_moderation_signal_handlers',
'handle_submission_approved',
'handle_submission_rejected',
'handle_submission_escalated',
'handle_submission_claimed',
'handle_submission_unclaimed',
'handle_report_resolved',
'handle_queue_completed',
'handle_bulk_operation_status',
]

View File

@@ -0,0 +1,185 @@
"""
Server-Sent Events (SSE) endpoint for real-time moderation dashboard updates.
This module provides a streaming HTTP response that broadcasts submission status
changes to connected moderators in real-time.
"""
import json
import logging
import queue
import threading
import time
from typing import Generator
from django.http import StreamingHttpResponse, JsonResponse
from django.views import View
from django.contrib.auth.mixins import LoginRequiredMixin
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from apps.moderation.permissions import CanViewModerationData
from apps.moderation.signals import submission_status_changed
logger = logging.getLogger(__name__)
# Thread-safe queue for broadcasting events to all connected clients
class SSEBroadcaster:
"""
Manages SSE connections and broadcasts events to all clients.
Uses a simple subscriber pattern where each connected client
gets its own queue of events to consume.
"""
def __init__(self):
self._subscribers: list[queue.Queue] = []
self._lock = threading.Lock()
def subscribe(self) -> queue.Queue:
"""Create a new subscriber queue and register it."""
client_queue = queue.Queue()
with self._lock:
self._subscribers.append(client_queue)
logger.debug(f"SSE client subscribed. Total clients: {len(self._subscribers)}")
return client_queue
def unsubscribe(self, client_queue: queue.Queue):
"""Remove a subscriber queue."""
with self._lock:
if client_queue in self._subscribers:
self._subscribers.remove(client_queue)
logger.debug(f"SSE client unsubscribed. Total clients: {len(self._subscribers)}")
def broadcast(self, event_data: dict):
"""Send an event to all connected clients."""
with self._lock:
for client_queue in self._subscribers:
try:
client_queue.put_nowait(event_data)
except queue.Full:
logger.warning("SSE client queue full, dropping event")
# Global broadcaster instance
sse_broadcaster = SSEBroadcaster()
def handle_submission_status_changed(sender, payload, **kwargs):
"""
Signal handler that broadcasts submission status changes to SSE clients.
Connected to the submission_status_changed signal from signals.py.
"""
sse_broadcaster.broadcast(payload)
logger.debug(f"Broadcast SSE event: {payload.get('submission_type')}#{payload.get('submission_id')}")
# Connect the signal handler
submission_status_changed.connect(handle_submission_status_changed)
class ModerationSSEView(APIView):
"""
Server-Sent Events endpoint for real-time moderation updates.
Provides a streaming response that sends submission status changes
as they occur. Clients should connect to this endpoint and keep
the connection open to receive real-time updates.
Response format (SSE):
data: {"submission_id": 1, "new_status": "CLAIMED", ...}
Usage:
const eventSource = new EventSource('/api/moderation/sse/')
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data)
// Handle update
}
"""
permission_classes = [IsAuthenticated, CanViewModerationData]
def get(self, request):
"""
Establish SSE connection and stream events.
Sends a heartbeat every 30 seconds to keep the connection alive.
"""
def event_stream() -> Generator[str, None, None]:
client_queue = sse_broadcaster.subscribe()
try:
# Send initial connection event
yield f"data: {json.dumps({'type': 'connected', 'message': 'SSE connection established'})}\n\n"
while True:
try:
# Wait for event with timeout for heartbeat
event = client_queue.get(timeout=30)
yield f"data: {json.dumps(event)}\n\n"
except queue.Empty:
# Send heartbeat to keep connection alive
yield f": heartbeat\n\n"
except GeneratorExit:
# Client disconnected
sse_broadcaster.unsubscribe(client_queue)
finally:
sse_broadcaster.unsubscribe(client_queue)
response = StreamingHttpResponse(
event_stream(),
content_type='text/event-stream'
)
response['Cache-Control'] = 'no-cache'
response['X-Accel-Buffering'] = 'no' # Disable nginx buffering
response['Connection'] = 'keep-alive'
return response
class ModerationSSETestView(APIView):
"""
Test endpoint to manually trigger an SSE event.
This is useful for testing the SSE connection without making
actual state transitions.
POST /api/moderation/sse/test/
{
"submission_id": 1,
"submission_type": "edit",
"new_status": "CLAIMED",
"previous_status": "PENDING"
}
"""
permission_classes = [IsAuthenticated, CanViewModerationData]
def post(self, request):
"""Broadcast a test event."""
test_payload = {
"submission_id": request.data.get("submission_id", 999),
"submission_type": request.data.get("submission_type", "edit"),
"new_status": request.data.get("new_status", "CLAIMED"),
"previous_status": request.data.get("previous_status", "PENDING"),
"locked_by": request.user.username,
"locked_at": None,
"changed_by": request.user.username,
"test": True,
}
sse_broadcaster.broadcast(test_payload)
return JsonResponse({
"status": "ok",
"message": f"Test event broadcast to {len(sse_broadcaster._subscribers)} clients",
"payload": test_payload,
})
__all__ = [
'ModerationSSEView',
'ModerationSSETestView',
'sse_broadcaster',
]

View File

@@ -16,7 +16,10 @@ from .views import (
ModerationActionViewSet,
BulkOperationViewSet,
UserModerationViewSet,
EditSubmissionViewSet,
PhotoSubmissionViewSet,
)
from .sse import ModerationSSEView, ModerationSSETestView
from apps.core.views.views import FSMTransitionView
@@ -68,9 +71,16 @@ router.register(r"queue", ModerationQueueViewSet, basename="moderation-queue")
router.register(r"actions", ModerationActionViewSet, basename="moderation-actions")
router.register(r"bulk-operations", BulkOperationViewSet, basename="bulk-operations")
router.register(r"users", UserModerationViewSet, basename="user-moderation")
# EditSubmission - register under both names for compatibility
router.register(r"submissions", EditSubmissionViewSet, basename="submissions")
router.register(r"edit-submissions", EditSubmissionViewSet, basename="edit-submissions")
# PhotoSubmission - register under both names for compatibility
router.register(r"photos", PhotoSubmissionViewSet, basename="photos")
router.register(r"photo-submissions", PhotoSubmissionViewSet, basename="photo-submissions")
app_name = "moderation"
# FSM transition convenience URLs for moderation models
fsm_transition_patterns = [
# EditSubmission transitions
@@ -161,9 +171,17 @@ html_patterns = [
path("history/", HistoryPageView.as_view(), name="history"),
]
# SSE endpoints for real-time updates
sse_patterns = [
path("sse/", ModerationSSEView.as_view(), name="moderation-sse"),
path("sse/test/", ModerationSSETestView.as_view(), name="moderation-sse-test"),
]
urlpatterns = [
# HTML page views
*html_patterns,
# SSE endpoints
*sse_patterns,
# Include all router URLs (API endpoints)
path("api/", include(router.urls)),
# FSM transition convenience endpoints

View File

@@ -34,6 +34,8 @@ from .models import (
ModerationQueue,
ModerationAction,
BulkOperation,
EditSubmission,
PhotoSubmission,
)
from .serializers import (
ModerationReportSerializer,
@@ -47,6 +49,9 @@ from .serializers import (
BulkOperationSerializer,
CreateBulkOperationSerializer,
UserModerationProfileSerializer,
EditSubmissionSerializer,
EditSubmissionListSerializer,
PhotoSubmissionSerializer,
)
from .filters import (
ModerationReportFilter,
@@ -1166,6 +1171,28 @@ class UserModerationViewSet(viewsets.ViewSet):
# Default serializer for schema generation
serializer_class = UserModerationProfileSerializer
def list(self, request):
"""Search for users to moderate."""
query = request.query_params.get("q", "")
if not query:
return Response([])
queryset = User.objects.filter(
Q(username__icontains=query) | Q(email__icontains=query)
)[:20]
users_data = [
{
"id": user.id,
"username": user.username,
"email": user.email,
"role": getattr(user, "role", "USER"),
"is_active": user.is_active,
}
for user in queryset
]
return Response(users_data)
def retrieve(self, request, pk=None):
"""Get moderation profile for a specific user."""
try:
@@ -1367,3 +1394,345 @@ class UserModerationViewSet(viewsets.ViewSet):
}
return Response(stats_data)
# ============================================================================
# Submission ViewSets
# ============================================================================
class EditSubmissionViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing edit submissions.
Includes claim/unclaim endpoints with concurrency protection using
database row locking (select_for_update) to prevent race conditions.
"""
queryset = EditSubmission.objects.all()
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["reason", "changes"]
ordering_fields = ["created_at", "status"]
ordering = ["-created_at"]
permission_classes = [CanViewModerationData]
def get_serializer_class(self):
if self.action == "list":
return EditSubmissionListSerializer
return EditSubmissionSerializer
def get_queryset(self):
queryset = super().get_queryset()
status = self.request.query_params.get("status")
if status:
queryset = queryset.filter(status=status)
# User filter
user_id = self.request.query_params.get("user")
if user_id:
queryset = queryset.filter(user_id=user_id)
return queryset
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def claim(self, request, pk=None):
"""
Claim a submission for review with concurrency protection.
Uses select_for_update() to acquire a database row lock,
preventing race conditions when multiple moderators try to
claim the same submission simultaneously.
Returns:
200: Submission successfully claimed
404: Submission not found
409: Submission already claimed or being claimed by another moderator
400: Invalid state for claiming
"""
from django.db import transaction, DatabaseError
from django.core.exceptions import ValidationError
with transaction.atomic():
try:
# Lock the row for update - other transactions will fail immediately
submission = EditSubmission.objects.select_for_update(nowait=True).get(pk=pk)
except EditSubmission.DoesNotExist:
return Response(
{"error": "Submission not found"},
status=status.HTTP_404_NOT_FOUND
)
except DatabaseError:
# Row is already locked by another transaction
return Response(
{"error": "Submission is being claimed by another moderator. Please try again."},
status=status.HTTP_409_CONFLICT
)
# Check if already claimed
if submission.status == "CLAIMED":
return Response(
{
"error": "Submission already claimed",
"claimed_by": submission.claimed_by.username if submission.claimed_by else None,
"claimed_at": submission.claimed_at.isoformat() if submission.claimed_at else None,
},
status=status.HTTP_409_CONFLICT
)
# Check if in valid state for claiming
if submission.status != "PENDING":
return Response(
{"error": f"Cannot claim submission in {submission.status} state"},
status=status.HTTP_400_BAD_REQUEST
)
try:
submission.claim(user=request.user)
log_business_event(
logger,
event_type="submission_claimed",
message=f"EditSubmission {submission.id} claimed by {request.user.username}",
context={
"model": "EditSubmission",
"object_id": submission.id,
"claimed_by": request.user.username,
},
request=request,
)
return Response(self.get_serializer(submission).data)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def unclaim(self, request, pk=None):
"""
Release claim on a submission.
Only the claiming moderator or an admin can unclaim a submission.
"""
from django.core.exceptions import ValidationError
submission = self.get_object()
# Only the claiming user or an admin can unclaim
if submission.claimed_by != request.user and not request.user.is_staff:
return Response(
{"error": "Only the claiming moderator or an admin can unclaim"},
status=status.HTTP_403_FORBIDDEN
)
if submission.status != "CLAIMED":
return Response(
{"error": "Submission is not claimed"},
status=status.HTTP_400_BAD_REQUEST
)
try:
submission.unclaim(user=request.user)
log_business_event(
logger,
event_type="submission_unclaimed",
message=f"EditSubmission {submission.id} unclaimed by {request.user.username}",
context={
"model": "EditSubmission",
"object_id": submission.id,
"unclaimed_by": request.user.username,
},
request=request,
)
return Response(self.get_serializer(submission).data)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def approve(self, request, pk=None):
submission = self.get_object()
user = request.user
try:
submission.approve(moderator=user)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def reject(self, request, pk=None):
submission = self.get_object()
user = request.user
reason = request.data.get("reason", "")
try:
submission.reject(moderator=user, reason=reason)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def escalate(self, request, pk=None):
submission = self.get_object()
user = request.user
reason = request.data.get("reason", "")
try:
submission.escalate(moderator=user, reason=reason)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
class PhotoSubmissionViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing photo submissions.
Includes claim/unclaim endpoints with concurrency protection using
database row locking (select_for_update) to prevent race conditions.
"""
queryset = PhotoSubmission.objects.all()
serializer_class = PhotoSubmissionSerializer
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["caption", "notes"]
ordering_fields = ["created_at", "status"]
ordering = ["-created_at"]
permission_classes = [CanViewModerationData]
def get_queryset(self):
queryset = super().get_queryset()
status = self.request.query_params.get("status")
if status:
queryset = queryset.filter(status=status)
# User filter
user_id = self.request.query_params.get("user")
if user_id:
queryset = queryset.filter(user_id=user_id)
return queryset
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def claim(self, request, pk=None):
"""
Claim a photo submission for review with concurrency protection.
Uses select_for_update() to acquire a database row lock.
"""
from django.db import transaction, DatabaseError
from django.core.exceptions import ValidationError
with transaction.atomic():
try:
submission = PhotoSubmission.objects.select_for_update(nowait=True).get(pk=pk)
except PhotoSubmission.DoesNotExist:
return Response(
{"error": "Submission not found"},
status=status.HTTP_404_NOT_FOUND
)
except DatabaseError:
return Response(
{"error": "Submission is being claimed by another moderator. Please try again."},
status=status.HTTP_409_CONFLICT
)
if submission.status == "CLAIMED":
return Response(
{
"error": "Submission already claimed",
"claimed_by": submission.claimed_by.username if submission.claimed_by else None,
"claimed_at": submission.claimed_at.isoformat() if submission.claimed_at else None,
},
status=status.HTTP_409_CONFLICT
)
if submission.status != "PENDING":
return Response(
{"error": f"Cannot claim submission in {submission.status} state"},
status=status.HTTP_400_BAD_REQUEST
)
try:
submission.claim(user=request.user)
log_business_event(
logger,
event_type="submission_claimed",
message=f"PhotoSubmission {submission.id} claimed by {request.user.username}",
context={
"model": "PhotoSubmission",
"object_id": submission.id,
"claimed_by": request.user.username,
},
request=request,
)
return Response(self.get_serializer(submission).data)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def unclaim(self, request, pk=None):
"""Release claim on a photo submission."""
from django.core.exceptions import ValidationError
submission = self.get_object()
if submission.claimed_by != request.user and not request.user.is_staff:
return Response(
{"error": "Only the claiming moderator or an admin can unclaim"},
status=status.HTTP_403_FORBIDDEN
)
if submission.status != "CLAIMED":
return Response(
{"error": "Submission is not claimed"},
status=status.HTTP_400_BAD_REQUEST
)
try:
submission.unclaim(user=request.user)
log_business_event(
logger,
event_type="submission_unclaimed",
message=f"PhotoSubmission {submission.id} unclaimed by {request.user.username}",
context={
"model": "PhotoSubmission",
"object_id": submission.id,
"unclaimed_by": request.user.username,
},
request=request,
)
return Response(self.get_serializer(submission).data)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def approve(self, request, pk=None):
submission = self.get_object()
user = request.user
notes = request.data.get("notes", "")
try:
submission.approve(moderator=user, notes=notes)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def reject(self, request, pk=None):
submission = self.get_object()
user = request.user
notes = request.data.get("notes", "")
try:
submission.reject(moderator=user, notes=notes)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def escalate(self, request, pk=None):
submission = self.get_object()
user = request.user
notes = request.data.get("notes", "")
try:
submission.escalate(moderator=user, notes=notes)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -4,3 +4,6 @@ class ReviewsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.reviews"
verbose_name = "User Reviews"
def ready(self):
import apps.reviews.signals

View File

@@ -0,0 +1,176 @@
# Generated by Django 5.1.6 on 2025-12-26 14:29
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("pghistory", "0006_delete_aggregateevent"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Review",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("object_id", models.PositiveIntegerField(help_text="ID of the item being reviewed")),
(
"rating",
models.PositiveSmallIntegerField(
choices=[(1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5")],
db_index=True,
help_text="Rating from 1 to 5",
),
),
("text", models.TextField(blank=True, help_text="Review text (optional)")),
("is_public", models.BooleanField(default=True, help_text="Whether this review is visible to others")),
(
"helpful_votes",
models.PositiveIntegerField(default=0, help_text="Number of users who found this helpful"),
),
(
"content_type",
models.ForeignKey(
help_text="Type of item being reviewed",
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"user",
models.ForeignKey(
help_text="User who wrote the review",
on_delete=django.db.models.deletion.CASCADE,
related_name="reviews",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Review",
"verbose_name_plural": "Reviews",
"ordering": ["-created_at"],
"abstract": False,
},
),
migrations.CreateModel(
name="ReviewEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("object_id", models.PositiveIntegerField(help_text="ID of the item being reviewed")),
(
"rating",
models.PositiveSmallIntegerField(
choices=[(1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5")], help_text="Rating from 1 to 5"
),
),
("text", models.TextField(blank=True, help_text="Review text (optional)")),
("is_public", models.BooleanField(default=True, help_text="Whether this review is visible to others")),
(
"helpful_votes",
models.PositiveIntegerField(default=0, help_text="Number of users who found this helpful"),
),
(
"content_type",
models.ForeignKey(
db_constraint=False,
help_text="Type of item being reviewed",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="reviews.review",
),
),
(
"user",
models.ForeignKey(
db_constraint=False,
help_text="User who wrote the review",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.AddIndex(
model_name="review",
index=models.Index(fields=["content_type", "object_id"], name="reviews_rev_content_627d80_idx"),
),
migrations.AddIndex(
model_name="review",
index=models.Index(fields=["rating"], name="reviews_rev_rating_2db6dd_idx"),
),
migrations.AlterUniqueTogether(
name="review",
unique_together={("user", "content_type", "object_id")},
),
pgtrigger.migrations.AddTrigger(
model_name="review",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "reviews_reviewevent" ("content_type_id", "created_at", "helpful_votes", "id", "is_public", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "text", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."helpful_votes", NEW."id", NEW."is_public", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."text", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="72f23486e0f1db9f6f47e7cd42888c4d87a6a31b",
operation="INSERT",
pgid="pgtrigger_insert_insert_7a7c1",
table="reviews_review",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="review",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "reviews_reviewevent" ("content_type_id", "created_at", "helpful_votes", "id", "is_public", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "text", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."helpful_votes", NEW."id", NEW."is_public", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."text", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="ca02efb281912450d6755ec9b07ebc998eabf421",
operation="UPDATE",
pgid="pgtrigger_update_update_b34c8",
table="reviews_review",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,29 @@
from rest_framework import serializers
from .models import Review
from apps.accounts.serializers import UserSerializer
class ReviewSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
class Meta:
model = Review
fields = [
"id",
"user",
"content_type",
"object_id",
"rating",
"text",
"is_public",
"helpful_votes",
"created_at",
"updated_at",
]
read_only_fields = ["id", "user", "helpful_votes", "created_at", "updated_at"]
def validate(self, data):
"""
Check that rating is between 1 and 5.
"""
# Rating is already validated by model field validation but explicit check is good
return data

View File

@@ -0,0 +1,30 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.db.models import Avg
from .models import Review
@receiver(post_save, sender=Review)
@receiver(post_delete, sender=Review)
def update_average_rating(sender, instance, **kwargs):
"""
Update the average rating of the content object when a review is saved or deleted.
"""
content_object = instance.content_object
if not content_object:
# If content object doesn't exist (orphaned review?), skip
return
# Check if the content object has an 'average_rating' field
if not hasattr(content_object, 'average_rating'):
return
# Calculate new average
# We query the Review model filtering by content_type and object_id
avg_rating = Review.objects.filter(
content_type=instance.content_type,
object_id=instance.object_id
).aggregate(Avg('rating'))['rating__avg']
# Update field
content_object.average_rating = avg_rating or 0 # Default to 0 if no reviews
content_object.save(update_fields=['average_rating'])

View File

@@ -0,0 +1,10 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ReviewViewSet
router = DefaultRouter()
router.register(r"reviews", ReviewViewSet, basename="review")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -0,0 +1,27 @@
from rest_framework import viewsets, permissions, filters
from django_filters.rest_framework import DjangoFilterBackend
from .models import Review
from .serializers import ReviewSerializer
from apps.core.permissions import IsOwnerOrReadOnly
class ReviewViewSet(viewsets.ModelViewSet):
queryset = Review.objects.filter(is_public=True)
serializer_class = ReviewSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["content_type", "object_id", "rating", "user"]
ordering_fields = ["created_at", "rating", "helpful_votes"]
ordering = ["-created_at"]
def get_queryset(self):
# Users can see their own non-public reviews?
# Standard queryset is public only.
# But if we want authors to see their own pending/private reviews:
qs = Review.objects.filter(is_public=True)
if self.request.user.is_authenticated:
# Add user's own reviews even if not public (if that's a use case)
qs = qs | Review.objects.filter(user=self.request.user)
return qs.distinct()
def perform_create(self, serializer):
serializer.save(user=self.request.user)

View File

@@ -0,0 +1,169 @@
# Generated by Django 5.1.6 on 2025-12-26 15:57
import django.core.validators
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pghistory", "0006_delete_aggregateevent"),
("rides", "0027_alter_company_options_alter_rankingsnapshot_options_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="RideCredit",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("count", models.PositiveIntegerField(default=1, help_text="Number of times ridden")),
(
"rating",
models.IntegerField(
blank=True,
help_text="Personal rating (1-5)",
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(5),
],
),
),
("first_ridden_at", models.DateField(blank=True, help_text="Date of first ride", null=True)),
("last_ridden_at", models.DateField(blank=True, help_text="Date of most recent ride", null=True)),
("notes", models.TextField(blank=True, help_text="Personal notes about the experience")),
(
"ride",
models.ForeignKey(
help_text="The ride that was ridden",
on_delete=django.db.models.deletion.CASCADE,
related_name="credits",
to="rides.ride",
),
),
(
"user",
models.ForeignKey(
help_text="User who rode the ride",
on_delete=django.db.models.deletion.CASCADE,
related_name="ride_credits",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Ride Credit",
"verbose_name_plural": "Ride Credits",
"ordering": ["-last_ridden_at", "-first_ridden_at", "-created_at"],
"abstract": False,
"unique_together": {("user", "ride")},
},
),
migrations.CreateModel(
name="RideCreditEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("count", models.PositiveIntegerField(default=1, help_text="Number of times ridden")),
(
"rating",
models.IntegerField(
blank=True,
help_text="Personal rating (1-5)",
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(5),
],
),
),
("first_ridden_at", models.DateField(blank=True, help_text="Date of first ride", null=True)),
("last_ridden_at", models.DateField(blank=True, help_text="Date of most recent ride", null=True)),
("notes", models.TextField(blank=True, help_text="Personal notes about the experience")),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridecredit",
),
),
(
"ride",
models.ForeignKey(
db_constraint=False,
help_text="The ride that was ridden",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
(
"user",
models.ForeignKey(
db_constraint=False,
help_text="User who rode the ride",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="ridecredit",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridecreditevent" ("count", "created_at", "first_ridden_at", "id", "last_ridden_at", "notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "updated_at", "user_id") VALUES (NEW."count", NEW."created_at", NEW."first_ridden_at", NEW."id", NEW."last_ridden_at", NEW."notes", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."ride_id", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="99d9f7e7134fbcb6f84a1966fe9539c8ccc22eee",
operation="INSERT",
pgid="pgtrigger_insert_insert_00439",
table="rides_ridecredit",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridecredit",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridecreditevent" ("count", "created_at", "first_ridden_at", "id", "last_ridden_at", "notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "updated_at", "user_id") VALUES (NEW."count", NEW."created_at", NEW."first_ridden_at", NEW."id", NEW."last_ridden_at", NEW."notes", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."ride_id", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="1795a528b3b188da59c8cf60053df8eddb80904c",
operation="UPDATE",
pgid="pgtrigger_update_update_32a65",
table="rides_ridecredit",
when="AFTER",
),
),
),
]

View File

@@ -14,6 +14,7 @@ from .location import RideLocation
from .reviews import RideReview
from .rankings import RideRanking, RidePairComparison, RankingSnapshot
from .media import RidePhoto
from .credits import RideCredit
__all__ = [
# Primary models
@@ -24,6 +25,7 @@ __all__ = [
"RideLocation",
"RideReview",
"RidePhoto",
"RideCredit",
# Rankings
"RideRanking",
"RidePairComparison",

View File

@@ -0,0 +1,55 @@
from django.db import models
from django.conf import settings
from django.core.validators import MinValueValidator, MaxValueValidator
import pghistory
from apps.core.history import TrackedModel
@pghistory.track()
class RideCredit(TrackedModel):
"""
Represents a user's ride credit (a ride they have ridden).
Functions as a through-model for tracking which rides a user has experienced.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="ride_credits",
help_text="User who rode the ride",
)
ride = models.ForeignKey(
"rides.Ride",
on_delete=models.CASCADE,
related_name="credits",
help_text="The ride that was ridden",
)
# Credit Details
count = models.PositiveIntegerField(
default=1, help_text="Number of times ridden"
)
rating = models.IntegerField(
null=True,
blank=True,
validators=[MinValueValidator(1), MaxValueValidator(5)],
help_text="Personal rating (1-5)",
)
first_ridden_at = models.DateField(
null=True, blank=True, help_text="Date of first ride"
)
last_ridden_at = models.DateField(
null=True, blank=True, help_text="Date of most recent ride"
)
notes = models.TextField(
blank=True, help_text="Personal notes about the experience"
)
class Meta(TrackedModel.Meta):
verbose_name = "Ride Credit"
verbose_name_plural = "Ride Credits"
unique_together = ["user", "ride"]
ordering = ["-last_ridden_at", "-first_ridden_at", "-created_at"]
def __str__(self):
return f"{self.user} - {self.ride}"

View File

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SupportConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.support"
verbose_name = "Support"

View File

@@ -0,0 +1,54 @@
# Generated by Django 5.1.6 on 2025-12-26 14:34
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Ticket",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("subject", models.CharField(max_length=255)),
("message", models.TextField()),
("email", models.EmailField(blank=True, help_text="Contact email", max_length=254)),
(
"status",
models.CharField(
choices=[("open", "Open"), ("in_progress", "In Progress"), ("closed", "Closed")],
db_index=True,
default="open",
max_length=20,
),
),
(
"user",
models.ForeignKey(
blank=True,
help_text="User who submitted the ticket (optional)",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="tickets",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Ticket",
"verbose_name_plural": "Tickets",
"ordering": ["-created_at"],
"abstract": False,
},
),
]

View File

@@ -0,0 +1,48 @@
from django.db import models
from django.conf import settings
from apps.core.history import TrackedModel
class Ticket(TrackedModel):
STATUS_OPEN = 'open'
STATUS_IN_PROGRESS = 'in_progress'
STATUS_CLOSED = 'closed'
STATUS_CHOICES = [
(STATUS_OPEN, 'Open'),
(STATUS_IN_PROGRESS, 'In Progress'),
(STATUS_CLOSED, 'Closed'),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="tickets",
help_text="User who submitted the ticket (optional)"
)
subject = models.CharField(max_length=255)
message = models.TextField()
email = models.EmailField(help_text="Contact email", blank=True)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default=STATUS_OPEN,
db_index=True
)
class Meta(TrackedModel.Meta):
verbose_name = "Ticket"
verbose_name_plural = "Tickets"
ordering = ["-created_at"]
def __str__(self):
return f"[{self.get_status_display()}] {self.subject}"
def save(self, *args, **kwargs):
# If user is set but email is empty, autofill from user
if self.user and not self.email:
self.email = self.user.email
super().save(*args, **kwargs)

View File

@@ -0,0 +1,27 @@
from rest_framework import serializers
from .models import Ticket
from apps.accounts.serializers import UserSerializer
class TicketSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
class Meta:
model = Ticket
fields = [
"id",
"user",
"subject",
"message",
"email",
"status",
"created_at",
"updated_at",
]
read_only_fields = ["id", "status", "created_at", "updated_at", "user"]
def validate(self, data):
# Ensure email is provided if user is anonymous
request = self.context.get('request')
if request and not request.user.is_authenticated and not data.get('email'):
raise serializers.ValidationError({"email": "Email is required for guests."})
return data

View File

@@ -0,0 +1,10 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import TicketViewSet
router = DefaultRouter()
router.register(r"tickets", TicketViewSet, basename="ticket")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -0,0 +1,32 @@
from rest_framework import viewsets, permissions, filters
from django_filters.rest_framework import DjangoFilterBackend
from .models import Ticket
from .serializers import TicketSerializer
class TicketViewSet(viewsets.ModelViewSet):
"""
Standard users/guests can CREATE.
Only Staff can LIST/RETRIEVE/UPDATE all.
Users can LIST/RETRIEVE their own.
"""
queryset = Ticket.objects.all()
serializer_class = TicketSerializer
permission_classes = [permissions.AllowAny] # We handle granular perms in get_queryset/perform_create
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["status"]
ordering_fields = ["created_at", "status"]
ordering = ["-created_at"]
def get_queryset(self):
user = self.request.user
if user.is_staff:
return Ticket.objects.all()
if user.is_authenticated:
return Ticket.objects.filter(user=user)
return Ticket.objects.none() # Guests can't list tickets
def perform_create(self, serializer):
if self.request.user.is_authenticated:
serializer.save(user=self.request.user, email=self.request.user.email)
else:
serializer.save()