""" منطق کمکیِ اپ 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 برمی‌گردونه؛ نتیجه‌ی واقعی توی # بدنه‌ی JSON، زیرِ return.status هست (200 یعنی موفق). صرفِ # status_code == 200 «موفقیت» در نظر گرفته نمی‌شه؛ بدنه parse می‌شه و # فقط return.status == 200 موفقیت محسوب می‌شه؛ هر پاسخِ ناقص/غیرمنتظره # هم شکستِ تحویل در نظر گرفته می‌شه (نه موفقیت). 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('ارسال پیامک با خطا مواجه شد.') 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) # # هدف: صرفاً کوئری‌های قابل‌استفاده‌ی مجدد برای انتخاب زیرمجموعه‌ای از # کاربران، تا فیچرهای آینده (مثلاً کمپین‌های پیامی) بدونِ تکرارِ منطقِ # فیلتر، از همین توابع استفاده کنن. این ماژول: # - هیچ پیامی نمی‌فرسته (نه SMS، نه notification). # - هیچ جدول/مایگریشنِ جدیدی اضافه نمی‌کنه. # - هیچ background job/endpoint‌ای نداره؛ فقط QuerySetِ User برمی‌گردونه. # - هیچ رفتارِ موجودی رو تغییر نمی‌ده (فقط SELECT). # # ارجاع به subscription.Subscription عمداً با import محلی (داخلِ تابع) و # بر اساسِ user_id انجام می‌شه — دقیقاً همون الگویی که payment/services.py # و discount/services.py برای صداکردنِ subscription استفاده می‌کنن؛ چون # accounts.User (طبقِ docstringِ خودش) هیچ‌وقت 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)