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
@@ -60,6 +60,15 @@ def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1,
|
||||
|
||||
def _send_sms(job):
|
||||
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":
|
||||
issued, url = _magic_link(job, "customer_created_ticket")
|
||||
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
|
||||
|
||||
def _send_bale(job):
|
||||
unit_chat_id = (getattr(job.ticket.owning_unit, "bale_chat_id", "") or "").strip()
|
||||
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"]:
|
||||
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")
|
||||
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")
|
||||
profile = job.staff_user.staff_profile
|
||||
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):
|
||||
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():
|
||||
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):
|
||||
"""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()
|
||||
if not policy.is_active: return None,False
|
||||
due=timezone.now()+timedelta(seconds=policy.quiet_period_seconds)
|
||||
|
||||
+7
-2
@@ -13,12 +13,17 @@ class UnitMembershipInline(admin.TabularInline):
|
||||
|
||||
@admin.register(OrganizationalUnit)
|
||||
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")
|
||||
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")
|
||||
filter_horizontal = ("permissions",)
|
||||
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)
|
||||
|
||||
@@ -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.contrib.auth.models import Permission
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel
|
||||
|
||||
@@ -26,6 +27,15 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
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:
|
||||
ordering = ["sort_order", "name", "pk"]
|
||||
@@ -35,6 +45,47 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
def __str__(self):
|
||||
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):
|
||||
"""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.messaging.models import Message,MessageMention
|
||||
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 .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)
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
elif ticket.status != Ticket.Status.NEW: ticket.status=Ticket.Status.WAITING_STAFF
|
||||
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:
|
||||
schedule_customer_reply_bale(ticket,msg)
|
||||
else:
|
||||
|
||||
Reference in new issue
Block a user