""" منطق کمکیِ اپ 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}, ) # کاوه‌نگار گاهی بیش از ۵ ثانیه طول می‌کشد (۵٫۴ ثانیه در ۲۰۲۶-۰۹-۲۷) و شبکه‌ی ایران # کند می‌شود؛ صاحبِ پروژه ۳۰ ثانیه خواست. جمعِ دو مهلت (۴۰ ثانیه) باید زیرِ مهلتِ # فرانت برای همین درخواست بماند (`requestOtp` در MathApp_frontend، ۴۵ ثانیه)، وگرنه # کاربر «ارتباط برقرار نشد» می‌بیند در حالی که پیامک رسیده. gunicorn ۱۲۰ ثانیه و # gthread است؛ مهلتِ nginx و ArvanCloud برای این مسیر سنجیده نشده. SMS_CONNECT_TIMEOUT_SECONDS = 10 SMS_READ_TIMEOUT_SECONDS = 30 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=(SMS_CONNECT_TIMEOUT_SECONDS, 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)