- content: POST nodes/{id}/quiz/complete and lessons/{id}/complete refuse a locked island
(403 node_locked, same rule as /api/home) and a user who has not answered every question
(409 answers_missing). Before, only a user with zero hearts and no answers was refused, so
anyone else finished any island, locked or not, and finishing a locked island's lessons
unlocked it. A node or lesson with no questions still passes when unlocked, with no XP.
The client can no longer understate totalQuestions to buy stars.
- throttle_scope on 15 routes that had none (question bank without login, intro, hearts,
notifications, tickets, plans, subscription status, orders, offers, affiliate, assistant
history and feedback, logout).
- admin: OTPCode and Session can only be added/changed by a superuser.
- deploy.sh: integrity-checked DB backup before migrate (keeps the last 2); no migrate without it.
- docs and CLAUDE.md updated to say what is verified and what is not.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
183 lines
7.0 KiB
Python
183 lines
7.0 KiB
Python
from django.db.models import Count, Q
|
|
from django.shortcuts import get_object_or_404
|
|
from drf_spectacular.utils import extend_schema
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.throttling import ScopedRateThrottle
|
|
from rest_framework.views import APIView
|
|
|
|
from .models import BugReport, Ticket, TicketMessage
|
|
from .serializers import (
|
|
BugReportCreateRequestSerializer,
|
|
BugReportCreateResponseSerializer,
|
|
TicketCreateRequestSerializer,
|
|
TicketCreateResponseSerializer,
|
|
TicketDetailResponseSerializer,
|
|
TicketListResponseSerializer,
|
|
TicketMessageCreateRequestSerializer,
|
|
TicketMessageCreateResponseSerializer,
|
|
)
|
|
|
|
|
|
class BugReportCreateView(APIView):
|
|
"""POST /api/support/bug-reports — ثبت گزارش باگ"""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
# بدونِ idempotency/throttle، یک کاربر میتونست تعدادِ نامحدودی BugReport
|
|
# بسازه.
|
|
throttle_scope = 'support_create'
|
|
throttle_classes = [ScopedRateThrottle]
|
|
|
|
@extend_schema(
|
|
request=BugReportCreateRequestSerializer,
|
|
responses=BugReportCreateResponseSerializer,
|
|
tags=['Support (support)'],
|
|
)
|
|
def post(self, request):
|
|
serializer = BugReportCreateRequestSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
v = serializer.validated_data
|
|
|
|
bug = BugReport.objects.create(
|
|
user_id=request.user.id,
|
|
title=v['title'],
|
|
description=v['description'],
|
|
screenshot_url=v.get('screenshotUrl', ''),
|
|
)
|
|
|
|
data = {'bugReportId': str(bug.pk), 'status': bug.status}
|
|
return Response(BugReportCreateResponseSerializer(data).data)
|
|
|
|
|
|
class TicketCreateView(APIView):
|
|
"""
|
|
GET /api/support/tickets — تیکتهای همین کاربر (۲۰ تای آخر)
|
|
POST /api/support/tickets — ایجاد تیکت
|
|
|
|
GET تا ۲۰۲۶-۰۹-۲۹ نبود و اپ شناسهی تیکتها را فقط در حافظهی همان مرورگر
|
|
نگه میداشت؛ با عوض شدنِ دستگاه، تیکت و پاسخِ پشتیبانی از دید پنهان میشد.
|
|
"""
|
|
|
|
LIST_LIMIT = 20
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
# مثلِ BugReportCreateView: هیچ idempotency ای نیست، پس این endpoint هم
|
|
# همون scope=support_create رو بهاشتراک میذاره تا محدودیتِ ساختِ
|
|
# رکوردهای پشتیبانی، مستقل از اینکه کدوم نوع رکورده، رعایت بشه.
|
|
throttle_scope = 'support_create'
|
|
throttle_classes = [ScopedRateThrottle]
|
|
|
|
def get_throttles(self):
|
|
# GET سهمِ خواندن را میخورد، نه سقفِ ۳۰ در ساعتِ ساختِ رکورد.
|
|
if self.request.method == 'GET':
|
|
self.throttle_scope = 'read'
|
|
return super().get_throttles()
|
|
|
|
@extend_schema(
|
|
operation_id='support_tickets_list',
|
|
responses=TicketListResponseSerializer,
|
|
tags=['Support (support)'],
|
|
)
|
|
def get(self, request):
|
|
tickets = (
|
|
Ticket.objects.filter(user_id=request.user.id)
|
|
.annotate(support_replies=Count('messages', filter=Q(messages__sender='support')))
|
|
.order_by('-created_at')[: self.LIST_LIMIT]
|
|
)
|
|
data = {'tickets': [
|
|
{
|
|
'ticketId': str(t.pk),
|
|
'subject': t.subject,
|
|
'category': t.category,
|
|
'status': t.status,
|
|
'createdAt': t.created_at,
|
|
'supportReplies': t.support_replies,
|
|
}
|
|
for t in tickets
|
|
]}
|
|
return Response(TicketListResponseSerializer(data).data)
|
|
|
|
@extend_schema(
|
|
request=TicketCreateRequestSerializer,
|
|
responses=TicketCreateResponseSerializer,
|
|
tags=['Support (support)'],
|
|
)
|
|
def post(self, request):
|
|
serializer = TicketCreateRequestSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
v = serializer.validated_data
|
|
|
|
ticket = Ticket.objects.create(
|
|
user_id=request.user.id,
|
|
subject=v['subject'],
|
|
category=v['category'],
|
|
priority=v.get('priority', 'normal'),
|
|
)
|
|
|
|
data = {'ticketId': str(ticket.pk), 'status': ticket.status}
|
|
return Response(TicketCreateResponseSerializer(data).data)
|
|
|
|
|
|
class TicketDetailView(APIView):
|
|
"""
|
|
GET /api/support/tickets/{id} — جزئیات و پیامهای تیکت
|
|
|
|
فقط تیکت متعلق به همون کاربر برمیگرده (فیلتر با user_id، دقیقاً
|
|
همون الگوی محدودسازی به مالک که در progress/views.py برای
|
|
UserStats و در ai_tutor/views.py برای Conversation استفاده شده).
|
|
"""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
throttle_scope = 'read'
|
|
throttle_classes = [ScopedRateThrottle]
|
|
|
|
@extend_schema(responses=TicketDetailResponseSerializer, tags=['Support (support)'])
|
|
def get(self, request, ticket_id):
|
|
ticket = get_object_or_404(Ticket, pk=ticket_id, user_id=request.user.id)
|
|
|
|
messages = [
|
|
{'id': str(m.pk), 'sender': m.sender, 'text': m.text}
|
|
for m in ticket.messages.all()
|
|
]
|
|
data = {
|
|
'ticketId': str(ticket.pk),
|
|
'subject': ticket.subject,
|
|
'status': ticket.status,
|
|
'messages': messages,
|
|
}
|
|
return Response(TicketDetailResponseSerializer(data).data)
|
|
|
|
|
|
class TicketMessageCreateView(APIView):
|
|
"""
|
|
POST /api/support/tickets/{id}/messages — ارسال پیام در تیکت
|
|
|
|
این Endpoint سمت کاربره، پس sender همیشه 'user' ثبت میشه. پاسخ
|
|
پشتیبانی (sender='support') از پنل ادمین ثبت میشه و طبق سند معماری
|
|
از همونجا رویداد TicketReplied برای Notifications ساطع میشه
|
|
(پیادهسازی در support/admin.py، نه اینجا).
|
|
"""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
# مثلِ دو Endpoint بالا: بدونِ throttle، یک کاربر میتونست تعدادِ
|
|
# نامحدودی پیام در یک تیکت بسازه.
|
|
throttle_scope = 'support_create'
|
|
throttle_classes = [ScopedRateThrottle]
|
|
|
|
@extend_schema(
|
|
request=TicketMessageCreateRequestSerializer,
|
|
responses=TicketMessageCreateResponseSerializer,
|
|
tags=['Support (support)'],
|
|
)
|
|
def post(self, request, ticket_id):
|
|
ticket = get_object_or_404(Ticket, pk=ticket_id, user_id=request.user.id)
|
|
|
|
serializer = TicketMessageCreateRequestSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
v = serializer.validated_data
|
|
|
|
message = TicketMessage.objects.create(ticket=ticket, sender='user', text=v['text'])
|
|
|
|
data = {'messageId': str(message.pk), 'success': True}
|
|
return Response(TicketMessageCreateResponseSerializer(data).data)
|