voip announcement
This commit is contained in:
1 parent
38b1cf06df
commit
c7993aae44
18 files changed
+679
-151
No files matched your search
+20
-1
@@ -1,4 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from django import forms
|
||||
|
||||
from .models import PageTitle, ProductionSeedState, SiteBranding, VoipConfiguration
|
||||
|
||||
@@ -31,9 +32,27 @@ class SiteBrandingAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(VoipConfiguration)
|
||||
class VoipConfigurationAdmin(admin.ModelAdmin):
|
||||
fields = ("voip_call_url", "updated_at")
|
||||
fields = ("voip_call_url", "announcement_url", "announcement_id", "announcement_api_key", "clear_announcement_api_key", "announcement_delay_minutes", "updated_at")
|
||||
readonly_fields = ("updated_at",)
|
||||
|
||||
class ConfigurationForm(forms.ModelForm):
|
||||
announcement_api_key = forms.CharField(
|
||||
required=False, widget=forms.PasswordInput,
|
||||
help_text="برای حفظ کلید فعلی خالی بگذارید؛ برای تغییر، کلید جدید را وارد کنید.",
|
||||
)
|
||||
clear_announcement_api_key = forms.BooleanField(required=False, label="حذف کلید فعلی")
|
||||
|
||||
class Meta:
|
||||
model = VoipConfiguration
|
||||
fields = "__all__"
|
||||
|
||||
def clean_announcement_api_key(self):
|
||||
if self.data.get("clear_announcement_api_key"):
|
||||
return ""
|
||||
return self.cleaned_data["announcement_api_key"] or (self.instance.announcement_api_key if self.instance.pk else "")
|
||||
|
||||
form = ConfigurationForm
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return not VoipConfiguration.objects.exists()
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-28 15:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_voipconfiguration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voipconfiguration',
|
||||
name='announcement_api_key',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='ANNOUNCEMENT_API_KEY'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voipconfiguration',
|
||||
name='announcement_delay_minutes',
|
||||
field=models.PositiveIntegerField(default=90, verbose_name='تأخیر تماس اطلاع\u200cرسانی (دقیقه)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voipconfiguration',
|
||||
name='announcement_id',
|
||||
field=models.CharField(blank=True, max_length=200, verbose_name='ANNOUNCEMENT_ID'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voipconfiguration',
|
||||
name='announcement_url',
|
||||
field=models.URLField(blank=True, verbose_name='ANNOUNCEMENT_URL'),
|
||||
),
|
||||
]
|
||||
@@ -124,6 +124,10 @@ class VoipConfiguration(SingletonConfiguration):
|
||||
blank=True,
|
||||
help_text="نشانی کامل endpoint تماس، مانند http://127.0.0.1:8000/v1/click2call",
|
||||
)
|
||||
announcement_url = models.URLField("ANNOUNCEMENT_URL", blank=True)
|
||||
announcement_id = models.CharField("ANNOUNCEMENT_ID", max_length=200, blank=True)
|
||||
announcement_api_key = models.CharField("ANNOUNCEMENT_API_KEY", max_length=500, blank=True)
|
||||
announcement_delay_minutes = models.PositiveIntegerField("تأخیر تماس اطلاعرسانی (دقیقه)", default=90)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Outbound prerecorded announcement calls."""
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class AnnouncementRejected(ValueError):
|
||||
"""The provider explicitly refused the requested announcement."""
|
||||
|
||||
|
||||
def place_announcement_call(configuration, *, mobile, idempotency_key):
|
||||
headers = {"Idempotency-Key": idempotency_key}
|
||||
if configuration.announcement_api_key:
|
||||
headers["X-API-Key"] = configuration.announcement_api_key
|
||||
response = requests.post(
|
||||
configuration.announcement_url,
|
||||
json={"customer_number": mobile, "announcement_id": configuration.announcement_id},
|
||||
headers=headers,
|
||||
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
if response.content:
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Invalid announcement provider response")
|
||||
if payload.get("success") is False:
|
||||
raise AnnouncementRejected("Announcement provider rejected the request")
|
||||
@@ -49,7 +49,7 @@ def place_voip_call(*, agent, customer, method):
|
||||
response.raise_for_status()
|
||||
if response.content:
|
||||
body = response.json()
|
||||
if isinstance(body, dict) and body.get("success") is False:
|
||||
if not isinstance(body, dict) or body.get("success") is False:
|
||||
return VoipCallResult(False, "سرویس تماس درخواست را نپذیرفت.")
|
||||
return VoipCallResult(True)
|
||||
except (requests.RequestException, ValueError):
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
from django.contrib import admin
|
||||
from .models import NotificationJob,NotificationPolicy,SMSTemplate
|
||||
from .models import AnnouncementJob,NotificationJob,NotificationPolicy,SMSTemplate
|
||||
@admin.register(NotificationJob)
|
||||
class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_status_code","delivery_status","delivery_retry_source","provider_message_id","created_at"); list_filter=("channel","status","delivery_status","delivery_is_final"); readonly_fields=("provider_message_id","provider_status_code","delivery_status_code","delivery_description","delivery_checked_at","delivery_is_final","delivery_retry_source","dedupe_key","created_at","sent_at")
|
||||
|
||||
@admin.register(AnnouncementJob)
|
||||
class AnnouncementJobAdmin(admin.ModelAdmin):
|
||||
list_display = ("message", "status", "attempts", "next_attempt_at", "sent_at")
|
||||
list_filter = ("status",)
|
||||
search_fields = ("message__ticket__ticket_number", "message__ticket__customer__mobile")
|
||||
readonly_fields = ("message", "status", "attempts", "next_attempt_at", "idempotency_key", "last_error", "created_at", "sent_at")
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
@admin.register(SMSTemplate)
|
||||
class SMSTemplateAdmin(admin.ModelAdmin):
|
||||
list_display=("event_type","unit","title","is_active","updated_at")
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Process due announcement calls from the notification worker."""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import requests
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.models import VoipConfiguration
|
||||
from apps.integrations.announcements import AnnouncementRejected, place_announcement_call
|
||||
from apps.messaging.models import MessageReadReceipt
|
||||
from .models import AnnouncementJob
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _failure_code(exc):
|
||||
if isinstance(exc, requests.Timeout):
|
||||
return "service_timeout"
|
||||
if isinstance(exc, requests.ConnectionError):
|
||||
return "service_unreachable"
|
||||
if isinstance(exc, requests.HTTPError):
|
||||
return "service_http_error"
|
||||
if isinstance(exc, AnnouncementRejected):
|
||||
return "service_rejected"
|
||||
if isinstance(exc, ValueError):
|
||||
return "invalid_service_response"
|
||||
return "unexpected_error"
|
||||
|
||||
|
||||
def _cancellation_reason(job, configuration):
|
||||
message = job.message
|
||||
customer = message.ticket.customer
|
||||
if message.is_internal or message.deleted_at or message.author.account_type != "STAFF":
|
||||
return "message_unavailable"
|
||||
if MessageReadReceipt.objects.filter(message=message, user=customer).exists():
|
||||
return "customer_read"
|
||||
if message.ticket.ticket_messages.filter(author=customer, created_at__gt=message.created_at, is_internal=False).exists():
|
||||
return "customer_replied"
|
||||
if not customer.is_active or not customer.local_mobile:
|
||||
return "customer_mobile_unavailable"
|
||||
if not configuration.announcement_url or not configuration.announcement_id:
|
||||
return "announcement_not_configured"
|
||||
return ""
|
||||
|
||||
|
||||
def _deliver(job):
|
||||
try:
|
||||
configuration = VoipConfiguration.load()
|
||||
reason = _cancellation_reason(job, configuration)
|
||||
if reason:
|
||||
job.status = AnnouncementJob.Status.CANCELLED
|
||||
job.last_error = reason
|
||||
return
|
||||
place_announcement_call(
|
||||
configuration,
|
||||
mobile=job.message.ticket.customer.local_mobile,
|
||||
idempotency_key=job.idempotency_key,
|
||||
)
|
||||
except Exception as exc:
|
||||
job.attempts += 1
|
||||
job.last_error = _failure_code(exc)
|
||||
job.status = AnnouncementJob.Status.FAILED if job.attempts >= MAX_ATTEMPTS else AnnouncementJob.Status.PENDING
|
||||
job.next_attempt_at = timezone.now() + timedelta(minutes=3)
|
||||
logger.exception("Announcement call failed for job %s", job.pk)
|
||||
else:
|
||||
job.attempts += 1
|
||||
job.status = AnnouncementJob.Status.SENT
|
||||
job.sent_at = timezone.now()
|
||||
job.last_error = ""
|
||||
|
||||
|
||||
def process_due_announcements():
|
||||
now = timezone.now()
|
||||
# A crashed worker can safely retry with the same provider idempotency key.
|
||||
AnnouncementJob.objects.filter(
|
||||
status=AnnouncementJob.Status.PROCESSING,
|
||||
next_attempt_at__lt=now - timedelta(minutes=10),
|
||||
).update(status=AnnouncementJob.Status.PENDING, next_attempt_at=now)
|
||||
with transaction.atomic():
|
||||
jobs = list(
|
||||
AnnouncementJob.objects.select_for_update(skip_locked=True)
|
||||
.filter(status=AnnouncementJob.Status.PENDING, next_attempt_at__lte=now)
|
||||
.select_related("message__ticket__customer")
|
||||
.order_by("next_attempt_at", "pk")[:20]
|
||||
)
|
||||
AnnouncementJob.objects.filter(pk__in=[job.pk for job in jobs]).update(
|
||||
status=AnnouncementJob.Status.PROCESSING, next_attempt_at=now,
|
||||
)
|
||||
|
||||
for job in jobs:
|
||||
_deliver(job)
|
||||
job.save(update_fields=["status", "attempts", "next_attempt_at", "sent_at", "last_error"])
|
||||
@@ -14,6 +14,7 @@ from apps.integrations.bale.providers import get_bale_provider
|
||||
from apps.integrations.sms.providers import get_sms_provider
|
||||
from apps.messaging.models import MessageReadReceipt
|
||||
from apps.notifications.models import NotificationJob,NotificationPolicy,SMSTemplate
|
||||
from apps.notifications.announcement_worker import process_due_announcements
|
||||
from apps.notifications.services import customer_reply_bale_policy,staff_reply_policy
|
||||
from apps.tickets.models import Ticket
|
||||
|
||||
@@ -343,6 +344,8 @@ class Command(BaseCommand):
|
||||
if job.ticket_id and job.ticket.group_dispatch_id:
|
||||
from apps.tickets.services import refresh_group_dispatch_status
|
||||
refresh_group_dispatch_status(job.ticket.group_dispatch_id)
|
||||
if not options.get("job_id") and not options.get("channel") and not options.get("min_id"):
|
||||
process_due_announcements()
|
||||
_process_delivery_checks(options)
|
||||
if options["once"] or options.get("job_id"): break
|
||||
time.sleep(5)
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-28 15:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('messaging', '0003_messagereadreceipt'),
|
||||
('notifications', '0014_backfill_safe_0098_delivery_retries'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AnnouncementJob',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('PENDING', 'در صف'), ('PROCESSING', 'در حال تماس'), ('SENT', 'ارسال\u200cشده'), ('FAILED', 'ناموفق'), ('CANCELLED', 'لغوشده')], db_index=True, default='PENDING', max_length=15)),
|
||||
('attempts', models.PositiveSmallIntegerField(default=0)),
|
||||
('next_attempt_at', models.DateTimeField(db_index=True)),
|
||||
('idempotency_key', models.CharField(max_length=100, unique=True)),
|
||||
('last_error', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('sent_at', models.DateTimeField(blank=True, null=True)),
|
||||
('message', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='announcement_job', to='messaging.message')),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['status', 'next_attempt_at'], name='announce_due_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -70,3 +70,49 @@ class NotificationJob(models.Model):
|
||||
)
|
||||
class Meta:
|
||||
indexes=[models.Index(fields=["status","next_attempt_at"],name="notif_due_idx"),models.Index(fields=["ticket","channel","status"],name="notif_ticket_chan_idx")]
|
||||
|
||||
|
||||
class AnnouncementJob(models.Model):
|
||||
"""One durable call reminder for each public message written by staff."""
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "PENDING", "در صف"
|
||||
PROCESSING = "PROCESSING", "در حال تماس"
|
||||
SENT = "SENT", "ارسالشده"
|
||||
FAILED = "FAILED", "ناموفق"
|
||||
CANCELLED = "CANCELLED", "لغوشده"
|
||||
|
||||
message = models.OneToOneField("messaging.Message", on_delete=models.CASCADE, related_name="announcement_job")
|
||||
status = models.CharField(max_length=15, choices=Status.choices, default=Status.PENDING, db_index=True)
|
||||
attempts = models.PositiveSmallIntegerField(default=0)
|
||||
next_attempt_at = models.DateTimeField(db_index=True)
|
||||
idempotency_key = models.CharField(max_length=100, unique=True)
|
||||
last_error = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
sent_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [models.Index(fields=["status", "next_attempt_at"], name="announce_due_idx")]
|
||||
|
||||
@property
|
||||
def status_label(self):
|
||||
if self.status == self.Status.SENT:
|
||||
return "درخواست تماس پذیرفته شد"
|
||||
if self.status == self.Status.PENDING and self.attempts:
|
||||
return "در انتظار تلاش مجدد"
|
||||
return self.get_status_display()
|
||||
|
||||
@property
|
||||
def detail_label(self):
|
||||
return {
|
||||
"customer_read": "مشتری پیام را مشاهده کرد.",
|
||||
"customer_replied": "مشتری پس از پیام پاسخ داد.",
|
||||
"message_unavailable": "پیام برای اطلاعرسانی در دسترس نیست.",
|
||||
"customer_mobile_unavailable": "شماره همراه مشتری در دسترس نیست.",
|
||||
"announcement_not_configured": "تنظیمات تماس اطلاعرسانی کامل نیست.",
|
||||
"service_timeout": "سرویس تماس در مهلت مقرر پاسخ نداد.",
|
||||
"service_unreachable": "ارتباط با سرویس تماس برقرار نشد.",
|
||||
"service_http_error": "سرویس تماس درخواست را با خطا پاسخ داد.",
|
||||
"service_rejected": "سرویس تماس درخواست را نپذیرفت.",
|
||||
"invalid_service_response": "پاسخ سرویس تماس معتبر نبود.",
|
||||
"unexpected_error": "خطای غیرمنتظره در پردازش درخواست تماس رخ داد.",
|
||||
}.get(self.last_error, "وضعیت درخواست تماس نیاز به بررسی دارد." if self.last_error else "")
|
||||
@@ -1,10 +1,22 @@
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
from django.utils import timezone
|
||||
from .models import NotificationJob,NotificationPolicy
|
||||
from apps.core.models import VoipConfiguration
|
||||
from .models import AnnouncementJob,NotificationJob,NotificationPolicy
|
||||
def enqueue(channel,event_type,ticket,dedupe_key,payload=None,customer=None,staff_user=None):
|
||||
return NotificationJob.objects.get_or_create(dedupe_key=dedupe_key,defaults={"channel":channel,"event_type":event_type,"ticket":ticket,"payload":payload or {},"customer":customer,"staff_user":staff_user,"next_attempt_at":timezone.now()})[0]
|
||||
|
||||
def schedule_announcement(message):
|
||||
"""The ticket transaction also commits this one-message reminder."""
|
||||
delay = VoipConfiguration.load().announcement_delay_minutes
|
||||
return AnnouncementJob.objects.get_or_create(
|
||||
message=message,
|
||||
defaults={
|
||||
"next_attempt_at": message.created_at + timedelta(minutes=delay),
|
||||
"idempotency_key": f"staff-message-{message.public_id.hex}",
|
||||
},
|
||||
)[0]
|
||||
|
||||
def enqueue_unit_supervisor_ticket_sms(ticket):
|
||||
"""Queue one concise new-ticket SMS for each configured unit supervisor."""
|
||||
unit = ticket.owning_unit
|
||||
|
||||
@@ -7,7 +7,7 @@ from apps.attachments.services import save_attachments
|
||||
from apps.core.persian_text import normalize_persian_text
|
||||
from apps.messaging.models import Message,MessageMention
|
||||
from apps.notifications.models import NotificationJob
|
||||
from apps.notifications.services import cancel_staff_reply_sms,enqueue,enqueue_unit_supervisor_ticket_sms,schedule_customer_reply_bale,schedule_staff_reply_sms
|
||||
from apps.notifications.services import cancel_staff_reply_sms,enqueue,enqueue_unit_supervisor_ticket_sms,schedule_announcement,schedule_customer_reply_bale,schedule_staff_reply_sms
|
||||
from .models import GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketEvent
|
||||
from .policies import DispatchPolicy, TicketPolicy
|
||||
|
||||
@@ -37,6 +37,7 @@ def create_ticket(*,customer,category,subject,selected_expert=None,body="",files
|
||||
ticket.full_clean(); ticket.save(); ticket.tags.set(tags); message=Message.objects.create(ticket=ticket,author=actor,body=body); save_attachments(message,files,actor); TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.CREATED,actor=actor,metadata={"staff_initiated":staff_initiated,"group_dispatch_id":group_dispatch.pk if group_dispatch else None,"unit_id":owning_unit.pk if owning_unit else None})
|
||||
if staff_initiated:
|
||||
enqueue(NotificationJob.Channel.SMS,"STAFF_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:staff-created",customer=customer)
|
||||
schedule_announcement(message)
|
||||
else:
|
||||
enqueue(NotificationJob.Channel.SMS,"CUSTOMER_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:customer-created",customer=customer)
|
||||
if owning_unit and owning_unit.bale_chat_id:
|
||||
@@ -214,6 +215,7 @@ def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_user
|
||||
ticket.last_staff_message_at=now; ticket.status=Ticket.Status.WAITING_CUSTOMER
|
||||
if not ticket.first_response_at: ticket.first_response_at=now
|
||||
schedule_staff_reply_sms(ticket,msg)
|
||||
schedule_announcement(msg)
|
||||
ticket.save(); return msg
|
||||
|
||||
@transaction.atomic
|
||||
|
||||
@@ -23,7 +23,7 @@ from apps.accounts.models import TicketMagicLink, User, normalize_mobile
|
||||
from apps.accounts.magic_access import get_ticket_magic_access
|
||||
from apps.customers.models import ContactGroup, CustomerProfile
|
||||
from apps.messaging.models import Message,MessageReadReceipt
|
||||
from apps.notifications.models import NotificationJob, SMSTemplate
|
||||
from apps.notifications.models import AnnouncementJob, 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
|
||||
@@ -444,6 +444,7 @@ def staff_detail(request,public_id):
|
||||
last=conversation[-1] if conversation else None; TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last})
|
||||
history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5]
|
||||
sms_jobs=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at"))
|
||||
announcement_jobs=list(AnnouncementJob.objects.filter(message__ticket=ticket).select_related("message").order_by("-message__created_at", "-pk"))
|
||||
can_retry_sms=TicketPolicy.can_reply(request.user,ticket)
|
||||
for sms_job in sms_jobs:
|
||||
sms_job.sms_retryable=can_retry_sms and _sms_job_is_retryable(sms_job)
|
||||
@@ -451,7 +452,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,"voip_call_methods":_voip_call_methods(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,"announcement_jobs":announcement_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):
|
||||
|
||||
Reference in new issue
Block a user