bale group for orgs, and sms for each org

This commit is contained in:
javad committed 2026-09-13 20:47:47 +03:30
1 parent a18a68578f
commit 1ee83374fd
13 files changed
+262 -20

No files matched your search

-1
View File
@@ -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
-1
View File
@@ -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
+18
View File
@@ -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
View File
@@ -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="شماره همراه سرپرست سوم"),
),
]
+51
View File
@@ -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."""
+6 -3
View File
@@ -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:
-1
View File
@@ -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"
-1
View File
@@ -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}
+3 -2
View File
@@ -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
View File
@@ -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