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