cleanup: trim bloated multi-line comments across the project
This commit is contained in:
@@ -56,47 +56,19 @@ class UserAdmin(admin.ModelAdmin):
|
||||
list_filter = ('is_guest',)
|
||||
actions = ['send_sms_to_selected']
|
||||
|
||||
# permissions=['change']: بدونِ این، جنگو این اکشن رو بر اساسِ
|
||||
# has_change_permission فیلتر نمیکنه (فقط اکشنهایی که با پارامترِ
|
||||
# permissions تعریف شده باشن فیلتر میشن)، یعنی کاربری که فقط
|
||||
# view_user داره (مثلِ گروهِ Supervisor) میتونه با یک POSTِ مستقیم
|
||||
# همین اکشن رو صدا بزنه و واقعاً پیامک بفرسته، حتی بدونِ اینکه دکمهی
|
||||
# اکشن رو در UI ببینه. با permissions=['change']، ModelAdmin.response_action
|
||||
# خودش (get_actions -> _filter_actions_by_permissions) این اکشن رو
|
||||
# فقط برای کاربرانِ دارایِ change_user در دسترس میذاره؛ برای بقیه
|
||||
# (مثلِ Supervisor) اصلاً در actions ثبتشده نیست، پس درخواستِ مستقیم
|
||||
# هم بیاثر میمونه (پیامِ هشدارِ استانداردِ «اکشنی انتخاب نشده»).
|
||||
# permissions=['change']: بدونِ این، کاربرِ فقط-view (گروهِ Supervisor)
|
||||
# میتونست با POSTِ مستقیم این اکشن رو صدا بزنه بدونِ دیدنِ دکمه در UI؛
|
||||
# این پارامتر باعث میشه ModelAdmin فقط برای change_user در دسترس بذارتش.
|
||||
@admin.action(description='ارسال پیامک به کاربران انتخابشده', permissions=['change'])
|
||||
def send_sms_to_selected(self, request, queryset):
|
||||
"""
|
||||
قدمِ اولیهی مینیمالِ فیچرِ «ارسالِ پیامک از ادمین»: یک اکشنِ ادمینِ
|
||||
معمولی روی UserAdmin که یک صفحهی میانیِ ساده (فرمِ متنِ پیامک) رو
|
||||
نشون میده و بعد از تأیید، دقیقاً به همون کاربرهایی که در changelist
|
||||
انتخاب شدن، همون متن رو با accounts.services.send_sms موجود ارسال
|
||||
میکنه.
|
||||
"""اکشنِ ادمین برای ارسالِ پیامک به کاربرانِ انتخابشده یا غیرفعال؛
|
||||
گیرنده با فیلدِ «target» انتخاب میشه (cutoffِ «غیرفعال» رو خودِ ادمین
|
||||
در فرم وارد میکنه، چیزی hardcode نیست).
|
||||
|
||||
گیرندهها با فیلدِ «target» در فرم انتخاب میشن:
|
||||
- 'selected' (پیشفرض): همون queryset که ادمین در changelist
|
||||
انتخاب کرده — رفتارِ قبلی، بدون تغییر.
|
||||
- 'inactive': کاربرانِ غیرفعال، با استفاده از
|
||||
accounts.services.get_inactive_users(since) — cutoff (تاریخ)
|
||||
رو خودِ ادمین در فرم وارد میکنه، هیچ عددِ روزی hardcode نشده.
|
||||
|
||||
عمداً محدود نگه داشته شده:
|
||||
- هیچ مدلِ کمپین/بدنهی جدیدی ساخته/persist نمیشه (نه رکوردی از
|
||||
پیام، نه رکوردی از تحویل)؛ ارسال کاملاً بلادرنگه.
|
||||
- هیچ زمانبندی، صفِ پسزمینه، یا فیلترِ هدفگیریِ دیگهای (غیر از
|
||||
selected/inactive) نیست.
|
||||
- محافظتِ سقف: چون ارسال هنوز کاملاً synchronous و توی همون
|
||||
request انجام میشه (نه صفِ پسزمینه)، اگر تعدادِ گیرندهها از
|
||||
settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشه، اکشن هیچ پیامکی
|
||||
ارسال نمیکنه و فقط خطا نشون میده — تا یک انتخاب/تاریخِ خیلی
|
||||
بزرگ باعثِ تایماوتِ request یا overload شدنِ پروایدر نشه.
|
||||
- هیچ منطقِ provider تکرار نشده؛ send_sms عیناً همون تابعِ موجودیه
|
||||
که OTP و campaigns هم ازش استفاده میکنن.
|
||||
- کاربرانِ بدونِ شمارهتلفن (مثلِ مهمان) بهسادگی skip میشن.
|
||||
- شکستِ ارسال برای یک کاربر (SMSDeliveryError) کلِ اکشن رو متوقف
|
||||
نمیکنه؛ فقط شمارشِ failed رو زیاد میکنه و برای بقیه ادامه میده.
|
||||
ارسال کاملاً synchronous و توی همون requestه (نه صفِ پسزمینه)، پس
|
||||
اگر گیرندهها از settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشن، اکشن
|
||||
هیچ پیامکی نمیفرسته تا request تایماوت نکنه یا provider overload نشه.
|
||||
شکستِ ارسال برای یک کاربر بقیه را متوقف نمیکنه.
|
||||
"""
|
||||
if 'apply' in request.POST:
|
||||
form = SendSMSForm(request.POST)
|
||||
@@ -123,10 +95,8 @@ class UserAdmin(admin.ModelAdmin):
|
||||
),
|
||||
level=messages.ERROR,
|
||||
)
|
||||
# بهجای return None، به رندرِ مشترکِ پایینِ تابع سقوط
|
||||
# میکنیم تا فرم دوباره (بدونِ ارسالِ هیچ پیامکی) نشون
|
||||
# داده بشه — دقیقاً همون الگویی که برای فرمِ نامعتبر
|
||||
# استفاده میشه.
|
||||
# سقوط به رندرِ مشترکِ پایین (نه return None) چون None از
|
||||
# یک اکشنِ ادمین باعثِ ریدایرکتِ ۳۰۲ میشه، نه رندرِ مستقیم.
|
||||
else:
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
@@ -158,12 +128,8 @@ class UserAdmin(admin.ModelAdmin):
|
||||
f'{skipped_no_phone} کاربر بدونِ شمارهتلفن رد شدن (پیامک ارسال نشد).',
|
||||
level=messages.WARNING,
|
||||
)
|
||||
# توجه: قبلاً اینجا `return None` بود؛ حذف شد چون در
|
||||
# جنگو، None برگردوندن از یک اکشنِ ادمین باعثِ یک
|
||||
# ریدایرکتِ ۳۰۲ واقعی میشه (نه رندرِ مستقیمِ ۲۰۰)،
|
||||
# درست برخلافِ چیزی که این کامنت قبلاً فرض کرده بود.
|
||||
# حالا همالگو با شاخهی cap-exceeded، سقوط میکنیم به
|
||||
# رندرِ مشترکِ پایینِ تابع.
|
||||
# سقوط به رندرِ مشترکِ پایین (نه return None)، همالگو با
|
||||
# شاخهی cap-exceeded.
|
||||
else:
|
||||
form = SendSMSForm()
|
||||
|
||||
@@ -195,38 +161,15 @@ class OTPCodeAdmin(admin.ModelAdmin):
|
||||
list_filter = ('is_used',)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# صفحهی «ناظر» (Supervisor)
|
||||
# صفحهی «ناظر» (Supervisor): یک ایندکسِ read-only از لینکها به
|
||||
# changelistهای موجود (کاربران/پرداختها/اشتراکها)، داخلِ همون AdminSite
|
||||
# پیشفرض — نه AdminSite جدا و بدون هیچ ModelAdmin جدید.
|
||||
#
|
||||
# یک صفحهی خیلی کوچیک و *read-only* داخلِ همون AdminSiteِ پیشفرضِ جنگو
|
||||
# (django.contrib.admin.site) — نه یک AdminSite جدا، و نه CRUDِ موازی.
|
||||
# این صفحه صرفاً یک ایندکسِ گروهی از لینکهایی به همون changelistهای
|
||||
# ModelAdmin موجودِ پروژهست: کاربران (accounts.UserAdmin)، پرداختها
|
||||
# (payment.OrderAdmin / payment.TransactionAdmin) و اشتراکها
|
||||
# (subscription.SubscriptionAdmin). هیچ منطقِ کسبوکار یا ModelAdmin
|
||||
# جدیدی اینجا ساخته نمیشه.
|
||||
#
|
||||
# محدودیتِ واقعیِ read-only از طریقِ سیستمِ استانداردِ Group/Permission
|
||||
# جنگو تأمین میشه، نه با پنهونکردنِ لینک:
|
||||
# - هر لینک در این صفحه فقط وقتی نشون داده میشه که کاربر پرمیشنِ
|
||||
# view_<model> مربوطه رو داشته باشه (request.user.has_perm(...)).
|
||||
# - حتی اگه کسی مستقیماً URL تغییر/افزودن/حذف رو حدس بزنه، خودِ
|
||||
# changelist/change/add/delete viewهای اون ModelAdminها (که اینجا هیچ
|
||||
# overrideای نشدن) طبقِ has_view_permission/has_change_permission/
|
||||
# has_add_permission/has_delete_permission پیشفرضِ ModelAdmin — که
|
||||
# مستقیماً از Permission مدلِ کاربر میخونن — چک میکنن؛ یعنی گروهِ
|
||||
# Supervisor (که با دستورِ مدیریتیِ setup_supervisor_group فقط پرمیشنِ
|
||||
# view_* میگیره) حتی با دسترسیِ مستقیم به URL هم نمیتونه add/change/
|
||||
# delete کنه.
|
||||
# - خودِ صفحه هم مثلِ هر صفحهی دیگهی Admin با admin.site.admin_view
|
||||
# wrap شده، یعنی همون احرازِ هویت/is_staff استانداردِ Admin رو داره؛
|
||||
# ولی is_staff بهتنهایی برای *ورود به خودِ صفحه* کافی نیست — پایینتر
|
||||
# قبل از هر کارِ دیگهای عضویتِ کاربر در گروهِ Supervisor هم چک میشه
|
||||
# (request.user.groups.filter(name=SUPERVISOR_GROUP_NAME))، وگرنه
|
||||
# PermissionDenied (۴۰۳) بالا میره. این یک سیستمِ پرمیشنِ جدید نیست؛
|
||||
# فقط از همون گروهِ Supervisorِ موجود (که setup_supervisor_group
|
||||
# میسازه) برای گیتکیپینگِ ورود به صفحه استفاده میکنه.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# read-only بودن با پنهونکردنِ لینک تضمین نمیشه، بلکه با پرمیشنِ استانداردِ
|
||||
# جنگو: هر لینک فقط با view_<model> نشون داده میشه و حتی دسترسیِ مستقیم به
|
||||
# URLِ change/add/delete هم توسطِ پرمیشنِ پیشفرضِ همون ModelAdminها رد میشه
|
||||
# (گروهِ Supervisor فقط view_* داره). is_staff بهتنهایی برای ورود کافی
|
||||
# نیست؛ عضویت در گروهِ Supervisor هم چک میشه، وگرنه PermissionDenied.
|
||||
|
||||
SUPERVISOR_GROUP_NAME = 'Supervisor'
|
||||
|
||||
|
||||
@@ -104,12 +104,9 @@ def send_sms(*, phone: str, message: str) -> None:
|
||||
)
|
||||
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||||
|
||||
# ⚠️ کاوهنگار حتی برای شکستهای سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبار
|
||||
# ناکافی، گیرندهی نامعتبر و…) HTTP 200 برمیگردونه؛ نتیجهی واقعی توی
|
||||
# بدنهی JSON، زیرِ return.status هست (200 یعنی موفق). صرفِ
|
||||
# status_code == 200 «موفقیت» در نظر گرفته نمیشه؛ بدنه parse میشه و
|
||||
# فقط return.status == 200 موفقیت محسوب میشه؛ هر پاسخِ ناقص/غیرمنتظره
|
||||
# هم شکستِ تحویل در نظر گرفته میشه (نه موفقیت).
|
||||
# کاوهنگار حتی برای شکستهای سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبارِ ناکافی و…)
|
||||
# HTTP 200 برمیگردونه؛ نتیجهی واقعی توی body.return.status هست. فقط
|
||||
# اون فیلد موفقیت رو تعیین میکنه، نه status_code.
|
||||
try:
|
||||
body = response.json()
|
||||
provider_status = body['return']['status']
|
||||
@@ -158,23 +155,10 @@ def send_otp_sms(*, phone: str, code: str) -> None:
|
||||
raise OTPDeliveryError(str(exc)) from exc
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Targeting helpers (internal, read-only)
|
||||
#
|
||||
# هدف: صرفاً کوئریهای قابلاستفادهی مجدد برای انتخاب زیرمجموعهای از
|
||||
# کاربران، تا فیچرهای آینده (مثلاً کمپینهای پیامی) بدونِ تکرارِ منطقِ
|
||||
# فیلتر، از همین توابع استفاده کنن. این ماژول:
|
||||
# - هیچ پیامی نمیفرسته (نه SMS، نه notification).
|
||||
# - هیچ جدول/مایگریشنِ جدیدی اضافه نمیکنه.
|
||||
# - هیچ background job/endpointای نداره؛ فقط QuerySetِ User برمیگردونه.
|
||||
# - هیچ رفتارِ موجودی رو تغییر نمیده (فقط SELECT).
|
||||
#
|
||||
# ارجاع به subscription.Subscription عمداً با import محلی (داخلِ تابع) و
|
||||
# بر اساسِ user_id انجام میشه — دقیقاً همون الگویی که payment/services.py
|
||||
# و discount/services.py برای صداکردنِ subscription استفاده میکنن؛ چون
|
||||
# accounts.User (طبقِ docstringِ خودش) هیچوقت FK واقعی به مدلهای اپهای
|
||||
# دیگه نمیزنه.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Targeting helpers (internal, read-only): کوئریهای قابلاستفادهی مجدد برای
|
||||
# انتخابِ زیرمجموعهای از کاربران (مثلاً برای کمپینهای پیامی)؛ فقط SELECT،
|
||||
# بدونِ ارسالِ پیام. ارجاع به subscription.Subscription با import محلی و
|
||||
# user_id انجام میشه چون accounts.User هیچوقت FK واقعی به اپهای دیگه نمیزنه.
|
||||
|
||||
def get_all_users():
|
||||
"""همهی کاربران (مهمان و ثبتنامشده)."""
|
||||
|
||||
@@ -837,27 +837,13 @@ class GuestSessionThrottleTests(TestCase):
|
||||
}
|
||||
)
|
||||
def test_repeated_guest_session_requests_are_eventually_throttled(self):
|
||||
# ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): این تست دو مشکلِ جدا
|
||||
# داشت:
|
||||
# ۱) `type` رو نمیفرستاد (پایینتر فیکس شده) — قراردادِ واقعیِ
|
||||
# API طبقِ README نیازش داره، پس هر درخواست قبل از throttle
|
||||
# با 400 رد میشد.
|
||||
# ۲) override_settings(REST_FRAMEWORK=...) بهتنهایی رفتارِ
|
||||
# ScopedRateThrottle رو تغییر نمیده: DRF مقدارِ
|
||||
# THROTTLE_RATES رو یکبار موقعِ import ماژولِ throttling
|
||||
# بهعنوانِ یک class attribute از api_settings.
|
||||
# DEFAULT_THROTTLE_RATES میگیره (همون دیکشنریِ اصلیِ
|
||||
# settings.py، با reference). override_settings یک دیکشنریِ
|
||||
# *جدید* برای settings.REST_FRAMEWORK میسازه، اما این
|
||||
# class attribute هنوز به همون دیکشنریِ قدیمی اشاره میکنه —
|
||||
# سیگنالِ setting_changed فقط api_settings رو reload میکنه،
|
||||
# نه این class attribute رو. نتیجه: نرخِ واقعیِ اعمالشده
|
||||
# همیشه همون دیفالتِ 20/hour در settings.py میموند، نه
|
||||
# 3/hour، پس ۵ درخواست هیچوقت به سقف نمیرسیدن. برای اینکه
|
||||
# این تست واقعاً نرخِ محدودشده رو بسنجه، مستقیماً همون
|
||||
# دیکشنریِ زندهی ScopedRateThrottle.THROTTLE_RATES رو
|
||||
# patch.dict میکنیم (که override_settings بالا هم برای
|
||||
# سازگاری/مستندسازیِ نیت نگه داشته میشه).
|
||||
# override_settings(REST_FRAMEWORK=...) بهتنهایی رفتارِ
|
||||
# ScopedRateThrottle رو تغییر نمیده: DRF مقدارِ THROTTLE_RATES رو
|
||||
# یکبار موقعِ import، بهعنوانِ class attribute، از دیکشنریِ اصلیِ
|
||||
# settings.py میگیره؛ override_settings دیکشنریِ settings رو عوض
|
||||
# میکنه ولی این class attribute هنوز به نسخهی قدیمی اشاره میکنه.
|
||||
# برای سنجیدنِ نرخِ واقعی، مستقیماً patch.dict روی همون دیکشنریِ
|
||||
# زندهی ScopedRateThrottle.THROTTLE_RATES لازمه.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'guest_session': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self.client.post(
|
||||
|
||||
@@ -116,24 +116,10 @@ class RequestOTPView(APIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
phone = serializer.validated_data['phone']
|
||||
|
||||
# حداقل فاصلهی زمانی بین دو درخواست برای همون شماره (burst
|
||||
# protection). سقفِ ساعتیِ زیر جلویِ حجمِ کلی رو میگیره، اما بهتنهایی
|
||||
# جلویِ این رو نمیگیره که همون ۵ تلاشِ مجاز در عرضِ چند ثانیه/کمتر
|
||||
# از یک ثانیه مصرف بشن (که هم بهمعنایِ flood پیامکی روی یک شمارهی
|
||||
# مشخصه، هم میتونه با invalidate کردنِ پیدرپیِ OTPهای قبلی
|
||||
# (رفتارِ «فقط آخرین کد معتبره» که پایینتره) عملاً یک DoS روی
|
||||
# کاربرِ واقعیِ اون شماره باشه). چک این cooldown *قبل* از شمارندهی
|
||||
# ساعتی انجام میشه تا یک درخواستِ بلاکشده توسطِ cooldown سهمیهی
|
||||
# ساعتی رو مصرف نکنه. از همون الگویِ اتمیکِ cache.add استفاده شده
|
||||
# (دقیقاً همون مکانیزمِ زیر و مکانیزمِ LoginView).
|
||||
#
|
||||
# کلید cooldown بهجای یک فلگِ ساده، زمانِ epoch انقضاء رو ذخیره
|
||||
# میکنه؛ اینطوری وقتی add شکست بخوره (کلید از قبل هست)، بدونِ
|
||||
# هیچ خواندنِ ناامنی میشه دقیقاً محاسبه کرد چند ثانیه تا پایانِ
|
||||
# cooldown مونده و اون رو هم در بدنهی پاسخ (retryAfter) و هم در
|
||||
# هدرِ استانداردِ Retry-After برگردوند — اینطوری frontend
|
||||
# countdown رو دقیقاً از رویِ همین پاسخِ backend شروع میکنه، نه
|
||||
# از رویِ یک عددِ hardcodeشدهی سمتِ کلاینت که مرجعِ امنیتی نیست.
|
||||
# حداقل فاصلهی زمانی بین دو درخواستِ همون شماره (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()
|
||||
@@ -161,13 +147,9 @@ class RequestOTPView(APIView):
|
||||
headers={'Retry-After': str(retry_after)},
|
||||
)
|
||||
|
||||
# محدودیت صدور OTP بر اساس شماره تلفن، مستقل از throttle IP-محورِ
|
||||
# otp_request بالا. throttle IP-محور بهتنهایی کافی نیست چون یک
|
||||
# مهاجم میتونه درخواستهای تکراری برای یک شماره رو بین چند IP
|
||||
# مختلف پخش کنه و پیامک OTP رو flood کنه. الگوی cache.add/incr
|
||||
# همون الگوی atomic زیرِ LoginView است: cache.add فقط وقتی کلید
|
||||
# وجود نداره مقدار میذاره (اتمیک روی سطح backend cache)، پس دو
|
||||
# درخواستِ همزمان نمیتونن هر دو از یک non-atomic check رد بشن.
|
||||
# محدودیتِ صدورِ OTP بر اساسِ شماره، مستقل از throttleِ IP-محورِ بالا
|
||||
# (وگرنه یه مهاجم میتونه بینِ چند IP پخش کنه). cache.add فقط وقتی
|
||||
# کلید نیست مقدار میذاره، پس اتمیکه.
|
||||
phone_requests_key = f'otp_request_phone_attempts:{phone}'
|
||||
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
|
||||
attempt_count = 1
|
||||
@@ -202,13 +184,9 @@ class RequestOTPView(APIView):
|
||||
try:
|
||||
send_otp_sms(phone=phone, code=code)
|
||||
except OTPDeliveryError:
|
||||
# ⚠️ فیکس: کاربر عملاً کد رو دریافت نکرده (ارسالِ پیامک شکست
|
||||
# خورده)، پس cooldownِ ۱۲۰ثانیهای که بالاتر برای همین درخواست
|
||||
# ثبت شده نباید جلوی تلاشِ فوریِ بعدیِ کاربر رو بگیره؛ در غیرِ
|
||||
# اینصورت کاربر ۲ دقیقه معطل میمونه بدونِ اینکه اصلاً کدی
|
||||
# گرفته باشه. سقفِ ساعتیِ per-phone (بالاتر) و throttle
|
||||
# IP-محور دستنخورده میمونن — اینها محدودیتِ حجمِ کلیان، نه
|
||||
# cooldownِ بینِ-درخواستی، و منطقِ متفاوتی دارن.
|
||||
# ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک میکنیم
|
||||
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه. سقفِ ساعتی/
|
||||
# throttleِ IP دستنخورده میمونن (محدودیتِ حجم، نه این cooldown).
|
||||
cache.delete(phone_cooldown_key)
|
||||
raise
|
||||
|
||||
|
||||
Reference in New Issue
Block a user