Remove the guest system entirely: no is_guest/device_id, no guest throttles, phone login is the only way in

accounts.0008 drops the columns and refuses to run while guest rows exist.
Docs describe the current state; tests that only proved guest behaviour are gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 18:20:10 +03:30
parent 8835e01595
commit 663252a260
61 changed files with 357 additions and 1518 deletions

View File

@@ -38,30 +38,6 @@ from .serializers import (
logger = logging.getLogger(__name__)
class GuestAwareAccountThrottle(ScopedRateThrottle):
"""
همان الگویِ GuestAwareContentThrottle (content/views.py)،
GuestAwareAIThrottle (ai_tutor/views.py) و بقیه؛ چون ورودِ مهمان (حذف‌شده؛ حساب‌های مهمانِ قبلی هنوز هستند)
با هر 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 RequestOTPView(APIView):
"""
POST /api/auth/otp/request — درخواستِ کدِ ورود.
@@ -300,28 +276,7 @@ class LoginView(APIView):
return error
with transaction.atomic():
user = User.objects.filter(phone=phone).first()
created = False
guest = _upgradable_guest(request, phone) if user is None else None
if guest is not None:
# مهمانی که ثبت‌نام می‌کند همان کاربر می‌ماند: امتیاز، استریک،
# جزیره‌ها و گفتگوها همه با همین user_id ذخیره شده‌اند. پیش‌تر یک
# کاربرِ تازه و خالی ساخته می‌شد و پیشرفتِ مهمان جا می‌ماند.
guest.phone = phone
# هر نشستی که پیش از این با شناسه‌ی دستگاه گرفته شده بود (گوشیِ مشترک، کسی که
# شناسه را دیده) نباید به حسابِ شماره‌دار برسد؛ فقط نشستِ همین ورود می‌ماند.
guest.tokens_valid_after = timezone.now()
guest.save(update_fields=['phone', 'tokens_valid_after'])
Session.objects.filter(user=guest, revoked=False).update(revoked=True)
user, created = guest, True
elif user is None:
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, created = User.objects.get_or_create(phone=phone, defaults={'name': ''})
# ثبتِ فعالیتِ کاربر برای ورودِ موفق.
user.last_activity_at = timezone.now()
@@ -336,7 +291,6 @@ class LoginView(APIView):
Session.objects.create(
user=user,
refresh_token=hash_refresh_token(refresh_token),
device_id=serializer.validated_data.get('deviceId', ''),
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
)
access_token = issue_access_token(user.id)
@@ -356,27 +310,6 @@ def _count_failed_attempt(phone: str) -> None:
active.filter(failed_attempts__gte=settings.OTP_MAX_FAILED_ATTEMPTS).update(is_used=True)
def _upgradable_guest(request, phone: str):
"""
مهمانی که با توکنِ خودش درخواستِ ورود داده و این شماره هنوز حسابی ندارد.
شماره‌های TEST_LOGIN_PHONES و مهمان‌های DEMO_GUEST_DEVICE_IDS عمداً کنار گذاشته می‌شوند:
هر دو حسابِ نمایشیِ مشترک‌اند و ورود نباید یکی را به دیگری یا به شماره‌ی یک نفر تبدیل کند.
"""
if phone in settings.TEST_LOGIN_PHONES:
return None
user = getattr(request, 'user', None)
if user is None or not getattr(user, 'is_authenticated', False) or not user.is_guest:
return None
# مهمانِ نمایشیِ عمومی (DEMO_GUEST_DEVICE_IDS) هم مالِ یک نفر نیست: بدونِ این، هر کس از
# نشستِ آن با شماره‌ی خودش وارد می‌شد کلِ حساب و داده‌ی دمو را به شماره‌ی خودش می‌برد و
# نشستِ بقیه را باطل می‌کرد. آن کاربر حسابِ تازه‌ی خودش را می‌گیرد.
if user.device_id in settings.DEMO_GUEST_DEVICE_IDS:
return None
return User.objects.filter(pk=user.pk, is_guest=True, phone__isnull=True).first()
DELETE_CONFIRM_WORD = 'حذف'
# سوابقِ پرداخت سندِ مالی‌اند و با حذفِ حساب نمی‌روند (تراکنش‌ها با سفارش می‌مانند).
# Conversion هم می‌ماند: ردیفِ «کاربرِ معرفی‌شده» سابقه‌ی پورسانتِ *معرف* است، نه داده‌ی این
# کاربر؛ تا ۲۰۲۶-۱۰-۰۳ با حذفِ حسابِ معرفی‌شده پورسانتِ معرف هم پاک می‌شد.
@@ -394,9 +327,8 @@ class DeleteAccountView(APIView):
- کاربرِ شماره‌دار باید کدِ پیامکیِ تازه بدهد (همان verify_phone_otpِ ورود، با همان سقفِ حدس).
توکنِ access به‌تنهایی کافی نیست: گوشیِ بازمانده دستِ یک بچه‌ی دیگر یا توکنِ دزدیده‌شده نباید
بتواند حساب را برای همیشه پاک کند.
- مهمان شماره ندارد؛ باید کلمه‌ی «حذف» را تایپ کرده باشد. حسابش فقط به همین دستگاه وصل است.
- داده در همه‌ی اپ‌ها پاک می‌شود (delete_users)، جز سفارش‌ها و تراکنش‌هایشان که سندِ مالی‌اند.
- حساب‌های دمو (TEST_LOGIN_PHONES و مهمان‌های DEMO_GUEST_DEVICE_IDS) حذف نمی‌شوند؛ مشترک‌اند و برای ارائه پُرشده‌اند.
- حساب‌های دمو (TEST_LOGIN_PHONES) حذف نمی‌شوند؛ مشترک‌اند و برای ارائه پُرشده‌اند.
- بعد از حذف، توکن‌های همین کاربر کار نمی‌کنند چون کاربری با آن شناسه نمانده.
"""
@@ -417,21 +349,13 @@ class DeleteAccountView(APIView):
v = serializer.validated_data
user = request.user
if (user.phone and user.phone in settings.TEST_LOGIN_PHONES) or (
user.is_guest and user.device_id in settings.DEMO_GUEST_DEVICE_IDS):
if user.phone in settings.TEST_LOGIN_PHONES:
return Response({'detail': 'حسابِ نمایشی را نمی‌شود حذف کرد.'}, status=status.HTTP_403_FORBIDDEN)
if user.phone:
if not v.get('otp'):
return Response({'detail': 'برای حذفِ حساب، کدی را که به شماره‌ات پیامک شد وارد کن.'},
status=status.HTTP_400_BAD_REQUEST)
error = verify_phone_otp(user.phone, v['otp'])
if error is not None:
return error
elif (v.get('confirm') or '').strip() != DELETE_CONFIRM_WORD:
return Response({'detail': f'برای حذفِ حساب کلمه‌ی «{DELETE_CONFIRM_WORD}» را بنویس.'},
status=status.HTTP_400_BAD_REQUEST)
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 guest=%s', user.id, user.is_guest)
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)
@@ -510,7 +434,6 @@ class RefreshTokenView(APIView):
new_session = Session.objects.create(
user_id=old_session.user_id,
refresh_token=hash_refresh_token(new_refresh_token),
device_id=old_session.device_id,
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
)
@@ -527,11 +450,10 @@ class SetUserGradeView(APIView):
"""POST /api/user/grade — ثبت پایه‌ی کاربر (نیاز به احراز هویت)"""
permission_classes = [IsAuthenticated]
# بدونِ throttle، یک کاربرِ احرازهویت‌شده (از جمله مهمان) می‌تواند
# نامحدود درخواستِ نوشتن روی دیتابیس بفرستد. کلیدِ شمارش برای مهمان‌ها
# باید IP باشد نه pk — دلیلش در GuestAwareAccountThrottle بالا.
# بدونِ throttle، یک کاربرِ احرازهویت‌شده می‌تواند
# نامحدود درخواستِ نوشتن روی دیتابیس بفرستد.
throttle_scope = 'user_profile_write'
throttle_classes = [GuestAwareAccountThrottle]
throttle_classes = [ScopedRateThrottle]
@extend_schema(
request=SetGradeRequestSerializer,
@@ -560,7 +482,6 @@ def _profile_payload(user, profile) -> dict:
'phone': user.phone,
'name': user.name,
'avatar': user.avatar,
'isGuest': user.is_guest,
'gradeId': user.grade_id,
'birthYear': profile.birth_year,
'birthDate': jalali.to_string(profile.birth_date) if profile.birth_date else None,
@@ -581,7 +502,7 @@ class UserProfileView(APIView):
permission_classes = [IsAuthenticated]
# نوشتن است، پس همان throttleِ نوشتنِ پروفایل که SetUserGradeView دارد.
throttle_scope = 'user_profile_write'
throttle_classes = [GuestAwareAccountThrottle]
throttle_classes = [ScopedRateThrottle]
def get_throttles(self):
# GET خواندنی است و نباید سهمیه‌ی نوشتن را مصرف کند.