Files
MathApp/accounts/serializers.py
Zareian 663252a260 Remove the guest system entirely: no is_guest/device_id, no guest throttles, phone login is the only way in
accounts.0008 drops the columns and refuses to run while guest rows exist.
Docs describe the current state; tests that only proved guest behaviour are gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 18:20:10 +03:30

216 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
import unicodedata
from rest_framework import serializers
from .phone import ascii_digits, normalize_phone
# لینک، آیدیِ شبکه‌ی اجتماعی، و هر رشته‌ی ۵ رقمی یا بیشتر با هر جداکننده‌ی غیرِ حرفی (فاصله،
# خط‌تیره، ستاره، ایموجی…). \d در پایتون هر رقمِ یونیکد است (فارسی، عربی، تمام‌عرض، ریاضی).
# این نام در رتبه‌بندی به بچه‌های دیگر نشان داده می‌شود. حرف بینِ رقم‌ها («0x9x1…») و عدد با
# حروف («صفر نه یک…») گرفته نمی‌شود.
_CONTACT = re.compile(
r'(https?://\S+|www\.\S+|\S+\.(?:com|ir|org|net|me)\S*|@\w+'
r'|(?:\d(?:[^\w@]|_)*){5,})',
re.IGNORECASE,
)
# «ig: name»، «insta name»، «آیدی name» و مانندش: کلیدواژه و هر چه بعدش آمده حذف می‌شود.
_HANDLE = re.compile(
r'(?:\b(?:ig|insta(?:gram)?|tele(?:gram)?|tg|id|whats ?app|rubika|eitaa|bale)\b'
r'|اینستا\S*|(?:تلگرام|تل|آیدی|ایدی|واتساپ|روبیکا|ایتا)(?!\w)|بله\s*:)\s*[::]?.*',
re.IGNORECASE,
)
def clean_display_name(value: str) -> str:
# NFKC: «①»، «²»، «𝟬» و «@» به رقم و @ِ معمولی برمی‌گردند تا الگوهای پایین آن‌ها را هم بگیرند؛
# نامِ ذخیره‌شده همین شکلِ نرمال است.
value = _HANDLE.sub(' ', unicodedata.normalize('NFKC', value or ''))
return ' '.join(_CONTACT.sub(' ', value).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 OTPCodeField(serializers.CharField):
"""
کدِ ورود: ۴ تا ۶ رقم (کدِ پیامکی ۶ رقمی است، کدهای TEST_LOGIN_PHONES ۴ تا ۶). رقمِ فارسی و
دیگر رقم‌های یونیکد به لاتین برمی‌گردند؛ هر چیزِ دیگر ۴۰۰ است. بدونِ این، کدِ غیرِ ASCII برای
شماره‌ی تست به secrets.compare_digest می‌رسید و TypeError (۵۰۰) می‌داد.
"""
def __init__(self, **kwargs):
kwargs.setdefault('max_length', 6)
super().__init__(**kwargs)
def to_internal_value(self, data):
code = ascii_digits(super().to_internal_value(data))
if not re.fullmatch(r'[0-9]{4,6}', code):
raise serializers.ValidationError('کد باید ۴ تا ۶ رقم باشد.')
return code
class LoginRequestSerializer(serializers.Serializer):
phone = IranMobileField()
otp = OTPCodeField()
class RefreshRequestSerializer(serializers.Serializer):
refreshToken = serializers.CharField()
class SetGradeRequestSerializer(serializers.Serializer):
gradeId = serializers.IntegerField()
def validate_gradeId(self, value):
# پایه‌ی ناموجود ۴۰۰ است، نه ذخیره‌ی بی‌صدا (نقشه‌ی خالی). عددِ بزرگ‌تر از ستونِ SQLite هم
# پیش‌تر هنگامِ ذخیره OverflowError (۵۰۰) می‌داد؛ exists() آن را بی‌خطا «نیست» می‌بیند.
from content.models import Grade
if not Grade.objects.filter(pk=value).exists():
raise serializers.ValidationError('این پایه وجود ندارد.')
return value
# همان بازه‌ای که پیش‌تر برای «سن» پذیرفته می‌شد. بزرگسال هم می‌آید (سینیور،
# ۲۰۲۶-۱۰-۰۱): کسی که فقط یک مبحثِ ریاضی را می‌خواهد یاد بگیرد.
MIN_AGE = 5
MAX_AGE = 99
class UpdateProfileRequestSerializer(serializers.Serializer):
"""
ورودی PATCH /api/user/profile — همه‌ی فیلدها اختیاری‌اند و فقط همان‌هایی
که فرستاده شوند تغییر می‌کنند (partial update واقعی).
`phone` عمداً اینجا نیست: شماره فقط از مسیرِ 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 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()
# شناسه‌ی نمایشی («User482917»، accounts/public_id.py)؛ اپ آن را زیرِ نام نشان می‌دهد
# و پشتیبانی با همین در پنلِ ادمین حساب را پیدا می‌کند.
publicId = serializers.CharField()
phone = serializers.CharField(allow_null=True)
name = serializers.CharField()
avatar = serializers.CharField()
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 DeleteAccountRequestSerializer(serializers.Serializer):
# کدِ پیامکیِ تازه (از POST /api/auth/otp/request با همان شماره).
otp = OTPCodeField()
class DeleteAccountResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
class LogoutRequestSerializer(serializers.Serializer):
refreshToken = serializers.CharField(required=False, allow_blank=True)
class LogoutResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()