The frontend plays node quizzes, but a node only completed once every one of its
8-13 lessons was completed separately, so students were stuck after the first
island of each unit. POST /api/nodes/{id}/quiz/complete now completes the node
(any score; score only sets stars/xp) and returns nextNodeId.
Also closes the other backend gaps found while auditing the frontend:
- /api/ai/ask without lessonId now picks the book from the user's grade instead
of skipping retrieval, so the "ask Pi" tab is grounded in the textbook.
- guest sessions return a refreshToken; they used to go silently 401 after 1h.
- lesson_complete throttle 10/hour -> 120/hour (a lesson here is 1-2 questions).
- progressPercent is per grade, not over all 2,617 lessons of every grade.
- a one-year plan (299,000 toman) is created by migration when no plan exists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
511 lines
24 KiB
Python
511 lines
24 KiB
Python
import logging
|
|
import math
|
|
import secrets
|
|
import uuid
|
|
|
|
from django.conf import settings
|
|
from django.core.cache import cache
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
from drf_spectacular.utils import extend_schema
|
|
from rest_framework import status
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.throttling import ScopedRateThrottle
|
|
from rest_framework.views import APIView
|
|
|
|
from .authentication import issue_access_token
|
|
from .models import OTPCode, Profile, Session, User
|
|
from .services import OTPDeliveryError, send_otp_sms
|
|
from .serializers import (
|
|
GuestSessionRequestSerializer,
|
|
GuestSessionResponseSerializer,
|
|
LoginRequestSerializer,
|
|
LoginResponseSerializer,
|
|
ProfileResponseSerializer,
|
|
RefreshRequestSerializer,
|
|
RefreshResponseSerializer,
|
|
RequestOTPRequestSerializer,
|
|
RequestOTPResponseSerializer,
|
|
SetGradeRequestSerializer,
|
|
UpdateProfileRequestSerializer,
|
|
SetGradeResponseSerializer,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class GuestAwareAccountThrottle(ScopedRateThrottle):
|
|
"""
|
|
همان الگویِ GuestAwareContentThrottle (content/views.py)،
|
|
GuestAwareAIThrottle (ai_tutor/views.py) و بقیه؛ چون GuestSessionView
|
|
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک
|
|
ScopedRateThrottleِ سادهٔ per-user توسطِ چرخشِ حساب مهمان دورزدنی
|
|
بود. اینجا برای SetUserGradeView (که مهمانها هم میتونن صداش بزنن)
|
|
از همین کلید بر اساسِ IP برای مهمانها استفاده میشه؛ رفتارِ
|
|
throttle برای کاربرهای غیرمهمان (per-user، مثلِ قبل) دستنخورده
|
|
میمونه.
|
|
"""
|
|
|
|
def get_cache_key(self, request, view):
|
|
user = request.user
|
|
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
|
ident = f'guest-ip:{self.get_ident(request)}'
|
|
elif user and user.is_authenticated:
|
|
ident = user.pk
|
|
else:
|
|
ident = self.get_ident(request)
|
|
|
|
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
|
|
|
|
|
class GuestSessionView(APIView):
|
|
"""POST /api/auth/session — ایجاد سشن مهمان"""
|
|
|
|
throttle_scope = 'guest_session'
|
|
throttle_classes = [ScopedRateThrottle]
|
|
|
|
@extend_schema(
|
|
request=GuestSessionRequestSerializer,
|
|
responses=GuestSessionResponseSerializer,
|
|
tags=['Identity & Profile (accounts)'],
|
|
)
|
|
def post(self, request):
|
|
serializer = GuestSessionRequestSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
device_id = serializer.validated_data['deviceId']
|
|
|
|
user, is_new_user = User.objects.get_or_create(
|
|
device_id=device_id,
|
|
is_guest=True,
|
|
defaults={'name': ''},
|
|
)
|
|
|
|
# ثبتِ فعالیتِ کاربر: ایجاد/استفادهی سشنِ مهمان یک سشنِ واقعیِ
|
|
# کاربره، پس last_activity_at بهروزرسانی میشه (بدونِ تأثیر روی
|
|
# سایرِ رفتارِ endpoint).
|
|
user.last_activity_at = timezone.now()
|
|
user.save(update_fields=['last_activity_at'])
|
|
|
|
token = issue_access_token(user.id)
|
|
|
|
# مهمان هم refresh token میگیرد. قبلاً فقط توکنِ یکساعته داشت؛ بعد از
|
|
# یک ساعت همهی درخواستها ۴۰۱ میشدند درحالیکه اپ هنوز خودش را
|
|
# واردشده میدید. امنیتی کم نمیشود: خودِ deviceId از قبل برای گرفتنِ
|
|
# توکنِ تازه کافی بود.
|
|
refresh_token = secrets.token_hex(32)
|
|
Session.objects.create(
|
|
user=user,
|
|
refresh_token=refresh_token,
|
|
device_id=device_id,
|
|
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
|
)
|
|
|
|
data = {
|
|
'userId': str(user.id),
|
|
'token': token,
|
|
'refreshToken': refresh_token,
|
|
'isNewUser': is_new_user,
|
|
'isGuest': True,
|
|
}
|
|
return Response(GuestSessionResponseSerializer(data).data)
|
|
|
|
|
|
class RequestOTPView(APIView):
|
|
"""
|
|
POST /api/auth/otp/request — درخواستِ کدِ ورود.
|
|
|
|
کد فقط با پیامک تحویل داده میشود. دو استثنا: در حالتِ DEBUG کد در فیلدِ
|
|
devOtp برمیگردد تا تستِ محلی به پیامکِ واقعی نیاز نداشته باشد، و برای
|
|
شمارههای TEST_LOGIN_PHONES کدِ ثابتِ از پیش تعریفشده معتبر است.
|
|
"""
|
|
|
|
throttle_scope = 'otp_request'
|
|
throttle_classes = [ScopedRateThrottle]
|
|
|
|
@extend_schema(
|
|
request=RequestOTPRequestSerializer,
|
|
responses=RequestOTPResponseSerializer,
|
|
tags=['Identity & Profile (accounts)'],
|
|
)
|
|
def post(self, request):
|
|
serializer = RequestOTPRequestSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
phone = serializer.validated_data['phone']
|
|
|
|
# شمارهی تستِ تیم: کد ثابت و از قبل معلوم است، پس نه ردیفِ OTP تازهای
|
|
# لازم است نه پیامکی. cooldown و سقفِ ساعتی هم رد میشوند چون هر دو از
|
|
# گیرندهی واقعیِ پیامک محافظت میکنند و اینجا پیامکی در کار نیست.
|
|
#
|
|
# ⚠️ کد عمداً در پاسخ برنمیگردد، برخلافِ devOtp در حالتِ DEBUG: این
|
|
# مسیر روی سرورِ عمومی هم فعال است، پس برگرداندنش یعنی هر کسی که شماره
|
|
# را بداند میتواند وارد شود.
|
|
if phone in settings.TEST_LOGIN_PHONES:
|
|
logger.info('OTP request for configured test phone: phone_suffix=%s', phone[-4:])
|
|
data = {
|
|
'success': True,
|
|
'devOtp': '',
|
|
'note': 'این شمارهی تستِ تیم است؛ کدِ ثابتش در تنظیماتِ سرور تعریف شده و پیامکی ارسال نمیشود.',
|
|
}
|
|
return Response(RequestOTPResponseSerializer(data).data)
|
|
|
|
# حداقل فاصلهی زمانی بین دو درخواستِ همون شماره (burst protection)؛
|
|
# قبل از شمارندهی ساعتی چک میشه تا یک درخواستِ بلاکشده سهمیهی
|
|
# ساعتی رو مصرف نکنه. کلید مقدارِ epoch انقضا رو ذخیره میکنه تا
|
|
# retryAfter/هدرِ Retry-After بدونِ خواندنِ اضافه محاسبه بشه.
|
|
phone_cooldown_key = f'otp_request_phone_cooldown:{phone}'
|
|
cooldown_seconds = settings.OTP_REQUEST_PHONE_COOLDOWN_SECONDS
|
|
now_epoch = timezone.now().timestamp()
|
|
cooldown_expires_at_epoch = now_epoch + cooldown_seconds
|
|
if not cache.add(phone_cooldown_key, cooldown_expires_at_epoch, timeout=cooldown_seconds):
|
|
stored_expiry_epoch = cache.get(phone_cooldown_key)
|
|
if stored_expiry_epoch is None:
|
|
# بینِ شکستِ add و این get دقیقاً منقضی شده؛ عملاً دیگه
|
|
# cooldownی در کار نیست، اما برای رفتارِ یکدست یک
|
|
# retry_after حداقلی برمیگردونیم (کلاینت با تلاشِ بعدی
|
|
# مسیرِ عادی رو طی میکنه).
|
|
retry_after = 1
|
|
else:
|
|
retry_after = max(1, math.ceil(stored_expiry_epoch - now_epoch))
|
|
logger.warning(
|
|
'OTP request phone cooldown active: phone_suffix=%s retry_after_seconds=%s',
|
|
phone[-4:], retry_after,
|
|
)
|
|
return Response(
|
|
{
|
|
'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.',
|
|
'retryAfter': retry_after,
|
|
},
|
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
headers={'Retry-After': str(retry_after)},
|
|
)
|
|
|
|
# محدودیتِ صدورِ OTP بر اساسِ شماره، مستقل از throttleِ IP-محورِ بالا
|
|
# (وگرنه یه مهاجم میتونه بینِ چند IP پخش کنه). cache.add فقط وقتی
|
|
# کلید نیست مقدار میذاره، پس اتمیکه.
|
|
phone_requests_key = f'otp_request_phone_attempts:{phone}'
|
|
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
|
|
attempt_count = 1
|
|
else:
|
|
try:
|
|
attempt_count = cache.incr(phone_requests_key)
|
|
except ValueError:
|
|
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
|
|
cache.set(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS)
|
|
attempt_count = 1
|
|
|
|
if attempt_count > settings.OTP_REQUEST_PHONE_MAX_ATTEMPTS:
|
|
logger.warning(
|
|
'OTP request phone limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
|
|
phone[-4:], attempt_count, settings.OTP_REQUEST_PHONE_MAX_ATTEMPTS,
|
|
)
|
|
return Response(
|
|
{'detail': 'تعداد درخواستهای کد OTP برای این شماره بیش از حد مجاز است.'},
|
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
)
|
|
|
|
# فقط آخرین کد صادرشده معتبر است.
|
|
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
|
|
|
|
code = f'{secrets.randbelow(10000):04d}'
|
|
OTPCode.objects.create(
|
|
phone=phone,
|
|
code=code,
|
|
expires_at=timezone.now() + timezone.timedelta(minutes=5),
|
|
)
|
|
|
|
try:
|
|
send_otp_sms(phone=phone, code=code)
|
|
except OTPDeliveryError:
|
|
# ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک میکنیم
|
|
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه. سقفِ ساعتی/
|
|
# throttleِ IP دستنخورده میمونن (محدودیتِ حجم، نه این cooldown).
|
|
cache.delete(phone_cooldown_key)
|
|
raise
|
|
|
|
# devOtp فقط در محیط dev برمیگردد؛ در پروداکشن کد فقط از طریق پیامک تحویل داده میشود.
|
|
note = 'در پروداکشن این کد پیامک میشه.'
|
|
if settings.DEV_MASTER_OTP is not None:
|
|
note = f'در پروداکشن این کد پیامک میشه؛ برای تست همیشه {settings.DEV_MASTER_OTP} هم قبول میشه.'
|
|
data = {
|
|
'success': True,
|
|
'devOtp': code if settings.DEBUG else '',
|
|
'note': note,
|
|
}
|
|
return Response(RequestOTPResponseSerializer(data).data)
|
|
|
|
|
|
class LoginView(APIView):
|
|
"""POST /api/auth/login — ورود با OTP"""
|
|
|
|
throttle_scope = 'otp_login'
|
|
throttle_classes = [ScopedRateThrottle]
|
|
|
|
@extend_schema(
|
|
request=LoginRequestSerializer,
|
|
responses=LoginResponseSerializer,
|
|
tags=['Identity & Profile (accounts)'],
|
|
)
|
|
def post(self, request):
|
|
serializer = LoginRequestSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
phone = serializer.validated_data['phone']
|
|
otp = serializer.validated_data['otp']
|
|
|
|
# محدودیت تلاش ورود بر اساس شماره تلفن (مستقل از throttle IP-محور بالا).
|
|
phone_attempts_key = f'otp_login_phone_attempts:{phone}'
|
|
if cache.add(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS):
|
|
attempt_count = 1
|
|
else:
|
|
try:
|
|
attempt_count = cache.incr(phone_attempts_key)
|
|
except ValueError:
|
|
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
|
|
cache.set(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS)
|
|
attempt_count = 1
|
|
|
|
if attempt_count > settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS:
|
|
# ⚠️ افزوده شده: لاگ هشدارِ امنیتی برای رصد/آلارمِ brute-force روی
|
|
# این endpoint. هیچ سکرتی (OTP، access/refresh token) لاگ نمیشه؛
|
|
# شمارهی تلفن هم کامل نوشته نمیشه، فقط ۴ رقم آخرش (برای
|
|
# correlation در تحلیل/پشتیبانی، بدون افشای کامل PII).
|
|
logger.warning(
|
|
'OTP login phone attempt limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
|
|
phone[-4:], attempt_count, settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS,
|
|
)
|
|
return Response(
|
|
{'detail': 'تعداد تلاشهای ورود برای این شماره بیش از حد مجاز است.'},
|
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
)
|
|
|
|
# قفل ردیف OTP جلوی مصرف دوبارهی یک کد یکبارمصرف را در درخواستهای همزمان میگیرد.
|
|
with transaction.atomic():
|
|
valid = False
|
|
test_code = settings.TEST_LOGIN_PHONES.get(phone, '')
|
|
if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP:
|
|
valid = True
|
|
elif test_code and secrets.compare_digest(test_code, otp):
|
|
# شمارهی تستِ تیم. compare_digest تا نتیجهی مقایسه از روی زمانِ
|
|
# پاسخ قابلِ استنتاج نباشه. سقفِ تلاشِ همین شماره بالاتر چک شده،
|
|
# پس حدسِ کد با brute-force بستهست.
|
|
logger.info('Login via configured test phone: phone_suffix=%s', phone[-4:])
|
|
valid = True
|
|
else:
|
|
otp_obj = OTPCode.objects.select_for_update().filter(
|
|
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now()
|
|
).order_by('-id').first()
|
|
if otp_obj:
|
|
otp_obj.is_used = True
|
|
otp_obj.save(update_fields=['is_used'])
|
|
valid = True
|
|
|
|
if not valid:
|
|
return Response({'detail': 'کد OTP نامعتبر یا منقضیشده است.'}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
user, created = User.objects.get_or_create(
|
|
phone=phone,
|
|
defaults={'is_guest': False, 'name': ''},
|
|
)
|
|
if user.is_guest:
|
|
user.is_guest = False
|
|
user.save(update_fields=['is_guest'])
|
|
|
|
# ثبتِ فعالیتِ کاربر برای ورودِ موفق.
|
|
user.last_activity_at = timezone.now()
|
|
user.save(update_fields=['last_activity_at'])
|
|
|
|
if created:
|
|
# آفرهای ثبتنام (مثل یک هفته رایگان) از طریق discount/receivers.py روی این سیگنال اعمال میشوند.
|
|
from .signals import user_registered
|
|
user_registered.send(sender=User, user_id=user.id)
|
|
|
|
refresh_token = uuid.uuid4().hex
|
|
Session.objects.create(
|
|
user=user,
|
|
refresh_token=refresh_token,
|
|
device_id=request.data.get('deviceId', ''),
|
|
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
|
)
|
|
access_token = issue_access_token(user.id)
|
|
|
|
data = {
|
|
'userId': str(user.id),
|
|
'token': access_token,
|
|
'refreshToken': refresh_token,
|
|
}
|
|
return Response(LoginResponseSerializer(data).data)
|
|
|
|
|
|
class RefreshTokenView(APIView):
|
|
"""POST /api/auth/refresh — تمدید توکن"""
|
|
|
|
# این endpoint احرازهویت نمیخواد (خودِ refresh token شناسه است)، پس
|
|
# ScopedRateThrottle طبیعتاً روی IP کلید میخوره. یک توکنِ رفرش عادتاً
|
|
# فقط گاهبهگاه (نه با فرکانسِ بالا) باید استفاده بشه؛ این throttle
|
|
# جلوی سوءاستفاده از این endpoint بهعنوان یک عملیاتِ پرتکرارِ
|
|
# دیتابیس/تولیدِ توکن رو میگیره، بدون تغییری در rotation یا محافظتِ
|
|
# اتمیکِ replay که پایینتر پیادهسازی شده.
|
|
throttle_scope = 'token_refresh'
|
|
throttle_classes = [ScopedRateThrottle]
|
|
|
|
@extend_schema(
|
|
request=RefreshRequestSerializer,
|
|
responses=RefreshResponseSerializer,
|
|
tags=['Identity & Profile (accounts)'],
|
|
)
|
|
def post(self, request):
|
|
serializer = RefreshRequestSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
refresh_token = serializer.validated_data['refreshToken']
|
|
|
|
# UPDATE اتمیک جلوی استفادهی همزمان از یک refresh token را میگیرد (replay protection).
|
|
with transaction.atomic():
|
|
updated_count = Session.objects.filter(
|
|
refresh_token=refresh_token, revoked=False, expires_at__gte=timezone.now()
|
|
).update(revoked=True)
|
|
|
|
if not updated_count:
|
|
return Response({'detail': 'رفرشتوکن نامعتبر یا منقضیشده است.'}, status=status.HTTP_401_UNAUTHORIZED)
|
|
|
|
old_session = Session.objects.get(refresh_token=refresh_token)
|
|
|
|
# ثبتِ فعالیتِ کاربر برای رفرشِ موفقِ توکن.
|
|
User.objects.filter(pk=old_session.user_id).update(last_activity_at=timezone.now())
|
|
|
|
new_refresh_token = secrets.token_hex(32)
|
|
new_session = Session.objects.create(
|
|
user_id=old_session.user_id,
|
|
refresh_token=new_refresh_token,
|
|
device_id=old_session.device_id,
|
|
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
|
)
|
|
|
|
access_token = issue_access_token(new_session.user_id)
|
|
data = {
|
|
'token': access_token,
|
|
'refreshToken': new_refresh_token,
|
|
'expiresIn': settings.ACCESS_TOKEN_TTL_SECONDS,
|
|
}
|
|
return Response(RefreshResponseSerializer(data).data)
|
|
|
|
|
|
class SetUserGradeView(APIView):
|
|
"""POST /api/user/grade — ثبت پایهی کاربر (نیاز به احراز هویت)"""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
# بدونِ throttle، یک کاربرِ احرازهویتشده (از جمله مهمان) میتواند
|
|
# نامحدود درخواستِ نوشتن روی دیتابیس بفرستد. کلیدِ شمارش برای مهمانها
|
|
# باید IP باشد نه pk — دلیلش در GuestAwareAccountThrottle بالا.
|
|
throttle_scope = 'user_profile_write'
|
|
throttle_classes = [GuestAwareAccountThrottle]
|
|
|
|
@extend_schema(
|
|
request=SetGradeRequestSerializer,
|
|
responses=SetGradeResponseSerializer,
|
|
tags=['Identity & Profile (accounts)'],
|
|
)
|
|
def post(self, request):
|
|
serializer = SetGradeRequestSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
grade_id = serializer.validated_data['gradeId']
|
|
|
|
user = request.user
|
|
user.grade_id = grade_id
|
|
user.save(update_fields=['grade_id'])
|
|
|
|
data = {'success': True, 'gradeId': grade_id}
|
|
return Response(SetGradeResponseSerializer(data).data)
|
|
|
|
|
|
class UserProfileView(APIView):
|
|
"""
|
|
GET /api/user/profile — اطلاعات پروفایل کاربر جاری
|
|
PATCH /api/user/profile — ویرایشِ همان اطلاعات (نیاز به احراز هویت)
|
|
|
|
PATCH برای صفحهی «بیا باهم آشنا بشیم» در اپ لازم است: آنجا اسم (و بعداً
|
|
آواتار) گرفته میشود و تا پیش از این هیچ مسیری برای نوشتنشان وجود نداشت —
|
|
فقط پایهی تحصیلی endpoint اختصاصیِ خودش را داشت.
|
|
"""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
# نوشتن است، پس همان throttleِ نوشتنِ پروفایل که SetUserGradeView دارد.
|
|
throttle_scope = 'user_profile_write'
|
|
throttle_classes = [GuestAwareAccountThrottle]
|
|
|
|
def get_throttles(self):
|
|
# GET خواندنی است و نباید سهمیهی نوشتن را مصرف کند.
|
|
if self.request.method == 'GET':
|
|
return []
|
|
return super().get_throttles()
|
|
|
|
@extend_schema(
|
|
responses=ProfileResponseSerializer,
|
|
tags=['Identity & Profile (accounts)'],
|
|
)
|
|
def get(self, request):
|
|
user = request.user
|
|
# مدل Profile بهصورت lazy ساخته میشه چون هیچجای دیگهای (نه
|
|
# GuestSessionView، نه LoginView) رکورد Profile رو از قبل نمیسازه؛
|
|
# get_or_create اینجا امن است چون فقط توسطِ کاربرِ همون رکورد صدا
|
|
# زده میشه (OneToOne روی user_id جاری، نه ورودیِ خارجی).
|
|
profile, _ = Profile.objects.get_or_create(user_id=user.id)
|
|
|
|
data = {
|
|
'userId': str(user.id),
|
|
'phone': user.phone,
|
|
'name': user.name,
|
|
'avatar': user.avatar,
|
|
'isGuest': user.is_guest,
|
|
'gradeId': user.grade_id,
|
|
'birthYear': profile.birth_year,
|
|
'parentPhone': profile.parent_phone,
|
|
'bio': profile.bio,
|
|
}
|
|
return Response(ProfileResponseSerializer(data).data)
|
|
|
|
@extend_schema(
|
|
request=UpdateProfileRequestSerializer,
|
|
responses=ProfileResponseSerializer,
|
|
tags=['Identity & Profile (accounts)'],
|
|
)
|
|
def patch(self, request):
|
|
serializer = UpdateProfileRequestSerializer(data=request.data, partial=True)
|
|
serializer.is_valid(raise_exception=True)
|
|
v = serializer.validated_data
|
|
|
|
user = request.user
|
|
profile, _ = Profile.objects.get_or_create(user_id=user.id)
|
|
|
|
user_fields = {'name': 'name', 'avatar': 'avatar'}
|
|
profile_fields = {'birthYear': 'birth_year', 'parentPhone': 'parent_phone', 'bio': 'bio'}
|
|
|
|
changed_user = [attr for key, attr in user_fields.items() if key in v]
|
|
changed_profile = [attr for key, attr in profile_fields.items() if key in v]
|
|
|
|
for key, attr in user_fields.items():
|
|
if key in v:
|
|
setattr(user, attr, v[key])
|
|
for key, attr in profile_fields.items():
|
|
if key in v:
|
|
setattr(profile, attr, v[key])
|
|
|
|
# update_fields: فقط همان ستونهایی که واقعاً عوض شدهاند نوشته شوند تا
|
|
# PATCH همزمان از یک دستگاهِ دیگر، فیلدهای دستنخورده را بازنویسی نکند.
|
|
if changed_user:
|
|
user.save(update_fields=changed_user)
|
|
if changed_profile:
|
|
profile.save(update_fields=changed_profile)
|
|
|
|
data = {
|
|
'userId': str(user.id),
|
|
'phone': user.phone,
|
|
'name': user.name,
|
|
'avatar': user.avatar,
|
|
'isGuest': user.is_guest,
|
|
'gradeId': user.grade_id,
|
|
'birthYear': profile.birth_year,
|
|
'parentPhone': profile.parent_phone,
|
|
'bio': profile.bio,
|
|
}
|
|
return Response(ProfileResponseSerializer(data).data)
|