Files
MathApp/support/views.py
Zareian 39247f6d53 senior's post-demo changes: parent report in the student's own account, Jalali birth date, invoices, math-only tutor, priced quotas
- 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>
2026-10-02 02:38:43 +03:30

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)