Files
MathApp/accounts/services.py
Zareian 0580aec7b0 harden OTP for real SMS and close the second audit round
Kavenegar is being switched on, so every OTP request now costs credit:
- phone numbers are normalised to 09XXXXXXXXX everywhere (login, OTP,
  family invite, TEST_LOGIN_PHONES keys) and existing rows are migrated;
  non-Iranian and landline numbers are rejected before any SMS
- cooldown and a daily per-phone cap are counted in the database, so the
  three gunicorn workers no longer triple them
- a global daily OTP SMS cap (default 300) stops SMS pumping
- per-IP rates widened for CGNAT / school Wi-Fi (env-overridable)
- provider message id is logged; SMS text warns not to share the code

Second audit round: an offer no longer shortens a longer paid plan,
Zarinpal error bodies no longer 500, island XP reaches the parent report,
a non-numeric questionId no longer 500s after the answer is generated.

596 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:28:06 +03:30

215 lines
11 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
منطق کمکیِ اپ 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 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).
"""
if not settings.KAVENEGAR_API_KEY:
logger.error(
'SMS misconfigured: KAVENEGAR_API_KEY is not set (phone_suffix=%s)',
_phone_suffix(phone),
)
raise SMSDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.')
payload = {'receptor': phone, 'message': message}
if settings.KAVENEGAR_SENDER:
payload['sender'] = settings.KAVENEGAR_SENDER
try:
response = requests.post(
f'https://api.kavenegar.com/v1/{settings.KAVENEGAR_API_KEY}/sms/send.json',
data=payload,
timeout=5,
)
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): 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
message = settings.OTP_SMS_TEMPLATE.format(code=code)
try:
send_sms(phone=phone, message=message)
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)