adding
This commit is contained in:
@@ -45,3 +45,18 @@ class BulkMessageSerializer(serializers.ModelSerializer):
|
||||
def get_needs_manual(self, obj: BulkMessage):
|
||||
pending = obj.recipients.filter(status=MessageRecipient.Status.NEEDS_MANUAL)
|
||||
return MessageRecipientSerializer(pending, many=True).data
|
||||
|
||||
|
||||
class ContactMessageHistorySerializer(serializers.ModelSerializer):
|
||||
"""یک ردیفِ تاریخچه — متنِ واقعیِ فرستادهشده، کانال، وضعیت و زمان."""
|
||||
|
||||
sent_at = serializers.DateTimeField(source="bulk_message.created_at", read_only=True)
|
||||
channel_display = serializers.CharField(source="get_channel_display", read_only=True)
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = MessageRecipient
|
||||
fields = [
|
||||
"id", "channel", "channel_display", "status", "status_display",
|
||||
"rendered_body", "error", "sent_at",
|
||||
]
|
||||
|
||||
@@ -10,7 +10,13 @@ from apps.banker.models import ContactGroup
|
||||
from apps.billing.permissions import HasActiveFeature
|
||||
from apps.messaging.models import BulkMessage, MessageRecipient
|
||||
|
||||
from .serializers import BulkMessageSerializer, SendBulkMessageSerializer
|
||||
from apps.billing.forms import normalize_iran_phone
|
||||
|
||||
from .serializers import (
|
||||
BulkMessageSerializer,
|
||||
ContactMessageHistorySerializer,
|
||||
SendBulkMessageSerializer,
|
||||
)
|
||||
from .services import BulkMessageService
|
||||
|
||||
|
||||
@@ -90,3 +96,34 @@ class ConfirmManualSendView(APIView):
|
||||
except MessageRecipient.DoesNotExist:
|
||||
raise NotFound("گیرنده یافت نشد.")
|
||||
return Response({"success": True, "status": recipient.status})
|
||||
|
||||
|
||||
class ContactMessageHistoryView(APIView):
|
||||
"""تاریخچهی پیامهای فرستادهشده به یک شماره.
|
||||
|
||||
چرا بر اساس *شماره* و نه شناسهی مخاطب: صفحهی «مشتریان» و «مخاطبین»
|
||||
دو موجودیتِ جدا هستند و کاربر انتظار دارد روی پروفایلِ مشتری هم ببیند
|
||||
چه پیامی برایش رفته. شماره تنها چیزی است که این دو را به هم وصل
|
||||
میکند، پس با شمارهی نرمالشده جستجو میکنیم.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
raw = request.query_params.get("phone", "") or ""
|
||||
phone = normalize_iran_phone(raw)
|
||||
if not phone:
|
||||
raise ValidationError({"phone": "شماره لازم است."})
|
||||
|
||||
# ارقامِ ذخیرهشده ممکن است با ۰ یا +۹۸ باشند؛ روی چند شکل تطبیق
|
||||
# میدهیم تا تاریخچه بهخاطر تفاوتِ قالبِ شماره گم نشود.
|
||||
variants = {phone, phone.lstrip("0"), "+98" + phone.lstrip("0"), "98" + phone.lstrip("0")}
|
||||
rows = (
|
||||
MessageRecipient.objects.filter(
|
||||
bulk_message__user=request.user,
|
||||
contact__phone__in=variants,
|
||||
)
|
||||
.select_related("bulk_message")
|
||||
.order_by("-bulk_message__created_at", "-id")[:100]
|
||||
)
|
||||
return Response(ContactMessageHistorySerializer(rows, many=True).data)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 5.1.15 on 2026-09-02 00:19
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('banker', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BulkMessage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('body_override', models.TextField(blank=True, verbose_name='متن آزاد (اگر قالب انتخاب نشده)')),
|
||||
('channels', models.JSONField(default=list, verbose_name='کانال\u200cهای انتخاب\u200cشده')),
|
||||
('status', models.CharField(choices=[('dispatched', 'ارسال\u200cشده'), ('partially_manual', 'بخشی نیاز به تایید دستی')], default='dispatched', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='banker.contactgroup')),
|
||||
('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='banker.messagetemplate')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bulk_messages', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ارسال گروهی',
|
||||
'verbose_name_plural': 'ارسال\u200cهای گروهی',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MessageRecipient',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('channel', models.CharField(choices=[('sms', 'پیامک'), ('whatsapp', 'واتساپ'), ('telegram', 'تلگرام'), ('bale', 'بله'), ('eitaa', 'ایتا')], max_length=10)),
|
||||
('status', models.CharField(choices=[('sent', 'ارسال\u200cشده'), ('failed', 'ناموفق'), ('needs_manual', 'نیاز به تایید دستی'), ('manual_confirmed', 'تایید دستی شد')], max_length=20)),
|
||||
('rendered_body', models.TextField()),
|
||||
('error', models.CharField(blank=True, max_length=255)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('bulk_message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recipients', to='messaging.bulkmessage')),
|
||||
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='banker.contact')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'گیرنده\u200cی پیام',
|
||||
'verbose_name_plural': 'گیرنده\u200cهای پیام',
|
||||
'indexes': [models.Index(fields=['bulk_message', 'status'], name='messaging_m_bulk_me_a7a1eb_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
import uuid
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.account.models import User
|
||||
from apps.banker.models import Contact
|
||||
from apps.messaging.models import BulkMessage, MessageRecipient
|
||||
|
||||
|
||||
class ContactMessageHistoryTests(TestCase):
|
||||
"""تاریخچهی پیامهای یک شماره — چیزی که پروفایلِ مشتری/مخاطب نشان میدهد."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(phone="09120000010")
|
||||
self.other = User.objects.create(phone="09120000011")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
self.url = reverse("messaging:contact-history")
|
||||
|
||||
def _recipient(self, user, phone, body="سلام", status=MessageRecipient.Status.SENT):
|
||||
contact = Contact.objects.create(
|
||||
id=str(uuid.uuid4()), user=user, name="رضایی", phone=phone
|
||||
)
|
||||
bulk = BulkMessage.objects.create(user=user, channels=["sms"])
|
||||
return MessageRecipient.objects.create(
|
||||
bulk_message=bulk, contact=contact, channel="sms",
|
||||
status=status, rendered_body=body,
|
||||
)
|
||||
|
||||
def test_returns_messages_sent_to_that_number(self):
|
||||
self._recipient(self.user, "09121112233", body="سفارشت آماده است")
|
||||
response = self.client.get(self.url, {"phone": "09121112233"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]["rendered_body"], "سفارشت آماده است")
|
||||
self.assertEqual(response.data[0]["channel_display"], "پیامک")
|
||||
|
||||
def test_matches_regardless_of_how_the_number_was_stored(self):
|
||||
"""شماره ممکن است +۹۸… ذخیره شده باشد ولی کاربر ۰۹… جستجو کند."""
|
||||
self._recipient(self.user, "+989121112233")
|
||||
response = self.client.get(self.url, {"phone": "09121112233"})
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
def test_never_leaks_another_users_messages(self):
|
||||
self._recipient(self.other, "09121112233", body="مالِ کاربر دیگر")
|
||||
response = self.client.get(self.url, {"phone": "09121112233"})
|
||||
self.assertEqual(response.data, [])
|
||||
|
||||
def test_missing_phone_is_a_400_not_a_dump_of_everything(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_requires_authentication(self):
|
||||
self.client.force_authenticate(None)
|
||||
response = self.client.get(self.url, {"phone": "09121112233"})
|
||||
self.assertIn(response.status_code, (401, 403))
|
||||
@@ -3,6 +3,7 @@ from django.urls import path
|
||||
from .api.views import (
|
||||
BulkMessageDetailView,
|
||||
ConfirmManualSendView,
|
||||
ContactMessageHistoryView,
|
||||
MessagingStatsView,
|
||||
SendBulkMessageView,
|
||||
)
|
||||
@@ -11,6 +12,7 @@ app_name = "messaging"
|
||||
urlpatterns = [
|
||||
path("send/", SendBulkMessageView.as_view(), name="send"),
|
||||
path("stats/", MessagingStatsView.as_view(), name="stats"),
|
||||
path("history/", ContactMessageHistoryView.as_view(), name="contact-history"),
|
||||
path("<int:pk>/", BulkMessageDetailView.as_view(), name="detail"),
|
||||
path("<int:bulk_id>/confirm/<int:recipient_id>/", ConfirmManualSendView.as_view(), name="confirm"),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user