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()