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.db.models import F 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 invalidate_user_cache, issue_access_token from .models import OTPCode, Profile, Session, User, hash_refresh_token from .services import OTPDeliveryError, send_otp_sms from .serializers import ( DeleteAccountRequestSerializer, DeleteAccountResponseSerializer, LoginRequestSerializer, LoginResponseSerializer, LogoutRequestSerializer, LogoutResponseSerializer, ProfileResponseSerializer, RefreshRequestSerializer, RefreshResponseSerializer, RequestOTPRequestSerializer, RequestOTPResponseSerializer, SetGradeRequestSerializer, UpdateProfileRequestSerializer, SetGradeResponseSerializer, ) logger = logging.getLogger(__name__) 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 فقط وقتی # کلید نیست مقدار می‌ذاره، پس اتمیکه. # شماره پیش از اینجا به 09XXXXXXXXX نرمال شده (accounts/phone.py)، پس # املای دیگرِ همان شماره سهمِ جدا نمی‌گیرد. 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, ) # سقف‌های دیتابیسی: cooldown و سقفِ ساعتیِ بالا در کشِ LocMem و مالِ هر # worker‌اند (روی سرور ×۳). این‌ها از ردیف‌های OTPCode شمرده می‌شوند و بینِ # workerها مشترک‌اند. زمانِ صدور = expires_at − OTP_TTL_SECONDS (مدل # created_at ندارد). بررسی و ساختِ ردیف در یک تراکنش‌اند؛ با # transaction_mode=IMMEDIATE دو درخواستِ هم‌زمان پشتِ هم اجرا می‌شوند # (هم‌زمانیِ بینِ دو workerِ واقعی با تست پوشش داده نشده). # فراخوانیِ کاوه‌نگار عمداً بیرونِ تراکنش است تا قفلِ نوشتن حینِ شبکه نماند. now = timezone.now() ttl = timezone.timedelta(seconds=settings.OTP_TTL_SECONDS) day_window_expiry = now - timezone.timedelta(days=1) + ttl with transaction.atomic(): phone_codes = OTPCode.objects.filter(phone=phone) last_expiry = phone_codes.order_by('-expires_at').values_list('expires_at', flat=True).first() if cooldown_seconds and last_expiry is not None: elapsed = (now - (last_expiry - ttl)).total_seconds() if elapsed < cooldown_seconds: retry_after = max(1, math.ceil(cooldown_seconds - elapsed)) return Response( { 'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.', 'retryAfter': retry_after, }, status=status.HTTP_429_TOO_MANY_REQUESTS, headers={'Retry-After': str(retry_after)}, ) if phone_codes.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_REQUEST_PHONE_DAILY_MAX: logger.warning('OTP request phone daily limit exceeded: phone_suffix=%s', phone[-4:]) return Response( {'detail': 'سقفِ روزانه‌ی درخواستِ کد برای این شماره پر شده است؛ فردا دوباره تلاش کنید.'}, status=status.HTTP_429_TOO_MANY_REQUESTS, ) if OTPCode.objects.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_SMS_GLOBAL_DAILY_MAX: # یا حمله‌ی SMS pumping است یا رشدِ واقعی؛ هر دو یعنی کسی باید نگاه کند. logger.error( 'OTP global daily SMS cap reached: cap=%s phone_suffix=%s', settings.OTP_SMS_GLOBAL_DAILY_MAX, phone[-4:], ) return Response( {'detail': 'ارسالِ پیامک موقتاً ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.'}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) # فقط آخرین کد صادرشده معتبر است. OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True) # ۶ رقم: با ۴ رقم و محدودیتِ تلاشِ per-worker، گرفتنِ حسابِ یک نفرِ مشخص # روزی چند درصد شانس داشت. فرانت تا ۶ رقم را می‌پذیرد. code = f'{secrets.randbelow(1_000_000):06d}' otp_row = OTPCode.objects.create(phone=phone, code=code, expires_at=now + ttl) try: send_otp_sms(phone=phone, code=code) except OTPDeliveryError: # ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک می‌کنیم # تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه، و ردیفِ کد رو هم # حذف می‌کنیم تا در cooldown و سقفِ روزانه‌ی دیتابیسی شمرده نشه. # سقفِ ساعتیِ کش و throttleِ IP دست‌نخورده می‌مونن (محدودیتِ حجم). cache.delete(phone_cooldown_key) otp_row.delete() 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) def verify_phone_otp(phone: str, otp: str) -> Response | None: """ کدِ پیامکیِ این شماره را می‌سنجد و مصرف می‌کند؛ None یعنی درست بود، وگرنه پاسخِ خطا. مشترکِ ورود (LoginView) و حذفِ حساب (DeleteAccountView): هر دو باید همان سقفِ تلاشِ شماره، همان قفلِ ردیف و همان باطل شدنِ کد پس از چند حدسِ غلط را داشته باشند. پیش از ۲۰۲۶-۱۰-۰۲ این منطق فقط داخلِ LoginView بود. """ # محدودیت تلاش بر اساس شماره تلفن (مستقل از throttle IP-محورِ هر view). 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: # هیچ سکرتی (OTP، token) لاگ نمی‌شه؛ از شماره فقط ۴ رقمِ آخر. 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(): test_code = settings.TEST_LOGIN_PHONES.get(phone, '') if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP: return None if test_code and secrets.compare_digest(test_code, otp): # شماره‌ی تستِ تیم. compare_digest تا نتیجه از روی زمانِ پاسخ قابلِ استنتاج نباشه. logger.info('Login via configured test phone: phone_suffix=%s', phone[-4:]) return None 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']) return None _count_failed_attempt(phone) return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST) 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'] error = verify_phone_otp(phone, otp) if error is not None: return error with transaction.atomic(): user, created = User.objects.get_or_create(phone=phone, defaults={'name': ''}) # ثبتِ فعالیتِ کاربر برای ورودِ موفق. 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=hash_refresh_token(refresh_token), 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) def _count_failed_attempt(phone: str) -> None: """حدسِ غلط روی کدهای فعالِ این شماره؛ کدی که به سقف برسد باطل می‌شود.""" active = OTPCode.objects.filter(phone=phone, is_used=False, expires_at__gte=timezone.now()) active.update(failed_attempts=F('failed_attempts') + 1) active.filter(failed_attempts__gte=settings.OTP_MAX_FAILED_ATTEMPTS).update(is_used=True) # سوابقِ پرداخت سندِ مالی‌اند و با حذفِ حساب نمی‌روند (تراکنش‌ها با سفارش می‌مانند). # Conversion هم می‌ماند: ردیفِ «کاربرِ معرفی‌شده» سابقه‌ی پورسانتِ *معرف* است، نه داده‌ی این # کاربر؛ تا ۲۰۲۶-۱۰-۰۳ با حذفِ حسابِ معرفی‌شده پورسانتِ معرف هم پاک می‌شد. # Redemption و UserOffer هم می‌مانند: سقفِ مصرفِ کدِ تخفیف ردیف‌های Redemption را می‌شمارد، پس پاک # شدنشان یک کدِ یک‌بارمصرف را با «بساز، بخر، حذف کن» دوباره آزاد می‌کرد. KEPT_ON_ACCOUNT_DELETE = ('payment.Order', 'statuslog.StatusChangeLog', 'affiliate.Conversion', 'discount.Redemption', 'discount.UserOffer') class DeleteAccountView(APIView): """ POST /api/user/delete — حذفِ همیشگیِ حسابِ خودِ کاربر و همه‌ی داده‌اش. چرا این‌طور (۲۰۲۶-۱۰-۰۲): - کاربرِ شماره‌دار باید کدِ پیامکیِ تازه بدهد (همان verify_phone_otpِ ورود، با همان سقفِ حدس). توکنِ access به‌تنهایی کافی نیست: گوشیِ بازمانده دستِ یک بچه‌ی دیگر یا توکنِ دزدیده‌شده نباید بتواند حساب را برای همیشه پاک کند. - داده در همه‌ی اپ‌ها پاک می‌شود (delete_users)، جز سفارش‌ها و تراکنش‌هایشان که سندِ مالی‌اند. - حساب‌های دمو (TEST_LOGIN_PHONES) حذف نمی‌شوند؛ مشترک‌اند و برای ارائه پُرشده‌اند. - بعد از حذف، توکن‌های همین کاربر کار نمی‌کنند چون کاربری با آن شناسه نمانده. """ permission_classes = [IsAuthenticated] throttle_scope = 'account_delete' throttle_classes = [ScopedRateThrottle] @extend_schema( request=DeleteAccountRequestSerializer, responses=DeleteAccountResponseSerializer, tags=['Identity & Profile (accounts)'], ) def post(self, request): from .management.commands.delete_users import delete_users serializer = DeleteAccountRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) v = serializer.validated_data user = request.user if user.phone in settings.TEST_LOGIN_PHONES: return Response({'detail': 'حسابِ نمایشی را نمی‌شود حذف کرد.'}, status=status.HTTP_403_FORBIDDEN) error = verify_phone_otp(user.phone, v['otp']) if error is not None: return error logger.info('Account deleted by its owner: user_id=%s', user.id) # کدهای پیامکیِ شماره می‌مانند: سقفِ روزانه‌ی پیامکِ هر شماره از همین ردیف‌ها شمرده می‌شود. delete_users([user.id], keep=KEPT_ON_ACCOUNT_DELETE, delete_otp_codes=False) return Response(DeleteAccountResponseSerializer({'success': True}).data) class LogoutView(APIView): """ POST /api/auth/logout — خروج از حساب. refreshTokenِ این دستگاه (اگر فرستاده شود) باطل می‌شود و access tokenهای صادرشده *پیش از ثانیه‌ی خروج* رد می‌شوند. دستگاه‌های دیگر با refreshِ خودشان بی‌صدا توکنِ تازه می‌گیرند؛ فقط همین دستگاه بیرون می‌ماند. ⚠️ مقایسه ثانیه‌ای است: توکنی که در همان ثانیه‌ی خروج صادر شده معتبر می‌ماند (بازتولید شد). با AUTH_USER_CACHE_TTL>0 و کشِ per-worker، کشِ کاربر فقط در همین worker پاک می‌شود؛ پیش‌فرض ۰ است و آن حالت تست نشده. """ permission_classes = [IsAuthenticated] @extend_schema( request=LogoutRequestSerializer, responses=LogoutResponseSerializer, tags=['Identity & Profile (accounts)'], ) def post(self, request): serializer = LogoutRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) refresh_token = serializer.validated_data.get('refreshToken') or '' with transaction.atomic(): if refresh_token: Session.objects.filter( user_id=request.user.id, refresh_token=hash_refresh_token(refresh_token), ).update(revoked=True) User.objects.filter(pk=request.user.id).update(tokens_valid_after=timezone.now()) invalidate_user_cache(request.user.id) return Response(LogoutResponseSerializer({'success': True}).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=hash_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=hash_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=hash_refresh_token(new_refresh_token), 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، یک کاربرِ احرازهویت‌شده می‌تواند # نامحدود درخواستِ نوشتن روی دیتابیس بفرستد. throttle_scope = 'user_profile_write' throttle_classes = [ScopedRateThrottle] @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) def _profile_payload(user, profile) -> dict: from . import jalali, public_id return { 'userId': str(user.id), 'publicId': public_id.encode(user.id), 'phone': user.phone, 'name': user.name, 'avatar': user.avatar, 'gradeId': user.grade_id, 'birthYear': profile.birth_year, 'birthDate': jalali.to_string(profile.birth_date) if profile.birth_date else None, 'bio': profile.bio, } 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 = [ScopedRateThrottle] 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 ساخته می‌شه چون هیچ‌جای دیگه‌ای (نه # LoginView) رکورد Profile رو از قبل نمی‌سازه؛ # get_or_create این‌جا امن است چون فقط توسطِ کاربرِ همون رکورد صدا # زده می‌شه (OneToOne روی user_id جاری، نه ورودیِ خارجی). profile, _ = Profile.objects.get_or_create(user_id=user.id) return Response(ProfileResponseSerializer(_profile_payload(user, profile)).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', 'birthDate': 'birth_date', '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]) # سال و تاریخ نباید از هم جدا بیفتند: تاریخ سال را می‌سازد، و سالِ تنها # (اپِ قدیمی) تاریخی را که دیگر با آن نمی‌خواند پاک می‌کند. if 'birthDate' in v: from . import jalali profile.birth_year = jalali.from_gregorian(v['birthDate'])[0] if v['birthDate'] else None changed_profile.append('birth_year') elif 'birthYear' in v and profile.birth_date is not None: from . import jalali if jalali.from_gregorian(profile.birth_date)[0] != v['birthYear']: profile.birth_date = None changed_profile.append('birth_date') # update_fields: فقط همان ستون‌هایی که واقعاً عوض شده‌اند نوشته شوند تا # PATCH همزمان از یک دستگاهِ دیگر، فیلدهای دست‌نخورده را بازنویسی نکند. if changed_user: user.save(update_fields=changed_user) if changed_profile: profile.save(update_fields=changed_profile) return Response(ProfileResponseSerializer(_profile_payload(user, profile)).data)