adding
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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})
|
||||
Reference in New Issue
Block a user