The Swagger header was a list of features. That is what the project does, not what a caller needs. It now opens with the three things anyone integrating against this API asks first: how do I get a token, what does an error look like, and when do I get rate limited -- plus the field-naming and date conventions, and a note that the schema is the contract and clients should be generated from it rather than written from memory. Swagger UI and Redoc both render Markdown, so it is structured rather than one long paragraph, which also fixes how badly the old mixed Persian/Latin line wrapped right-to-left. Two defects surfaced while checking the rendered schema: - /api/health was absent from the schema entirely. The trailing-slash alias was excluded with extend_schema(exclude=True)(HealthView), but that decorator mutates the class, and both routes pointed at the same class, so the canonical route disappeared with it. A subclass now carries the exclusion, and the endpoint is back, exactly once. Endpoint count is 56, not 55. - One endpoint was tagged 'محتوا (content)' while every other content endpoint used 'Curriculum (content)', which split the group into two sections. Tags are now declared with descriptions and ordered by the path a real user takes, rather than alphabetically. Comment pass over the codebase, keeping the substance and dropping the noise: the "⚠️ اضافه شده" prefixes dated comments against a moment no current reader shares; "فاز ۲ / بخش ۴" referenced a plan that no longer exists and only repeated the app name on the next line; "فاز اول - ۴ از ۱۱ اپ" had drifted (there are fourteen). INSTALLED_APPS is now grouped by responsibility. The TEST_LOGIN_PHONES block in settings shrank from 29 lines to 8 now that the same explanation lives in docs/operations.md and .env.example -- a comment beside the code should carry what a code reader needs, not a copy of the runbook. Checked mechanically that no comment references an identifier that no longer exists: zero. The dated measurements in rag/services/ingest.py and generate_questions_from_chunks.py were deliberately left alone -- a measurement carrying the date it was taken is the useful kind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
498 lines
23 KiB
Python
498 lines
23 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)
|
|
|
|
data = {
|
|
'userId': str(user.id),
|
|
'token': 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)
|