- accounts admin: phone/is_guest read-only for non-superusers, device_id filter lookups refused, auth.User/Group admin writable only by superusers - campaigns: delivery log needs view permission - phone/OTP/birth date: any Unicode digit normalised or rejected with 400; unknown gradeId is 400 - shared demo guest is never turned into a person's account by logging in - /api/home with unknown gradeId is no longer cached (LocMemCache flood reset throttle counters) - /api/progress/nodes: one query, huge unit is 400 - BazaarCredential token filter lookups refused Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
235 lines
10 KiB
Python
235 lines
10 KiB
Python
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 GuestSessionRequestSerializer(serializers.Serializer):
|
||
type = serializers.ChoiceField(choices=['guest'])
|
||
deviceId = serializers.CharField(max_length=64)
|
||
|
||
|
||
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()
|
||
# فرانتِ فعلی نمیفرستد؛ اگر بیاید روی Session.device_id مینشیند. null یا بیش از ۶۴ نویسه ۴۰۰ است
|
||
# (پیشتر null به NOT NULL میخورد و ۵۰۰ میداد).
|
||
deviceId = serializers.CharField(max_length=64, required=False, allow_blank=True)
|
||
|
||
|
||
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` و `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()
|
||
# شناسهی نمایشی («User482917»، accounts/public_id.py)؛ اپ آن را زیرِ نام نشان میدهد
|
||
# و پشتیبانی با همین در پنلِ ادمین حساب را پیدا میکند.
|
||
publicId = 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 DeleteAccountRequestSerializer(serializers.Serializer):
|
||
# کاربرِ شمارهدار: کدِ پیامکیِ تازه (از POST /api/auth/otp/request با همان شماره).
|
||
otp = OTPCodeField(required=False, allow_blank=True)
|
||
# مهمان شمارهای ندارد که کد بگیرد؛ باید همین کلمه را تایپ کرده باشد.
|
||
confirm = serializers.CharField(max_length=10, required=False, allow_blank=True)
|
||
|
||
|
||
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()
|