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

@@ -6,18 +6,75 @@
از این ماژول import و صدا می‌زنن — دقیقاً همون الگوی call_payment_gateway
در payment/services.py و create_order که سرور-به-سرور از subscription
صدا زده می‌شه.
⚙️ افزوده شد: پارامترِ channel (فقط 'in_app' یا 'sms'، پیش‌فرض 'in_app')
تا notify() علاوه‌بر ثبتِ Notificationِ داخل‌اپ، بتونه مستقیماً پیامک هم
بفرسته. برای sms هیچ منطقِ provider تکرار نشده — دقیقاً همون
accounts.services.send_sms که campaigns.services هم برای ارسالِ کمپینِ
پیامکی ازش استفاده می‌کنه، اینجا هم صدا زده می‌شه. این اپ همچنان به
accounts.models رفرنسی نداره (طبق پاراگرافِ بالا)؛ به همین دلیل شماره‌تلفن
از خودِ notify() استخراج نمی‌شه و caller (که از قبل User رو در اختیار
داره) باید phone رو صریحاً پاس بده.
"""
from accounts.services import send_sms
from .models import Notification
CHANNEL_IN_APP = 'in_app'
CHANNEL_SMS = 'sms'
CHANNEL_CHOICES = {CHANNEL_IN_APP, CHANNEL_SMS}
def notify(*, user_id: int, type: str, title: str, body: str = '', related_id: int | None = None) -> Notification:
def notify(
*,
user_id: int,
type: str,
title: str,
body: str = '',
related_id: int | None = None,
channel: str = CHANNEL_IN_APP,
phone: str | None = None,
) -> Notification | None:
"""
ثبت یک اعلان جدید برای کاربر. نمونه‌ی استفاده از اپ‌های دیگه:
ثبت یک اعلانِ جدید برای کاربر (channel='in_app'، پیش‌فرض و بدونِ هیچ
تغییری در رفتارِ قبلی) یا ارسالِ همون پیام به‌صورتِ پیامک
(channel='sms').
نمونه‌ی استفاده‌ی in_app (بدون تغییر نسبت به قبل):
from notifications.services import notify
notify(user_id=order.user_id, type='order_paid',
title='پرداخت با موفقیت انجام شد', related_id=order.id)
نمونه‌ی استفاده‌ی sms:
notify(user_id=user.id, type='order_paid', title='...',
body='سفارش شما با موفقیت پرداخت شد.',
channel='sms', phone=user.phone)
برای channel='sms':
- phone الزامیه؛ بدونِ اون ValueError می‌ده (نه یک ارسالِ خاموش/ناقص).
- هیچ رکوردِ Notification ساخته نمی‌شه — پیامک مستقیماً از طریقِ
accounts.services.send_sms ارسال می‌شه، پس مقدارِ برگشتی None‌ه.
- متنِ پیامک، body است؛ اگه body خالی باشه از title استفاده می‌شه (تا
caller مجبور به تکرارِ متن در هر دو فیلد نباشه).
- SMSDeliveryError (که send_sms در صورتِ شکستِ ارسال raise می‌کنه)
عمداً اینجا catch نمی‌شه؛ caller باید تصمیم بگیره با شکستِ ارسال چه
کار کنه — دقیقاً هم‌الگو با این‌که send_sms خودش هیچ حالتِ سکوت/
best-effort نداره.
مقدارِ نامعتبر برای channel (نه in_app نه sms) با ValueError رد
می‌شه، نه با یک ارسالِ خاموش/پیش‌فرض.
"""
if channel not in CHANNEL_CHOICES:
raise ValueError(f"Unsupported notification channel: {channel!r}")
if channel == CHANNEL_SMS:
if not phone:
raise ValueError("phone is required when channel='sms'")
send_sms(phone=phone, message=body or title)
return None
return Notification.objects.create(
user_id=user_id,
type=type,

View File

@@ -1 +1,82 @@
# Create your tests here.
from unittest.mock import patch
from django.test import TestCase
from accounts.services import SMSDeliveryError
from .models import Notification
from .services import notify
class NotifyInAppChannelTests(TestCase):
"""
channel='in_app' (پیش‌فرض) باید دقیقاً همون رفتارِ قبلیِ notify() رو
داشته باشه: صرفاً یک Notification.objects.create ساده، بدونِ
فراخوانیِ send_sms.
"""
@patch('notifications.services.send_sms')
def test_default_channel_creates_notification_and_does_not_send_sms(self, mock_send_sms):
result = notify(user_id=1, type='order_paid', title='t', body='b', related_id=5)
self.assertIsInstance(result, Notification)
self.assertEqual(Notification.objects.count(), 1)
notification = Notification.objects.get()
self.assertEqual(notification.user_id, 1)
self.assertEqual(notification.type, 'order_paid')
self.assertEqual(notification.title, 't')
self.assertEqual(notification.body, 'b')
self.assertEqual(notification.related_id, 5)
mock_send_sms.assert_not_called()
@patch('notifications.services.send_sms')
def test_explicit_in_app_channel_same_as_default(self, mock_send_sms):
result = notify(user_id=2, type='xp_earned', title='t', channel='in_app')
self.assertIsInstance(result, Notification)
self.assertEqual(Notification.objects.count(), 1)
mock_send_sms.assert_not_called()
class NotifySmsChannelTests(TestCase):
"""
channel='sms' نباید هیچ Notificationی بسازه؛ فقط باید
accounts.services.send_sms رو صدا بزنه (بدونِ تکرارِ منطقِ provider).
"""
@patch('notifications.services.send_sms')
def test_sms_channel_calls_send_sms_with_body_and_creates_no_notification(self, mock_send_sms):
result = notify(
user_id=3, type='order_paid', title='t', body='پیام پیامکی',
channel='sms', phone='0910000000',
)
mock_send_sms.assert_called_once_with(phone='0910000000', message='پیام پیامکی')
self.assertIsNone(result)
self.assertEqual(Notification.objects.count(), 0)
@patch('notifications.services.send_sms')
def test_sms_channel_falls_back_to_title_when_body_empty(self, mock_send_sms):
notify(user_id=3, type='order_paid', title='fallback-title', channel='sms', phone='0910000000')
mock_send_sms.assert_called_once_with(phone='0910000000', message='fallback-title')
def test_sms_channel_without_phone_raises_value_error(self):
with self.assertRaises(ValueError):
notify(user_id=3, type='order_paid', title='t', channel='sms')
self.assertEqual(Notification.objects.count(), 0)
@patch('notifications.services.send_sms')
def test_sms_delivery_error_propagates_and_creates_no_notification(self, mock_send_sms):
mock_send_sms.side_effect = SMSDeliveryError('boom')
with self.assertRaises(SMSDeliveryError):
notify(user_id=3, type='order_paid', title='t', channel='sms', phone='0910000000')
self.assertEqual(Notification.objects.count(), 0)
class NotifyInvalidChannelTests(TestCase):
def test_unsupported_channel_raises_value_error_and_creates_nothing(self):
with self.assertRaises(ValueError):
notify(user_id=3, type='order_paid', title='t', channel='email')
self.assertEqual(Notification.objects.count(), 0)