- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and curriculum import need add/delete; content browser needs view_question; guest device ids are shown to superusers only. - Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles. - Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409; orders snapshot credits/hearts/days at creation (payment.0011). - Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker refuses the answer (not stored, not cached); a disconnect saves the partial answer; only KNOWN_COLLECTIONS are searched. - Infra: secret-key file is created atomically (workers no longer split keys); no default INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400; ticket text capped at 5000. - docs/security-before-production.md: remaining open items 24-31 and the guard tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
8.3 KiB
Python
200 lines
8.3 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,
|
||
)
|
||
# «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:
|
||
value = _HANDLE.sub(' ', 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 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()
|