- profile publicId ("User482917", accounts/public_id.py); admin shows and searches it
- plans API reports aiDailyQuota per plan and freeAiDailyQuota; migration 0007 sets the
temporary prices (monthly 99,000 / yearly 990,000 toman, 5 tutor questions a day) unless
an admin already edited the yearly plan
- tutor tone follows the learner's age (18+ gets an adult tone at any grade); notation
still follows the grade's book; answer cache keyed by tone (prompt tutor-v12-age-tone)
- POST /api/user/delete: phone users confirm with a fresh SMS code (same checks as login,
now shared in verify_phone_otp), guests type "حذف"; payment records are kept, demo
accounts refused
- delete_users --skip-paid
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
7.8 KiB
Python
193 lines
7.8 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()
|
||
# شناسهی نمایشی («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 = serializers.CharField(max_length=6, 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()
|