Files
MathApp/accounts/views.py
Zareian feaa5eb540 Postgres connection pool, clean 503 under overload, time indexes, 4 workers
Postgres here allows 60 connections, but each of the 96 gunicorn threads held its
own persistent one: at 100 concurrent users (local Postgres with the same limit)
21% of requests were 500 "remaining connection slots are reserved". Now each worker
has a psycopg pool (8), so the same load gives 0 errors and 714 req/s (was 460).

- A full pool answers 503 + Retry-After (DRF handler and middleware), not 500.
- The AI tutor and the OTP SMS call return their connection before the network wait.
- statement_timeout 30 s, idle-in-transaction 60 s; bad pool settings and the old
  DB_CONN_MAX_AGE stop startup instead of failing every request.
- /api/health dbPool: per-worker pool stats and workers x pool + 6 vs max_connections.
- Indexes on the columns global counters filter by time (weekly leaderboard,
  global SMS cap, global AI cap); the leaderboard recomputes once per expiry.
- Worker count moves into gunicorn.conf.py (4 on a shared 8-core box).
- security doc: Redis item closed (server uses our own Redis since 2026-10-08).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:21:35 +03:30

605 lines
32 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 BaseThrottle, ScopedRateThrottle
from rest_framework.views import APIView
from config.db import release_db_connection
from .authentication import invalidate_user_cache, issue_access_token
from .models import LoginEvent, 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__)
def record_login_event(request, event: str, *, user_id=None, phone: str = '') -> None:
"""
ردیفِ LoginEvent می‌نویسد. شکستش ورود/خروج را خراب نمی‌کند (ثبتِ سابقه نباید کاربر را بیرون
بیندازد) ولی با logger.exception دیده می‌شود. زیرِ بارِ هم‌زمان و روی Postgres سنجیده نشده.
"""
try:
LoginEvent.objects.create(
event=event, user_id=user_id, phone=phone,
ip=BaseThrottle().get_ident(request) or None,
user_agent=request.META.get('HTTP_USER_AGENT', '')[:300],
)
except Exception:
logger.exception('LoginEvent نوشته نشد: event=%s user_id=%s', event, user_id)
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)
# کاوه‌نگار تا چند ثانیه طول می‌کشد؛ روزِ لانچ این پرترافیک‌ترین مسیر است و اتصالِ دیتابیس
# در این مدت به pool برمی‌گردد (config/db.py).
release_db_connection()
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:
record_login_event(
request,
LoginEvent.LOGIN_BLOCKED if error.status_code == status.HTTP_429_TOO_MANY_REQUESTS
else LoginEvent.LOGIN_FAILED,
phone=phone,
)
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),
)
record_login_event(request, LoginEvent.LOGIN_OK, user_id=user.id, phone=phone)
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', 'accounts.LoginEvent')
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)
record_login_event(request, LoginEvent.ACCOUNT_DELETED, user_id=user.id, phone=user.phone or '')
# کدهای پیامکیِ شماره می‌مانند: سقفِ روزانه‌ی پیامکِ هر شماره از همین ردیف‌ها شمرده می‌شود.
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 پاک می‌شود؛ پیش‌فرض ۰ است و آن حالت تست نشده.
"""
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `user_profile_write` همان نرخِ مسیرهای هم‌خانواده است.
throttle_scope = 'user_profile_write'
throttle_classes = [ScopedRateThrottle]
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)
record_login_event(request, LoginEvent.LOGOUT, user_id=request.user.id, phone=request.user.phone or '')
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)