Add campaigns/ Add supervisor/ Add messaging features/ Separate SMS
This commit is contained in:
@@ -5,8 +5,12 @@ 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__)
|
||||
|
||||
|
||||
@@ -26,7 +30,19 @@ def _phone_suffix(phone: str) -> str:
|
||||
return phone[-4:] if phone else phone
|
||||
|
||||
|
||||
class OTPDeliveryError(APIException):
|
||||
class SMSDeliveryError(APIException):
|
||||
"""
|
||||
خطای عمومیِ ارسالِ پیامک، برای هر فراخوانیای از send_sms (نه فقط OTP).
|
||||
اگه پیکربندیِ سرویسِ پیامک ناقص باشه یا خودِ درخواست به ارائهدهنده
|
||||
شکست بخوره، همین استثنا raise میشه (نه یک پاسخِ ساختگیِ موفق).
|
||||
"""
|
||||
|
||||
status_code = 503
|
||||
default_detail = 'در حال حاضر امکان ارسال پیامک وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.'
|
||||
default_code = 'sms_delivery_failed'
|
||||
|
||||
|
||||
class OTPDeliveryError(SMSDeliveryError):
|
||||
"""
|
||||
⚠️ اصلاح: قبلاً send_otp_sms یک استاب بود که فقط لاگ میکرد و همیشه
|
||||
(بیقیدوشرط) به RequestOTPView «موفقیت» برمیگردوند؛ یعنی در پروداکشن
|
||||
@@ -36,44 +52,37 @@ class OTPDeliveryError(APIException):
|
||||
پیکربندیِ سرویسِ پیامک ناقص باشه یا خودِ درخواست به ارائهدهنده شکست
|
||||
بخوره، همین استثنا raise میشه و RequestOTPView یک خطای صریح (نه یک
|
||||
پاسخِ ساختگیِ موفق) برمیگردونه.
|
||||
|
||||
این کلاس زیرکلاسِ SMSDeliveryError است تا رفتار/نوعِ استثنایی که
|
||||
RequestOTPView (و تستها) انتظارش رو دارن دقیقاً حفظ بشه، در حالی که
|
||||
منطقِ واقعیِ ارتباط با ارائهدهنده در send_sms (عمومی) پیاده شده.
|
||||
"""
|
||||
|
||||
status_code = 503
|
||||
default_detail = 'در حال حاضر امکان ارسال پیامک OTP وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.'
|
||||
default_code = 'otp_sms_delivery_failed'
|
||||
|
||||
|
||||
def send_otp_sms(*, phone: str, code: str) -> None:
|
||||
def send_sms(*, phone: str, message: str) -> None:
|
||||
"""
|
||||
ارسالِ واقعیِ پیامکِ OTP از طریق کاوهنگار (kavenegar.com) — دقیقاً همون
|
||||
ارائهدهندهای که در نسخهی قبلی (استاب) بهعنوان نمونه اشاره شده بود.
|
||||
کلید API هیچوقت داخل کد نیست؛ همیشه از env خونده میشه
|
||||
(settings.KAVENEGAR_API_KEY). دو حالت:
|
||||
⚙️ Reusable هلپر: تنها بخشِ «ارتباط با ارائهدهنده» (کاوهنگار،
|
||||
kavenegar.com) که قبلاً داخلِ send_otp_sms با منطقِ OTP قاطی شده بود،
|
||||
به این تابعِ عمومی منتقل شده — بدونِ هیچ تغییری در رفتار/پیامهای
|
||||
provider. هر فیچرِ آینده (مثلاً notifications) هم میتونه بدونِ
|
||||
تکرارِ کدِ ارائهدهنده، مستقیماً از همین تابع برای ارسالِ پیامک استفاده
|
||||
کنه.
|
||||
|
||||
- DEBUG=True (dev/test): دقیقاً مثل قبل، فقط لاگ میکنه و هیچ پیامکِ
|
||||
واقعیای نمیفرسته — چون در این محیط، RequestOTPView خودِ کد رو در
|
||||
فیلدِ devOtp پاسخ برمیگردونه و SMS واقعی لازم نیست (رفتار قبلی dev
|
||||
حفظ شده).
|
||||
- DEBUG=False (production): اگه KAVENEGAR_API_KEY تنظیم نشده باشه، یا
|
||||
درخواست به کاوهنگار fail بشه/کدِ غیر ۲xx برگردونه، بهجای گزارشِ
|
||||
خاموشِ «موفقیت»، OTPDeliveryError raise میشه (کاربر پاسخ ۵۰۳ صریح
|
||||
میگیره، نه یک success دروغین).
|
||||
این تابع هیچ منطقِ مخصوصِ OTP (dev-mode short-circuit، تمپلیتِ پیامک،
|
||||
یا OTPDeliveryError) نداره؛ فقط payload میسازه، به کاوهنگار درخواست
|
||||
میزنه، و پاسخ رو اعتبارسنجی میکنه. کلیدِ API هیچوقت داخل کد نیست؛
|
||||
همیشه از env خونده میشه (settings.KAVENEGAR_API_KEY).
|
||||
"""
|
||||
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
|
||||
|
||||
if not settings.KAVENEGAR_API_KEY:
|
||||
logger.error(
|
||||
'OTP SMS misconfigured: KAVENEGAR_API_KEY is not set (phone_suffix=%s)',
|
||||
'SMS misconfigured: KAVENEGAR_API_KEY is not set (phone_suffix=%s)',
|
||||
_phone_suffix(phone),
|
||||
)
|
||||
raise OTPDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.')
|
||||
raise SMSDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.')
|
||||
|
||||
message = settings.OTP_SMS_TEMPLATE.format(code=code)
|
||||
payload = {'receptor': phone, 'message': message}
|
||||
if settings.KAVENEGAR_SENDER:
|
||||
payload['sender'] = settings.KAVENEGAR_SENDER
|
||||
@@ -85,37 +94,127 @@ def send_otp_sms(*, phone: str, code: str) -> None:
|
||||
timeout=5,
|
||||
)
|
||||
except requests.RequestException:
|
||||
logger.exception('OTP SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone))
|
||||
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')
|
||||
logger.exception('SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone))
|
||||
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
'OTP SMS provider returned non-200: phone_suffix=%s status=%s',
|
||||
'SMS provider returned non-200: phone_suffix=%s status=%s',
|
||||
_phone_suffix(phone), response.status_code,
|
||||
)
|
||||
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')
|
||||
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||||
|
||||
# ⚠️ اصلاح: کاوهنگار حتی برای شکستهای سطحِ اپلیکیشن (کلیدِ نامعتبر،
|
||||
# اعتبار ناکافی، گیرندهی نامعتبر و…) HTTP 200 برمیگردونه؛ نتیجهی
|
||||
# واقعی توی بدنهی JSON، زیرِ return.status هست (200 یعنی موفق). قبلاً
|
||||
# همینِ status_code == 200 بهتنهایی «موفقیت» در نظر گرفته میشد، یعنی
|
||||
# یک رد شدنِ واقعی از طرفِ کاوهنگار هم success=True دروغین برمیگردوند.
|
||||
# حالا بدنه parse میشه و فقط return.status == 200 موفقیت محسوب میشه؛
|
||||
# هر پاسخِ ناقص/غیرمنتظره هم شکستِ تحویل در نظر گرفته میشه (نه موفقیت).
|
||||
# ⚠️ کاوهنگار حتی برای شکستهای سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبار
|
||||
# ناکافی، گیرندهی نامعتبر و…) 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(
|
||||
'OTP SMS provider returned malformed response body: phone_suffix=%s status=%s',
|
||||
'SMS provider returned malformed response body: phone_suffix=%s status=%s',
|
||||
_phone_suffix(phone), response.status_code,
|
||||
)
|
||||
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')
|
||||
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||||
|
||||
if provider_status != 200:
|
||||
logger.error(
|
||||
'OTP SMS provider rejected message at application level: phone_suffix=%s '
|
||||
'SMS provider rejected message at application level: phone_suffix=%s '
|
||||
'provider_status=%s',
|
||||
_phone_suffix(phone), provider_status,
|
||||
)
|
||||
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user