Add campaigns/ Add supervisor/ Add messaging features/ Separate SMS

This commit is contained in:
2026-08-25 03:39:41 +03:30
parent 80298b47b1
commit a9200772a3
27 changed files with 2337 additions and 49 deletions

View File

@@ -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)