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:
@@ -49,36 +49,10 @@ class SendSMSForm(forms.Form):
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class DeviceIdForSuperuserOnly:
|
||||
"""
|
||||
شناسهی دستگاهِ حسابهای مهمانِ قدیمی (که دیگر ساخته نمیشوند) تنها رمزِ ورودشان بود؛ پس فقط ابرکاربر
|
||||
آن را در فهرست، جستجو و فرم میبیند، نه کارمندی با اجازهی دیدن (گروهِ Supervisor).
|
||||
"""
|
||||
|
||||
def get_list_display(self, request):
|
||||
cols = super().get_list_display(request)
|
||||
return cols if request.user.is_superuser else tuple(c for c in cols if c != 'device_id')
|
||||
|
||||
def get_search_fields(self, request):
|
||||
cols = super().get_search_fields(request)
|
||||
return cols if request.user.is_superuser else tuple(c for c in cols if c != 'device_id')
|
||||
|
||||
def get_exclude(self, request, obj=None):
|
||||
exclude = tuple(super().get_exclude(request, obj) or ())
|
||||
return exclude if request.user.is_superuser else exclude + ('device_id',)
|
||||
|
||||
def lookup_allowed(self, lookup, value, request=None):
|
||||
# جنگو هر فیلترِ querystring روی فیلدِ خودِ مدل را میپذیرد (?device_id__startswith=a)؛
|
||||
# بیاین، با شمردنِ نتیجه شناسه حرفبهحرف درمیآید. جنگو برای فیلترِ ردشده ۴۰۰ میدهد.
|
||||
if lookup.split('__')[0] == 'device_id' and not (request and request.user.is_superuser):
|
||||
return False
|
||||
return super().lookup_allowed(lookup, value, request)
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin):
|
||||
list_display = ('id', 'public_id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at')
|
||||
search_fields = ('name', 'phone', 'device_id')
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'public_id', 'name', 'phone', 'grade_id', 'created_at')
|
||||
search_fields = ('name', 'phone')
|
||||
|
||||
@admin.display(description='شناسهی نمایشی')
|
||||
def public_id(self, obj):
|
||||
@@ -94,15 +68,13 @@ class UserAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin):
|
||||
if user_id:
|
||||
results |= queryset.filter(pk=user_id)
|
||||
return results, may_have_duplicates
|
||||
list_filter = ('is_guest',)
|
||||
actions = ['send_sms_to_selected']
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
# شماره کلیدِ ورود است: کارمندی با change_user که شمارهی کاربری را به شمارهی خودش عوض
|
||||
# کند، کد میگیرد و واردِ همان حساب میشود. is_guest هم همینطور ورود با شناسهی دستگاه را
|
||||
# روی حسابِ شمارهدار باز میکند. هر دو فقط برای ابرکاربر قابلِ تغییرند.
|
||||
# کند، کد میگیرد و واردِ همان حساب میشود؛ پس فقط ابرکاربر آن را عوض میکند.
|
||||
fields = tuple(super().get_readonly_fields(request, obj))
|
||||
return fields if request.user.is_superuser else fields + ('phone', 'is_guest')
|
||||
return fields if request.user.is_superuser else fields + ('phone',)
|
||||
|
||||
# permissions=['change']: بدونِ این، کاربرِ فقط-view (گروهِ Supervisor)
|
||||
# میتونست با POSTِ مستقیم این اکشن رو صدا بزنه بدونِ دیدنِ دکمه در UI؛
|
||||
@@ -198,8 +170,8 @@ class ProfileAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
@admin.register(Session)
|
||||
class SessionAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin):
|
||||
list_display = ('id', 'user', 'device_id', 'expires_at', 'revoked')
|
||||
class SessionAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'user', 'expires_at', 'revoked')
|
||||
list_filter = ('revoked',)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
یک دیتاست کوچک و واقعی برای تست دستی/API لوکال یا staging میسازه، طوری که
|
||||
اجرای مکرر (repeat) هیچ رکورد تکراریای نسازه — همهچیز با get_or_create روی
|
||||
یک کلید طبیعی/یکتا (phone، device_id، code، idempotency_key و ...) انجام
|
||||
یک کلید طبیعی/یکتا (phone، code، idempotency_key و ...) انجام
|
||||
میشه، نه با create ساده.
|
||||
|
||||
هدف: پوشش کمینهی جریانهای اصلی API در هر ۱۰ اپ (accounts، content،
|
||||
@@ -45,7 +45,6 @@ from accounts.models import OTPCode, Profile, Session, User, hash_refresh_token
|
||||
# یکسان و بازهی اختصاصی، شناساییِ رکوردهای seed در دیتابیس هم ساده میمونه.
|
||||
MAIN_PHONE = '09120000001'
|
||||
REFERRED_PHONE = '09120000004'
|
||||
GUEST_DEVICE_ID = 'seed-guest-device-0001'
|
||||
|
||||
SEED_TAG = '[SEED]'
|
||||
|
||||
@@ -107,9 +106,6 @@ class Command(BaseCommand):
|
||||
|
||||
phones = [MAIN_PHONE, REFERRED_PHONE]
|
||||
user_ids = list(User.objects.filter(phone__in=phones).values_list('pk', flat=True))
|
||||
guest = User.objects.filter(device_id=GUEST_DEVICE_ID, is_guest=True).first()
|
||||
if guest:
|
||||
user_ids.append(guest.pk)
|
||||
|
||||
if user_ids:
|
||||
UserAnswer.objects.filter(user_id__in=user_ids).delete()
|
||||
@@ -140,17 +136,13 @@ class Command(BaseCommand):
|
||||
|
||||
OTPCode.objects.filter(phone__in=phones).delete()
|
||||
Session.objects.filter(user__phone__in=phones).delete()
|
||||
if guest:
|
||||
Session.objects.filter(user=guest).delete()
|
||||
User.objects.filter(phone__in=phones).delete()
|
||||
if guest:
|
||||
guest.delete()
|
||||
|
||||
self.stdout.write('رکوردهای قبلیِ seed حذف شدن؛ در حال ساختِ دوباره...')
|
||||
|
||||
# -- seed -------------------------------------------------------------
|
||||
def _seed(self):
|
||||
main_user, guest_user, referred_user = self._seed_accounts()
|
||||
main_user, referred_user = self._seed_accounts()
|
||||
self._seed_content_and_progress(main_user)
|
||||
self._seed_characters()
|
||||
self._seed_ai_tutor(main_user)
|
||||
@@ -161,8 +153,7 @@ class Command(BaseCommand):
|
||||
self._seed_support(main_user)
|
||||
|
||||
tokens = [
|
||||
('کاربر اصلی (ثبتنامشده)', main_user, issue_access_token(main_user.pk)),
|
||||
('کاربر مهمان', guest_user, issue_access_token(guest_user.pk)),
|
||||
('کاربر اصلی', main_user, issue_access_token(main_user.pk)),
|
||||
]
|
||||
return {'tokens': tokens}
|
||||
|
||||
@@ -170,7 +161,7 @@ class Command(BaseCommand):
|
||||
def _seed_accounts(self):
|
||||
main_user, _ = User.objects.get_or_create(
|
||||
phone=MAIN_PHONE,
|
||||
defaults=dict(name=f'{SEED_TAG} کاربر اصلی', is_guest=False, grade_id=None),
|
||||
defaults=dict(name=f'{SEED_TAG} کاربر اصلی', grade_id=None),
|
||||
)
|
||||
Profile.objects.get_or_create(
|
||||
user=main_user, defaults=dict(birth_year=1394, bio=f'{SEED_TAG} پروفایل تستی')
|
||||
@@ -179,33 +170,17 @@ class Command(BaseCommand):
|
||||
user=main_user,
|
||||
refresh_token=hash_refresh_token(f'seed-refresh-{MAIN_PHONE}'),
|
||||
defaults=dict(
|
||||
device_id='seed-device-main',
|
||||
expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
||||
revoked=False,
|
||||
),
|
||||
)
|
||||
|
||||
guest_user, _ = User.objects.get_or_create(
|
||||
device_id=GUEST_DEVICE_ID,
|
||||
is_guest=True,
|
||||
defaults=dict(name=f'{SEED_TAG} کاربر مهمان'),
|
||||
)
|
||||
Session.objects.get_or_create(
|
||||
user=guest_user,
|
||||
refresh_token=hash_refresh_token(f'seed-refresh-{GUEST_DEVICE_ID}'),
|
||||
defaults=dict(
|
||||
device_id=GUEST_DEVICE_ID,
|
||||
expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
||||
revoked=False,
|
||||
),
|
||||
)
|
||||
|
||||
referred_user, _ = User.objects.get_or_create(
|
||||
phone=REFERRED_PHONE, defaults=dict(name=f'{SEED_TAG} کاربر معرفیشده', is_guest=False)
|
||||
phone=REFERRED_PHONE, defaults=dict(name=f'{SEED_TAG} کاربر معرفیشده')
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('accounts: 3 کاربر (اصلی/مهمان/معرفیشده) آماده شد.'))
|
||||
return main_user, guest_user, referred_user
|
||||
self.stdout.write(self.style.SUCCESS('accounts: ۲ کاربر (اصلی/معرفیشده) آماده شد.'))
|
||||
return main_user, referred_user
|
||||
|
||||
# -- content + progress --------------------------------------------------
|
||||
def _seed_content_and_progress(self, main_user):
|
||||
|
||||
45
accounts/migrations/0008_remove_guest_login.py
Normal file
45
accounts/migrations/0008_remove_guest_login.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-05 14:25
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def refuse_if_guests_remain(apps, schema_editor):
|
||||
"""
|
||||
ورودِ مهمان حذف شده؛ ستونِ is_guest که برود، حسابِ مهمان بیهیچ نشانهای یک کاربرِ بیشماره
|
||||
میشود. پس تا مهمانی مانده، migration عمداً میشکند: اول مهمانها را پاک کنید
|
||||
(manage.py delete_users)، بعد migrate.
|
||||
"""
|
||||
User = apps.get_model('accounts', 'User')
|
||||
left = User.objects.filter(is_guest=True).count()
|
||||
if left:
|
||||
raise RuntimeError(
|
||||
f'{left} حسابِ مهمان (is_guest=True) هنوز در دیتابیس هست؛ پیش از این migration با '
|
||||
f'delete_users پاکشان کنید، وگرنه به کاربرِ بیشماره تبدیل میشوند.'
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0007_hash_refresh_tokens'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(refuse_if_guests_remain, migrations.RunPython.noop),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='user',
|
||||
name='uniq_guest_device_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='session',
|
||||
name='device_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='device_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='is_guest',
|
||||
),
|
||||
]
|
||||
@@ -5,7 +5,7 @@ from django.db import models
|
||||
|
||||
class User(models.Model):
|
||||
"""
|
||||
کاربر اصلی سیستم (مهمان یا ثبتنامشده با شماره).
|
||||
کاربر اصلی سیستم: ثبتنامشده با شمارهی موبایل (تنها راه ورود).
|
||||
توجه: این مدل، AUTH_USER_MODEL جنگو نیست؛ یک مدل بیزنسی مستقله که
|
||||
با TadrisinoTokenAuthentication سفارشی احراز هویت میشه.
|
||||
|
||||
@@ -13,14 +13,11 @@ class User(models.Model):
|
||||
این اپ عمداً هیچوقت FK واقعی به مدل اپ دیگه نمیزنه.
|
||||
"""
|
||||
phone = models.CharField(max_length=15, unique=True, null=True, blank=True, db_index=True)
|
||||
device_id = models.CharField(max_length=64, null=True, blank=True, db_index=True)
|
||||
name = models.CharField(max_length=100, blank=True)
|
||||
avatar = models.CharField(max_length=100, blank=True)
|
||||
is_guest = models.BooleanField(default=True)
|
||||
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
# آخرین زمانِ فعالیتِ کاربر (لاگین موفق، رفرش توکن موفق، یا سشنِ
|
||||
# مهمانِ واقعی). فقط برای هدفگیریِ آیندهی کمپینهای پیامی نگهداری
|
||||
# آخرین زمانِ فعالیتِ کاربر (لاگین موفق یا رفرش توکن موفق). فقط برای هدفگیریِ آیندهی کمپینهای پیامی نگهداری
|
||||
# میشه؛ در حال حاضر هیچ منطقِ دیگهای به این فیلد وابسته نیست.
|
||||
last_activity_at = models.DateTimeField(null=True, blank=True)
|
||||
# access tokenهای صادرشده پیش از این لحظه رد میشوند (خروج از حساب). توکنِ
|
||||
@@ -30,17 +27,9 @@ class User(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
constraints = [
|
||||
# جلوگیری از ساخت دو کاربر مهمان همزمان با یک device_id یکسان.
|
||||
models.UniqueConstraint(
|
||||
fields=['device_id'],
|
||||
condition=models.Q(is_guest=True) & ~models.Q(device_id=None) & ~models.Q(device_id=''),
|
||||
name='uniq_guest_device_id',
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name or self.phone or f"guest-{self.pk}"
|
||||
return self.name or self.phone or f"user-{self.pk}"
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
@@ -77,7 +66,6 @@ class Session(models.Model):
|
||||
"""
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions')
|
||||
refresh_token = models.CharField(max_length=200, unique=True)
|
||||
device_id = models.CharField(max_length=64, blank=True)
|
||||
expires_at = models.DateTimeField()
|
||||
revoked = models.BooleanField(default=False)
|
||||
|
||||
|
||||
@@ -66,9 +66,6 @@ class OTPCodeField(serializers.CharField):
|
||||
class LoginRequestSerializer(serializers.Serializer):
|
||||
phone = IranMobileField()
|
||||
otp = OTPCodeField()
|
||||
# فرانتِ فعلی نمیفرستد؛ اگر بیاید روی Session.device_id مینشیند. null یا بیش از ۶۴ نویسه ۴۰۰ است
|
||||
# (پیشتر null به NOT NULL میخورد و ۵۰۰ میداد).
|
||||
deviceId = serializers.CharField(max_length=64, required=False, allow_blank=True)
|
||||
|
||||
|
||||
class RefreshRequestSerializer(serializers.Serializer):
|
||||
@@ -99,7 +96,7 @@ class UpdateProfileRequestSerializer(serializers.Serializer):
|
||||
ورودی PATCH /api/user/profile — همهی فیلدها اختیاریاند و فقط همانهایی
|
||||
که فرستاده شوند تغییر میکنند (partial update واقعی).
|
||||
|
||||
`phone` و `isGuest` عمداً اینجا نیستند: شماره فقط از مسیرِ OTP عوض
|
||||
`phone` عمداً اینجا نیست: شماره فقط از مسیرِ OTP عوض
|
||||
میشود و مهمانبودن را کاربر نباید بتواند خودش تغییر دهد.
|
||||
"""
|
||||
name = serializers.CharField(max_length=100, required=False, allow_blank=True)
|
||||
@@ -195,7 +192,6 @@ class ProfileResponseSerializer(serializers.Serializer):
|
||||
phone = serializers.CharField(allow_null=True)
|
||||
name = serializers.CharField()
|
||||
avatar = serializers.CharField()
|
||||
isGuest = serializers.BooleanField()
|
||||
gradeId = serializers.IntegerField(allow_null=True)
|
||||
birthYear = serializers.IntegerField(allow_null=True)
|
||||
birthDate = serializers.CharField(allow_null=True, help_text='شمسی، «1385-04-12»؛ null اگر فقط سال داده شده.')
|
||||
@@ -203,10 +199,8 @@ class ProfileResponseSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class DeleteAccountRequestSerializer(serializers.Serializer):
|
||||
# کاربرِ شمارهدار: کدِ پیامکیِ تازه (از POST /api/auth/otp/request با همان شماره).
|
||||
otp = OTPCodeField(required=False, allow_blank=True)
|
||||
# مهمان شمارهای ندارد که کد بگیرد؛ باید همین کلمه را تایپ کرده باشد.
|
||||
confirm = serializers.CharField(max_length=10, required=False, allow_blank=True)
|
||||
# کدِ پیامکیِ تازه (از POST /api/auth/otp/request با همان شماره).
|
||||
otp = OTPCodeField()
|
||||
|
||||
|
||||
class DeleteAccountResponseSerializer(serializers.Serializer):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
سیگنال ثبتنامِ واقعی کاربر (نه guest session). سایر اپها (مثلاً discount
|
||||
سیگنال ثبتنامِ واقعی کاربر. سایر اپها (مثلاً discount
|
||||
برای اختصاص خودکار آفر) بهجای import کردن مستقیم از accounts، فقط این
|
||||
سیگنال رو در apps.py خودشون connect میکنن؛ این یعنی accounts هیچوقت لازم
|
||||
نیست بدونه discount/offer اصلاً وجود داره — دقیقاً همون درخواست سینیور برای
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""
|
||||
ابزارِ تست: یک حسابِ مهمانِ قدیمی با نشست میسازد.
|
||||
|
||||
ورودِ مهمان (`POST /api/auth/session`) حذف شد، اما ردیفهای مهمانی که پیشتر ساخته شدهاند
|
||||
(حسابهای دمو و کاربرانِ قبلی) هنوز در دیتابیساند و رفتارِ ارتقایشان به حسابِ شمارهدار
|
||||
(ورود با شماره، حفظ پیشرفت، حذف حساب) باید تست شود. این تابع همان ردیفی را میسازد که آن اندپوینت میساخت.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from .authentication import issue_access_token
|
||||
from .models import Session, User, hash_refresh_token
|
||||
|
||||
|
||||
def guest_session(device_id):
|
||||
"""مثلِ پاسخِ قدیمیِ اندپوینت: userId, token, refreshToken, isNewUser, isGuest."""
|
||||
user, is_new = User.objects.get_or_create(device_id=device_id, is_guest=True, defaults={'name': ''})
|
||||
user.last_activity_at = timezone.now()
|
||||
user.save(update_fields=['last_activity_at'])
|
||||
refresh = secrets.token_hex(32)
|
||||
Session.objects.create(
|
||||
user=user, refresh_token=hash_refresh_token(refresh), device_id=device_id,
|
||||
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
||||
)
|
||||
return {
|
||||
'userId': str(user.id), 'token': issue_access_token(user.id),
|
||||
'refreshToken': refresh, 'isNewUser': is_new, 'isGuest': True,
|
||||
}
|
||||
@@ -14,7 +14,6 @@ from rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
from .authentication import issue_access_token
|
||||
from .models import OTPCode, Profile, Session, User, hash_refresh_token
|
||||
from .testing import guest_session
|
||||
from .services import OTPDeliveryError, SMSDeliveryError, send_otp_sms
|
||||
|
||||
|
||||
@@ -162,7 +161,6 @@ class SendOtpSmsTests(TestCase):
|
||||
self.assertIn('provider_status=401', message)
|
||||
|
||||
|
||||
|
||||
class RequestOTPViewTests(TestCase):
|
||||
"""
|
||||
تستهای سطح API برای RequestOTPView: مطمئن میشیم رفتارِ dev قبلی حفظ
|
||||
@@ -286,12 +284,11 @@ class RefreshTokenReplayTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(is_guest=True, device_id='device-refresh-test')
|
||||
self.user = User.objects.create(phone='09121230201')
|
||||
self.raw = 'test-refresh-token-abc123'
|
||||
self.session = Session.objects.create(
|
||||
user=self.user,
|
||||
refresh_token=hash_refresh_token(self.raw),
|
||||
device_id='device-refresh-test',
|
||||
expires_at=timezone.now() + timezone.timedelta(days=30),
|
||||
revoked=False,
|
||||
)
|
||||
@@ -760,14 +757,13 @@ class RefreshTokenThrottleTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(is_guest=True, device_id='device-refresh-throttle-test')
|
||||
self.user = User.objects.create(phone='09121230202')
|
||||
|
||||
def _create_session(self, token_suffix):
|
||||
raw = f'test-refresh-token-{token_suffix}'
|
||||
session = Session.objects.create(
|
||||
user=self.user,
|
||||
refresh_token=hash_refresh_token(raw),
|
||||
device_id='device-refresh-throttle-test',
|
||||
expires_at=timezone.now() + timezone.timedelta(days=30),
|
||||
revoked=False,
|
||||
)
|
||||
@@ -824,28 +820,6 @@ class RefreshTokenThrottleTests(TestCase):
|
||||
self.assertFalse(blocked_session.revoked)
|
||||
|
||||
|
||||
class GuestSessionThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ ScopedRateThrottle (scope='guest_session') روی
|
||||
GuestSessionView: درخواستهای تکراری از همون کلاینت (IP) باید بعد از
|
||||
حدِ مجاز با ۴۲۹ رد بشن.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
cache.clear()
|
||||
|
||||
def test_guest_login_endpoint_is_gone(self):
|
||||
# ورودِ مهمان حذف شده: هر کاربر باید با شماره ثبتنام کند. قدیمیها باید ۴۰۴
|
||||
# بگیرند، نه حسابِ مهمانِ ساختهشده.
|
||||
before = User.objects.count()
|
||||
response = self.client.post(
|
||||
'/api/auth/session', {'type': 'guest', 'deviceId': 'device-x'}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(User.objects.count(), before)
|
||||
|
||||
|
||||
class UserProfileViewTests(TestCase):
|
||||
"""تستهای GET /api/user/profile — پروفایل کاربر جاری."""
|
||||
|
||||
@@ -862,8 +836,8 @@ class UserProfileViewTests(TestCase):
|
||||
response = self._get_profile()
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_returns_profile_for_guest_without_existing_profile_row(self):
|
||||
user = User.objects.create(device_id='device-profile-1', is_guest=True, name='')
|
||||
def test_returns_profile_for_user_without_existing_profile_row(self):
|
||||
user = User.objects.create(phone='09000000055', name='')
|
||||
token = issue_access_token(user.id)
|
||||
|
||||
response = self._get_profile(token)
|
||||
@@ -871,8 +845,7 @@ class UserProfileViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data['userId'], str(user.id))
|
||||
self.assertEqual(data['isGuest'], True)
|
||||
self.assertIsNone(data['phone'])
|
||||
self.assertEqual(data['phone'], '09000000055')
|
||||
self.assertIsNone(data['gradeId'])
|
||||
self.assertIsNone(data['birthYear'])
|
||||
self.assertIsNone(data['birthDate'])
|
||||
@@ -882,7 +855,7 @@ class UserProfileViewTests(TestCase):
|
||||
|
||||
def test_returns_existing_profile_fields_for_registered_user(self):
|
||||
user = User.objects.create(
|
||||
phone='09120000001', is_guest=False, name='علی', avatar='avatar.png', grade_id=2,
|
||||
phone='09120000001', name='علی', avatar='avatar.png', grade_id=2,
|
||||
)
|
||||
Profile.objects.create(user=user, birth_year=1384, bio='سلام')
|
||||
token = issue_access_token(user.id)
|
||||
@@ -894,15 +867,14 @@ class UserProfileViewTests(TestCase):
|
||||
self.assertEqual(data['phone'], '09120000001')
|
||||
self.assertEqual(data['name'], 'علی')
|
||||
self.assertEqual(data['avatar'], 'avatar.png')
|
||||
self.assertEqual(data['isGuest'], False)
|
||||
self.assertEqual(data['gradeId'], 2)
|
||||
self.assertEqual(data['birthYear'], 1384)
|
||||
self.assertEqual(data['bio'], 'سلام')
|
||||
|
||||
def test_does_not_leak_other_users_profile(self):
|
||||
other = User.objects.create(device_id='device-other', is_guest=True, name='')
|
||||
other = User.objects.create(phone='09000000056', name='')
|
||||
Profile.objects.create(user=other, bio='محرمانه')
|
||||
user = User.objects.create(device_id='device-profile-2', is_guest=True, name='')
|
||||
user = User.objects.create(phone='09000000057', name='')
|
||||
token = issue_access_token(user.id)
|
||||
|
||||
response = self._get_profile(token)
|
||||
@@ -932,13 +904,13 @@ class SendSmsAdminActionTests(TestCase):
|
||||
# inactive/never-active موردنظرِ خودشون رو صریحاً میسازن.
|
||||
now = timezone.now()
|
||||
self.user1 = User.objects.create(
|
||||
name='u1', is_guest=False, phone='0910000001', last_activity_at=now,
|
||||
name='u1', phone='0910000001', last_activity_at=now,
|
||||
)
|
||||
self.user2 = User.objects.create(
|
||||
name='u2', is_guest=False, phone='0910000002', last_activity_at=now,
|
||||
name='u2', phone='0910000002', last_activity_at=now,
|
||||
)
|
||||
self.guest_no_phone = User.objects.create(
|
||||
name='guest', is_guest=True, phone=None, last_activity_at=now,
|
||||
self.no_phone_user = User.objects.create(
|
||||
name='no-phone', phone=None, last_activity_at=now,
|
||||
)
|
||||
self.changelist_url = reverse('admin:accounts_user_changelist')
|
||||
|
||||
@@ -963,10 +935,10 @@ class SendSmsAdminActionTests(TestCase):
|
||||
mock_send_sms.assert_not_called()
|
||||
|
||||
@patch('accounts.admin.send_sms')
|
||||
def test_apply_sends_sms_to_users_with_phone_and_skips_guest(self, mock_send_sms):
|
||||
def test_apply_sends_sms_to_users_with_phone_and_skips_users_without(self, mock_send_sms):
|
||||
response = self.client.post(self.changelist_url, {
|
||||
'action': 'send_sms_to_selected',
|
||||
'_selected_action': [self.user1.pk, self.user2.pk, self.guest_no_phone.pk],
|
||||
'_selected_action': [self.user1.pk, self.user2.pk, self.no_phone_user.pk],
|
||||
'apply': 'ارسال پیامک',
|
||||
'message': 'سلام از طرف ادمین',
|
||||
})
|
||||
@@ -1030,14 +1002,14 @@ class SendSmsAdminActionTests(TestCase):
|
||||
"""
|
||||
now = timezone.now()
|
||||
active_user = User.objects.create(
|
||||
name='active', is_guest=False, phone='0910000009', last_activity_at=now,
|
||||
name='active', phone='0910000009', last_activity_at=now,
|
||||
)
|
||||
inactive_user = User.objects.create(
|
||||
name='inactive', is_guest=False, phone='0910000010',
|
||||
name='inactive', phone='0910000010',
|
||||
last_activity_at=now - timezone.timedelta(days=90),
|
||||
)
|
||||
never_active_user = User.objects.create(
|
||||
name='never-active', is_guest=False, phone='0910000011', last_activity_at=None,
|
||||
name='never-active', phone='0910000011', last_activity_at=None,
|
||||
)
|
||||
cutoff_date = (now - timezone.timedelta(days=30)).date()
|
||||
|
||||
@@ -1085,7 +1057,7 @@ class SendSmsAdminActionTests(TestCase):
|
||||
'inactive' هم مثلِ 'selected' حفظ بشه.
|
||||
"""
|
||||
User.objects.create(
|
||||
name='inactive-no-phone', is_guest=True, phone=None,
|
||||
name='inactive-no-phone', phone=None,
|
||||
last_activity_at=timezone.now() - timezone.timedelta(days=90),
|
||||
)
|
||||
cutoff_date = (timezone.now() - timezone.timedelta(days=30)).date()
|
||||
@@ -1115,7 +1087,7 @@ class SendSmsAdminActionTests(TestCase):
|
||||
"""
|
||||
response = self.client.post(self.changelist_url, {
|
||||
'action': 'send_sms_to_selected',
|
||||
'_selected_action': [self.user1.pk, self.user2.pk, self.guest_no_phone.pk],
|
||||
'_selected_action': [self.user1.pk, self.user2.pk, self.no_phone_user.pk],
|
||||
'apply': 'ارسال پیامک',
|
||||
'message': 'متن',
|
||||
'target': 'selected',
|
||||
@@ -1137,11 +1109,11 @@ class SendSmsAdminActionTests(TestCase):
|
||||
"""
|
||||
now = timezone.now()
|
||||
User.objects.create(
|
||||
name='inactive-1', is_guest=False, phone='0910000021',
|
||||
name='inactive-1', phone='0910000021',
|
||||
last_activity_at=now - timezone.timedelta(days=90),
|
||||
)
|
||||
User.objects.create(
|
||||
name='inactive-2', is_guest=False, phone='0910000022',
|
||||
name='inactive-2', phone='0910000022',
|
||||
last_activity_at=now - timezone.timedelta(days=90),
|
||||
)
|
||||
cutoff_date = (now - timezone.timedelta(days=30)).date()
|
||||
@@ -1185,7 +1157,7 @@ class SendSmsAdminActionTests(TestCase):
|
||||
"""
|
||||
response = self.client.post(self.changelist_url, {
|
||||
'action': 'send_sms_to_selected',
|
||||
'_selected_action': [self.user1.pk, self.guest_no_phone.pk],
|
||||
'_selected_action': [self.user1.pk, self.no_phone_user.pk],
|
||||
'apply': 'ارسال پیامک',
|
||||
'message': 'متن',
|
||||
'target': 'selected',
|
||||
@@ -1226,7 +1198,7 @@ class SupervisorGroupAccessTests(TestCase):
|
||||
is_staff=True,
|
||||
)
|
||||
|
||||
self.target_user = User.objects.create(name='target', is_guest=False, phone='0910000099')
|
||||
self.target_user = User.objects.create(name='target', phone='0910000099')
|
||||
self.plan = Plan.objects.create(title='یکماهه', price=10000)
|
||||
self.order = Order.objects.create(
|
||||
user_id=self.target_user.id, amount=10000, idempotency_key='sup-order-1',
|
||||
@@ -1333,7 +1305,7 @@ class SupervisorGroupAccessTests(TestCase):
|
||||
original_name = self.target_user.name
|
||||
response = self.client.post(
|
||||
reverse('admin:accounts_user_change', args=[self.target_user.pk]),
|
||||
{'name': 'دستکاریشده', 'is_guest': 'on'},
|
||||
{'name': 'دستکاریشده'},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.target_user.refresh_from_db()
|
||||
@@ -1422,7 +1394,7 @@ class AuthUserRowCacheTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='dev-authcache', is_guest=True, name='n')
|
||||
self.user = User.objects.create(phone='09000000058', name='n')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
# هر endpointِ authenticated؛ /api/user/hearts سبک است.
|
||||
self.url = '/api/user/hearts'
|
||||
@@ -1492,7 +1464,7 @@ class UpdateUserProfileTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
cache.clear()
|
||||
self.user = User.objects.create(device_id='device-patch-1', is_guest=True, name='')
|
||||
self.user = User.objects.create(phone='09000000059', name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
def tearDown(self):
|
||||
@@ -1536,13 +1508,12 @@ class UpdateUserProfileTests(TestCase):
|
||||
def test_empty_body_is_rejected(self):
|
||||
self.assertEqual(self._patch({}).status_code, 400)
|
||||
|
||||
def test_phone_and_guest_flag_cannot_be_changed(self):
|
||||
response = self._patch({'name': 'رضا', 'phone': '09120000000', 'isGuest': False})
|
||||
def test_phone_cannot_be_changed(self):
|
||||
response = self._patch({'name': 'رضا', 'phone': '09120000000'})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.user.refresh_from_db()
|
||||
self.assertIsNone(self.user.phone)
|
||||
self.assertTrue(self.user.is_guest)
|
||||
self.assertNotEqual(self.user.phone, '09120000000')
|
||||
|
||||
def test_out_of_range_birth_year_is_rejected(self):
|
||||
self.assertEqual(self._patch({'birthYear': 2026}).status_code, 400)
|
||||
@@ -1660,8 +1631,11 @@ class RefreshTokenStoredHashedTests(TestCase):
|
||||
|
||||
def test_issued_tokens_are_stored_only_as_hash(self):
|
||||
client = APIClient()
|
||||
body = guest_session('hash-dev')
|
||||
raw = body['refreshToken']
|
||||
user = User.objects.create(phone='09121230203')
|
||||
raw = 'raw-token-for-hash-test'
|
||||
Session.objects.create(user=user, refresh_token=hash_refresh_token(raw),
|
||||
expires_at=timezone.now() + timezone.timedelta(days=1))
|
||||
self.assertFalse(Session.objects.filter(refresh_token=raw).exists())
|
||||
self.assertFalse(Session.objects.filter(refresh_token=raw).exists())
|
||||
self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(raw)).exists())
|
||||
|
||||
@@ -1674,7 +1648,7 @@ class RefreshTokenStoredHashedTests(TestCase):
|
||||
|
||||
from django.apps import apps as global_apps
|
||||
|
||||
user = User.objects.create(is_guest=True, device_id='hash-mig')
|
||||
user = User.objects.create(phone='09121230204')
|
||||
Session.objects.create(user=user, refresh_token='old-raw-token',
|
||||
expires_at=timezone.now() + timezone.timedelta(days=1))
|
||||
import_module('accounts.migrations.0007_hash_refresh_tokens').hash_existing(global_apps, None)
|
||||
|
||||
@@ -9,7 +9,6 @@ from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.testing import guest_session
|
||||
from accounts.models import OTPCode, User
|
||||
from content.models import Choice, Grade, Lesson, Node, Question, Unit
|
||||
from progress.models import UserHearts, UserStats
|
||||
@@ -21,26 +20,35 @@ def _client(user):
|
||||
return client
|
||||
|
||||
|
||||
def _login(phone):
|
||||
"""ورودِ واقعی با پیامکِ (ماکشده) و کد؛ پاسخِ login را برمیگرداند."""
|
||||
with patch('accounts.views.secrets.randbelow', return_value=123456), \
|
||||
patch('accounts.views.send_otp_sms'):
|
||||
APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
|
||||
return APIClient().post('/api/auth/login', {'phone': phone, 'otp': '123456'}, format='json').json()
|
||||
|
||||
|
||||
@override_settings(OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
class LogoutTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def test_logout_kills_the_current_token_and_its_refresh(self):
|
||||
guest = guest_session('dev-out')
|
||||
session = _login('09121234901')
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f'Bearer {guest["token"]}')
|
||||
client.credentials(HTTP_AUTHORIZATION=f'Bearer {session["token"]}')
|
||||
self.assertEqual(client.get('/api/user/profile').status_code, 200)
|
||||
|
||||
time.sleep(1.1) # زمانِ توکن ثانیهای است؛ توکن باید پیش از خروج صادر شده باشد
|
||||
response = client.post('/api/auth/logout', {'refreshToken': guest['refreshToken']}, format='json')
|
||||
response = client.post('/api/auth/logout', {'refreshToken': session['refreshToken']}, format='json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertEqual(client.get('/api/user/profile').status_code, 401)
|
||||
refreshed = APIClient().post('/api/auth/refresh', {'refreshToken': guest['refreshToken']}, format='json')
|
||||
refreshed = APIClient().post('/api/auth/refresh', {'refreshToken': session['refreshToken']}, format='json')
|
||||
self.assertEqual(refreshed.status_code, 401)
|
||||
|
||||
# ورودِ دوباره کار میکند
|
||||
again = guest_session('dev-out')
|
||||
again = _login('09121234901')
|
||||
fresh = APIClient()
|
||||
fresh.credentials(HTTP_AUTHORIZATION=f'Bearer {again["token"]}')
|
||||
self.assertEqual(fresh.get('/api/user/profile').status_code, 200)
|
||||
@@ -66,49 +74,9 @@ class OtpTests(TestCase):
|
||||
self.assertEqual(right.status_code, 400)
|
||||
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0,
|
||||
TEST_LOGIN_PHONES={'09120000101': '4321'})
|
||||
class GuestSignupKeepsProgressTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
guest = guest_session('dev-up')
|
||||
self.guest_id = int(guest['userId'])
|
||||
UserStats.objects.create(user_id=self.guest_id, xp=120)
|
||||
self.client = APIClient()
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {guest["token"]}')
|
||||
|
||||
def _login(self, phone, otp):
|
||||
return self.client.post('/api/auth/login', {'phone': phone, 'otp': otp}, format='json')
|
||||
|
||||
def test_the_guest_becomes_the_registered_user(self):
|
||||
with patch('accounts.views.secrets.randbelow', return_value=654321), \
|
||||
patch('accounts.views.send_otp_sms'):
|
||||
self.client.post('/api/auth/otp/request', {'phone': '09121234002'}, format='json')
|
||||
body = self._login('09121234002', '654321').json()
|
||||
|
||||
self.assertEqual(int(body['userId']), self.guest_id)
|
||||
user = User.objects.get(pk=self.guest_id)
|
||||
self.assertEqual((user.phone, user.is_guest), ('09121234002', False))
|
||||
self.assertEqual(UserStats.objects.get(user_id=self.guest_id).xp, 120)
|
||||
|
||||
def test_an_existing_account_is_not_merged(self):
|
||||
existing = User.objects.create(phone='09121234003', is_guest=False)
|
||||
with patch('accounts.views.secrets.randbelow', return_value=111222), \
|
||||
patch('accounts.views.send_otp_sms'):
|
||||
self.client.post('/api/auth/otp/request', {'phone': '09121234003'}, format='json')
|
||||
body = self._login('09121234003', '111222').json()
|
||||
self.assertEqual(int(body['userId']), existing.id)
|
||||
self.assertTrue(User.objects.get(pk=self.guest_id).is_guest)
|
||||
|
||||
def test_demo_test_phones_never_absorb_a_guest(self):
|
||||
body = self._login('09120000101', '4321').json()
|
||||
self.assertNotEqual(int(body['userId']), self.guest_id)
|
||||
self.assertTrue(User.objects.get(pk=self.guest_id).is_guest)
|
||||
|
||||
|
||||
class DisplayNameTests(TestCase):
|
||||
def test_contact_details_are_stripped_from_the_name(self):
|
||||
user = User.objects.create(phone='09121234004', is_guest=False)
|
||||
user = User.objects.create(phone='09121234004')
|
||||
body = _client(user).patch('/api/user/profile', {'name': 'سارا 0912 345 6789 @sara'},
|
||||
format='json').json()
|
||||
self.assertEqual(body['name'], 'سارا')
|
||||
@@ -122,7 +90,7 @@ class HeartsTests(TestCase):
|
||||
self.question = Question.objects.create(lesson=Lesson.objects.create(node=node, title='د'),
|
||||
type='multiple_choice', prompt='؟')
|
||||
self.right = Choice.objects.create(question=self.question, value='۱', is_correct=True)
|
||||
self.user = User.objects.create(phone='09121234005', is_guest=False)
|
||||
self.user = User.objects.create(phone='09121234005')
|
||||
UserHearts.objects.create(user_id=self.user.id, current_hearts=0)
|
||||
|
||||
def _answer(self):
|
||||
|
||||
@@ -47,7 +47,7 @@ class JalaliConversionTests(SimpleTestCase):
|
||||
class ProfileBirthDateApiTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User.objects.create(device_id='birth-date-device', is_guest=True)
|
||||
self.user = User.objects.create(phone='09000000053')
|
||||
self.client = APIClient()
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||||
|
||||
@@ -94,7 +94,7 @@ class PublicIdTests(TestCase):
|
||||
def test_profile_shows_a_six_digit_code_that_maps_back_to_the_user(self):
|
||||
from accounts.public_id import decode
|
||||
|
||||
user = User.objects.create(device_id='public-id-device', is_guest=True)
|
||||
user = User.objects.create(phone='09000000054')
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||||
code = client.get('/api/user/profile').json()['publicId']
|
||||
|
||||
@@ -22,7 +22,7 @@ def _client(user):
|
||||
class DeleteAccountTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User.objects.create(phone='09121115001', is_guest=False)
|
||||
self.user = User.objects.create(phone='09121115001')
|
||||
UserStats.objects.create(user_id=self.user.id, xp=50)
|
||||
self.client = _client(self.user)
|
||||
|
||||
@@ -53,12 +53,6 @@ class DeleteAccountTests(TestCase):
|
||||
# توکنِ قبلی دیگر به هیچ کاربری نمیرسد.
|
||||
self.assertIn(self.client.get('/api/user/profile').status_code, (401, 403))
|
||||
|
||||
def test_guest_confirms_by_typing_the_word(self):
|
||||
guest = User.objects.create(device_id='guest-del', is_guest=True)
|
||||
client = _client(guest)
|
||||
self.assertEqual(client.post('/api/user/delete', {'confirm': 'باشه'}, format='json').status_code, 400)
|
||||
self.assertEqual(client.post('/api/user/delete', {'confirm': 'حذف'}, format='json').status_code, 200)
|
||||
self.assertFalse(User.objects.filter(pk=guest.pk).exists())
|
||||
|
||||
@override_settings(TEST_LOGIN_PHONES={'09121115001': '4321'})
|
||||
def test_demo_accounts_cannot_be_deleted(self):
|
||||
|
||||
@@ -13,8 +13,8 @@ from progress.models import UserStats
|
||||
|
||||
class DeleteUsersTests(TestCase):
|
||||
def setUp(self):
|
||||
self.doomed = User.objects.create(phone='09121110001', is_guest=False)
|
||||
self.kept = User.objects.create(phone='09121110002', is_guest=False)
|
||||
self.doomed = User.objects.create(phone='09121110001')
|
||||
self.kept = User.objects.create(phone='09121110002')
|
||||
for user in (self.doomed, self.kept):
|
||||
Profile.objects.create(user=user)
|
||||
UserStats.objects.create(user_id=user.id, xp=10)
|
||||
@@ -51,12 +51,10 @@ class DeleteUsersTests(TestCase):
|
||||
self.assertFalse(Order.objects.filter(user_id=self.doomed.pk).exists())
|
||||
|
||||
@override_settings(TEST_LOGIN_PHONES={'09121110002': '1234'})
|
||||
def test_real_phones_keeps_demo_numbers_and_guests(self):
|
||||
guest = User.objects.create(device_id='guest-x', is_guest=True)
|
||||
def test_real_phones_keeps_demo_numbers(self):
|
||||
self.run_command('--real-phones', '--yes')
|
||||
self.assertFalse(User.objects.filter(pk=self.doomed.pk).exists())
|
||||
self.assertTrue(User.objects.filter(pk=self.kept.pk).exists())
|
||||
self.assertTrue(User.objects.filter(pk=guest.pk).exists())
|
||||
|
||||
def test_skip_paid_keeps_the_payer_and_deletes_the_rest(self):
|
||||
Order.objects.create(user_id=self.doomed.id, amount=1000, status='paid', idempotency_key='k2')
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"""
|
||||
مهمان هم refresh token میگیرد.
|
||||
|
||||
قبلاً مهمان فقط توکنِ یکساعته داشت؛ بعد از یک ساعت همهی درخواستها ۴۰۱
|
||||
میشدند و اپ که هنوز خودش را واردشده میدید، بیصدا خطا میخورد.
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
|
||||
from accounts.testing import guest_session
|
||||
|
||||
|
||||
class GuestRefreshTokenTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def _guest(self, device='device-abc'):
|
||||
return guest_session(device)
|
||||
|
||||
def test_guest_session_has_a_refresh_token(self):
|
||||
body = self._guest()
|
||||
self.assertTrue(body['refreshToken'])
|
||||
|
||||
def test_the_refresh_token_renews_the_same_guest(self):
|
||||
body = self._guest()
|
||||
|
||||
renewed = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': body['refreshToken']},
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
self.assertEqual(renewed.status_code, 200)
|
||||
profile = self.client.get(
|
||||
'/api/user/profile', HTTP_AUTHORIZATION=f"Bearer {renewed.json()['token']}",
|
||||
).json()
|
||||
self.assertEqual(profile['userId'], body['userId'])
|
||||
self.assertTrue(profile['isGuest'])
|
||||
|
||||
def test_each_guest_session_gets_its_own_refresh_token(self):
|
||||
self.assertNotEqual(self._guest()['refreshToken'], self._guest()['refreshToken'])
|
||||
@@ -36,7 +36,7 @@ class PhoneNormalizationTests(TestCase):
|
||||
self.assertEqual(set(OTPCode.objects.values_list('phone', flat=True)), {'09121112233'})
|
||||
|
||||
def test_login_with_another_spelling_reaches_the_same_account(self):
|
||||
User.objects.create(phone='09121112244', is_guest=False)
|
||||
User.objects.create(phone='09121112244')
|
||||
with patch('accounts.views.secrets.randbelow', return_value=246810), \
|
||||
patch('accounts.views.send_otp_sms'):
|
||||
_request('+989121112244')
|
||||
|
||||
@@ -14,7 +14,6 @@ from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.testing import guest_session
|
||||
from accounts.models import OTPCode, User
|
||||
from config.log_formatters import JSONLogFormatter
|
||||
|
||||
@@ -30,33 +29,6 @@ def _otp_row(phone, code='482913'):
|
||||
return code
|
||||
|
||||
|
||||
def _guest(device):
|
||||
return guest_session(device)
|
||||
|
||||
|
||||
class GuestUpgradeRevokesEarlierSessionsTests(TestCase):
|
||||
"""کسی که پیش از ثبتِ شماره با شناسهی دستگاه نشست گرفته بود، بعد از آن به حسابِ شمارهدار نمیرسید."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def test_pre_upgrade_tokens_stop_working(self):
|
||||
attacker = _guest('shared-device')
|
||||
victim = _guest('shared-device')
|
||||
login = _bearer(victim['token']).post('/api/auth/login',
|
||||
{'phone': '09121230001', 'otp': _otp_row('09121230001')},
|
||||
format='json')
|
||||
self.assertEqual(login.status_code, 200)
|
||||
self.assertEqual(login.json()['userId'], victim['userId'])
|
||||
|
||||
refresh = APIClient().post('/api/auth/refresh', {'refreshToken': attacker['refreshToken']}, format='json')
|
||||
self.assertEqual(refresh.status_code, 401)
|
||||
# توکنِ دسترسیِ پیش از ثبت هم (اگر همان ثانیه صادر نشده باشد) رد میشود؛ نشستِ تازه کار میکند.
|
||||
self.assertEqual(_bearer(login.json()['token']).get('/api/user/profile').status_code, 200)
|
||||
fresh = APIClient().post('/api/auth/refresh', {'refreshToken': login.json()['refreshToken']}, format='json')
|
||||
self.assertEqual(fresh.status_code, 200)
|
||||
|
||||
|
||||
class AccountDeletionKeepsLimitsTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
@@ -66,23 +38,25 @@ class AccountDeletionKeepsLimitsTests(TestCase):
|
||||
question_credits=20, hearts_days=7, duration_days=0)
|
||||
DiscountCode.objects.create(code='FREE1', type='percent', value=100, max_uses=1)
|
||||
|
||||
def _guest_redeems(self, device):
|
||||
s = _guest(device)
|
||||
c = _bearer(s['token'])
|
||||
def _user_redeems(self, phone):
|
||||
user = User.objects.create(phone=phone)
|
||||
c = _bearer(issue_access_token(user.id))
|
||||
p = c.post('/api/subscription/purchase', {'planId': str(self.plan.id)}, format='json')
|
||||
return c, c.post('/api/discount/redeem', {'code': 'FREE1', 'orderId': p.json()['orderId']}, format='json')
|
||||
|
||||
def test_single_use_code_stays_used_after_delete(self):
|
||||
c1, first = self._guest_redeems('g-1')
|
||||
c1, first = self._user_redeems('09121230101')
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(c1.post('/api/user/delete', {'confirm': 'حذف'}, format='json').status_code, 200)
|
||||
_, again = self._guest_redeems('g-2')
|
||||
OTPCode.objects.create(phone='09121230101', code='777777',
|
||||
expires_at=timezone.now() + timezone.timedelta(minutes=5))
|
||||
self.assertEqual(c1.post('/api/user/delete', {'otp': '777777'}, format='json').status_code, 200)
|
||||
_, again = self._user_redeems('09121230102')
|
||||
self.assertEqual(again.status_code, 400)
|
||||
|
||||
@override_settings(DEBUG=False, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_per_phone_sms_cap_survives_delete(self):
|
||||
phone = '09121230009'
|
||||
user = User.objects.create(phone=phone, is_guest=False)
|
||||
user = User.objects.create(phone=phone)
|
||||
for i in range(10):
|
||||
OTPCode.objects.create(phone=phone, code=f'{i:06d}', is_used=True,
|
||||
expires_at=timezone.now() - timezone.timedelta(minutes=i + 1))
|
||||
@@ -95,14 +69,6 @@ class AccountDeletionKeepsLimitsTests(TestCase):
|
||||
r = APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
|
||||
self.assertEqual(r.status_code, 429)
|
||||
|
||||
def test_public_demo_guest_cannot_be_deleted(self):
|
||||
demo = User.objects.create(device_id='frontend-test-device', is_guest=True, name='demo')
|
||||
s = _guest('frontend-test-device')
|
||||
self.assertEqual(int(s['userId']), demo.id)
|
||||
r = _bearer(s['token']).post('/api/user/delete', {'confirm': 'حذف'}, format='json')
|
||||
self.assertEqual(r.status_code, 403)
|
||||
self.assertTrue(User.objects.filter(pk=demo.id).exists())
|
||||
|
||||
|
||||
class DisplayNameContactFilterTests(TestCase):
|
||||
def test_contacts_are_removed_and_names_kept(self):
|
||||
@@ -151,35 +117,6 @@ class KavenegarKeyNotLoggedTests(TestCase):
|
||||
self.assertNotIn(FAKE_KEY, out)
|
||||
|
||||
|
||||
class SupervisorDoesNotSeeDeviceIdsTests(TestCase):
|
||||
"""شناسهی دستگاه برای مهمان رمزِ ورود است؛ کارمندِ فقطخواندنی آن را نمیبیند."""
|
||||
|
||||
def setUp(self):
|
||||
call_command('setup_supervisor_group', verbosity=0)
|
||||
self.guest = User.objects.create(device_id='secret-device-123', is_guest=True)
|
||||
from accounts.models import Session
|
||||
Session.objects.create(user=self.guest, refresh_token='h' * 64, device_id='secret-device-123',
|
||||
expires_at=timezone.now() + timezone.timedelta(days=1))
|
||||
staff = get_user_model().objects.create_user('sup', 'sup@example.com', 'pw-for-tests-only', is_staff=True)
|
||||
staff.groups.add(Group.objects.get(name='Supervisor'))
|
||||
# Supervisor نشستها را نمیبیند؛ اینجا اجازهی دیدن داده میشود تا ستونِ آن صفحه هم سنجیده شود.
|
||||
from django.contrib.auth.models import Permission
|
||||
staff.user_permissions.add(Permission.objects.get(codename='view_session', content_type__app_label='accounts'))
|
||||
self.client.force_login(staff)
|
||||
|
||||
def test_hidden_in_list_search_and_form(self):
|
||||
listing = self.client.get('/admin/accounts/user/')
|
||||
self.assertEqual(listing.status_code, 200)
|
||||
self.assertNotContains(listing, 'secret-device-123')
|
||||
# متنِ جستجو در خودِ صفحه تکرار میشود؛ نتیجه نباید همان مهمان باشد.
|
||||
search = self.client.get('/admin/accounts/user/', {'q': 'secret-device-123'})
|
||||
self.assertNotContains(search, f'/admin/accounts/user/{self.guest.pk}/change/')
|
||||
form = self.client.get(f'/admin/accounts/user/{self.guest.pk}/change/')
|
||||
self.assertNotContains(form, 'secret-device-123')
|
||||
sessions = self.client.get('/admin/accounts/session/')
|
||||
self.assertNotContains(sessions, 'secret-device-123')
|
||||
|
||||
|
||||
def _staff_with(*perms, username='staff1'):
|
||||
from django.contrib.auth.models import Permission
|
||||
staff = get_user_model().objects.create_user(username, f'{username}@example.com', 'pw-for-tests-only', is_staff=True)
|
||||
@@ -188,53 +125,24 @@ def _staff_with(*perms, username='staff1'):
|
||||
return staff
|
||||
|
||||
|
||||
class DeviceIdNotGuessableByAdminFilterTests(TestCase):
|
||||
"""فیلترِ querystringِ changelist (?device_id__startswith=…) شناسهی دستگاه را برای غیرِ ابرکاربر لو نمیدهد."""
|
||||
|
||||
def setUp(self):
|
||||
call_command('setup_supervisor_group', verbosity=0)
|
||||
self.guest = User.objects.create(device_id='abc-secret-999', is_guest=True)
|
||||
from accounts.models import Session
|
||||
Session.objects.create(user=self.guest, refresh_token='h' * 64, device_id='abc-secret-999',
|
||||
expires_at=timezone.now() + timezone.timedelta(days=1))
|
||||
staff = _staff_with(('accounts', 'view_session'))
|
||||
staff.groups.add(Group.objects.get(name='Supervisor'))
|
||||
self.client.force_login(staff)
|
||||
|
||||
def test_filter_on_device_id_is_refused(self):
|
||||
for lookup in ('device_id__startswith', 'device_id__regex', 'device_id'):
|
||||
r = self.client.get('/admin/accounts/user/', {lookup: 'abc'})
|
||||
self.assertEqual(r.status_code, 400, lookup)
|
||||
self.assertNotContains(r, f'/admin/accounts/user/{self.guest.pk}/change/', status_code=400)
|
||||
r = self.client.get('/admin/accounts/session/', {'device_id__startswith': 'abc'})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_superuser_still_filters(self):
|
||||
root = get_user_model().objects.create_superuser('root', 'root@example.com', 'pw-for-tests-only')
|
||||
self.client.force_login(root)
|
||||
r = self.client.get('/admin/accounts/user/', {'device_id__startswith': 'abc'})
|
||||
self.assertContains(r, f'/admin/accounts/user/{self.guest.pk}/change/')
|
||||
|
||||
|
||||
class StaffCannotSwapLoginPhoneTests(TestCase):
|
||||
"""کارمندی با change_user شمارهی کاربر را عوض نمیکند؛ وگرنه با شمارهی خودش کد میگیرد و واردِ آن حساب میشود."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.victim = User.objects.create(phone='09120000077', is_guest=False, name='victim')
|
||||
self.victim = User.objects.create(phone='09120000077', name='victim')
|
||||
self.client.force_login(_staff_with(('accounts', 'change_user'), ('accounts', 'view_user')))
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def test_phone_and_is_guest_are_read_only(self):
|
||||
def test_phone_is_read_only(self):
|
||||
r = self.client.post(f'/admin/accounts/user/{self.victim.pk}/change/', {
|
||||
'phone': '09129999999', 'name': 'renamed', 'avatar': '', 'grade_id': '', 'is_guest': 'on'})
|
||||
'phone': '09129999999', 'name': 'renamed', 'avatar': '', 'grade_id': ''})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.victim.refresh_from_db()
|
||||
self.assertEqual(self.victim.name, 'renamed')
|
||||
self.assertEqual(self.victim.phone, '09120000077')
|
||||
self.assertFalse(self.victim.is_guest)
|
||||
login = APIClient().post('/api/auth/login', {'phone': '09129999999', 'otp': _otp_row('09129999999')},
|
||||
format='json')
|
||||
self.assertNotEqual(login.json().get('userId'), str(self.victim.pk))
|
||||
@@ -270,7 +178,7 @@ class StaffAccountsAdminSuperuserOnlyTests(TestCase):
|
||||
|
||||
|
||||
class UnicodeInputsAreNormalisedOrRejectedTests(TestCase):
|
||||
"""رقمهای تمامعرض/ریاضی، کدِ غیرِ ASCII، پایهی ناموجود و deviceIdِ null: ۴۰۰ یا نرمالشده، نه ۵۰۰ و نه حسابِ جدا."""
|
||||
"""رقمهای تمامعرض/ریاضی، کدِ غیرِ ASCIIو پایهی ناموجود: ۴۰۰ یا نرمالشده، نه ۵۰۰ و نه حسابِ جدا."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
@@ -290,20 +198,15 @@ class UnicodeInputsAreNormalisedOrRejectedTests(TestCase):
|
||||
r = APIClient().post('/api/auth/login', {'phone': phone, 'otp': code}, format='json')
|
||||
self.assertEqual(r.status_code, 400, code)
|
||||
|
||||
def test_login_device_id_null_is_400(self):
|
||||
phone = '09120000098'
|
||||
code = _otp_row(phone)
|
||||
r = APIClient().post('/api/auth/login', {'phone': phone, 'otp': code, 'deviceId': None}, format='json')
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_unknown_or_huge_grade_is_400(self):
|
||||
user = User.objects.create(phone='09120000097', is_guest=False)
|
||||
user = User.objects.create(phone='09120000097')
|
||||
app = _bearer(issue_access_token(user.id))
|
||||
for gid in (99999, 10 ** 30):
|
||||
self.assertEqual(app.post('/api/user/grade', {'gradeId': gid}, format='json').status_code, 400, gid)
|
||||
|
||||
def test_birth_date_with_superscript_digit_is_400(self):
|
||||
user = User.objects.create(phone='09120000096', is_guest=False)
|
||||
user = User.objects.create(phone='09120000096')
|
||||
r = _bearer(issue_access_token(user.id)).patch('/api/user/profile', {'birthDate': '13²5-04-12'}, format='json')
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
@@ -315,19 +218,3 @@ class UnicodeInputsAreNormalisedOrRejectedTests(TestCase):
|
||||
self.assertEqual(clean('علی رضایی'), 'علی رضایی')
|
||||
|
||||
|
||||
class DemoGuestNotUpgradableTests(TestCase):
|
||||
"""مهمانِ نمایشیِ مشترک با ورودِ یک شماره به آن شماره تبدیل نمیشود؛ شمارهی تازه حسابِ تازه میگیرد."""
|
||||
|
||||
def test_demo_guest_keeps_its_identity(self):
|
||||
cache.clear()
|
||||
self.addCleanup(cache.clear)
|
||||
s = _guest('frontend-test-device')
|
||||
demo_id = int(s['userId'])
|
||||
phone = '09120000095'
|
||||
code = _otp_row(phone)
|
||||
r = _bearer(s['token']).post('/api/auth/login', {'phone': phone, 'otp': code}, format='json')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertNotEqual(int(r.json()['userId']), demo_id)
|
||||
demo = User.objects.get(pk=demo_id)
|
||||
self.assertIsNone(demo.phone)
|
||||
self.assertTrue(demo.is_guest)
|
||||
|
||||
@@ -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 خواندنی است و نباید سهمیهی نوشتن را مصرف کند.
|
||||
|
||||
Reference in New Issue
Block a user