This commit is contained in:
2026-09-04 13:05:54 +03:30
parent 463b812e3e
commit 17082a5ea9
43 changed files with 5663 additions and 285 deletions
+15
View File
@@ -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",
]
+38 -1
View File
@@ -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)
+54
View File
@@ -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')],
},
),
]
+58
View File
@@ -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))
+2
View File
@@ -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"),
]