cleanup: trim bloated multi-line comments across the project

This commit is contained in:
2026-08-26 03:22:20 +03:30
parent 83435c5a5f
commit 683bd40169
27 changed files with 235 additions and 699 deletions

View File

@@ -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'

View File

@@ -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():
"""همه‌ی کاربران (مهمان و ثبت‌نام‌شده)."""

View File

@@ -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(

View File

@@ -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