voip click2calll

This commit is contained in:
javad committed 2026-09-26 12:51:12 +03:30
1 parent cb92abbb41
commit 38b1cf06df
18 files changed
+543 -26

No files matched your search

+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+57
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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