voip click2calll
This commit is contained in:
1 parent
cb92abbb41
commit
38b1cf06df
18 files changed
+543
-26
No files matched your search
@@ -58,10 +58,10 @@ class StaffProfileInline(admin.StackedInline):
|
||||
|
||||
@admin.register(User)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
fieldsets = UserAdmin.fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type")}),)
|
||||
add_fieldsets = UserAdmin.add_fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type")}),)
|
||||
list_display = ("username", "mobile", "account_type", "is_active", "is_staff")
|
||||
list_filter = UserAdmin.list_filter + ("account_type",)
|
||||
fieldsets = UserAdmin.fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type", "voip_extension", "voip_mobile", "voip_call")}),)
|
||||
add_fieldsets = UserAdmin.add_fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type", "voip_extension", "voip_mobile", "voip_call")}),)
|
||||
list_display = ("username", "mobile", "voip_extension", "voip_mobile", "voip_call", "account_type", "is_active", "is_staff")
|
||||
list_filter = UserAdmin.list_filter + ("account_type", "voip_call")
|
||||
inlines = (StaffProfileInline,)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0005_magiclinkaccesslog_magic_opened_idx_and_more")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="voip_call",
|
||||
field=models.BooleanField(default=True, verbose_name="امکان تماس VOIP"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="voip_extension",
|
||||
field=models.CharField(blank=True, max_length=20, verbose_name="شماره داخلی کارشناس"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="mobile",
|
||||
field=models.CharField(blank=True, max_length=16, null=True, unique=True, verbose_name="شماره موبایل"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def copy_staff_mobile_to_voip(apps, schema_editor):
|
||||
User = apps.get_model("accounts", "User")
|
||||
User.objects.filter(account_type="STAFF", voip_mobile="", mobile__isnull=False).exclude(mobile="").update(
|
||||
voip_mobile=models.F("mobile")
|
||||
)
|
||||
|
||||
|
||||
def clear_copied_voip_mobile(apps, schema_editor):
|
||||
User = apps.get_model("accounts", "User")
|
||||
User.objects.filter(account_type="STAFF", voip_mobile=models.F("mobile")).update(voip_mobile="")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0006_user_voip_call_user_voip_extension_and_more")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="voip_mobile",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="مستقل از شماره حساب مشتری است و میتواند تکراری باشد.",
|
||||
max_length=16,
|
||||
verbose_name="شماره موبایل کارشناس برای VOIP",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="mobile",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
max_length=16,
|
||||
null=True,
|
||||
unique=True,
|
||||
verbose_name="شماره موبایل حساب",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(copy_staff_mobile_to_voip, clear_copied_voip_mobile),
|
||||
]
|
||||
+13
-1
@@ -19,14 +19,26 @@ class User(PublicIdModel, AbstractUser):
|
||||
class AccountType(models.TextChoices):
|
||||
CUSTOMER = "CUSTOMER", "مشتری"
|
||||
STAFF = "STAFF", "کارشناس"
|
||||
mobile = models.CharField(max_length=16, unique=True, null=True, blank=True)
|
||||
mobile = models.CharField("شماره موبایل حساب", max_length=16, unique=True, null=True, blank=True)
|
||||
account_type = models.CharField(max_length=10, choices=AccountType.choices, default=AccountType.CUSTOMER)
|
||||
voip_extension = models.CharField("شماره داخلی کارشناس", max_length=20, blank=True)
|
||||
voip_mobile = models.CharField(
|
||||
"شماره موبایل کارشناس برای VOIP",
|
||||
max_length=16,
|
||||
blank=True,
|
||||
help_text="مستقل از شماره حساب مشتری است و میتواند تکراری باشد.",
|
||||
)
|
||||
voip_call = models.BooleanField("امکان تماس VOIP", default=True)
|
||||
def save(self, *args, **kwargs):
|
||||
if self.mobile: self.mobile = normalize_mobile(self.mobile)
|
||||
if self.voip_mobile: self.voip_mobile = normalize_mobile(self.voip_mobile)
|
||||
super().save(*args, **kwargs)
|
||||
@property
|
||||
def local_mobile(self):
|
||||
return f"0{self.mobile[3:]}" if self.mobile and self.mobile.startswith("+98") else (self.mobile or "")
|
||||
@property
|
||||
def voip_local_mobile(self):
|
||||
return f"0{self.voip_mobile[3:]}" if self.voip_mobile.startswith("+98") else self.voip_mobile
|
||||
|
||||
class OTPChallenge(models.Model):
|
||||
class Purpose(models.TextChoices): LOGIN = "LOGIN", "ورود"
|
||||
|
||||
+13
-1
@@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import PageTitle, ProductionSeedState, SiteBranding
|
||||
from .models import PageTitle, ProductionSeedState, SiteBranding, VoipConfiguration
|
||||
|
||||
|
||||
@admin.register(SiteBranding)
|
||||
@@ -29,6 +29,18 @@ class SiteBrandingAdmin(admin.ModelAdmin):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(VoipConfiguration)
|
||||
class VoipConfigurationAdmin(admin.ModelAdmin):
|
||||
fields = ("voip_call_url", "updated_at")
|
||||
readonly_fields = ("updated_at",)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return not VoipConfiguration.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(PageTitle)
|
||||
class PageTitleAdmin(admin.ModelAdmin):
|
||||
list_display = ("route_name", "title", "is_active", "updated_at")
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("core", "0006_productionseedstate")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="VoipConfiguration",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("voip_call_url", models.URLField(blank=True, help_text="نشانی کامل endpoint تماس، مانند http://127.0.0.1:8000/v1/click2call", verbose_name="VOIP_CALL_URL")),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={"verbose_name": "تنظیمات تماس VOIP", "verbose_name_plural": "تنظیمات تماس VOIP"},
|
||||
),
|
||||
]
|
||||
+32
-9
@@ -31,7 +31,20 @@ class PublicIdModel(models.Model):
|
||||
abstract = True
|
||||
|
||||
|
||||
class SiteBranding(models.Model):
|
||||
class SingletonConfiguration(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
return cls.objects.filter(pk=1).first() or cls()
|
||||
|
||||
|
||||
class SiteBranding(SingletonConfiguration):
|
||||
"""Database-backed copy shared by customer, authentication and staff pages."""
|
||||
site_title = models.CharField(max_length=160, default="گفتوگو با واحد فروش")
|
||||
header_title = models.CharField(max_length=160, default="گفتوگو با واحد فروش")
|
||||
@@ -99,18 +112,28 @@ class SiteBranding(models.Model):
|
||||
verbose_name = "تنظیمات عنوان و برند"
|
||||
verbose_name_plural = "تنظیمات عنوان و برند"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
return cls.objects.filter(pk=1).first() or cls()
|
||||
|
||||
def __str__(self):
|
||||
return self.site_title
|
||||
|
||||
|
||||
class VoipConfiguration(SingletonConfiguration):
|
||||
"""Singleton configuration for the backend click-to-call service."""
|
||||
|
||||
voip_call_url = models.URLField(
|
||||
"VOIP_CALL_URL",
|
||||
blank=True,
|
||||
help_text="نشانی کامل endpoint تماس، مانند http://127.0.0.1:8000/v1/click2call",
|
||||
)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "تنظیمات تماس VOIP"
|
||||
verbose_name_plural = "تنظیمات تماس VOIP"
|
||||
|
||||
def __str__(self):
|
||||
return "تنظیمات تماس VOIP"
|
||||
|
||||
|
||||
class PageTitle(models.Model):
|
||||
"""Editable browser title for a named Django route."""
|
||||
route_name = models.CharField(max_length=160, unique=True, help_text="مانند accounts:login")
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
from apps.core.models import VoipConfiguration
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoipCallResult:
|
||||
success: bool
|
||||
error: str = ""
|
||||
|
||||
|
||||
def place_voip_call(*, agent, customer, method):
|
||||
configuration = VoipConfiguration.load()
|
||||
if not configuration.voip_call_url:
|
||||
return VoipCallResult(False, "VOIP_CALL_URL تنظیم نشده است.")
|
||||
|
||||
payload = {
|
||||
"method": method,
|
||||
"customer_number": customer.local_mobile,
|
||||
"metadata": {
|
||||
"agent_id": str(agent.public_id),
|
||||
"customer_id": str(customer.public_id),
|
||||
},
|
||||
}
|
||||
if method == "ip_phone" and agent.voip_extension:
|
||||
payload["agent_extension"] = agent.voip_extension
|
||||
key_prefix = "internal"
|
||||
elif method == "mobile" and agent.voip_local_mobile:
|
||||
payload["agent_mobile"] = agent.voip_local_mobile
|
||||
key_prefix = "mobile"
|
||||
else:
|
||||
return VoipCallResult(False, "روش تماس انتخابشده در دسترس نیست.")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
configuration.voip_call_url,
|
||||
json=payload,
|
||||
headers={"Idempotency-Key": f"{key_prefix}-{uuid4().hex}"},
|
||||
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
if response.content:
|
||||
body = response.json()
|
||||
if isinstance(body, dict) and body.get("success") is False:
|
||||
return VoipCallResult(False, "سرویس تماس درخواست را نپذیرفت.")
|
||||
return VoipCallResult(True)
|
||||
except (requests.RequestException, ValueError):
|
||||
logger.exception("VOIP click-to-call failed")
|
||||
return VoipCallResult(False, "خطا در ارتباط با سرویس تماس.")
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
app_name="staff_tickets"
|
||||
urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts/<uuid:public_id>/edit/",views.staff_contact_edit,name="contact_edit"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups/<int:group_id>/",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links/<int:link_id>/revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates/<int:template_id>/",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches/<uuid:public_id>/",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches/<uuid:public_id>/retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches/<uuid:public_id>/resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets/<uuid:public_id>/messages/older/",views.staff_messages,name="messages"),path("tickets/<uuid:public_id>/messages/poll/",views.staff_poll,name="poll"),path("tickets/<uuid:public_id>/sms/<int:job_id>/retry/",views.staff_sms_retry,name="sms_retry"),path("tickets/<uuid:public_id>/",views.staff_detail,name="detail"),path("tickets/<uuid:public_id>/claim/",views.staff_claim,name="claim"),path("tickets/<uuid:public_id>/reply/",views.staff_reply,name="reply"),path("tickets/<uuid:public_id>/note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets/<uuid:public_id>/transfer/",views.staff_transfer,name="transfer"),path("tickets/<uuid:public_id>/share/",views.staff_share,name="share"),path("tickets/<uuid:public_id>/revoke/<uuid:user_id>/",views.staff_revoke,name="revoke"),path("tickets/<uuid:public_id>/status/",views.staff_status,name="status"),path("tickets/<uuid:public_id>/priority/",views.staff_priority,name="priority"),path("tickets/<uuid:public_id>/delete/",views.staff_delete,name="delete")]
|
||||
urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts/<uuid:public_id>/edit/",views.staff_contact_edit,name="contact_edit"),path("contacts/<uuid:public_id>/call/",views.staff_contact_call,name="contact_call"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups/<int:group_id>/",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links/<int:link_id>/revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates/<int:template_id>/",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches/<uuid:public_id>/",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches/<uuid:public_id>/retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches/<uuid:public_id>/resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets/<uuid:public_id>/messages/older/",views.staff_messages,name="messages"),path("tickets/<uuid:public_id>/messages/poll/",views.staff_poll,name="poll"),path("tickets/<uuid:public_id>/sms/<int:job_id>/retry/",views.staff_sms_retry,name="sms_retry"),path("tickets/<uuid:public_id>/",views.staff_detail,name="detail"),path("tickets/<uuid:public_id>/claim/",views.staff_claim,name="claim"),path("tickets/<uuid:public_id>/reply/",views.staff_reply,name="reply"),path("tickets/<uuid:public_id>/note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets/<uuid:public_id>/transfer/",views.staff_transfer,name="transfer"),path("tickets/<uuid:public_id>/share/",views.staff_share,name="share"),path("tickets/<uuid:public_id>/revoke/<uuid:user_id>/",views.staff_revoke,name="revoke"),path("tickets/<uuid:public_id>/status/",views.staff_status,name="status"),path("tickets/<uuid:public_id>/priority/",views.staff_priority,name="priority"),path("tickets/<uuid:public_id>/delete/",views.staff_delete,name="delete")]
|
||||
+36
-3
@@ -26,6 +26,7 @@ from apps.messaging.models import Message,MessageReadReceipt
|
||||
from apps.notifications.models import NotificationJob, SMSTemplate
|
||||
from apps.core.iran_locations import location_data
|
||||
from apps.core.persian_text import normalize_persian_text
|
||||
from apps.integrations.voip import place_voip_call
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,ShareForm,StaffContactForm,StaffCreateTicketForm,StaffReplyForm,TicketForm,TransferForm,UnitSMSTemplateForm
|
||||
from .models import GroupDispatch,Ticket,TicketCategory,TicketReadState,TicketTag
|
||||
@@ -450,7 +451,7 @@ def staff_detail(request,public_id):
|
||||
group_dispatch = ticket.group_dispatch
|
||||
can_resolve_dispatch = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch) and (request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, group_dispatch.unit, "tickets.change_ticket")))
|
||||
group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0
|
||||
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"viewer_user":request.user,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
|
||||
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
|
||||
@staff_required
|
||||
@require_POST
|
||||
def staff_sms_retry(request,public_id,job_id):
|
||||
@@ -750,6 +751,38 @@ def _contact_queryset(user):
|
||||
).distinct()
|
||||
|
||||
|
||||
def _voip_call_methods(user):
|
||||
if not user.voip_call:
|
||||
return ()
|
||||
methods = []
|
||||
if user.voip_extension:
|
||||
methods.append("ip_phone")
|
||||
if user.voip_mobile:
|
||||
methods.append("mobile")
|
||||
return tuple(methods)
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_POST
|
||||
def staff_contact_call(request, public_id):
|
||||
profile = get_object_or_404(_contact_queryset(request.user), user__public_id=public_id)
|
||||
if not request.user.voip_call:
|
||||
raise PermissionDenied
|
||||
try:
|
||||
data = json.loads(request.body or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({"success": False, "message": "درخواست تماس معتبر نیست."}, status=400)
|
||||
method = data.get("method")
|
||||
if method not in _voip_call_methods(request.user):
|
||||
return JsonResponse({"success": False, "message": "روش تماس در دسترس نیست."}, status=400)
|
||||
if not profile.user.mobile:
|
||||
return JsonResponse({"success": False, "message": "شماره مخاطب ثبت نشده است."}, status=400)
|
||||
result = place_voip_call(agent=request.user, customer=profile.user, method=method)
|
||||
if not result.success:
|
||||
return JsonResponse({"success": False, "message": "برقراری تماس ناموفق بود."}, status=502)
|
||||
return JsonResponse({"success": True, "message": "درخواست تماس ارسال شد."})
|
||||
|
||||
|
||||
def _has_any_unit_permission(user, permission):
|
||||
return user.is_superuser or any(
|
||||
TicketPolicy.has_unit_permission(user, membership.unit, permission)
|
||||
@@ -782,7 +815,7 @@ def staff_contacts(request):
|
||||
"carriers": base_profiles.filter(stakeholder_type=CustomerProfile.StakeholderType.CARRIER).count(),
|
||||
"needs_action": sum(item.waiting_staff_count for item in contacts),
|
||||
}
|
||||
context = {"contacts": contacts, "metrics": metrics, "groups": visible_groups.annotate(contact_count=Count("contacts", distinct=True)), "stakeholder_types": CustomerProfile.StakeholderType.choices, "provinces": [row["province"] for row in location_data()], "can_add_contact": _has_any_unit_permission(request.user, "customers.add_customerprofile"), "active_staff_section": "contacts", "active_tab_title": "دفترچه مخاطبان"}
|
||||
context = {"contacts": contacts, "metrics": metrics, "groups": visible_groups.annotate(contact_count=Count("contacts", distinct=True)), "stakeholder_types": CustomerProfile.StakeholderType.choices, "provinces": [row["province"] for row in location_data()], "can_add_contact": _has_any_unit_permission(request.user, "customers.add_customerprofile"), "voip_call_methods": _voip_call_methods(request.user), "active_staff_section": "contacts", "active_tab_title": "دفترچه مخاطبان"}
|
||||
return render(request, "tickets/staff_contacts.html", context)
|
||||
|
||||
|
||||
@@ -830,7 +863,7 @@ def staff_contact_edit(request, public_id):
|
||||
profile.groups.add(*form.cleaned_data["groups"])
|
||||
messages.success(request, "اطلاعات مخاطب بروزرسانی شد.")
|
||||
return redirect("staff_tickets:contacts")
|
||||
return render(request, "tickets/staff_entity_form.html", {"form": form, "active_staff_section": "contacts", "active_tab_title": "ویرایش مخاطب", "form_title": str(profile), "form_help": "عضویت همزمان مخاطب در چند گروه قابل مدیریت است."})
|
||||
return render(request, "tickets/staff_entity_form.html", {"form": form, "call_customer": profile.user, "voip_call_methods": _voip_call_methods(request.user), "active_staff_section": "contacts", "active_tab_title": "ویرایش مخاطب", "form_title": str(profile), "form_help": "عضویت همزمان مخاطب در چند گروه قابل مدیریت است."})
|
||||
|
||||
|
||||
@staff_required
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
.contact-card-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem;padding:1rem;background:#f7fafc}.contact-card{display:grid;gap:.75rem;min-width:0;border:1px solid #dce7ef;border-radius:1rem;background:#fff;padding:1rem;transition:.18s ease;box-shadow:0 10px 28px -26px #08376b}.contact-card:hover{transform:translateY(-2px);border-color:#9dcbe2;box-shadow:0 18px 36px -29px #08376b}.contact-card header{display:flex;align-items:center;gap:.7rem}.contact-avatar{display:grid;width:2.65rem;height:2.65rem;flex:none;place-items:center;border-radius:.8rem;background:linear-gradient(145deg,#dff4ff,#dff7ef);color:#084d73;font-size:1rem;font-weight:900}.contact-identity{min-width:0;flex:1}.contact-identity h2{overflow:hidden;margin:0;color:#153f60;font-size:.88rem;text-overflow:ellipsis;white-space:nowrap}.contact-identity p{overflow:hidden;margin:.15rem 0 0;color:#718698;font-size:.63rem;text-overflow:ellipsis;white-space:nowrap}.contact-type{flex:none;border-radius:999px;background:#e7f4fb;padding:.2rem .48rem;color:#146b98;font-size:.57rem;font-weight:900}.contact-type.type-supplier{background:#fff3d8;color:#966000}.contact-type.type-carrier{background:#f0eaff;color:#6841b8}.contact-meta{display:flex;flex-wrap:wrap;gap:.4rem .9rem;color:#5f7689;font-size:.64rem}.contact-groups{display:flex;min-height:1.6rem;flex-wrap:wrap;gap:.3rem}.contact-groups span{border-radius:999px;background:#edf5f8;padding:.18rem .48rem;color:#49677c;font-size:.58rem;font-weight:800}.contact-groups small{color:#93a2ae;font-size:.6rem}.contact-ticket-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:.35rem}.contact-ticket-stats span{display:grid;gap:.1rem;border-radius:.6rem;background:#f3f6f8;padding:.45rem;text-align:center;color:#718394;font-size:.55rem}.contact-ticket-stats b{color:#173f60;font-size:.82rem}.contact-ticket-stats .needs-action{background:#fff5df;color:#916000}.contact-ticket-stats .needs-action b{color:#c37700}.contact-card footer{display:flex;align-items:center;justify-content:space-between;border-top:1px solid #edf2f5;padding-top:.7rem}.contact-card footer a{color:#557187;font-size:.64rem;font-weight:900}.contact-card footer .primary-action{border-radius:.6rem;background:#e8f6ff;padding:.4rem .65rem;color:#0875b5}.contact-empty{grid-column:1/-1}.contact-empty>span{display:grid;width:3rem;height:3rem;margin:0 auto .5rem;place-items:center;border-radius:1rem;background:#eaf5fb;color:#1679aa;font-size:1.3rem}
|
||||
.operations-split,.template-layout,.dispatch-layout{display:grid;grid-template-columns:minmax(0,1fr) 20rem;align-items:start;gap:1rem}.panel-heading{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.1rem;border-bottom:1px solid #e5edf3;background:#f8fbfd}.panel-heading small{color:#74889a;font-size:.6rem}.panel-heading h2{margin:.15rem 0 0;color:#173f60;font-size:.95rem}.panel-heading>span{border-radius:999px;background:#e7f4fb;padding:.3rem .6rem;color:#126c9d;font-size:.6rem;font-weight:900}.group-card-list,.template-card-list{display:grid;gap:.55rem;padding:.8rem}.group-card,.template-card{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:.75rem;border:1px solid #e0e9ef;border-radius:.8rem;padding:.75rem;transition:.15s}.group-card:hover,.template-card:hover{border-color:#acd2e5;background:#fafdff}.group-symbol,.template-icon{display:grid;width:2.35rem;height:2.35rem;place-items:center;border-radius:.7rem;background:#e8f5fb;color:#0870a6;font-weight:900}.group-card h3,.template-card h3{margin:0;color:#173f60;font-size:.78rem}.group-card p,.template-card p{margin:.12rem 0;color:#667d90;font-size:.62rem}.group-card small,.template-card small{color:#8a9aa7;font-size:.56rem}.group-card>a,.template-card>a{color:#0875b5;font-size:.62rem;font-weight:900}.operations-form-side{overflow:hidden;border:1px solid #d8e4ec;border-radius:1rem;background:#fff;box-shadow:0 18px 40px -36px #08376b;position:sticky;top:5.6rem}.operations-form-side>header{display:flex;align-items:center;gap:.65rem;padding:1rem;background:linear-gradient(135deg,#0a4d78,#087b91);color:#fff}.operations-form-side>header>span{display:grid;width:2rem;height:2rem;place-items:center;border-radius:.6rem;background:rgba(255,255,255,.16);font-weight:900}.operations-form-side header small{font-size:.56rem;color:rgba(255,255,255,.65)}.operations-form-side header h2{margin:.12rem 0 0;font-size:.85rem}.operations-form{display:grid;gap:.75rem;padding:1rem}.operations-form label{display:grid;gap:.35rem}.operations-form label>span{color:#405e75;font-size:.65rem;font-weight:900}.operations-form input,.operations-form select,.operations-form textarea{width:100%;min-height:2.65rem;border:1px solid #c8d8e4;border-radius:.7rem;background:#fbfdff;padding:.6rem .7rem;color:#143e60}.operations-form textarea{min-height:6rem;resize:vertical}.operations-form button{min-height:2.75rem;border:0;border-radius:.7rem;background:linear-gradient(135deg,#0879b7,#075b8d);color:#fff;font-size:.7rem;font-weight:900}.operations-form>a{text-align:center;color:#6e8293;font-size:.62rem;font-weight:800}.operations-form .errorlist,.editor-section .errorlist,.dispatch-composer .errorlist{margin:0;padding:0;color:#b42318;font-size:.6rem;list-style:none}
|
||||
.contact-editor,.dispatch-composer{display:grid;gap:.85rem}.contact-editor{max-width:58rem;width:100%;margin-inline:auto}.editor-section,.dispatch-composer>section{overflow:hidden;border:1px solid #dbe6ee;border-radius:1rem;background:#fff;box-shadow:0 15px 38px -35px #08376b}.editor-section>header,.dispatch-composer>section>header{display:flex;align-items:center;gap:.7rem;border-bottom:1px solid #e8eef3;padding:.85rem 1rem;background:#f9fcfe}.editor-section>header>span,.dispatch-composer>section>header>span{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:.65rem;background:linear-gradient(135deg,#0a699f,#098d9c);color:#fff;font-size:.7rem;font-weight:900}.editor-section h2,.dispatch-composer h2{margin:0;color:#173f60;font-size:.82rem}.editor-section header p,.dispatch-composer header p{margin:.15rem 0 0;color:#73889a;font-size:.6rem}.editor-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem;padding:1rem}.editor-grid label,.group-selector,.message-editor{display:grid;align-content:start;gap:.35rem;color:#3f5f77;font-size:.65rem;font-weight:900}.editor-grid input,.editor-grid select,.group-selector select,.message-editor textarea{width:100%;min-height:2.75rem;border:1px solid #c8d8e4;border-radius:.72rem;background:#fbfdff;padding:.62rem .72rem;color:#123f61}.editor-grid input:focus,.editor-grid select:focus,.group-selector select:focus,.message-editor textarea:focus{border-color:#1689bd;outline:3px solid rgba(22,137,189,.11);background:#fff}.editor-grid small,.group-selector small{color:#7a8e9e;font-size:.56rem;font-weight:500}.editor-grid em,.group-selector em{color:#d05c3b;font-style:normal}.editor-wide{grid-column:1/-1}.group-selector,.message-editor{margin:1rem}.group-selector select{min-height:9rem}.location-status{margin:-.35rem 1rem .85rem;border-radius:.6rem;background:#edf7fc;padding:.55rem .7rem;color:#39708e;font-size:.58rem}.location-status.is-error{background:#fff0ed;color:#b04432}.editor-actions{display:flex;align-items:center;gap:.7rem;border:1px solid #d9e5ed;border-radius:1rem;background:#fff;padding:.8rem}.editor-actions button{min-height:2.75rem;border:0;border-radius:.7rem;background:linear-gradient(135deg,#0879b7,#075b8d);padding:.65rem 1.4rem;color:#fff;font-weight:900}.editor-actions a{color:#657d90;font-size:.65rem;font-weight:900}.editor-actions span{margin-right:auto;color:#8798a5;font-size:.58rem}
|
||||
.voip-call{position:relative;display:inline-flex;align-items:center;gap:.4rem}.voip-call-trigger{min-height:2.35rem;border:1px solid #0b8b74!important;border-radius:.65rem!important;background:#e8f8f3!important;padding:.45rem .8rem!important;color:#08735f!important;font-size:.68rem;font-weight:900}.voip-call-menu{position:absolute;z-index:80;top:calc(100% + .35rem);right:0;min-width:10.5rem;border:1px solid #d4e5e1;border-radius:.75rem;background:#fff;padding:.35rem;box-shadow:0 16px 38px -20px #08376b}.voip-call-menu button{display:block;width:100%;border:0!important;border-radius:.5rem!important;background:#fff!important;padding:.6rem .7rem!important;text-align:right;color:#164e63!important;font-size:.65rem;font-weight:800}.voip-call-menu button:hover{background:#edf9f6!important}.voip-call-status{max-width:11rem;color:#647b88;font-size:.57rem}.voip-call-status:empty{display:none}.voip-call-status.is-error{color:#b42318}.voip-call-status.is-success{color:#08735f}.contact-card footer .voip-call-status{position:absolute;right:1rem;bottom:.15rem}.ticket-head-actions .voip-call-status{position:absolute;top:100%;right:0;border-radius:.4rem;background:#fff;padding:.2rem .4rem;white-space:nowrap}.ticket-head-actions .voip-call{position:relative}
|
||||
.voip-call{isolation:isolate}.voip-call-trigger{display:flex!important;align-items:center;gap:.55rem;min-height:2.65rem!important;border:1px solid rgba(7,132,112,.28)!important;border-radius:.8rem!important;background:linear-gradient(135deg,#edfbf7,#dcf5ed)!important;padding:.38rem .55rem .38rem .72rem!important;color:#076a59!important;box-shadow:0 8px 22px -17px rgba(7,106,89,.8)!important;transition:transform .16s ease,border-color .16s ease,box-shadow .16s ease!important}.voip-call-trigger:hover{transform:translateY(-1px);border-color:rgba(7,132,112,.52)!important;box-shadow:0 12px 24px -16px rgba(7,106,89,.9)!important}.voip-call-trigger:focus-visible{outline:3px solid rgba(18,170,142,.2);outline-offset:2px}.voip-call-trigger:disabled{cursor:wait;opacity:.72}.voip-call-trigger-icon{display:grid;width:1.85rem;height:1.85rem;flex:none;place-items:center;border-radius:.58rem;background:linear-gradient(145deg,#0d987f,#08715f);color:#fff;box-shadow:0 6px 14px -8px #075c4e}.voip-call-trigger-icon svg{width:1rem;height:1rem;fill:currentColor}.voip-call-trigger-copy{display:grid;text-align:right;line-height:1.2}.voip-call-trigger-copy b{font-size:.68rem}.voip-call-trigger-copy small{margin-top:.16rem;color:#438478;font-size:.5rem;font-weight:700}.voip-call-chevron{margin-right:.1rem;color:#4b897e;font-size:.8rem;transition:transform .16s}.voip-call-trigger.is-open .voip-call-chevron{transform:rotate(180deg)}.voip-call-trigger.is-success .voip-call-trigger-icon{background:linear-gradient(145deg,#16a36f,#087d55)}.voip-call-trigger.is-loading .voip-call-trigger-icon svg{animation:voip-pulse .9s ease-in-out infinite alternate}@keyframes voip-pulse{to{opacity:.42;transform:scale(.82)}}
|
||||
body>.voip-call-menu{position:fixed;z-index:2147483000;top:0;right:auto;left:0;width:min(20rem,calc(100vw - 1.5rem));min-width:18rem;overflow:hidden;border:1px solid rgba(22,77,99,.13);border-radius:1rem;background:rgba(255,255,255,.98);padding:0;direction:rtl;box-shadow:0 24px 65px -24px rgba(8,47,85,.48),0 7px 18px -12px rgba(8,47,85,.25);backdrop-filter:blur(14px);transform-origin:top right;animation:voip-menu-in .16s ease-out}body>.voip-call-menu[hidden]{display:none!important}@keyframes voip-menu-in{from{opacity:0;transform:translateY(-5px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}body>.voip-call-menu>header{display:flex;align-items:center;gap:.7rem;border-bottom:1px solid #e7eff2;background:linear-gradient(135deg,#f1fbf8,#f7fbfd);padding:.85rem .9rem}body>.voip-call-menu>header>span{display:grid;width:2.2rem;height:2.2rem;flex:none;place-items:center;border-radius:.7rem;background:linear-gradient(145deg,#0c927b,#096c78);color:#fff;font-size:.85rem;box-shadow:0 8px 18px -11px #075f51}body>.voip-call-menu header b,body>.voip-call-menu header small{display:block}body>.voip-call-menu header b{color:#173f56;font-size:.72rem}body>.voip-call-menu header small{margin-top:.15rem;color:#718795;font-size:.55rem}.voip-call-options{display:grid;gap:.35rem;padding:.48rem}.voip-call-menu .voip-call-options button{display:grid!important;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.65rem;width:100%;min-height:3.8rem;border:1px solid transparent!important;border-radius:.75rem!important;background:#fff!important;padding:.55rem .6rem!important;text-align:right;color:#173f56!important;box-shadow:none!important;transition:background .14s,border-color .14s,transform .14s!important}.voip-call-menu .voip-call-options button:hover,.voip-call-menu .voip-call-options button:focus-visible{transform:translateX(-2px);border-color:#cbe8df!important;background:#f1fbf8!important;outline:none}.voip-call-menu .voip-call-options button>span:nth-child(2){min-width:0}.voip-call-menu .voip-call-options b,.voip-call-menu .voip-call-options small{display:block}.voip-call-menu .voip-call-options b{font-size:.68rem}.voip-call-menu .voip-call-options small{margin-top:.18rem;overflow:hidden;color:#718795;font-size:.53rem;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.voip-method-icon{display:grid;width:2.35rem;height:2.35rem;place-items:center;border-radius:.68rem;background:#e4f7f1;color:#08715f;font-size:1rem;font-weight:900}.voip-method-icon.is-mobile{background:#e9f3fb;color:#176b99}.voip-method-arrow{color:#7da19a;font-size:.85rem}
|
||||
body>.voip-call-status{position:fixed!important;z-index:2147483001;right:auto!important;bottom:1.25rem!important;left:50%!important;top:auto!important;display:flex;align-items:center;gap:.55rem;width:max-content;max-width:min(26rem,calc(100vw - 1.5rem));min-height:2.8rem;border:1px solid rgba(21,64,82,.12);border-radius:.85rem;background:#fff;padding:.55rem .85rem!important;color:#23485b!important;font-size:.64rem;font-weight:850;white-space:normal;box-shadow:0 18px 46px -20px rgba(8,47,85,.65);opacity:0;pointer-events:none;transform:translate(-50%,1rem);transition:opacity .18s,transform .18s}body>.voip-call-status:before{display:grid;width:1.55rem;height:1.55rem;flex:none;place-items:center;border-radius:.48rem;background:#e7f4f8;color:#176c88;content:'…'}body>.voip-call-status.is-visible{opacity:1;transform:translate(-50%,0)}body>.voip-call-status.is-loading:before{content:'↻';animation:voip-spin .9s linear infinite}body>.voip-call-status.is-success{border-color:#bce5d7!important;background:#f6fdfa!important;color:#08715f!important}body>.voip-call-status.is-success:before{background:#dff6ed;color:#08715f;content:'✓'}body>.voip-call-status.is-error{border-color:#f0c5be!important;background:#fff9f8!important;color:#a33a2b!important}body>.voip-call-status.is-error:before{background:#ffebe8;color:#b34231;content:'!'}@keyframes voip-spin{to{transform:rotate(360deg)}}.contact-card footer .voip-call-trigger{min-height:2.25rem!important;padding:.28rem .42rem!important}.contact-card footer .voip-call-trigger-icon{width:1.65rem;height:1.65rem}.contact-card footer .voip-call-trigger-copy small,.contact-card footer .voip-call-chevron{display:none}
|
||||
@media(max-width:560px){body>.voip-call-menu{top:auto!important;right:.65rem!important;bottom:.65rem!important;left:.65rem!important;width:auto;min-width:0;border-radius:1.05rem;transform-origin:bottom center;animation:voip-sheet-in .2s ease-out}@keyframes voip-sheet-in{from{opacity:0;transform:translateY(1rem)}to{opacity:1;transform:translateY(0)}}body>.voip-call-status{bottom:5.25rem!important}.operations-hero-actions .voip-call-trigger{width:100%;justify-content:center}.ticket-head-actions .voip-call-trigger-copy small{display:none}}
|
||||
.group-selector{border:0}.group-selector legend{color:#3f5f77;font-size:.7rem;font-weight:900}.group-selector-tools{display:grid;grid-template-columns:minmax(12rem,1fr) auto auto;align-items:center;gap:.55rem;margin:.4rem 0 .65rem}.group-selector-tools label{display:block}.group-selector-tools input{width:100%;min-height:2.65rem;border:1px solid #c8d8e4;border-radius:.72rem;background:#fbfdff;padding:.58rem .75rem}.group-selection-count{border-radius:999px;background:#e7f4fb;padding:.42rem .7rem;color:#126c9d;white-space:nowrap}.group-selector-tools button{min-height:2.55rem;border:1px solid #d8e4ec;border-radius:.65rem;background:#fff;padding:.45rem .7rem;color:#64798c;font-size:.6rem;font-weight:900}.group-selector-tools button:disabled{cursor:not-allowed;opacity:.45}.group-choice-list>div{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.55rem}.group-choice-list>div>div{border:1px solid #dbe5ec;border-radius:.75rem;background:#fbfdff;transition:.15s}.group-choice-list label{display:flex;align-items:center;gap:.65rem;min-height:3.15rem;padding:.65rem .75rem;cursor:pointer;color:#35566e;font-size:.65rem}.group-choice-list input{width:1.1rem;height:1.1rem;flex:none;accent-color:#0879a7}.group-choice-list>div>div.is-selected{border-color:#58aeca;background:#eaf8fb;box-shadow:0 0 0 2px rgba(22,137,189,.08)}.group-choice-list>div>div[hidden]{display:none}.group-empty{margin:.5rem 0;border-radius:.65rem;background:#f5f8fa;padding:.75rem;color:#758999;font-size:.62rem}.staff-attachment-picker{display:grid;gap:.8rem}.attachment-picker-copy{display:flex;align-items:center;gap:.7rem}.staff-attachment-picker .media-picker-actions{margin:0}.staff-attachment-picker .file-preview:empty{display:none}
|
||||
.dispatch-composer>footer{display:flex;align-items:center;justify-content:space-between;gap:1rem;border:1px solid #d7e4ec;border-radius:1rem;background:#fff;padding:.9rem 1rem}.dispatch-composer footer b,.dispatch-composer footer small{display:block}.dispatch-composer footer b{color:#173f60;font-size:.68rem}.dispatch-composer footer small{margin-top:.18rem;color:#73889a;font-size:.56rem}.dispatch-composer footer button{min-height:2.8rem;border:0;border-radius:.72rem;background:linear-gradient(135deg,#0879b7,#087c86);padding:.65rem 1rem;color:#fff;font-size:.68rem;font-weight:900}.message-editor textarea{min-height:11rem;resize:vertical;line-height:1.9}.dispatch-history{overflow:hidden;border:1px solid #d8e4ec;border-radius:1rem;background:#fff;position:sticky;top:5.6rem}.dispatch-history>header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #e5edf3;padding:1rem}.dispatch-history header small{color:#7b8d9d;font-size:.56rem}.dispatch-history header h2{margin:.12rem 0 0;color:#173f60;font-size:.88rem}.dispatch-history header>span{display:grid;width:2rem;height:2rem;place-items:center;border-radius:.65rem;background:#e8f4fb;color:#0873a9;font-size:.65rem;font-weight:900}.dispatch-history>div{display:grid;max-height:39rem;overflow:auto}.dispatch-history-card{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.55rem;border-bottom:1px solid #edf2f5;padding:.75rem;color:inherit}.dispatch-history-card:hover{background:#f7fbfd}.dispatch-state{width:.5rem;height:.5rem;border-radius:50%;background:#8da0ae}.dispatch-state.state-sent{background:#12a36d}.dispatch-state.state-queued{background:#e19b18}.dispatch-state.state-partial{background:#dc5c42}.dispatch-history-card b{display:block;overflow:hidden;color:#24485f;font-size:.68rem;text-overflow:ellipsis;white-space:nowrap}.dispatch-history-card small{display:block;margin-top:.15rem;color:#718698;font-size:.56rem}.dispatch-history-card p{display:flex;gap:.6rem;margin:.35rem 0 0;color:#8798a5;font-size:.52rem}.dispatch-history-card em{color:#8aa0b0;font-style:normal}
|
||||
.template-card-list{max-height:44rem;overflow:auto}.variable-box{border:1px dashed #b9cedb;border-radius:.7rem;background:#f7fbfd;padding:.7rem}.variable-box>b{display:block;margin-bottom:.45rem;color:#3e6077;font-size:.62rem}.variable-box>div{display:flex;flex-wrap:wrap;gap:.3rem}.variable-box code{border-radius:.35rem;background:#e7f3f9;padding:.18rem .35rem;color:#075e8d;font-size:.53rem}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
(()=>{
|
||||
const roots=[...document.querySelectorAll('[data-voip-call]')];
|
||||
if(!roots.length)return;
|
||||
|
||||
const csrfToken=()=>{
|
||||
const item=document.cookie.split(';').map(value=>value.trim()).find(value=>value.startsWith('csrftoken='));
|
||||
return item?decodeURIComponent(item.slice('csrftoken='.length)):'';
|
||||
};
|
||||
const menuFor=root=>root._voipMenu;
|
||||
const statusFor=root=>root._voipStatus;
|
||||
|
||||
const positionMenu=root=>{
|
||||
const menu=menuFor(root);
|
||||
const trigger=root.querySelector('[data-voip-call-trigger]');
|
||||
if(!menu||menu.hidden)return;
|
||||
if(window.matchMedia('(max-width:560px)').matches){
|
||||
menu.style.removeProperty('top');
|
||||
menu.style.removeProperty('left');
|
||||
return;
|
||||
}
|
||||
const rect=trigger.getBoundingClientRect();
|
||||
const gap=10;
|
||||
const edge=12;
|
||||
const width=menu.offsetWidth;
|
||||
const height=menu.offsetHeight;
|
||||
const top=rect.bottom+gap+height<=window.innerHeight-edge?rect.bottom+gap:Math.max(edge,rect.top-height-gap);
|
||||
const left=Math.min(Math.max(edge,rect.right-width),window.innerWidth-width-edge);
|
||||
menu.style.top=`${Math.round(top)}px`;
|
||||
menu.style.left=`${Math.round(left)}px`;
|
||||
};
|
||||
|
||||
const setOpen=(root,open,{restoreFocus=false}={})=>{
|
||||
const menu=menuFor(root);
|
||||
const trigger=root.querySelector('[data-voip-call-trigger]');
|
||||
if(!menu||!trigger)return;
|
||||
menu.hidden=!open;
|
||||
menu.classList.toggle('is-open',open);
|
||||
trigger.classList.toggle('is-open',open);
|
||||
trigger.setAttribute('aria-expanded',String(open));
|
||||
if(open){
|
||||
positionMenu(root);
|
||||
requestAnimationFrame(()=>menu.querySelector('[data-voip-method]')?.focus());
|
||||
}else{
|
||||
menu.style.removeProperty('top');
|
||||
menu.style.removeProperty('left');
|
||||
if(restoreFocus)trigger.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const closeOthers=current=>roots.forEach(root=>{if(root!==current)setOpen(root,false);});
|
||||
|
||||
const notify=(root,message,state)=>{
|
||||
const status=statusFor(root);
|
||||
clearTimeout(status._hideTimer);
|
||||
status.textContent=message;
|
||||
status.className=`voip-call-status is-visible is-${state}`;
|
||||
if(state!=='loading')status._hideTimer=setTimeout(()=>status.classList.remove('is-visible'),4200);
|
||||
};
|
||||
|
||||
const call=async(root,method)=>{
|
||||
const trigger=root.querySelector('[data-voip-call-trigger]');
|
||||
const buttons=[trigger,...menuFor(root).querySelectorAll('button')];
|
||||
setOpen(root,false);
|
||||
buttons.forEach(button=>button.disabled=true);
|
||||
trigger.classList.add('is-loading');
|
||||
notify(root,'در حال ارسال درخواست تماس…','loading');
|
||||
try{
|
||||
const response=await fetch(root.dataset.callUrl,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrfToken(),'Accept':'application/json'},body:JSON.stringify({method})});
|
||||
const data=await response.json().catch(()=>({}));
|
||||
if(!response.ok||!data.success)throw new Error();
|
||||
notify(root,data.message||'درخواست تماس با موفقیت ارسال شد.','success');
|
||||
trigger.classList.add('is-success');
|
||||
setTimeout(()=>trigger.classList.remove('is-success'),1800);
|
||||
}catch(error){
|
||||
notify(root,'برقراری تماس ناموفق بود.','error');
|
||||
}finally{
|
||||
trigger.classList.remove('is-loading');
|
||||
buttons.forEach(button=>button.disabled=false);
|
||||
}
|
||||
};
|
||||
|
||||
roots.forEach((root,index)=>{
|
||||
const menu=root.querySelector('[data-voip-call-menu]');
|
||||
const status=root.querySelector('[data-voip-call-status]');
|
||||
const trigger=root.querySelector('[data-voip-call-trigger]');
|
||||
menu.id=`voip-call-menu-${index+1}`;
|
||||
trigger.setAttribute('aria-controls',menu.id);
|
||||
root._voipMenu=menu;
|
||||
root._voipStatus=status;
|
||||
menu._voipRoot=root;
|
||||
document.body.append(menu,status);
|
||||
});
|
||||
|
||||
document.addEventListener('click',event=>{
|
||||
const methodButton=event.target.closest('[data-voip-method]');
|
||||
if(methodButton){
|
||||
const root=methodButton.closest('[data-voip-call-menu]')._voipRoot;
|
||||
call(root,methodButton.dataset.voipMethod);
|
||||
return;
|
||||
}
|
||||
const trigger=event.target.closest('[data-voip-call-trigger]');
|
||||
if(trigger){
|
||||
const root=trigger.closest('[data-voip-call]');
|
||||
const methods=menuFor(root).querySelectorAll('[data-voip-method]');
|
||||
if(methods.length===1){call(root,methods[0].dataset.voipMethod);return;}
|
||||
const opening=menuFor(root).hidden;
|
||||
closeOthers(root);
|
||||
setOpen(root,opening);
|
||||
return;
|
||||
}
|
||||
if(!event.target.closest('[data-voip-call-menu]'))roots.forEach(root=>setOpen(root,false));
|
||||
});
|
||||
|
||||
document.addEventListener('keydown',event=>{
|
||||
if(event.key==='Escape')roots.forEach(root=>{if(!menuFor(root).hidden)setOpen(root,false,{restoreFocus:true});});
|
||||
});
|
||||
window.addEventListener('resize',()=>roots.forEach(positionMenu));
|
||||
window.addEventListener('scroll',()=>roots.forEach(positionMenu),true);
|
||||
})();
|
||||
@@ -0,0 +1,17 @@
|
||||
{% if voip_call_methods and customer.mobile %}
|
||||
<div class="voip-call" data-voip-call data-call-url="{% url 'staff_tickets:contact_call' customer.public_id %}">
|
||||
<button type="button" class="voip-call-trigger" data-voip-call-trigger aria-expanded="false" aria-haspopup="menu">
|
||||
<span class="voip-call-trigger-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M6.6 10.8c1.7 3.3 3.3 4.9 6.6 6.6l2.2-2.2c.3-.3.7-.4 1.1-.3 1.2.4 2.5.6 3.8.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.8 21 3 13.2 3 3.7c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.3.2 2.6.6 3.8.1.4 0 .8-.3 1.1l-2.2 2.2Z"/></svg></span>
|
||||
<span class="voip-call-trigger-copy"><b>تماس</b><small>ارتباط با مخاطب</small></span>
|
||||
<span class="voip-call-chevron" aria-hidden="true">⌄</span>
|
||||
</button>
|
||||
<div class="voip-call-menu" data-voip-call-menu role="menu" aria-label="انتخاب روش تماس" hidden>
|
||||
<header><span aria-hidden="true">☎</span><div><b>شروع تماس</b><small>روش ارتباط کارشناس را انتخاب کنید</small></div></header>
|
||||
<div class="voip-call-options">
|
||||
{% if 'ip_phone' in voip_call_methods %}<button type="button" role="menuitem" data-voip-method="ip_phone"><span class="voip-method-icon" aria-hidden="true">⌁</span><span><b>تلفن داخلی</b><small>تماس از داخلی ثبتشده کارشناس</small></span><span class="voip-method-arrow" aria-hidden="true">←</span></button>{% endif %}
|
||||
{% if 'mobile' in voip_call_methods %}<button type="button" role="menuitem" data-voip-method="mobile"><span class="voip-method-icon is-mobile" aria-hidden="true">▯</span><span><b>موبایل کارشناس</b><small>اتصال تماس به شماره VOIP کارشناس</small></span><span class="voip-method-arrow" aria-hidden="true">←</span></button>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<small class="voip-call-status" data-voip-call-status role="status" aria-live="polite"></small>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,7 +1,8 @@
|
||||
{% extends 'base.html' %}
|
||||
{% extends 'base.html' %}{% load static %}
|
||||
{% block title %}{{ page_title }}{% endblock %}
|
||||
{% block content %}<div class="operations-page operations-contacts">
|
||||
<section class="operations-hero"><div><p class="eyebrow">مرکز یکپارچه ارتباطات</p><h1>مخاطبان و مشتریان</h1><p>پرونده ارتباطی مشتریان، تأمینکنندگان و باربریها همراه با گروهها و وضعیت تیکتها در یک نما.</p></div><div class="operations-hero-actions"><a class="button button-secondary" href="{% url 'staff_tickets:groups' %}">مدیریت گروهها</a>{% if can_add_contact %}<a class="button" href="{% url 'staff_tickets:contact_create' %}">+ مخاطب جدید</a>{% endif %}</div></section>
|
||||
<section class="operations-kpis" aria-label="آمار مخاطبان"><article><span class="kpi-icon">◎</span><div><b>{{ metrics.total }}</b><small>نتیجه قابل مشاهده</small></div></article><article><span class="kpi-icon is-blue">♙</span><div><b>{{ metrics.customers }}</b><small>مشتری</small></div></article><article><span class="kpi-icon is-amber">▣</span><div><b>{{ metrics.suppliers }}</b><small>تأمینکننده</small></div></article><article><span class="kpi-icon is-violet">⇄</span><div><b>{{ metrics.carriers }}</b><small>باربری</small></div></article><article><span class="kpi-icon is-coral">!</span><div><b>{{ metrics.needs_action }}</b><small>تیکت نیازمند اقدام</small></div></article></section>
|
||||
<section class="operations-panel"><form method="get" class="contact-filter-bar"><label class="contact-search"><span>⌕</span><input type="search" name="q" value="{{ request.GET.q }}" placeholder="جستوجو با نام، سازمان یا شماره همراه"></label><label><span>نوع مخاطب</span><select name="stakeholder_type"><option value="">همه انواع</option>{% for value,label in stakeholder_types %}<option value="{{ value }}" {% if request.GET.stakeholder_type == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><label><span>گروه</span><select name="group"><option value="">همه گروهها</option>{% for group in groups %}<option value="{{ group.pk }}" {% if request.GET.group == group.pk|stringformat:'s' %}selected{% endif %}>{{ group.name }} ({{ group.contact_count }})</option>{% endfor %}</select></label><label><span>استان</span><select name="province"><option value="">همه استانها</option>{% for province in provinces %}<option value="{{ province }}" {% if request.GET.province == province %}selected{% endif %}>{{ province }}</option>{% endfor %}</select></label><button type="submit">اعمال فیلتر</button>{% if request.GET %}<a class="filter-reset" href="{% url 'staff_tickets:contacts' %}">پاککردن</a>{% endif %}</form>
|
||||
<div class="contact-result-meta"><div><b>{{ contacts|length }} مخاطب</b><small>مرتبشده بر اساس تیکتهای فعال و آخرین ثبت</small></div><a href="{% url 'staff_tickets:groups' %}">{{ groups|length }} گروه قابل دسترس ←</a></div><div class="contact-card-grid">{% for contact in contacts %}{% with profile=contact.customer_profile %}<article class="contact-card"><header><span class="contact-avatar">{{ profile.first_name|default:contact.local_mobile|slice:':1' }}</span><div class="contact-identity"><h2>{{ profile|default:contact.local_mobile }}</h2><p>{{ profile.organization_name|default:profile.get_stakeholder_type_display }}</p></div><span class="contact-type type-{{ profile.stakeholder_type|lower }}">{{ profile.get_stakeholder_type_display }}</span></header><div class="contact-meta"><span dir="ltr">☎ {{ contact.local_mobile }}</span><span>⌖ {% if profile.city or profile.province %}{{ profile.city }}{% if profile.city and profile.province %}، {% endif %}{{ profile.province }}{% else %}موقعیت ثبت نشده{% endif %}</span></div><div class="contact-groups">{% for group in profile.visible_groups %}<span>{{ group.name }}</span>{% empty %}<small>بدون گروه قابل مشاهده</small>{% endfor %}</div><div class="contact-ticket-stats"><span><b>{{ contact.ticket_count }}</b>کل تیکت</span><span><b>{{ contact.active_count }}</b>فعال</span><span class="needs-action"><b>{{ contact.waiting_staff_count }}</b>نیازمند اقدام</span><span><b>{{ contact.resolved_count }}</b>مختومه</span></div><footer><a href="{% url 'staff_tickets:contact_edit' contact.public_id %}">ویرایش پرونده</a>{% if staff_operations_access.create_ticket %}<a class="primary-action" href="{% url 'staff_tickets:create' %}?customer={{ contact.public_id }}">+ ثبت تیکت</a>{% endif %}</footer></article>{% endwith %}{% empty %}<div class="empty-state contact-empty"><span>⌕</span><h2>مخاطبی پیدا نشد</h2><p>فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.</p>{% if can_add_contact %}<a class="button" href="{% url 'staff_tickets:contact_create' %}">ثبت مخاطب جدید</a>{% endif %}</div>{% endfor %}</div></section></div>{% endblock %}
|
||||
<div class="contact-result-meta"><div><b>{{ contacts|length }} مخاطب</b><small>مرتبشده بر اساس تیکتهای فعال و آخرین ثبت</small></div><a href="{% url 'staff_tickets:groups' %}">{{ groups|length }} گروه قابل دسترس ←</a></div><div class="contact-card-grid">{% for contact in contacts %}{% with profile=contact.customer_profile %}<article class="contact-card"><header><span class="contact-avatar">{{ profile.first_name|default:contact.local_mobile|slice:':1' }}</span><div class="contact-identity"><h2>{{ profile|default:contact.local_mobile }}</h2><p>{{ profile.organization_name|default:profile.get_stakeholder_type_display }}</p></div><span class="contact-type type-{{ profile.stakeholder_type|lower }}">{{ profile.get_stakeholder_type_display }}</span></header><div class="contact-meta"><span dir="ltr">☎ {{ contact.local_mobile }}</span><span>⌖ {% if profile.city or profile.province %}{{ profile.city }}{% if profile.city and profile.province %}، {% endif %}{{ profile.province }}{% else %}موقعیت ثبت نشده{% endif %}</span></div><div class="contact-groups">{% for group in profile.visible_groups %}<span>{{ group.name }}</span>{% empty %}<small>بدون گروه قابل مشاهده</small>{% endfor %}</div><div class="contact-ticket-stats"><span><b>{{ contact.ticket_count }}</b>کل تیکت</span><span><b>{{ contact.active_count }}</b>فعال</span><span class="needs-action"><b>{{ contact.waiting_staff_count }}</b>نیازمند اقدام</span><span><b>{{ contact.resolved_count }}</b>مختومه</span></div><footer><a href="{% url 'staff_tickets:contact_edit' contact.public_id %}">ویرایش پرونده</a>{% include 'tickets/_voip_call_button.html' with customer=contact %}{% if staff_operations_access.create_ticket %}<a class="primary-action" href="{% url 'staff_tickets:create' %}?customer={{ contact.public_id }}">+ ثبت تیکت</a>{% endif %}</footer></article>{% endwith %}{% empty %}<div class="empty-state contact-empty"><span>⌕</span><h2>مخاطبی پیدا نشد</h2><p>فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.</p>{% if can_add_contact %}<a class="button" href="{% url 'staff_tickets:contact_create' %}">ثبت مخاطب جدید</a>{% endif %}</div>{% endfor %}</div></section></div>{% endblock %}
|
||||
{% block extra_scripts %}<script defer src="{% static 'js/voip-call.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="ticket-workspace staff-ticket-workspace" data-conversation-workspace data-poll-url="{% url 'staff_tickets:poll' ticket.public_id %}" data-poll-version="{{ poll_version }}" data-poll-cursor="{{ poll_cursor }}" data-draft-key="ticket-{{ ticket.public_id }}-staff" data-reply-result="{{ request.GET.reply_result|default:'' }}">
|
||||
<header class="ticket-workspace-head">
|
||||
<div class="ticket-title"><a class="workspace-back" href="{% url 'staff_tickets:list' %}" aria-label="بازگشت">→</a><div><span class="ticket-number" dir="ltr">{{ ticket.ticket_number }}</span><h1>{{ ticket.subject }}</h1></div></div>
|
||||
<div class="ticket-head-actions"><span class="badge" data-ticket-status>{{ ticket.get_status_display }}</span>{% if can_claim %}<form method="post" action="{% url 'staff_tickets:claim' ticket.public_id %}">{% csrf_token %}<button type="submit">اختصاص به من</button></form>{% endif %}<button type="button" class="icon-button" data-toggle-panel="ticket-actions" aria-label="عملیات تیکت">⚙</button><button type="button" class="icon-button" data-toggle-panel="customer-info" aria-label="اطلاعات مشتری">◉</button></div>
|
||||
<div class="ticket-head-actions"><span class="badge" data-ticket-status>{{ ticket.get_status_display }}</span>{% include 'tickets/_voip_call_button.html' with customer=ticket.customer %}{% if can_claim %}<form method="post" action="{% url 'staff_tickets:claim' ticket.public_id %}">{% csrf_token %}<button type="submit">اختصاص به من</button></form>{% endif %}<button type="button" class="icon-button" data-toggle-panel="ticket-actions" aria-label="عملیات تیکت">⚙</button><button type="button" class="icon-button" data-toggle-panel="customer-info" aria-label="اطلاعات مشتری">◉</button></div>
|
||||
</header>
|
||||
{% if ticket.group_dispatch %}
|
||||
<section class="ticket-dispatch-context" aria-label="اطلاعات ارسال گروهی">
|
||||
@@ -42,5 +42,5 @@
|
||||
</div>
|
||||
</div>
|
||||
{% include 'tickets/_image_viewer.html' %}
|
||||
<script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/image-viewer.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/conversation.js' %}?v={{ static_asset_version }}"></script>
|
||||
<script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/image-viewer.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/conversation.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/voip-call.js' %}?v={{ static_asset_version }}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'base.html' %}{% load static %}
|
||||
{% block title %}{{ page_title }}{% endblock %}
|
||||
{% block content %}<div class="operations-page operations-contact-form" data-location-picker data-locations-url="{% static 'data/iran-locations.json' %}">
|
||||
<section class="operations-hero"><div><p class="eyebrow">پرونده ارتباطی</p><h1>{{ form_title }}</h1><p>{{ form_help }}</p></div><div class="operations-hero-actions"><a class="button button-secondary" href="{% url 'staff_tickets:contacts' %}">بازگشت به مخاطبان</a></div></section>
|
||||
<section class="operations-hero"><div><p class="eyebrow">پرونده ارتباطی</p><h1>{{ form_title }}</h1><p>{{ form_help }}</p></div><div class="operations-hero-actions">{% if call_customer %}{% include 'tickets/_voip_call_button.html' with customer=call_customer %}{% endif %}<a class="button button-secondary" href="{% url 'staff_tickets:contacts' %}">بازگشت به مخاطبان</a></div></section>
|
||||
<form method="post" class="contact-editor" data-disable-on-submit>{% csrf_token %}
|
||||
{% if form.non_field_errors %}<div class="create-form-errors" role="alert">{{ form.non_field_errors }}</div>{% endif %}
|
||||
<section class="editor-section"><header><span>۱</span><div><h2>هویت و راه ارتباطی</h2><p>اطلاعات پایه مخاطب برای شناسایی و ارسال پیامک</p></div></header><div class="editor-grid"><label><span>{{ form.mobile.label }} <em>*</em></span>{{ form.mobile }}{{ form.mobile.errors }}<small>شماره معتبر به شکل 09xxxxxxxxx</small></label><label><span>{{ form.stakeholder_type.label }} <em>*</em></span>{{ form.stakeholder_type }}{{ form.stakeholder_type.errors }}</label><label><span>{{ form.first_name.label }} <em>*</em></span>{{ form.first_name }}{{ form.first_name.errors }}</label><label><span>{{ form.last_name.label }}</span>{{ form.last_name }}{{ form.last_name.errors }}</label><label class="editor-wide"><span>{{ form.organization_name.label }}</span>{{ form.organization_name }}{{ form.organization_name.errors }}</label></div></section>
|
||||
@@ -9,4 +9,4 @@
|
||||
<section class="editor-section"><header><span>۳</span><div><h2>گروهبندی و دسترسی</h2><p>با یک کلیک عضویت مخاطب را در گروههای قابل مدیریت اضافه یا حذف کنید</p></div></header><fieldset class="group-selector" data-contact-group-selector><legend>{{ form.groups.label }} <em>*</em></legend><div class="group-selector-tools"><label><span class="sr-only">جستوجوی گروه</span><input type="search" placeholder="جستوجوی نام گروه یا واحد…" autocomplete="off" data-group-search></label><span class="group-selection-count" data-group-selection-count aria-live="polite">۰ گروه انتخاب شده</span><button type="button" data-clear-groups>پاککردن انتخابها</button></div><div class="group-choice-list">{{ form.groups }}</div>{{ form.groups.errors }}<small>{{ form.groups.help_text }}</small><p class="group-empty" data-group-empty hidden>گروهی با این عبارت پیدا نشد.</p></fieldset></section>
|
||||
<footer class="editor-actions"><button type="submit">ذخیره مخاطب</button><a href="{% url 'staff_tickets:contacts' %}">انصراف</a><span>اطلاعات ورود یا رمز عبور برای مخاطب ساخته نمیشود.</span></footer>
|
||||
</form></div>{% endblock %}
|
||||
{% block extra_scripts %}<script defer src="{% static 'js/location-picker.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/contact-group-selector.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
|
||||
{% block extra_scripts %}<script defer src="{% static 'js/location-picker.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/contact-group-selector.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/voip-call.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
|
||||
@@ -45,7 +45,13 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server,
|
||||
part = Q(content_type__app_label=app_label, codename__in=codenames)
|
||||
query = part if query is None else query | part
|
||||
unit.permissions.add(*Permission.objects.filter(query))
|
||||
staff = User.objects.create_user(username="e2e-operations", password=PASSWORD, account_type=User.AccountType.STAFF)
|
||||
staff = User.objects.create_user(
|
||||
username="e2e-operations",
|
||||
password=PASSWORD,
|
||||
account_type=User.AccountType.STAFF,
|
||||
voip_extension="300",
|
||||
voip_mobile="09384999655",
|
||||
)
|
||||
StaffProfile.objects.create(user=staff, display_name="کارشناس عملیات", primary_unit=unit)
|
||||
customer = User.objects.create_user(username="e2e-carrier", mobile="09120009121", account_type=User.AccountType.CUSTOMER)
|
||||
profile = CustomerProfile.objects.create(user=customer, first_name="باربری", stakeholder_type=CustomerProfile.StakeholderType.CARRIER)
|
||||
@@ -76,6 +82,22 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server,
|
||||
assert page.evaluate("getComputedStyle(document.querySelector('.operations-page')).display") == "grid"
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
|
||||
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:contacts')}", wait_until="domcontentloaded")
|
||||
call_trigger = page.locator("[data-voip-call-trigger]").first
|
||||
call_trigger.click()
|
||||
call_menu = page.locator("body > .voip-call-menu:not([hidden])")
|
||||
expect(call_menu).to_be_visible()
|
||||
expect(call_menu.get_by_role("menuitem")).to_have_count(2)
|
||||
assert call_menu.evaluate("menu => menu.parentElement === document.body")
|
||||
assert int(call_menu.evaluate("menu => getComputedStyle(menu).zIndex")) > 100000
|
||||
menu_box = call_menu.bounding_box()
|
||||
assert menu_box is not None
|
||||
assert menu_box["x"] >= 0 and menu_box["y"] >= 0
|
||||
assert menu_box["x"] + menu_box["width"] <= page.viewport_size["width"]
|
||||
assert menu_box["y"] + menu_box["height"] <= page.viewport_size["height"]
|
||||
page.keyboard.press("Escape")
|
||||
expect(call_menu).to_be_hidden()
|
||||
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:dispatch_create')}", wait_until="domcontentloaded")
|
||||
expect(page).to_have_title("ردیابی E2E")
|
||||
expect(page.locator(".staff-brand-copy b")).to_have_text("مرکز عملیات E2E")
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.core.models import VoipConfiguration
|
||||
from apps.customers.models import ContactGroup, CustomerProfile
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import TicketCategory
|
||||
from apps.tickets.services import create_ticket
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def voip_data(db):
|
||||
unit = OrganizationalUnit.objects.create(name="فروش تماس", slug="voip-sales")
|
||||
group = ContactGroup.objects.create(name="مخاطبان تماس", unit=unit)
|
||||
existing_customer = User.objects.create_user(
|
||||
username="existing-voip-mobile-customer",
|
||||
mobile="09384999655",
|
||||
account_type=User.AccountType.CUSTOMER,
|
||||
)
|
||||
agent = User.objects.create_superuser(
|
||||
username="voip-agent",
|
||||
password="safe-password",
|
||||
mobile=None,
|
||||
account_type=User.AccountType.STAFF,
|
||||
voip_extension="300",
|
||||
voip_mobile="09384999655",
|
||||
)
|
||||
StaffProfile.objects.create(user=agent, primary_unit=unit, display_name="کارشناس تماس")
|
||||
customer = User.objects.create_user(
|
||||
username="voip-customer",
|
||||
mobile="09139591708",
|
||||
account_type=User.AccountType.CUSTOMER,
|
||||
)
|
||||
profile = CustomerProfile.objects.create(user=customer, first_name="مخاطب", last_name="آزمایشی")
|
||||
profile.groups.add(group)
|
||||
category = TicketCategory.objects.create(unit=unit, title="تماس فروش", slug="voip-call")
|
||||
ticket = create_ticket(customer=customer, category=category, subject="پیگیری تماس", body="شرح تماس")
|
||||
VoipConfiguration.objects.create(voip_call_url="http://127.0.0.1:9000/v1/click2call")
|
||||
assert agent.voip_mobile == existing_customer.mobile
|
||||
return agent, customer, ticket
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
(
|
||||
{"method": "ip_phone", "agent_field": "agent_extension", "agent_value": "300", "key_prefix": "internal-"},
|
||||
{"method": "mobile", "agent_field": "agent_mobile", "agent_value": "09384999655", "key_prefix": "mobile-"},
|
||||
),
|
||||
)
|
||||
def test_backend_sends_voip_call(client, voip_data, case):
|
||||
agent, customer, _ = voip_data
|
||||
method = case["method"]
|
||||
client.force_login(agent)
|
||||
response_mock = Mock(content=b"{}")
|
||||
response_mock.json.return_value = {}
|
||||
with patch("apps.integrations.voip.requests.post", return_value=response_mock) as post:
|
||||
response = client.post(
|
||||
reverse("staff_tickets:contact_call", args=[customer.public_id]),
|
||||
data=json.dumps({"method": method}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
call = post.call_args
|
||||
assert call.args == ("http://127.0.0.1:9000/v1/click2call",)
|
||||
assert call.kwargs["json"] == {
|
||||
"method": method,
|
||||
case["agent_field"]: case["agent_value"],
|
||||
"customer_number": "09139591708",
|
||||
"metadata": {"agent_id": str(agent.public_id), "customer_id": str(customer.public_id)},
|
||||
}
|
||||
assert call.kwargs["headers"]["Idempotency-Key"].startswith(case["key_prefix"])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_call_pages_hide_provider_url(client, voip_data):
|
||||
agent, customer, ticket = voip_data
|
||||
client.force_login(agent)
|
||||
for route in (
|
||||
reverse("staff_tickets:contacts"),
|
||||
reverse("staff_tickets:contact_edit", args=[customer.public_id]),
|
||||
reverse("staff_tickets:detail", args=[ticket.public_id]),
|
||||
):
|
||||
content = client.get(route).content.decode()
|
||||
assert 'data-voip-method="ip_phone"' in content
|
||||
assert 'data-voip-method="mobile"' in content
|
||||
assert reverse("staff_tickets:contact_call", args=[customer.public_id]) in content
|
||||
assert "http://127.0.0.1:9000/v1/click2call" not in content
|
||||
assert "voip-call.js" in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_call_method_availability(client, voip_data):
|
||||
agent, _, ticket = voip_data
|
||||
agent.voip_mobile = ""
|
||||
agent.save(update_fields=["voip_mobile"])
|
||||
client.force_login(agent)
|
||||
content = client.get(reverse("staff_tickets:detail", args=[ticket.public_id])).content.decode()
|
||||
assert 'data-voip-method="ip_phone"' in content
|
||||
assert 'data-voip-method="mobile"' not in content
|
||||
|
||||
agent.voip_call = False
|
||||
agent.save(update_fields=["voip_call"])
|
||||
content = client.get(reverse("staff_tickets:detail", args=[ticket.public_id])).content.decode()
|
||||
assert "data-voip-call" not in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_voip_error_is_minimal(client, voip_data):
|
||||
agent, customer, _ = voip_data
|
||||
client.force_login(agent)
|
||||
with patch("apps.integrations.voip.requests.post", side_effect=requests.ConnectionError("provider detail")):
|
||||
response = client.post(
|
||||
reverse("staff_tickets:contact_call", args=[customer.public_id]),
|
||||
data=json.dumps({"method": "mobile"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 502
|
||||
assert response.json() == {"success": False, "message": "برقراری تماس ناموفق بود."}
|
||||
|
||||
agent.voip_call = False
|
||||
agent.save(update_fields=["voip_call"])
|
||||
response = client.post(
|
||||
reverse("staff_tickets:contact_call", args=[customer.public_id]),
|
||||
data=json.dumps({"method": "mobile"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
Reference in new issue
Block a user