- 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>
181 lines
7.2 KiB
Python
181 lines
7.2 KiB
Python
import re
|
||
|
||
from rest_framework import serializers
|
||
|
||
from .phone import normalize_phone
|
||
|
||
# لینک، آیدیِ شبکهی اجتماعی، و هر رشتهی ۵ رقمی یا بیشتر (شمارهی تلفن با
|
||
# فاصله/خطتیره هم) — ارقامِ فارسی و عربی هم.
|
||
_CONTACT = re.compile(
|
||
r'(https?://\S+|www\.\S+|\S+\.(?:com|ir|org|net|me)\S*|@\w+'
|
||
r'|(?:[0-9۰-۹٠-٩][\s\-.]?){5,})',
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def clean_display_name(value: str) -> str:
|
||
return ' '.join(_CONTACT.sub(' ', value or '').split())[:30]
|
||
|
||
|
||
class IranMobileField(serializers.CharField):
|
||
"""CharField که خروجیاش همیشه 09XXXXXXXXX است."""
|
||
|
||
def __init__(self, **kwargs):
|
||
kwargs.setdefault('max_length', 20)
|
||
super().__init__(**kwargs)
|
||
|
||
def to_internal_value(self, data):
|
||
phone = normalize_phone(super().to_internal_value(data))
|
||
if phone is None:
|
||
raise serializers.ValidationError('شمارهی موبایل معتبر نیست؛ مثلاً 09121234567.')
|
||
return phone
|
||
|
||
|
||
# ---- Request serializers ----
|
||
|
||
class GuestSessionRequestSerializer(serializers.Serializer):
|
||
type = serializers.ChoiceField(choices=['guest'])
|
||
deviceId = serializers.CharField(max_length=64)
|
||
|
||
|
||
class LoginRequestSerializer(serializers.Serializer):
|
||
phone = IranMobileField()
|
||
otp = serializers.CharField(max_length=6)
|
||
|
||
|
||
class RefreshRequestSerializer(serializers.Serializer):
|
||
refreshToken = serializers.CharField()
|
||
|
||
|
||
class SetGradeRequestSerializer(serializers.Serializer):
|
||
gradeId = serializers.IntegerField()
|
||
|
||
|
||
# همان بازهای که پیشتر برای «سن» پذیرفته میشد. بزرگسال هم میآید (سینیور،
|
||
# ۲۰۲۶-۱۰-۰۱): کسی که فقط یک مبحثِ ریاضی را میخواهد یاد بگیرد.
|
||
MIN_AGE = 5
|
||
MAX_AGE = 99
|
||
|
||
|
||
class UpdateProfileRequestSerializer(serializers.Serializer):
|
||
"""
|
||
ورودی PATCH /api/user/profile — همهی فیلدها اختیاریاند و فقط همانهایی
|
||
که فرستاده شوند تغییر میکنند (partial update واقعی).
|
||
|
||
`phone` و `isGuest` عمداً اینجا نیستند: شماره فقط از مسیرِ OTP عوض
|
||
میشود و مهمانبودن را کاربر نباید بتواند خودش تغییر دهد.
|
||
"""
|
||
name = serializers.CharField(max_length=100, required=False, allow_blank=True)
|
||
avatar = serializers.CharField(max_length=100, required=False, allow_blank=True)
|
||
|
||
def validate_name(self, value):
|
||
"""
|
||
نام در جدولِ رتبهبندی به بچههای دیگر نشان داده میشود؛ شماره، لینک و
|
||
آیدی (راهِ تماس گرفتن با یک کودکِ غریبه) از آن حذف میشود. عمداً خطا
|
||
نمیدهد تا صفحهی پروفایلِ اپ گیر نکند؛ فقط بقیهی نام ذخیره میشود.
|
||
"""
|
||
return clean_display_name(value)
|
||
# فقط سال (روشِ قدیمِ اپ). birthDate دقیقتر است و ترجیح دارد.
|
||
birthYear = serializers.IntegerField(required=False, allow_null=True,
|
||
min_value=1300, max_value=1500)
|
||
# تاریخِ تولدِ شمسی «1385-04-12». سرور میلادی ذخیره میکند و birthYear را هم
|
||
# از آن پر میکند؛ تبدیل فقط در accounts/jalali.py است.
|
||
birthDate = serializers.CharField(required=False, allow_null=True, max_length=10)
|
||
bio = serializers.CharField(max_length=200, required=False, allow_blank=True)
|
||
|
||
def validate_birthDate(self, value):
|
||
if value in (None, ''):
|
||
return None
|
||
from django.utils import timezone
|
||
|
||
from . import jalali
|
||
|
||
try:
|
||
born = jalali.parse(value)
|
||
except ValueError as exc:
|
||
raise serializers.ValidationError(str(exc))
|
||
today = timezone.localdate()
|
||
age = today.year - born.year - ((today.month, today.day) < (born.month, born.day))
|
||
if not MIN_AGE <= age <= MAX_AGE:
|
||
raise serializers.ValidationError(f'سن باید بینِ {MIN_AGE} و {MAX_AGE} سال باشد.')
|
||
return born
|
||
|
||
def validate(self, attrs):
|
||
if not attrs:
|
||
raise serializers.ValidationError('حداقل یکی از فیلدها باید ارسال شود.')
|
||
if 'birthDate' in attrs and 'birthYear' in attrs:
|
||
raise serializers.ValidationError('یکی از birthDate یا birthYear را بفرستید، نه هر دو را.')
|
||
return attrs
|
||
|
||
|
||
class RequestOTPRequestSerializer(serializers.Serializer):
|
||
"""اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تستپذیری Swagger اضافه شد)."""
|
||
phone = IranMobileField()
|
||
|
||
|
||
# ---- Response serializers ----
|
||
|
||
class GuestSessionResponseSerializer(serializers.Serializer):
|
||
userId = serializers.CharField()
|
||
token = serializers.CharField()
|
||
refreshToken = serializers.CharField()
|
||
isNewUser = serializers.BooleanField()
|
||
isGuest = serializers.BooleanField()
|
||
|
||
|
||
class LoginResponseSerializer(serializers.Serializer):
|
||
userId = serializers.CharField()
|
||
token = serializers.CharField()
|
||
refreshToken = serializers.CharField()
|
||
|
||
|
||
class RefreshResponseSerializer(serializers.Serializer):
|
||
token = serializers.CharField()
|
||
refreshToken = serializers.CharField()
|
||
expiresIn = serializers.IntegerField()
|
||
|
||
|
||
class GradeSerializer(serializers.Serializer):
|
||
id = serializers.IntegerField()
|
||
title = serializers.CharField()
|
||
icon = serializers.CharField()
|
||
color = serializers.CharField()
|
||
|
||
|
||
class GradesListResponseSerializer(serializers.Serializer):
|
||
grades = GradeSerializer(many=True)
|
||
|
||
|
||
class SetGradeResponseSerializer(serializers.Serializer):
|
||
success = serializers.BooleanField()
|
||
gradeId = serializers.IntegerField()
|
||
|
||
|
||
class RequestOTPResponseSerializer(serializers.Serializer):
|
||
success = serializers.BooleanField()
|
||
devOtp = serializers.CharField()
|
||
note = serializers.CharField()
|
||
|
||
|
||
class ProfileResponseSerializer(serializers.Serializer):
|
||
"""پاسخ GET /api/user/profile — فقط دادهی لازم برای تب Profile اپ موبایل."""
|
||
# شناسهی کاربر همان شمارهی ثبتِ اوست (کاربرِ چندم)؛ اپ آن را زیرِ نام نشان میدهد
|
||
# تا در پشتیبانی بشود حساب را بیشمارهی تلفن پیدا کرد.
|
||
userId = serializers.CharField()
|
||
phone = serializers.CharField(allow_null=True)
|
||
name = serializers.CharField()
|
||
avatar = serializers.CharField()
|
||
isGuest = serializers.BooleanField()
|
||
gradeId = serializers.IntegerField(allow_null=True)
|
||
birthYear = serializers.IntegerField(allow_null=True)
|
||
birthDate = serializers.CharField(allow_null=True, help_text='شمسی، «1385-04-12»؛ null اگر فقط سال داده شده.')
|
||
bio = serializers.CharField()
|
||
|
||
|
||
class LogoutRequestSerializer(serializers.Serializer):
|
||
refreshToken = serializers.CharField(required=False, allow_blank=True)
|
||
|
||
|
||
class LogoutResponseSerializer(serializers.Serializer):
|
||
success = serializers.BooleanField()
|