- remove the family app from the root (separate parent accounts, invite codes, parental settings); progress.0006 drops its tables, permissions and migration history, and Profile.parent_phone, the two family notification types and ?userId= on /api/progress/nodes go with it - GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes, questions, accuracy, islands, tutor questions) for the "parent report" page - profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it - GET /api/payment/invoices; orders now snapshot list price and plan title - tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11), refusal marker replaced with fixed text; self-harm gets a help line instead - streamed answers now record token usage (it was always 0) - daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota - delete_users management command for wiping test accounts across every app - docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
224 lines
10 KiB
Python
224 lines
10 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 GuestAwareSupportThrottle(ScopedRateThrottle):
|
|
"""
|
|
همان مشکلِ GuestAwareAIThrottle (ai_tutor/views.py) و GuestAwareDiscountThrottle/
|
|
GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py)
|
|
اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ
|
|
احرازهویتشده از request.user.pk بهعنوانِ کلیدِ throttle استفاده
|
|
میکنه؛ چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ
|
|
تازه (pk جدید) میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای
|
|
مهمان، برای هر حساب یک bucket تازه از throttle_scope 'support_create'
|
|
بگیره. چون هر سه Endpointِ این اپ (BugReportCreateView،
|
|
TicketCreateView، TicketMessageCreateView) این scope رو بهاشتراک
|
|
میذارن و هرکدوم واقعاً یک رکورد در دیتابیس میسازن (BugReport،
|
|
Ticket، یا TicketMessage)، چرخشِ حساب مهمان میتونست محدودیتِ
|
|
۳/ساعتِ در نظر گرفتهشده رو چند برابر کنه و باعثِ spam بشه.
|
|
|
|
این کلاسِ throttle یکبار اینجا تعریف شده و بینِ هر سه Endpoint
|
|
بهاشتراک گذاشته میشه (بهجایِ تکرارِ یک کلاسِ مشابه برای هرکدوم)،
|
|
دقیقاً چون هر سه از یک throttle_scope مشترک استفاده میکنن. فقط
|
|
برای کاربرهای مهمان (is_guest=True) کلیدِ cache از pk کاربر به IP
|
|
کلاینت (get_ident) تغییر میکنه، پس همهی حسابهای مهمانِ ساختهشده
|
|
از یک IP یک bucket مشترک دارن و چرخشِ حساب دیگه bucket تازه نمیده.
|
|
رفتارِ throttle برای کاربرهای غیرمهمان (ثبتنامشده) دقیقاً مثلِ قبل
|
|
(کلید بر اساسِ pk) باقی میمونه؛ منطقِ ساختِ رکورد/ownership/serializer
|
|
در هر سه ویو دستنخوردهست.
|
|
"""
|
|
|
|
def get_cache_key(self, request, view):
|
|
user = request.user
|
|
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
|
ident = f'guest-ip:{self.get_ident(request)}'
|
|
elif user and user.is_authenticated:
|
|
ident = user.pk
|
|
else:
|
|
ident = self.get_ident(request)
|
|
|
|
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
|
|
|
|
|
class BugReportCreateView(APIView):
|
|
"""POST /api/support/bug-reports — ثبت گزارش باگ"""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
# بدونِ idempotency/throttle، یک کاربر میتونست تعدادِ نامحدودی BugReport
|
|
# بسازه؛ GuestAwareSupportThrottle برای مهمانها کلید رو بر اساسِ IP
|
|
# میسازه تا چرخشِ حساب bucket تازه نده.
|
|
throttle_scope = 'support_create'
|
|
throttle_classes = [GuestAwareSupportThrottle]
|
|
|
|
@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 رو بهاشتراک میذاره تا محدودیتِ ساختِ
|
|
# رکوردهای پشتیبانی، مستقل از اینکه کدوم نوع رکورده، رعایت بشه.
|
|
# ⚠️ اصلاح شده: از همون GuestAwareSupportThrottleِ مشترک استفاده میشه
|
|
# تا چرخشِ حساب مهمان bucket تازه ندهد.
|
|
throttle_scope = 'support_create'
|
|
throttle_classes = [GuestAwareSupportThrottle]
|
|
|
|
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]
|
|
|
|
@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، یک کاربر میتونست تعدادِ
|
|
# نامحدودی پیام در یک تیکت بسازه.
|
|
# ⚠️ اصلاح شده: از همون GuestAwareSupportThrottleِ مشترک استفاده میشه
|
|
# تا چرخشِ حساب مهمان bucket تازه ندهد.
|
|
throttle_scope = 'support_create'
|
|
throttle_classes = [GuestAwareSupportThrottle]
|
|
|
|
@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)
|