bale group for orgs, and sms for each org
This commit is contained in:
1 parent
a18a68578f
commit
1ee83374fd
13 files changed
+262
-20
No files matched your search
@@ -39,7 +39,6 @@ SMS_API_TOKEN=replace-me
|
|||||||
SMS_SENDER=
|
SMS_SENDER=
|
||||||
BALE_API_BASE=https://tapi.bale.ai
|
BALE_API_BASE=https://tapi.bale.ai
|
||||||
BALE_BOT_TOKEN=
|
BALE_BOT_TOKEN=
|
||||||
BALE_GROUP_CHAT_ID=5924558401
|
|
||||||
INTEGRATION_HTTP_TIMEOUT_SECONDS=10
|
INTEGRATION_HTTP_TIMEOUT_SECONDS=10
|
||||||
MAX_NOTIFICATION_ATTEMPTS=5
|
MAX_NOTIFICATION_ATTEMPTS=5
|
||||||
SECURE_SSL_REDIRECT=true
|
SECURE_SSL_REDIRECT=true
|
||||||
|
|||||||
@@ -8,5 +8,4 @@ DEV_OTP_CODE=12345
|
|||||||
SMS_PROVIDER_CLASS=apps.integrations.sms.console.ConsoleSMSProvider
|
SMS_PROVIDER_CLASS=apps.integrations.sms.console.ConsoleSMSProvider
|
||||||
BALE_API_BASE=https://tapi.bale.ai
|
BALE_API_BASE=https://tapi.bale.ai
|
||||||
BALE_BOT_TOKEN=
|
BALE_BOT_TOKEN=
|
||||||
BALE_GROUP_CHAT_ID=5924558401
|
|
||||||
INTEGRATION_HTTP_TIMEOUT_SECONDS=10
|
INTEGRATION_HTTP_TIMEOUT_SECONDS=10
|
||||||
@@ -60,6 +60,15 @@ def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1,
|
|||||||
|
|
||||||
def _send_sms(job):
|
def _send_sms(job):
|
||||||
issued = None
|
issued = None
|
||||||
|
if job.event_type == "UNIT_SUPERVISOR_NEW_TICKET":
|
||||||
|
recipient = job.payload.get("recipient_mobile", "")
|
||||||
|
if not recipient:
|
||||||
|
raise ValueError("Unit supervisor mobile is not configured")
|
||||||
|
subject = " ".join(job.ticket.subject.split())[:80] or "بدون موضوع"
|
||||||
|
category = " ".join(job.ticket.category.title.split())[:50]
|
||||||
|
customer_mobile = job.ticket.customer.local_mobile or "ثبت نشده"
|
||||||
|
message = f"تیکت جدید {job.ticket.ticket_number}\nتلفن: {customer_mobile}\nموضوع: {subject}\nدسته: {category}"
|
||||||
|
return get_sms_provider().send_message(recipient, message), issued
|
||||||
if job.event_type == "CUSTOMER_TICKET_CREATED":
|
if job.event_type == "CUSTOMER_TICKET_CREATED":
|
||||||
issued, url = _magic_link(job, "customer_created_ticket")
|
issued, url = _magic_link(job, "customer_created_ticket")
|
||||||
message = _render_sms(job.event_type,_sms_context(job,url),job.ticket.owning_unit)
|
message = _render_sms(job.event_type,_sms_context(job,url),job.ticket.owning_unit)
|
||||||
@@ -72,12 +81,13 @@ def _send_sms(job):
|
|||||||
return get_sms_provider().send_message(job.customer.mobile, message), issued
|
return get_sms_provider().send_message(job.customer.mobile, message), issued
|
||||||
|
|
||||||
def _send_bale(job):
|
def _send_bale(job):
|
||||||
|
unit_chat_id = (getattr(job.ticket.owning_unit, "bale_chat_id", "") or "").strip()
|
||||||
if job.event_type == "NEW_CUSTOMER_TICKET":
|
if job.event_type == "NEW_CUSTOMER_TICKET":
|
||||||
return get_bale_provider().send_message(settings.BALE_GROUP_CHAT_ID, _bale_ticket_text(job.ticket))
|
return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket)) if unit_chat_id else None
|
||||||
if job.event_type in ["CUSTOMER_TICKET_REPLY","CUSTOMER_TICKET_REOPENED"]:
|
if job.event_type in ["CUSTOMER_TICKET_REPLY","CUSTOMER_TICKET_REOPENED"]:
|
||||||
message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first()
|
message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first()
|
||||||
if not message: raise ValueError("Customer reply message no longer exists")
|
if not message: raise ValueError("Customer reply message no longer exists")
|
||||||
return get_bale_provider().send_message(settings.BALE_GROUP_CHAT_ID, _bale_ticket_text(job.ticket,message,is_reply=True,message_count=job.payload.get("message_count",1),reopened=job.event_type=="CUSTOMER_TICKET_REOPENED"))
|
return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket,message,is_reply=True,message_count=job.payload.get("message_count",1),reopened=job.event_type=="CUSTOMER_TICKET_REOPENED")) if unit_chat_id else None
|
||||||
if not job.staff_user or not hasattr(job.staff_user, "staff_profile"): raise ValueError("Bale recipient is not configured")
|
if not job.staff_user or not hasattr(job.staff_user, "staff_profile"): raise ValueError("Bale recipient is not configured")
|
||||||
profile = job.staff_user.staff_profile
|
profile = job.staff_user.staff_profile
|
||||||
if not profile.bale_notifications_enabled: return None
|
if not profile.bale_notifications_enabled: return None
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ from .models import NotificationJob,NotificationPolicy
|
|||||||
def enqueue(channel,event_type,ticket,dedupe_key,payload=None,customer=None,staff_user=None):
|
def enqueue(channel,event_type,ticket,dedupe_key,payload=None,customer=None,staff_user=None):
|
||||||
return NotificationJob.objects.get_or_create(dedupe_key=dedupe_key,defaults={"channel":channel,"event_type":event_type,"ticket":ticket,"payload":payload or {},"customer":customer,"staff_user":staff_user,"next_attempt_at":timezone.now()})[0]
|
return NotificationJob.objects.get_or_create(dedupe_key=dedupe_key,defaults={"channel":channel,"event_type":event_type,"ticket":ticket,"payload":payload or {},"customer":customer,"staff_user":staff_user,"next_attempt_at":timezone.now()})[0]
|
||||||
|
|
||||||
|
def enqueue_unit_supervisor_ticket_sms(ticket):
|
||||||
|
"""Queue one concise new-ticket SMS for each configured unit supervisor."""
|
||||||
|
unit = ticket.owning_unit
|
||||||
|
if not unit:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
enqueue(
|
||||||
|
NotificationJob.Channel.SMS,
|
||||||
|
"UNIT_SUPERVISOR_NEW_TICKET",
|
||||||
|
ticket,
|
||||||
|
f"ticket:{ticket.pk}:unit-supervisor:{mobile}",
|
||||||
|
payload={"recipient_mobile": mobile},
|
||||||
|
)
|
||||||
|
for mobile in unit.supervisor_mobiles
|
||||||
|
]
|
||||||
|
|
||||||
def staff_reply_policy():
|
def staff_reply_policy():
|
||||||
return NotificationPolicy.objects.get_or_create(key=NotificationPolicy.Key.STAFF_REPLY_SMS,defaults={"quiet_period_seconds":300})[0]
|
return NotificationPolicy.objects.get_or_create(key=NotificationPolicy.Key.STAFF_REPLY_SMS,defaults={"quiet_period_seconds":300})[0]
|
||||||
|
|
||||||
@@ -40,6 +56,8 @@ def cancel_staff_reply_sms_if_read(ticket,customer):
|
|||||||
|
|
||||||
def schedule_customer_reply_bale(ticket,message):
|
def schedule_customer_reply_bale(ticket,message):
|
||||||
"""Debounce consecutive customer chat messages into one Bale group alert."""
|
"""Debounce consecutive customer chat messages into one Bale group alert."""
|
||||||
|
if not ticket.owning_unit_id or not ticket.owning_unit.bale_chat_id:
|
||||||
|
return None,False
|
||||||
policy=customer_reply_bale_policy()
|
policy=customer_reply_bale_policy()
|
||||||
if not policy.is_active: return None,False
|
if not policy.is_active: return None,False
|
||||||
due=timezone.now()+timedelta(seconds=policy.quiet_period_seconds)
|
due=timezone.now()+timedelta(seconds=policy.quiet_period_seconds)
|
||||||
|
|||||||
+7
-2
@@ -13,12 +13,17 @@ class UnitMembershipInline(admin.TabularInline):
|
|||||||
|
|
||||||
@admin.register(OrganizationalUnit)
|
@admin.register(OrganizationalUnit)
|
||||||
class OrganizationalUnitAdmin(admin.ModelAdmin):
|
class OrganizationalUnitAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "slug", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at")
|
list_display = ("name", "slug", "bale_chat_id", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at")
|
||||||
list_filter = ("show_in_customer_form", "accepts_external_tickets", "is_active")
|
list_filter = ("show_in_customer_form", "accepts_external_tickets", "is_active")
|
||||||
search_fields = ("name", "slug")
|
search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3")
|
||||||
list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active")
|
list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active")
|
||||||
filter_horizontal = ("permissions",)
|
filter_horizontal = ("permissions",)
|
||||||
inlines = (UnitMembershipInline,)
|
inlines = (UnitMembershipInline,)
|
||||||
|
fieldsets = (
|
||||||
|
("مشخصات واحد", {"fields": ("name", "slug", "description", "search_keywords", "sort_order")}),
|
||||||
|
("اطلاعرسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3")}),
|
||||||
|
("نمایش و دسترسی", {"fields": ("permissions", "show_in_customer_form", "accepts_external_tickets", "is_active")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(UnitMembership)
|
@admin.register(UnitMembership)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2026-09-13
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("staff", "0009_alter_staffprofile_sort_order"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="organizationalunit",
|
||||||
|
name="bale_chat_id",
|
||||||
|
field=models.CharField(blank=True, help_text="شناسه chat_id گروه بله این واحد؛ در صورت خالی بودن، اعلان گروهی بله برای این واحد ارسال نمیشود.", max_length=100, verbose_name="شناسه گروه بله"),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="organizationalunit",
|
||||||
|
name="supervisor_mobile_1",
|
||||||
|
field=models.CharField(blank=True, max_length=16, verbose_name="شماره همراه سرپرست اول"),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="organizationalunit",
|
||||||
|
name="supervisor_mobile_2",
|
||||||
|
field=models.CharField(blank=True, max_length=16, verbose_name="شماره همراه سرپرست دوم"),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="organizationalunit",
|
||||||
|
name="supervisor_mobile_3",
|
||||||
|
field=models.CharField(blank=True, max_length=16, verbose_name="شماره همراه سرپرست سوم"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel
|
from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel
|
||||||
|
|
||||||
@@ -26,6 +27,15 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
|
|||||||
help_text="اگر غیرفعال باشد، مشتریان نمیتوانند مستقیماً برای این واحد تیکت ثبت کنند.",
|
help_text="اگر غیرفعال باشد، مشتریان نمیتوانند مستقیماً برای این واحد تیکت ثبت کنند.",
|
||||||
)
|
)
|
||||||
show_in_customer_form = models.BooleanField(default=True, verbose_name="نمایش در فرم مشتری", help_text="در صورت خاموش بودن، واحد در فرم ثبت تیکت و جستجوی مشتری نمایش داده نمیشود.")
|
show_in_customer_form = models.BooleanField(default=True, verbose_name="نمایش در فرم مشتری", help_text="در صورت خاموش بودن، واحد در فرم ثبت تیکت و جستجوی مشتری نمایش داده نمیشود.")
|
||||||
|
bale_chat_id = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="شناسه گروه بله",
|
||||||
|
help_text="شناسه chat_id گروه بله این واحد؛ در صورت خالی بودن، اعلان گروهی بله برای این واحد ارسال نمیشود.",
|
||||||
|
)
|
||||||
|
supervisor_mobile_1 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست اول")
|
||||||
|
supervisor_mobile_2 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست دوم")
|
||||||
|
supervisor_mobile_3 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست سوم")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["sort_order", "name", "pk"]
|
ordering = ["sort_order", "name", "pk"]
|
||||||
@@ -35,6 +45,47 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supervisor_mobiles(self):
|
||||||
|
"""Return configured supervisor mobiles once, in their configured order."""
|
||||||
|
return tuple(dict.fromkeys(filter(None, (
|
||||||
|
self.supervisor_mobile_1,
|
||||||
|
self.supervisor_mobile_2,
|
||||||
|
self.supervisor_mobile_3,
|
||||||
|
))))
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
from apps.accounts.models import normalize_mobile
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
errors = {}
|
||||||
|
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3"):
|
||||||
|
value = getattr(self, field_name, "").strip()
|
||||||
|
if not value:
|
||||||
|
setattr(self, field_name, "")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
value = normalize_mobile(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
errors[field_name] = str(exc)
|
||||||
|
continue
|
||||||
|
setattr(self, field_name, value)
|
||||||
|
if value in normalized:
|
||||||
|
errors[field_name] = "این شماره همراه قبلاً برای همین واحد وارد شده است."
|
||||||
|
normalized.append(value)
|
||||||
|
if errors:
|
||||||
|
raise ValidationError(errors)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
from apps.accounts.models import normalize_mobile
|
||||||
|
|
||||||
|
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3"):
|
||||||
|
value = getattr(self, field_name, "").strip()
|
||||||
|
setattr(self, field_name, normalize_mobile(value) if value else "")
|
||||||
|
self.bale_chat_id = self.bale_chat_id.strip()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class UnitMembership(TimeStampedModel):
|
class UnitMembership(TimeStampedModel):
|
||||||
"""Dynamic per-user unit membership with optional extra permissions."""
|
"""Dynamic per-user unit membership with optional extra permissions."""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from apps.attachments.services import save_attachments
|
|||||||
from apps.core.persian_text import normalize_persian_text
|
from apps.core.persian_text import normalize_persian_text
|
||||||
from apps.messaging.models import Message,MessageMention
|
from apps.messaging.models import Message,MessageMention
|
||||||
from apps.notifications.models import NotificationJob
|
from apps.notifications.models import NotificationJob
|
||||||
from apps.notifications.services import cancel_staff_reply_sms,enqueue,schedule_customer_reply_bale,schedule_staff_reply_sms
|
from apps.notifications.services import cancel_staff_reply_sms,enqueue,enqueue_unit_supervisor_ticket_sms,schedule_customer_reply_bale,schedule_staff_reply_sms
|
||||||
from .models import GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketEvent
|
from .models import GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketEvent
|
||||||
from .policies import DispatchPolicy, TicketPolicy
|
from .policies import DispatchPolicy, TicketPolicy
|
||||||
|
|
||||||
@@ -39,7 +39,9 @@ def create_ticket(*,customer,category,subject,selected_expert=None,body="",files
|
|||||||
enqueue(NotificationJob.Channel.SMS,"STAFF_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:staff-created",customer=customer)
|
enqueue(NotificationJob.Channel.SMS,"STAFF_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:staff-created",customer=customer)
|
||||||
else:
|
else:
|
||||||
enqueue(NotificationJob.Channel.SMS,"CUSTOMER_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:customer-created",customer=customer)
|
enqueue(NotificationJob.Channel.SMS,"CUSTOMER_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:customer-created",customer=customer)
|
||||||
enqueue(NotificationJob.Channel.BALE,"NEW_CUSTOMER_TICKET",ticket,f"ticket:{ticket.pk}:bale-group",payload={"ticket_number":ticket.ticket_number})
|
if owning_unit and owning_unit.bale_chat_id:
|
||||||
|
enqueue(NotificationJob.Channel.BALE,"NEW_CUSTOMER_TICKET",ticket,f"ticket:{ticket.pk}:bale-group",payload={"ticket_number":ticket.ticket_number})
|
||||||
|
enqueue_unit_supervisor_ticket_sms(ticket)
|
||||||
return ticket
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
@@ -163,7 +165,8 @@ def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_user
|
|||||||
if reopened: ticket.status=Ticket.Status.REOPENED; ticket.reopened_at=now; TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.REOPENED,actor=user)
|
if reopened: ticket.status=Ticket.Status.REOPENED; ticket.reopened_at=now; TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.REOPENED,actor=user)
|
||||||
elif ticket.status != Ticket.Status.NEW: ticket.status=Ticket.Status.WAITING_STAFF
|
elif ticket.status != Ticket.Status.NEW: ticket.status=Ticket.Status.WAITING_STAFF
|
||||||
if reopened:
|
if reopened:
|
||||||
enqueue(NotificationJob.Channel.BALE,"CUSTOMER_TICKET_REOPENED",ticket,f"message:{msg.pk}:bale-reopened",customer=ticket.customer,payload={"message_id":msg.pk,"message_count":1})
|
if ticket.owning_unit_id and ticket.owning_unit.bale_chat_id:
|
||||||
|
enqueue(NotificationJob.Channel.BALE,"CUSTOMER_TICKET_REOPENED",ticket,f"message:{msg.pk}:bale-reopened",customer=ticket.customer,payload={"message_id":msg.pk,"message_count":1})
|
||||||
else:
|
else:
|
||||||
schedule_customer_reply_bale(ticket,msg)
|
schedule_customer_reply_bale(ticket,msg)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ SMS0098_API_URL = os.getenv("SMS0098_API_URL", "http://www.0098sms.com/sendsmsli
|
|||||||
SMS_TEST_ALLOWED_MOBILES = ("+989202202129", "+989384999655")
|
SMS_TEST_ALLOWED_MOBILES = ("+989202202129", "+989384999655")
|
||||||
BALE_API_BASE = os.getenv("BALE_API_BASE", "https://tapi.bale.ai")
|
BALE_API_BASE = os.getenv("BALE_API_BASE", "https://tapi.bale.ai")
|
||||||
BALE_BOT_TOKEN = os.getenv("BALE_BOT_TOKEN", "")
|
BALE_BOT_TOKEN = os.getenv("BALE_BOT_TOKEN", "")
|
||||||
BALE_GROUP_CHAT_ID = os.getenv("BALE_GROUP_CHAT_ID", "")
|
|
||||||
INTEGRATION_HTTP_TIMEOUT_SECONDS = int(os.getenv("INTEGRATION_HTTP_TIMEOUT_SECONDS", "10"))
|
INTEGRATION_HTTP_TIMEOUT_SECONDS = int(os.getenv("INTEGRATION_HTTP_TIMEOUT_SECONDS", "10"))
|
||||||
STAFF_REPLY_SMS_THROTTLE_SECONDS = int(os.getenv("STAFF_REPLY_SMS_THROTTLE_SECONDS", "180"))
|
STAFF_REPLY_SMS_THROTTLE_SECONDS = int(os.getenv("STAFF_REPLY_SMS_THROTTLE_SECONDS", "180"))
|
||||||
X_FRAME_OPTIONS = "DENY"
|
X_FRAME_OPTIONS = "DENY"
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ x-django-service: &django-service
|
|||||||
SMS_SENDER: ${SMS_SENDER:-}
|
SMS_SENDER: ${SMS_SENDER:-}
|
||||||
BALE_API_BASE: ${BALE_API_BASE:-https://tapi.bale.ai}
|
BALE_API_BASE: ${BALE_API_BASE:-https://tapi.bale.ai}
|
||||||
BALE_BOT_TOKEN: ${BALE_BOT_TOKEN:-}
|
BALE_BOT_TOKEN: ${BALE_BOT_TOKEN:-}
|
||||||
BALE_GROUP_CHAT_ID: ${BALE_GROUP_CHAT_ID:-}
|
|
||||||
INTEGRATION_HTTP_TIMEOUT_SECONDS: ${INTEGRATION_HTTP_TIMEOUT_SECONDS:-10}
|
INTEGRATION_HTTP_TIMEOUT_SECONDS: ${INTEGRATION_HTTP_TIMEOUT_SECONDS:-10}
|
||||||
MAX_NOTIFICATION_ATTEMPTS: ${MAX_NOTIFICATION_ATTEMPTS:-5}
|
MAX_NOTIFICATION_ATTEMPTS: ${MAX_NOTIFICATION_ATTEMPTS:-5}
|
||||||
SECURE_SSL_REDIRECT: ${SECURE_SSL_REDIRECT:-true}
|
SECURE_SSL_REDIRECT: ${SECURE_SSL_REDIRECT:-true}
|
||||||
|
|||||||
@@ -107,12 +107,13 @@ SMS_SENDER=<sender>
|
|||||||
```dotenv
|
```dotenv
|
||||||
BALE_API_BASE=https://tapi.bale.ai
|
BALE_API_BASE=https://tapi.bale.ai
|
||||||
BALE_BOT_TOKEN=<bot-token>
|
BALE_BOT_TOKEN=<bot-token>
|
||||||
BALE_GROUP_CHAT_ID=<group-chat-id>
|
|
||||||
INTEGRATION_HTTP_TIMEOUT_SECONDS=10
|
INTEGRATION_HTTP_TIMEOUT_SECONDS=10
|
||||||
MAX_NOTIFICATION_ATTEMPTS=5
|
MAX_NOTIFICATION_ATTEMPTS=5
|
||||||
```
|
```
|
||||||
|
|
||||||
در production نبودن `BALE_BOT_TOKEN` خطای قابلمشاهده ایجاد میکند و job را بیصدا SENT علامت نمیزند. worker هر پنج ثانیه outbox دیتابیس را بررسی میکند، با قفل `skip_locked` از پردازش همزمان جلوگیری میکند و خطاها را با backoff تا سقف `MAX_NOTIFICATION_ATTEMPTS` دوباره امتحان میکند.
|
در production نبودن `BALE_BOT_TOKEN` خطای قابلمشاهده ایجاد میکند و job را بیصدا SENT علامت نمیزند. شناسه گروه دیگر یک env سراسری نیست: برای هر واحد از مسیر `x5admin → واحدهای سازمانی`، فیلد «شناسه گروه بله» را جداگانه وارد کنید. خالی بودن این فیلد یعنی اعلانهای گروهی تیکت جدید و پاسخ مشتری برای همان واحد ارسال نمیشود؛ سامانه هرگز به گروه واحد دیگر fallback نمیکند. سه شماره اختیاری سرپرست نیز در همین صفحه تنظیم میشوند و فقط هنگام ایجاد تیکت جدید، پیامک کوتاه دریافت میکنند.
|
||||||
|
|
||||||
|
worker هر پنج ثانیه outbox دیتابیس را بررسی میکند، با قفل `skip_locked` از پردازش همزمان جلوگیری میکند و خطاها را با backoff تا سقف `MAX_NOTIFICATION_ATTEMPTS` دوباره امتحان میکند.
|
||||||
|
|
||||||
سیاست تأخیر پیامک پاسخ کارشناس و اعلان پاسخ مشتری در Bale از مدل `NotificationPolicy` در پنل مدیریت دیتابیس کنترل میشود؛ متغیر قدیمی `STAFF_REPLY_SMS_THROTTLE_SECONDS` مرجع اصلی این رفتار نیست.
|
سیاست تأخیر پیامک پاسخ کارشناس و اعلان پاسخ مشتری در Bale از مدل `NotificationPolicy` در پنل مدیریت دیتابیس کنترل میشود؛ متغیر قدیمی `STAFF_REPLY_SMS_THROTTLE_SECONDS` مرجع اصلی این رفتار نیست.
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ def test_e2e_02_contact_group_picker_excludes_other_units(live_server, browser_p
|
|||||||
with browser_page() as page:
|
with browser_page() as page:
|
||||||
login_staff(page, live_server, users["kimiya_ghaffari"])
|
login_staff(page, live_server, users["kimiya_ghaffari"])
|
||||||
page.goto(f"{live_server.url}{reverse('staff_tickets:contact_create')}", wait_until="domcontentloaded")
|
page.goto(f"{live_server.url}{reverse('staff_tickets:contact_create')}", wait_until="domcontentloaded")
|
||||||
options = page.locator('select[name="groups"] option')
|
options = page.locator('[data-contact-group-selector] label')
|
||||||
expect(options.filter(has_text="مخاطبان فروش")).to_have_count(1)
|
expect(options.filter(has_text="مخاطبان فروش")).to_have_count(1)
|
||||||
expect(options.filter(has_text="بارگیری انبار")).to_have_count(0)
|
expect(options.filter(has_text="بارگیری انبار")).to_have_count(0)
|
||||||
expect(options.filter(has_text="مالی / حسابداری")).to_have_count(0)
|
expect(options.filter(has_text="مالی / حسابداری")).to_have_count(0)
|
||||||
@@ -96,7 +96,7 @@ def test_e2e_02_contact_group_picker_excludes_other_units(live_server, browser_p
|
|||||||
page.locator('input[name="first_name"]').fill("مخاطب")
|
page.locator('input[name="first_name"]').fill("مخاطب")
|
||||||
page.locator('input[name="last_name"]').fill("فروش")
|
page.locator('input[name="last_name"]').fill("فروش")
|
||||||
page.locator('select[name="stakeholder_type"]').select_option(CustomerProfile.StakeholderType.CUSTOMER)
|
page.locator('select[name="stakeholder_type"]').select_option(CustomerProfile.StakeholderType.CUSTOMER)
|
||||||
page.locator('select[name="groups"]').select_option(str(sales_group.pk))
|
page.locator(f'input[name="groups"][value="{sales_group.pk}"]').check()
|
||||||
with page.expect_navigation(wait_until="domcontentloaded"):
|
with page.expect_navigation(wait_until="domcontentloaded"):
|
||||||
page.get_by_role("button", name="ذخیره مخاطب").click()
|
page.get_by_role("button", name="ذخیره مخاطب").click()
|
||||||
expect(page.locator("body")).to_contain_text("مخاطب فروش")
|
expect(page.locator("body")).to_contain_text("مخاطب فروش")
|
||||||
|
|||||||
+130
-5
@@ -2,7 +2,7 @@ import pytest
|
|||||||
import base64
|
import base64
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
@@ -16,7 +16,7 @@ from apps.tickets.services import claim_ticket, create_ticket, delete_ticket, re
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def category(db):
|
def category(db):
|
||||||
unit = OrganizationalUnit.objects.create(name="واحد تست دامنه", slug="domain-test-unit")
|
unit = OrganizationalUnit.objects.create(name="واحد تست دامنه", slug="domain-test-unit", bale_chat_id="domain-bale-group")
|
||||||
unit.permissions.add(
|
unit.permissions.add(
|
||||||
Permission.objects.get(codename="view_ticket", content_type__app_label="tickets"),
|
Permission.objects.get(codename="view_ticket", content_type__app_label="tickets"),
|
||||||
Permission.objects.get(codename="add_message", content_type__app_label="messaging"),
|
Permission.objects.get(codename="add_message", content_type__app_label="messaging"),
|
||||||
@@ -50,9 +50,8 @@ def test_customer_reply_enqueues_bale_group_notification(category, customer):
|
|||||||
assert job.next_attempt_at>=timezone.now()+timedelta(seconds=595)
|
assert job.next_attempt_at>=timezone.now()+timedelta(seconds=595)
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_customer_ticket_and_reply_bale_events_always_target_group(category, customer, settings, monkeypatch):
|
def test_customer_ticket_and_reply_bale_events_target_owning_unit_group(category, customer, monkeypatch):
|
||||||
from apps.notifications.management.commands.process_notifications import _send_bale
|
from apps.notifications.management.commands.process_notifications import _send_bale
|
||||||
settings.BALE_GROUP_CHAT_ID = "group-123"
|
|
||||||
sent = []
|
sent = []
|
||||||
class Provider:
|
class Provider:
|
||||||
def send_message(self, chat_id, text):
|
def send_message(self, chat_id, text):
|
||||||
@@ -68,7 +67,7 @@ def test_customer_ticket_and_reply_bale_events_always_target_group(category, cus
|
|||||||
reply = reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ مشتری")
|
reply = reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ مشتری")
|
||||||
reply_job = NotificationJob.objects.get(ticket=ticket, event_type="CUSTOMER_TICKET_REPLY")
|
reply_job = NotificationJob.objects.get(ticket=ticket, event_type="CUSTOMER_TICKET_REPLY")
|
||||||
_send_bale(reply_job)
|
_send_bale(reply_job)
|
||||||
assert [chat_id for chat_id, _ in sent] == ["group-123", "group-123"]
|
assert [chat_id for chat_id, _ in sent] == ["domain-bale-group", "domain-bale-group"]
|
||||||
assert "تیکت جدید مشتری" in sent[0][1]
|
assert "تیکت جدید مشتری" in sent[0][1]
|
||||||
assert "پاسخ جدید مشتری" in sent[1][1]
|
assert "پاسخ جدید مشتری" in sent[1][1]
|
||||||
assert reply.body in sent[1][1]
|
assert reply.body in sent[1][1]
|
||||||
@@ -79,6 +78,132 @@ def test_customer_ticket_and_reply_bale_events_always_target_group(category, cus
|
|||||||
assert "🎫" in text and "📌 اعلان بله" in text and "📝" in text
|
assert "🎫" in text and "📌 اعلان بله" in text and "📝" in text
|
||||||
assert "📍 تهران" in sent[0][1]
|
assert "📍 تهران" in sent[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_bale_group_notifications_are_isolated_between_units(category, customer, monkeypatch):
|
||||||
|
from apps.notifications.management.commands.process_notifications import _send_bale
|
||||||
|
|
||||||
|
second_unit = OrganizationalUnit.objects.create(
|
||||||
|
name="واحد دوم اعلان بله",
|
||||||
|
slug="second-bale-unit",
|
||||||
|
bale_chat_id="second-bale-group",
|
||||||
|
)
|
||||||
|
second_category = TicketCategory.objects.create(unit=second_unit, title="حسابداری", slug="second-bale-category")
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
class Provider:
|
||||||
|
def send_message(self, chat_id, text):
|
||||||
|
sent.append((chat_id, text))
|
||||||
|
return type("Result", (), {"success": True})()
|
||||||
|
|
||||||
|
monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_bale_provider", lambda: Provider())
|
||||||
|
first_ticket = create_ticket(customer=customer, category=category, subject="درخواست فروش", body="پیام فروش")
|
||||||
|
second_ticket = create_ticket(customer=customer, category=second_category, subject="درخواست مالی", body="پیام مالی")
|
||||||
|
first_reply = reply_ticket(ticket_id=first_ticket.pk, user=customer, body="پاسخ فروش")
|
||||||
|
second_reply = reply_ticket(ticket_id=second_ticket.pk, user=customer, body="پاسخ مالی")
|
||||||
|
|
||||||
|
for ticket, event_type in (
|
||||||
|
(first_ticket, "NEW_CUSTOMER_TICKET"),
|
||||||
|
(second_ticket, "NEW_CUSTOMER_TICKET"),
|
||||||
|
(first_ticket, "CUSTOMER_TICKET_REPLY"),
|
||||||
|
(second_ticket, "CUSTOMER_TICKET_REPLY"),
|
||||||
|
):
|
||||||
|
_send_bale(NotificationJob.objects.get(ticket=ticket, event_type=event_type))
|
||||||
|
|
||||||
|
assert [chat_id for chat_id, _ in sent] == [
|
||||||
|
"domain-bale-group",
|
||||||
|
"second-bale-group",
|
||||||
|
"domain-bale-group",
|
||||||
|
"second-bale-group",
|
||||||
|
]
|
||||||
|
assert first_reply.body in sent[2][1]
|
||||||
|
assert second_reply.body in sent[3][1]
|
||||||
|
assert "درخواست مالی" not in sent[0][1] and "درخواست فروش" not in sent[1][1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_blank_unit_bale_chat_never_falls_back_to_global_group(category, customer, settings):
|
||||||
|
settings.BALE_GROUP_CHAT_ID = "legacy-global-group"
|
||||||
|
category.unit.bale_chat_id = ""
|
||||||
|
category.unit.save(update_fields=["bale_chat_id", "updated_at"])
|
||||||
|
|
||||||
|
ticket = create_ticket(customer=customer, category=category, subject="بدون گروه", body="پیام")
|
||||||
|
reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ")
|
||||||
|
|
||||||
|
assert not NotificationJob.objects.filter(ticket=ticket, channel=NotificationJob.Channel.BALE).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_unit_notification_destinations_normalize_and_reject_duplicate_supervisors():
|
||||||
|
unit = OrganizationalUnit(
|
||||||
|
name="واحد مقصد اعلان",
|
||||||
|
slug="notification-destinations",
|
||||||
|
supervisor_mobile_1="0912 111 2233",
|
||||||
|
supervisor_mobile_2="0935 222 3344",
|
||||||
|
)
|
||||||
|
unit.full_clean()
|
||||||
|
unit.save()
|
||||||
|
assert unit.supervisor_mobiles == ("+989121112233", "+989352223344")
|
||||||
|
|
||||||
|
unit.supervisor_mobile_3 = "09121112233"
|
||||||
|
with pytest.raises(ValidationError, match="قبلاً"):
|
||||||
|
unit.full_clean()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_new_ticket_sms_is_queued_for_each_unit_supervisor_only_on_creation(category, customer, monkeypatch):
|
||||||
|
from apps.notifications.management.commands.process_notifications import _send_sms
|
||||||
|
|
||||||
|
unit = category.unit
|
||||||
|
unit.supervisor_mobile_1 = "09121112233"
|
||||||
|
unit.supervisor_mobile_2 = "09352223344"
|
||||||
|
unit.supervisor_mobile_3 = "09903334455"
|
||||||
|
unit.save()
|
||||||
|
ticket = create_ticket(customer=customer, category=category, subject=" پیگیری بارگیری ", body="متن")
|
||||||
|
jobs = list(NotificationJob.objects.filter(ticket=ticket, event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("id"))
|
||||||
|
assert [job.payload["recipient_mobile"] for job in jobs] == list(unit.supervisor_mobiles)
|
||||||
|
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
class Provider:
|
||||||
|
def send_message(self, mobile, message):
|
||||||
|
sent.append((mobile, message))
|
||||||
|
return type("Result", (), {"success": True})()
|
||||||
|
|
||||||
|
monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_sms_provider", lambda: Provider())
|
||||||
|
for job in jobs:
|
||||||
|
result, issued_link = _send_sms(job)
|
||||||
|
assert result.success and issued_link is None
|
||||||
|
|
||||||
|
assert [mobile for mobile, _ in sent] == list(unit.supervisor_mobiles)
|
||||||
|
for _, message in sent:
|
||||||
|
assert customer.local_mobile in message
|
||||||
|
assert "موضوع: پیگیری بارگیری" in message
|
||||||
|
assert f"دسته: {category.title}" in message
|
||||||
|
|
||||||
|
supervisor_job_count = NotificationJob.objects.filter(event_type="UNIT_SUPERVISOR_NEW_TICKET").count()
|
||||||
|
reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ جدید")
|
||||||
|
assert NotificationJob.objects.filter(event_type="UNIT_SUPERVISOR_NEW_TICKET").count() == supervisor_job_count
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_staff_created_ticket_also_notifies_owning_unit_supervisors(category, customer):
|
||||||
|
category.unit.supervisor_mobile_1 = "09124445566"
|
||||||
|
category.unit.save()
|
||||||
|
staff = make_staff("a")
|
||||||
|
|
||||||
|
ticket = create_ticket(
|
||||||
|
customer=customer,
|
||||||
|
created_by=staff,
|
||||||
|
unit=category.unit,
|
||||||
|
category=category,
|
||||||
|
subject="ثبت توسط کارشناس",
|
||||||
|
body="متن",
|
||||||
|
)
|
||||||
|
|
||||||
|
job = NotificationJob.objects.get(ticket=ticket, event_type="UNIT_SUPERVISOR_NEW_TICKET")
|
||||||
|
assert job.payload == {"recipient_mobile": "+989124445566"}
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_customer_bale_replies_are_debounced_by_database_policy(category,customer):
|
def test_customer_bale_replies_are_debounced_by_database_policy(category,customer):
|
||||||
from apps.notifications.models import NotificationPolicy
|
from apps.notifications.models import NotificationPolicy
|
||||||
|
|||||||
Reference in new issue
Block a user