This commit is contained in:
2026-08-24 15:54:08 +03:30
commit 236002a87c
133 changed files with 5946 additions and 0 deletions
View File
+47
View File
@@ -0,0 +1,47 @@
from rest_framework import serializers
from apps.messaging.models import BulkMessage, Channel, MessageRecipient
class SendBulkMessageSerializer(serializers.Serializer):
group_id = serializers.CharField(required=False, allow_null=True, default=None)
contact_ids = serializers.ListField(child=serializers.CharField(), required=False, default=list)
template_id = serializers.CharField(required=False, allow_null=True, default=None)
body_override = serializers.CharField(required=False, allow_blank=True, default="")
channels = serializers.ListField(child=serializers.ChoiceField(choices=Channel.choices), min_length=1)
def validate(self, attrs):
if not attrs.get("template_id") and not attrs.get("body_override"):
raise serializers.ValidationError("یا template_id بده یا body_override.")
return attrs
class MessageRecipientSerializer(serializers.ModelSerializer):
contact_name = serializers.CharField(source="contact.name", read_only=True)
contact_phone = serializers.CharField(source="contact.phone", read_only=True)
class Meta:
model = MessageRecipient
fields = [
"id",
"contact",
"contact_name",
"contact_phone",
"channel",
"status",
"rendered_body",
"error",
]
class BulkMessageSerializer(serializers.ModelSerializer):
recipients = MessageRecipientSerializer(many=True, read_only=True)
needs_manual = serializers.SerializerMethodField()
class Meta:
model = BulkMessage
fields = ["id", "group", "template", "channels", "status", "created_at", "recipients", "needs_manual"]
def get_needs_manual(self, obj: BulkMessage):
pending = obj.recipients.filter(status=MessageRecipient.Status.NEEDS_MANUAL)
return MessageRecipientSerializer(pending, many=True).data
+166
View File
@@ -0,0 +1,166 @@
import re
from django.db import transaction
from apps.account.models import User
from apps.banker.models import Contact, ContactGroup, MessageTemplate
from apps.messaging.models import BulkMessage, Channel, MessageRecipient
from apps.messaging.providers.bale import send_bale
from apps.messaging.providers.eitaa import send_eitaa
from apps.messaging.providers.sms import send_bulk_sms
from apps.messaging.providers.telegram import send_telegram
from apps.messaging.providers.whatsapp import send_whatsapp
_FIELD_PATTERN = re.compile(r"\{(.*?)\}")
_BOT_CHANNEL_SENDERS = {
Channel.TELEGRAM: send_telegram,
Channel.BALE: send_bale,
Channel.EITAA: send_eitaa,
}
class TemplateRenderer:
@staticmethod
def field_values(contact: Contact) -> dict[str, str]:
"""برچسب فیلد -> مقدار، برای جایگزینی {برچسب} در متن قالب.
فیلدهای پایه همیشه هستند؛ فیلدهای سفارشی از گروه(های)ی می‌آیند که
این مخاطب عضوشان است (ContactGroup.member_ids شامل id این مخاطب).
"""
values = {"نام": contact.name, "شماره": contact.phone}
# فیلتر JSON-level (member_ids__contains) روی SQLite اصلا قابل‌کامپایل
# نیست (فقط PostgreSQL پشتیبانی می‌کند) — چون تعداد گروه‌های هر کاربر
# کم است، اینجا فیلتر را سمت پایتون انجام می‌دهیم تا روی هر دو دیتابیس
# کار کند.
groups = [
g
for g in ContactGroup.objects.filter(user_id=contact.user_id)
if contact.id in g.member_ids
]
for group in groups:
for field in group.fields.all():
value = contact.field_values.get(field.id)
if value:
values[field.label] = value
return values
@staticmethod
def render(contact: Contact, template: MessageTemplate | None, body_override: str) -> str:
body = template.body if template else body_override
values = TemplateRenderer.field_values(contact)
return _FIELD_PATTERN.sub(lambda m: values.get(m.group(1), m.group(0)), body)
class BulkMessageService:
@staticmethod
def _resolve_contacts(user: User, group_id: str | None, contact_ids: list[str] | None) -> list[Contact]:
if contact_ids:
return list(Contact.objects.filter(user=user, id__in=contact_ids))
if group_id:
group = ContactGroup.objects.get(user=user, id=group_id)
return list(Contact.objects.filter(user=user, id__in=group.member_ids))
return list(Contact.objects.filter(user=user))
@staticmethod
@transaction.atomic
def create_and_dispatch(
user: User,
channels: list[str],
group_id: str | None = None,
contact_ids: list[str] | None = None,
template_id: str | None = None,
body_override: str = "",
) -> BulkMessage:
template = MessageTemplate.objects.filter(user=user, id=template_id).first() if template_id else None
contacts = BulkMessageService._resolve_contacts(user, group_id, contact_ids)
bulk = BulkMessage.objects.create(
user=user,
group_id=group_id or None,
template=template,
body_override=body_override,
channels=channels,
)
rendered_by_contact = {c.id: TemplateRenderer.render(c, template, body_override) for c in contacts}
if Channel.SMS in channels:
BulkMessageService._dispatch_sms(bulk, contacts, rendered_by_contact)
for channel in (Channel.TELEGRAM, Channel.BALE, Channel.EITAA):
if channel in channels:
BulkMessageService._dispatch_bot_channel(bulk, contacts, rendered_by_contact, channel)
if Channel.WHATSAPP in channels:
BulkMessageService._dispatch_whatsapp(bulk, contacts, rendered_by_contact)
has_manual = bulk.recipients.filter(status=MessageRecipient.Status.NEEDS_MANUAL).exists()
bulk.status = BulkMessage.Status.PARTIALLY_MANUAL if has_manual else BulkMessage.Status.DISPATCHED
bulk.save(update_fields=["status"])
return bulk
@staticmethod
def _dispatch_sms(bulk: BulkMessage, contacts: list[Contact], rendered_by_contact: dict) -> None:
pairs = [(c.phone, rendered_by_contact[c.id]) for c in contacts]
results = send_bulk_sms(pairs)
MessageRecipient.objects.bulk_create(
MessageRecipient(
bulk_message=bulk,
contact=c,
channel=Channel.SMS,
status=MessageRecipient.Status.SENT if results.get(c.phone) else MessageRecipient.Status.FAILED,
rendered_body=rendered_by_contact[c.id],
)
for c in contacts
)
@staticmethod
def _dispatch_bot_channel(
bulk: BulkMessage, contacts: list[Contact], rendered_by_contact: dict, channel: str
) -> None:
sender = _BOT_CHANNEL_SENDERS[channel]
recipients = []
for c in contacts:
chat_id = c.channel_ids.get(channel)
body = rendered_by_contact[c.id]
if not chat_id:
status = MessageRecipient.Status.NEEDS_MANUAL
else:
status = MessageRecipient.Status.SENT if sender(chat_id, body) else MessageRecipient.Status.FAILED
recipients.append(
MessageRecipient(
bulk_message=bulk, contact=c, channel=channel, status=status, rendered_body=body
)
)
MessageRecipient.objects.bulk_create(recipients)
@staticmethod
def _dispatch_whatsapp(bulk: BulkMessage, contacts: list[Contact], rendered_by_contact: dict) -> None:
from django.conf import settings
recipients = []
configured = bool(settings.WHATSAPP_ACCESS_TOKEN and settings.WHATSAPP_PHONE_NUMBER_ID)
for c in contacts:
body = rendered_by_contact[c.id]
if not configured:
status = MessageRecipient.Status.NEEDS_MANUAL
else:
status = (
MessageRecipient.Status.SENT
if send_whatsapp(c.phone, body)
else MessageRecipient.Status.FAILED
)
recipients.append(
MessageRecipient(
bulk_message=bulk, contact=c, channel=Channel.WHATSAPP, status=status, rendered_body=body
)
)
MessageRecipient.objects.bulk_create(recipients)
@staticmethod
def confirm_manual(user: User, bulk_id: int, recipient_id: int) -> MessageRecipient:
recipient = MessageRecipient.objects.get(
id=recipient_id, bulk_message_id=bulk_id, bulk_message__user=user
)
recipient.status = MessageRecipient.Status.MANUAL_CONFIRMED
recipient.save(update_fields=["status"])
return recipient
+92
View File
@@ -0,0 +1,92 @@
from django.db.models import Count
from rest_framework import status
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.generics import RetrieveAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
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 .services import BulkMessageService
class SendBulkMessageView(APIView):
"""POST: می‌سازد و بلافاصله dispatch می‌کند (پیامک/کانال‌های Business-API-دار
خودکار، بقیه needs_manual برای صف deep-link سمت اپ)."""
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
throttle_scope = "messaging_send"
def post(self, request):
serializer = SendBulkMessageSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
if data["group_id"] and not ContactGroup.objects.filter(user=request.user, id=data["group_id"]).exists():
raise ValidationError({"group_id": "گروه یافت نشد."})
bulk = BulkMessageService.create_and_dispatch(
user=request.user,
channels=data["channels"],
group_id=data["group_id"],
contact_ids=data["contact_ids"],
template_id=data["template_id"],
body_override=data["body_override"],
)
return Response(BulkMessageSerializer(bulk).data, status=status.HTTP_201_CREATED)
class BulkMessageDetailView(RetrieveAPIView):
serializer_class = BulkMessageSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return BulkMessage.objects.filter(user=self.request.user)
class MessagingStatsView(APIView):
"""آمار ارسال: چند پیام موفق به گیت‌وی/API فرستاده شده، چند تا ناموفق
بوده، چند تا نیاز به تایید دستی داشته/دارد — بر اساس MessageRecipient
(نه رسید تحویل واقعی گوشی، چون آن نیاز به webhook از سرویس‌دهنده‌ی
پیامک دارد که در این نسخه وصل نشده؛ «ارسال‌شده» یعنی سرور با موفقیت آن
را به API/گیت‌وی تحویل داده)."""
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def get(self, request):
rows = (
MessageRecipient.objects.filter(bulk_message__user=request.user)
.values("status")
.annotate(count=Count("id"))
)
counts = {row["status"]: row["count"] for row in rows}
total = sum(counts.values())
return Response(
{
"total": total,
"sent": counts.get(MessageRecipient.Status.SENT, 0),
"failed": counts.get(MessageRecipient.Status.FAILED, 0),
"needs_manual": counts.get(MessageRecipient.Status.NEEDS_MANUAL, 0),
"manual_confirmed": counts.get(MessageRecipient.Status.MANUAL_CONFIRMED, 0),
}
)
class ConfirmManualSendView(APIView):
"""اپ بعد از این‌که کاربر دستی در واتساپ/تلگرام/بله/ایتا زد «ارسال»، این را صدا می‌زند."""
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "sms_bank"
def post(self, request, bulk_id, recipient_id):
try:
recipient = BulkMessageService.confirm_manual(request.user, bulk_id, recipient_id)
except MessageRecipient.DoesNotExist:
raise NotFound("گیرنده یافت نشد.")
return Response({"success": True, "status": recipient.status})