Seen live on 2026-09-27: Kavenegar took 5.4 s, our 5 s timeout fired, the API answered 503 and deleted the code, but the SMS arrived, so the site said "service unavailable" while the phone held a code that no longer worked. A read timeout now means "delivery unknown": the code is kept and the request succeeds (the cooldown still blocks a second SMS). A failed connection is still an explicit 503. Read timeout raised to 10 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
260 lines
14 KiB
Python
260 lines
14 KiB
Python
"""
|
||
منطق کمکیِ اپ Accounts که از views.py همین اپ صدا زده میشه.
|
||
"""
|
||
import logging
|
||
|
||
import requests
|
||
from django.conf import settings
|
||
from django.db import models
|
||
from django.utils import timezone
|
||
from rest_framework.exceptions import APIException
|
||
|
||
from .models import User
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _phone_suffix(phone: str) -> str:
|
||
"""
|
||
⚠️ اصلاح: قبلاً لاگهای مربوط به OTP، شمارهتلفنِ کامل رو لاگ میکردن
|
||
(PIIِ غیرضروری در لاگهای اپلیکیشن). حالا فقط ۴ رقمِ آخر (برای
|
||
correlation/دیباگ، بدونِ افشای کاملِ شماره) لاگ میشه — همون الگویی که
|
||
قبلاً در accounts/views.py::LoginView هم برای همین منظور استفاده شده.
|
||
|
||
⚠️ اصلاحِ این دور: لاگهای خطای ارائهدهنده (provider) دیگه
|
||
response.text خام رو لاگ نمیکنن — چون بدنهی پاسخِ کاوهنگار میتونه
|
||
دادهی حساس/غیرضروری (مثلاً جزئیاتِ گیرنده یا پیامِ متنی) داشته باشه.
|
||
بهجاش فقط context مفید و بیخطر (phone_suffix، status، provider_status)
|
||
لاگ میشه.
|
||
"""
|
||
return phone[-4:] if phone else phone
|
||
|
||
|
||
class SMSDeliveryError(APIException):
|
||
"""
|
||
خطای عمومیِ ارسالِ پیامک، برای هر فراخوانیای از send_sms (نه فقط OTP).
|
||
اگه پیکربندیِ سرویسِ پیامک ناقص باشه یا خودِ درخواست به ارائهدهنده
|
||
شکست بخوره، همین استثنا raise میشه (نه یک پاسخِ ساختگیِ موفق).
|
||
"""
|
||
|
||
status_code = 503
|
||
default_detail = 'در حال حاضر امکان ارسال پیامک وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.'
|
||
default_code = 'sms_delivery_failed'
|
||
|
||
|
||
class SMSDeliveryUncertain(SMSDeliveryError):
|
||
"""
|
||
درخواست به کاوهنگار رسید ولی جوابش در مهلت نیامد؛ پیامک شاید رفته باشد.
|
||
۲۰۲۶-۰۹-۲۷ دیده شد: پاسخ ۵٫۴ ثانیه طول کشید، ما با مهلتِ ۵ ثانیه ۵۰۳ دادیم و
|
||
کد را پاک کردیم، ولی پیامک رسید — کاربر کدی در دست داشت که دیگر کار نمیکرد.
|
||
"""
|
||
|
||
|
||
class OTPDeliveryError(SMSDeliveryError):
|
||
"""
|
||
⚠️ اصلاح: قبلاً send_otp_sms یک استاب بود که فقط لاگ میکرد و همیشه
|
||
(بیقیدوشرط) به RequestOTPView «موفقیت» برمیگردوند؛ یعنی در پروداکشن
|
||
(DEBUG=False، جایی که devOtp دیگه توی پاسخ برنمیگرده) هیچ پیامکِ
|
||
واقعیای ارسال نمیشد ولی API همچنان success=True میداد — کاربرِ واقعی
|
||
هیچوقت کدش رو نمیگرفت و حتی نمیفهمید که مشکلی پیش اومده. حالا اگه
|
||
پیکربندیِ سرویسِ پیامک ناقص باشه یا خودِ درخواست به ارائهدهنده شکست
|
||
بخوره، همین استثنا raise میشه و RequestOTPView یک خطای صریح (نه یک
|
||
پاسخِ ساختگیِ موفق) برمیگردونه.
|
||
|
||
این کلاس زیرکلاسِ SMSDeliveryError است تا رفتار/نوعِ استثنایی که
|
||
RequestOTPView (و تستها) انتظارش رو دارن دقیقاً حفظ بشه، در حالی که
|
||
منطقِ واقعیِ ارتباط با ارائهدهنده در send_sms (عمومی) پیاده شده.
|
||
"""
|
||
|
||
default_detail = 'در حال حاضر امکان ارسال پیامک OTP وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.'
|
||
default_code = 'otp_sms_delivery_failed'
|
||
|
||
|
||
def send_sms(*, phone: str, message: str) -> None:
|
||
"""
|
||
⚙️ Reusable هلپر: تنها بخشِ «ارتباط با ارائهدهنده» (کاوهنگار،
|
||
kavenegar.com) که قبلاً داخلِ send_otp_sms با منطقِ OTP قاطی شده بود،
|
||
به این تابعِ عمومی منتقل شده — بدونِ هیچ تغییری در رفتار/پیامهای
|
||
provider. هر فیچرِ آینده (مثلاً notifications) هم میتونه بدونِ
|
||
تکرارِ کدِ ارائهدهنده، مستقیماً از همین تابع برای ارسالِ پیامک استفاده
|
||
کنه.
|
||
|
||
این تابع هیچ منطقِ مخصوصِ OTP (dev-mode short-circuit، تمپلیتِ پیامک،
|
||
یا OTPDeliveryError) نداره؛ فقط payload میسازه، به کاوهنگار درخواست
|
||
میزنه، و پاسخ رو اعتبارسنجی میکنه. کلیدِ API هیچوقت داخل کد نیست؛
|
||
همیشه از env خونده میشه (settings.KAVENEGAR_API_KEY).
|
||
"""
|
||
payload = {'receptor': phone, 'message': message}
|
||
if settings.KAVENEGAR_SENDER:
|
||
payload['sender'] = settings.KAVENEGAR_SENDER
|
||
_kavenegar_post(phone=phone, method='sms/send.json', payload=payload)
|
||
|
||
|
||
def send_verify_lookup(*, phone: str, template: str, token: str) -> None:
|
||
"""
|
||
ارسال با الگوی تأییدشدهی کاوهنگار (verify/lookup) — متن را الگوی پنل میسازد
|
||
و ما فقط token را میفرستیم. کاوهنگارِ این حساب پیامکِ متنِ آزاد را از خطِ
|
||
پیشفرض لغو میکند (وضعیتِ ۱۳، ۲۰۲۶-۰۹-۲۵) و کدِ ورود را فقط از این راه میپذیرد.
|
||
token نباید فاصله داشته باشد (قاعدهی کاوهنگار)؛ کدِ OTP شش رقمِ پیوسته است.
|
||
"""
|
||
_kavenegar_post(
|
||
phone=phone, method='verify/lookup.json',
|
||
payload={'receptor': phone, 'template': template, 'token': token},
|
||
)
|
||
|
||
|
||
# کاوهنگار گاهی بیش از ۵ ثانیه طول میکشد (۵٫۴ ثانیه در ۲۰۲۶-۰۹-۲۷)؛ workerهای
|
||
# gunicorn مهلتِ ۱۲۰ ثانیه دارند و gthread است، پس ۱۰ ثانیه کسی را قفل نمیکند.
|
||
SMS_READ_TIMEOUT_SECONDS = 10
|
||
|
||
|
||
def _kavenegar_post(*, phone: str, method: str, payload: dict) -> None:
|
||
"""فراخوانیِ مشترکِ کاوهنگار و سنجشِ پاسخ؛ هر شکستی SMSDeliveryError است."""
|
||
if not settings.KAVENEGAR_API_KEY:
|
||
logger.error(
|
||
'SMS misconfigured: KAVENEGAR_API_KEY is not set (phone_suffix=%s)',
|
||
_phone_suffix(phone),
|
||
)
|
||
raise SMSDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.')
|
||
|
||
try:
|
||
response = requests.post(
|
||
f'https://api.kavenegar.com/v1/{settings.KAVENEGAR_API_KEY}/{method}',
|
||
data=payload,
|
||
# (اتصال، خواندن). وصلنشدن یعنی قطعاً نرفت؛ دیر رسیدنِ جواب یعنی نامعلوم.
|
||
timeout=(3.05, SMS_READ_TIMEOUT_SECONDS),
|
||
)
|
||
except requests.ReadTimeout:
|
||
logger.warning(
|
||
'SMS provider reply timed out after the request was sent; delivery unknown: '
|
||
'phone_suffix=%s method=%s', _phone_suffix(phone), method,
|
||
)
|
||
raise SMSDeliveryUncertain('پاسخِ سرویسِ پیامک دیر رسید.')
|
||
except requests.RequestException:
|
||
logger.exception('SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone))
|
||
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||
|
||
if response.status_code != 200:
|
||
logger.error(
|
||
'SMS provider returned non-200: phone_suffix=%s status=%s',
|
||
_phone_suffix(phone), response.status_code,
|
||
)
|
||
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||
|
||
# کاوهنگار حتی برای شکستهای سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبارِ ناکافی و…)
|
||
# HTTP 200 برمیگردونه؛ نتیجهی واقعی توی body.return.status هست. فقط
|
||
# اون فیلد موفقیت رو تعیین میکنه، نه status_code.
|
||
try:
|
||
body = response.json()
|
||
provider_status = body['return']['status']
|
||
except (ValueError, KeyError, TypeError):
|
||
logger.error(
|
||
'SMS provider returned malformed response body: phone_suffix=%s status=%s',
|
||
_phone_suffix(phone), response.status_code,
|
||
)
|
||
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||
|
||
if provider_status != 200:
|
||
logger.error(
|
||
'SMS provider rejected message at application level: phone_suffix=%s '
|
||
'provider_status=%s',
|
||
_phone_suffix(phone), provider_status,
|
||
)
|
||
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||
|
||
# پذیرفتهشدن ≠ رسیدن. شناسه و وضعیتِ پیام لاگ میشود تا اگر کسی گفت «کد
|
||
# نیامد»، همین شناسه در پنلِ کاوهنگار (گزارشِ ارسال) دنبال شود — مثلاً
|
||
# شمارهای که پیامکِ تبلیغاتی را مسدود کرده. متنِ پیام لاگ نمیشود.
|
||
entries = body.get('entries') if isinstance(body, dict) else None
|
||
first = entries[0] if isinstance(entries, list) and entries and isinstance(entries[0], dict) else {}
|
||
logger.info(
|
||
'SMS accepted by provider: phone_suffix=%s messageid=%s message_status=%s',
|
||
_phone_suffix(phone), first.get('messageid'), first.get('status'),
|
||
)
|
||
|
||
|
||
def send_otp_sms(*, phone: str, code: str) -> None:
|
||
"""
|
||
ارسالِ پیامکِ OTP. منطقِ مخصوصِ OTP (dev-mode short-circuit، تمپلیتِ
|
||
پیامک از OTP_SMS_TEMPLATE، و نگاشتِ خطا به OTPDeliveryError) اینجا
|
||
میمونه؛ ارتباطِ واقعی با ارائهدهنده به send_sms عمومی سپرده شده.
|
||
|
||
- DEBUG=True (dev/test): دقیقاً مثل قبل، فقط لاگ میکنه و هیچ پیامکِ
|
||
واقعیای نمیفرسته — چون در این محیط، RequestOTPView خودِ کد رو در
|
||
فیلدِ devOtp پاسخ برمیگردونه و SMS واقعی لازم نیست (رفتار قبلی dev
|
||
حفظ شده).
|
||
- DEBUG=False (production): اگر KAVENEGAR_OTP_TEMPLATE تنظیم باشد با الگو
|
||
(send_verify_lookup)، وگرنه متنِ آزادِ OTP_SMS_TEMPLATE با send_sms؛ هر SMSDeliveryError
|
||
به OTPDeliveryError نگاشت میشه تا رفتار/نوعِ استثنایی که
|
||
RequestOTPView انتظارش رو داره حفظ بمونه (کاربر پاسخ ۵۰۳ صریح
|
||
میگیره، نه یک success دروغین).
|
||
"""
|
||
if settings.DEBUG:
|
||
logger.info(
|
||
'OTP sms dispatch (dev, no real SMS sent): phone_suffix=%s code_len=%d',
|
||
_phone_suffix(phone), len(code),
|
||
)
|
||
return
|
||
|
||
try:
|
||
if settings.KAVENEGAR_OTP_TEMPLATE:
|
||
send_verify_lookup(phone=phone, template=settings.KAVENEGAR_OTP_TEMPLATE, token=code)
|
||
else:
|
||
send_sms(phone=phone, message=settings.OTP_SMS_TEMPLATE.format(code=code))
|
||
except SMSDeliveryUncertain:
|
||
# کد نگه داشته میشود و پاسخ موفق است: اگر پیامک رسیده، همان کد کار میکند؛
|
||
# اگر نرسیده، کاربر بعد از cooldown کدِ تازه میخواهد. برعکسش (۵۰۳ و پاک کردنِ
|
||
# کد) کاربری را که پیامک گرفته قفل میکرد. cooldown جلوی دو پیامک را میگیرد.
|
||
return
|
||
except SMSDeliveryError as exc:
|
||
raise OTPDeliveryError(str(exc)) from exc
|
||
|
||
|
||
# Targeting helpers (internal, read-only): کوئریهای قابلاستفادهی مجدد برای
|
||
# انتخابِ زیرمجموعهای از کاربران (مثلاً برای کمپینهای پیامی)؛ فقط SELECT،
|
||
# بدونِ ارسالِ پیام. ارجاع به subscription.Subscription با import محلی و
|
||
# user_id انجام میشه چون accounts.User هیچوقت FK واقعی به اپهای دیگه نمیزنه.
|
||
|
||
def get_all_users():
|
||
"""همهی کاربران (مهمان و ثبتنامشده)."""
|
||
return User.objects.all()
|
||
|
||
|
||
def get_users_by_grade(grade_id: int):
|
||
"""کاربرانی که پایهی (grade_id) مشخصی دارن."""
|
||
return User.objects.filter(grade_id=grade_id)
|
||
|
||
|
||
def get_inactive_users(since):
|
||
"""
|
||
کاربرانی که از زمانِ `since` به بعد فعالیتی (لاگین/رفرشتوکن/سشنِ
|
||
مهمانِ واقعی) نداشتن — یعنی last_activity_at قبل از `since` بوده یا
|
||
اصلاً هیچوقت ثبت نشده (last_activity_at is null، مثلِ کاربرهایی که
|
||
این فیلد قبل از افزودهشدنش ساخته شدن یا هیچوقت فعالیتِ ثبتشدهای
|
||
نداشتن).
|
||
|
||
`since` باید یک datetime (تایمزوندار، همراستا با USE_TZ پروژه)
|
||
باشه؛ کالر مسئولِ محاسبهی cutoff (مثلاً timezone.now() - timedelta(...))
|
||
هست — این تابع خودش هیچ عددِ روزِ hardcodeشدهای نداره.
|
||
"""
|
||
return User.objects.filter(models.Q(last_activity_at__lt=since) | models.Q(last_activity_at__isnull=True))
|
||
|
||
|
||
def get_users_with_active_subscription():
|
||
"""
|
||
کاربرانی که اشتراکِ فعال دارن. «فعال» یعنی هم status='active' باشه و
|
||
هم منقضی نشده باشه (expires_at خالیه یعنی نامحدود، یا هنوز نرسیده).
|
||
فقط user_id از subscription.Subscription خونده میشه (بدونِ FK واقعی،
|
||
طبقِ همون قراردادِ accounts.User) و برای فیلترِ accounts.User استفاده
|
||
میشه.
|
||
"""
|
||
from subscription.models import Subscription
|
||
|
||
active_user_ids = Subscription.objects.filter(
|
||
status='active',
|
||
).filter(
|
||
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now())
|
||
).values_list('user_id', flat=True)
|
||
|
||
return User.objects.filter(id__in=active_user_ids)
|