999 lines
67 KiB
Python
999 lines
67 KiB
Python
from django.contrib import messages
|
||
from django.conf import settings
|
||
from django.core.paginator import Paginator
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.contrib.auth.views import redirect_to_login
|
||
import mimetypes
|
||
from django.core.exceptions import PermissionDenied,ValidationError
|
||
from django.http import FileResponse, HttpResponse, JsonResponse
|
||
from django.template.loader import render_to_string
|
||
from django.db import transaction
|
||
from django.db.models import Count, Prefetch, Q, Sum
|
||
from django.shortcuts import get_object_or_404,redirect,render
|
||
from django.urls import reverse
|
||
from django.utils import timezone
|
||
from django.utils.dateparse import parse_date
|
||
from django.views.decorators.http import require_http_methods, require_POST
|
||
from uuid import uuid4
|
||
import hashlib
|
||
import json
|
||
import re
|
||
from difflib import SequenceMatcher
|
||
from apps.accounts.models import TicketMagicLink, User, normalize_mobile
|
||
from apps.accounts.magic_access import get_ticket_magic_access
|
||
from apps.customers.models import ContactEditLog, ContactGroup, CustomerProfile
|
||
from apps.messaging.models import Message,MessageReadReceipt
|
||
from apps.notifications.models import AnnouncementJob, NotificationJob, SMSTemplate
|
||
from apps.notifications.services import schedule_immediate_announcement
|
||
from apps.core.models import VoipConfiguration
|
||
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
|
||
from .policies import DispatchPolicy, TicketPolicy
|
||
from .selectors import customer_tickets,dashboard_counts,filter_staff_tickets,staff_ticket_rows
|
||
from .services import change_priority,change_status,claim_ticket,create_ticket,dispatch_group_tickets,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,revoke_access,share_ticket,transfer_ticket
|
||
MESSAGE_PAGE_SIZE=50
|
||
SMS_RETRYABLE_DELIVERY_STATUSES=(
|
||
NotificationJob.DeliveryStatus.UNDELIVERED,
|
||
NotificationJob.DeliveryStatus.EXPIRED,
|
||
NotificationJob.DeliveryStatus.BLOCKED,
|
||
NotificationJob.DeliveryStatus.UNKNOWN,
|
||
NotificationJob.DeliveryStatus.ERROR,
|
||
)
|
||
|
||
|
||
def _retryable_sms_jobs(jobs):
|
||
return jobs.filter(
|
||
Q(status=NotificationJob.Status.FAILED)
|
||
| Q(
|
||
status=NotificationJob.Status.SENT,
|
||
delivery_status__in=SMS_RETRYABLE_DELIVERY_STATUSES,
|
||
delivery_next_check_at__isnull=True,
|
||
)
|
||
)
|
||
|
||
|
||
def _sms_job_is_retryable(job):
|
||
return job.status == NotificationJob.Status.FAILED or (
|
||
job.status == NotificationJob.Status.SENT
|
||
and job.delivery_status in SMS_RETRYABLE_DELIVERY_STATUSES
|
||
and job.delivery_next_check_at is None
|
||
)
|
||
|
||
|
||
def _requeue_retryable_sms_jobs(jobs):
|
||
return _retryable_sms_jobs(jobs).update(
|
||
status=NotificationJob.Status.PENDING,
|
||
attempts=0,
|
||
next_attempt_at=timezone.now(),
|
||
last_error="",
|
||
provider_message_id="",
|
||
provider_status_code="",
|
||
sent_at=None,
|
||
delivery_status="",
|
||
delivery_status_code="",
|
||
delivery_description="",
|
||
delivery_checked_at=None,
|
||
delivery_check_attempts=0,
|
||
delivery_next_check_at=None,
|
||
delivery_is_final=False,
|
||
)
|
||
|
||
|
||
def conversation_page(queryset,before=None):
|
||
queryset=queryset.order_by("-created_at","-id")
|
||
if before:
|
||
cursor=get_object_or_404(queryset,public_id=before)
|
||
queryset=queryset.filter(Q(created_at__lt=cursor.created_at)|Q(created_at=cursor.created_at,id__lt=cursor.id))
|
||
batch=list(queryset[:MESSAGE_PAGE_SIZE+1]); has_older=len(batch)>MESSAGE_PAGE_SIZE
|
||
conversation=list(reversed(batch[:MESSAGE_PAGE_SIZE]))
|
||
return conversation,has_older,(conversation[0].public_id if conversation and has_older else None)
|
||
def mark_messages_read(conversation,user):
|
||
MessageReadReceipt.objects.bulk_create(
|
||
[MessageReadReceipt(message=item,user=user) for item in conversation if item.author_id != user.id],
|
||
ignore_conflicts=True,
|
||
)
|
||
def customer_required(view):
|
||
@login_required
|
||
def wrapped(request,*a,**kw):
|
||
if request.user.account_type!=User.AccountType.CUSTOMER: raise PermissionDenied
|
||
return view(request,*a,**kw)
|
||
return wrapped
|
||
def staff_required(view):
|
||
@login_required(login_url="/staff/login/")
|
||
def wrapped(request,*a,**kw):
|
||
if request.user.account_type!=User.AccountType.STAFF: raise PermissionDenied
|
||
return view(request,*a,**kw)
|
||
return wrapped
|
||
def customer_ticket_actor(request,ticket):
|
||
if request.user.is_authenticated:
|
||
if request.user.account_type!=User.AccountType.CUSTOMER or not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
|
||
return request.user,False
|
||
link=get_ticket_magic_access(request,ticket)
|
||
return (link.customer,True) if link else (None,False)
|
||
def conversation_version(ticket,latest):
|
||
source=f"{ticket.updated_at.isoformat()}:{latest.public_id if latest else ''}:{latest.updated_at.isoformat() if latest else ''}"
|
||
return hashlib.sha256(source.encode()).hexdigest()[:20]
|
||
def conversation_poll_response(request,ticket,actor,include_internal=False):
|
||
qs=ticket.ticket_messages.all()
|
||
if not include_internal: qs=qs.filter(is_internal=False)
|
||
latest=qs.order_by("-created_at","-id").only("id","public_id","updated_at").first()
|
||
version=conversation_version(ticket,latest)
|
||
requested_cursor = request.GET.get("cursor", "")
|
||
latest_cursor = str(latest.public_id) if latest else ""
|
||
# A client can have the newest version token but still miss the DOM update
|
||
# (navigation, aborted rendering, or a restored mobile page). Only report an
|
||
# unchanged conversation when both independent checkpoints agree.
|
||
if request.GET.get("version") == version and requested_cursor == latest_cursor:
|
||
response=JsonResponse({"changed":False,"version":version})
|
||
response["Cache-Control"]="private, no-cache, no-store, must-revalidate"
|
||
return response
|
||
cursor=requested_cursor
|
||
replace=not cursor or not latest or str(latest.public_id)==cursor
|
||
messages_qs=qs.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")
|
||
if not replace:
|
||
cursor_message=qs.filter(public_id=cursor).only("created_at","id").first()
|
||
if cursor_message:
|
||
messages_qs=messages_qs.filter(Q(created_at__gt=cursor_message.created_at)|Q(created_at=cursor_message.created_at,id__gt=cursor_message.id)).order_by("created_at","id")
|
||
else:
|
||
replace=True
|
||
if replace:
|
||
recent=list(messages_qs.order_by("-created_at","-id")[:MESSAGE_PAGE_SIZE]); conversation=list(reversed(recent))
|
||
else:
|
||
conversation=list(messages_qs[:MESSAGE_PAGE_SIZE])
|
||
if include_internal: mark_messages_read(conversation,actor)
|
||
html=render_to_string("tickets/_messages.html",{"conversation":conversation,"ticket":ticket,"viewer_user":actor},request=request)
|
||
response=JsonResponse({"changed":True,"version":version,"cursor":str(latest.public_id) if latest else "","replace":replace,"html":html,"count":qs.count(),"status":ticket.get_status_display()})
|
||
response["Cache-Control"]="private, no-cache, no-store, must-revalidate"
|
||
return response
|
||
@customer_required
|
||
def customer_list(request):
|
||
profile,_=CustomerProfile.objects.get_or_create(user=request.user)
|
||
labels={"first_name":"نام","last_name":"نام خانوادگی","province":"استان","city":"شهر"}
|
||
missing_profile_fields=[label for field,label in labels.items() if not getattr(profile,field,"").strip()]
|
||
return render(request,"tickets/customer_list.html",{"tickets":customer_tickets(request.user),"missing_profile_fields":missing_profile_fields})
|
||
@customer_required
|
||
def customer_new(request):
|
||
profile,_=CustomerProfile.objects.get_or_create(user=request.user)
|
||
initial={"province":profile.province,"city":profile.city} if request.method=="GET" else None
|
||
if initial is not None and OrganizationalUnit.objects.filter(pk=request.GET.get("unit"),is_active=True,accepts_external_tickets=True,show_in_customer_form=True).exists():
|
||
initial["unit"]=request.GET["unit"]
|
||
form=TicketForm(request.POST or None,request.FILES or None,initial=initial)
|
||
if request.method=="POST" and form.is_valid():
|
||
try:
|
||
ticket=create_ticket(customer=request.user,unit=form.cleaned_data["unit"],category=form.cleaned_data["category"],subject=form.cleaned_data["subject"],province=form.cleaned_data["province"],city=form.cleaned_data["city"],selected_expert=form.cleaned_data["selected_expert"],body=form.cleaned_data.get("body", ""),files=request.FILES.getlist("files")); return redirect("tickets:detail",public_id=ticket.public_id)
|
||
except ValidationError as exc: form.add_error(None,exc)
|
||
selected_unit_id = form.data.get("unit") if form.is_bound else form.initial.get("unit")
|
||
selected_unit = OrganizationalUnit.objects.filter(pk=selected_unit_id).first() if selected_unit_id else None
|
||
return render(request,"tickets/customer_new.html",{"form":form,"selected_unit":selected_unit})
|
||
|
||
@customer_required
|
||
def customer_new_options(request):
|
||
unit=get_object_or_404(OrganizationalUnit,pk=request.GET.get("unit"),is_active=True,accepts_external_tickets=True,show_in_customer_form=True)
|
||
categories=TicketCategory.objects.filter(unit=unit,is_active=True,show_in_customer_form=True).order_by("sort_order","title","pk")
|
||
experts=StaffProfile.objects.filter(is_active=True,is_customer_selectable=True,user__is_active=True).filter(Q(primary_unit=unit)|Q(user__unit_memberships__unit=unit,user__unit_memberships__is_active=True)).select_related("user").distinct().order_by("sort_order","display_name")
|
||
response = JsonResponse({"categories":[{"id":row.pk,"label":row.title} for row in categories],"experts":[{"id":row.pk,"name":row.display_name,"job_title":row.job_title or "کارشناس واحد","avatar_url":reverse("tickets:expert_avatar",args=[row.user.public_id])} for row in experts],"queue":{"unit_id":unit.pk,"label":f"صف عمومی {unit.name}"}})
|
||
response["Cache-Control"] = "private, no-cache, no-store, must-revalidate"
|
||
return response
|
||
|
||
def _search_text(value):
|
||
value=normalize_persian_text(value or "").lower().replace(""," ")
|
||
return " ".join(re.findall(r"[\wآ-ی]+",value))
|
||
|
||
SEARCH_ALIASES={"فیش":("پرداخت","واریز","رسید","مالی"),"واریزی":("پرداخت","رسید","مالی"),"قیمت":("استعلام","خرید","فروش"),"بار":("حمل","بارگیری","ارسال","تحویل"),"ارسال":("حمل","بارگیری","تحویل"),"موجودی":("انبار","کالا"),"فاکتور":("صورتحساب","مالی"),"شکایت":("اعتراض","پیگیری")}
|
||
SEARCH_STOP_WORDS = {"از", "با", "به", "برای", "در", "را", "که", "من", "یک", "و", "یا"}
|
||
|
||
|
||
def _meaningful_search_tokens(value):
|
||
return {
|
||
token for token in _search_text(value).split()
|
||
if len(token) >= 2 and token not in SEARCH_STOP_WORDS
|
||
}
|
||
|
||
|
||
def _field_match_score(query_tokens, field_tokens, *, exact_weight, prefix_weight):
|
||
score = 0
|
||
matched = set()
|
||
for query_token in query_tokens:
|
||
if query_token in field_tokens:
|
||
score += exact_weight
|
||
matched.add(query_token)
|
||
elif len(query_token) >= 3 and any(token.startswith(query_token) for token in field_tokens):
|
||
score += prefix_weight
|
||
matched.add(query_token)
|
||
return score, matched
|
||
|
||
|
||
def _category_search_score(category, query):
|
||
"""Rank explicit category fields first; unit/description are supporting signals."""
|
||
query_tokens = _meaningful_search_tokens(query)
|
||
expanded_tokens = set(query_tokens)
|
||
for token in query_tokens:
|
||
expanded_tokens.update(SEARCH_ALIASES.get(token, ()))
|
||
alias_tokens = expanded_tokens - query_tokens
|
||
|
||
title = _search_text(category.title)
|
||
keywords = _search_text(category.search_keywords)
|
||
description = _search_text(category.description)
|
||
unit_text = _search_text(
|
||
f"{category.unit.name} {category.unit.search_keywords}"
|
||
)
|
||
title_tokens = set(title.split())
|
||
keyword_tokens = set(keywords.split())
|
||
description_tokens = set(description.split())
|
||
unit_tokens = set(unit_text.split())
|
||
|
||
score = 0
|
||
if query == title:
|
||
score += 140
|
||
elif title.startswith(query):
|
||
score += 95
|
||
elif query in title:
|
||
score += 75
|
||
if query and query in keywords:
|
||
score += 60
|
||
|
||
title_score, title_matches = _field_match_score(
|
||
query_tokens, title_tokens, exact_weight=18, prefix_weight=12
|
||
)
|
||
keyword_score, keyword_matches = _field_match_score(
|
||
query_tokens, keyword_tokens, exact_weight=12, prefix_weight=8
|
||
)
|
||
description_score, _ = _field_match_score(
|
||
query_tokens, description_tokens, exact_weight=4, prefix_weight=2
|
||
)
|
||
unit_score, _ = _field_match_score(
|
||
query_tokens, unit_tokens, exact_weight=2, prefix_weight=1
|
||
)
|
||
alias_title_score, _ = _field_match_score(
|
||
alias_tokens, title_tokens, exact_weight=5, prefix_weight=3
|
||
)
|
||
alias_keyword_score, _ = _field_match_score(
|
||
alias_tokens, keyword_tokens, exact_weight=3, prefix_weight=2
|
||
)
|
||
score += title_score + keyword_score + description_score + unit_score
|
||
score += alias_title_score + alias_keyword_score
|
||
|
||
direct_matches = title_matches | keyword_matches
|
||
if query_tokens:
|
||
coverage = len(direct_matches) / len(query_tokens)
|
||
score += coverage * 24
|
||
if coverage == 1:
|
||
score += 20
|
||
score += SequenceMatcher(None, query, title).ratio() * 6
|
||
return score
|
||
|
||
@customer_required
|
||
def customer_new_search(request):
|
||
query=_search_text(request.GET.get("q"))
|
||
if len(query)<2: return JsonResponse({"results":[]})
|
||
results=[]
|
||
for category in TicketCategory.objects.filter(is_active=True,show_in_customer_form=True,unit__is_active=True,unit__accepts_external_tickets=True,unit__show_in_customer_form=True).select_related("unit"):
|
||
score = _category_search_score(category, query)
|
||
if score >= 10:
|
||
results.append((score,category.unit.sort_order,category.sort_order,{"unit_id":category.unit_id,"unit":category.unit.name,"category_id":category.pk,"category":category.title,"description":category.description[:120]}))
|
||
results.sort(key=lambda item:(-item[0],item[1],item[2],item[3]["unit"],item[3]["category"]))
|
||
return JsonResponse({"results":[item for *_,item in results[:6]]})
|
||
|
||
@login_required
|
||
def expert_avatar(request, public_id):
|
||
from apps.attachments.delivery import signed_media_redirect
|
||
from apps.staff.models import StaffProfile
|
||
profiles = StaffProfile.objects.select_related("user").filter(user__public_id=public_id, is_active=True, user__is_active=True)
|
||
if request.user.account_type == User.AccountType.CUSTOMER:
|
||
profiles = profiles.filter(is_customer_selectable=True)
|
||
elif request.user.account_type != User.AccountType.STAFF:
|
||
raise PermissionDenied
|
||
profile = get_object_or_404(profiles)
|
||
if profile.avatar:
|
||
if settings.USE_NGINX_MEDIA:
|
||
return signed_media_redirect(profile.avatar.name)
|
||
response=FileResponse(profile.avatar.open("rb"), content_type=mimetypes.guess_type(profile.avatar.name)[0] or "application/octet-stream")
|
||
response["Cache-Control"]="private, no-cache, must-revalidate"
|
||
return response
|
||
initials = "".join(part[0] for part in profile.display_name.split()[:2]) or "ک"
|
||
svg = f'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160"><rect width="160" height="160" rx="80" fill="#e0f2fe"/><text x="80" y="98" text-anchor="middle" font-family="Tahoma" font-size="52" font-weight="bold" fill="#075985">{initials}</text></svg>'
|
||
response=HttpResponse(svg, content_type="image/svg+xml"); response["Cache-Control"]="private, no-cache, must-revalidate"; return response
|
||
def customer_detail(request,public_id):
|
||
ticket=get_object_or_404(Ticket.objects.select_related("category","owner__staff_profile","selected_expert__staff_profile"),public_id=public_id)
|
||
actor,magic_link_access=customer_ticket_actor(request,ticket)
|
||
if actor is None: return redirect_to_login(request.get_full_path(),reverse("accounts:login"))
|
||
qs=ticket.ticket_messages.filter(is_internal=False).select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")
|
||
conversation,has_older,older_cursor=conversation_page(qs)
|
||
latest=conversation[-1] if conversation else None
|
||
return render(request,"tickets/customer_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":ReplyForm(),"viewer_user":actor,"magic_link_access":magic_link_access,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
|
||
def customer_messages(request,public_id):
|
||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||
actor,magic_link_access=customer_ticket_actor(request,ticket)
|
||
if actor is None: return redirect_to_login(request.get_full_path(),reverse("accounts:login"))
|
||
qs=ticket.ticket_messages.filter(is_internal=False).select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")
|
||
conversation,has_older,older_cursor=conversation_page(qs,request.GET.get("before"))
|
||
return render(request,"tickets/_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"older_route":"tickets:messages","viewer_user":actor,"magic_link_access":magic_link_access})
|
||
def customer_poll(request,public_id):
|
||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||
actor,_=customer_ticket_actor(request,ticket)
|
||
if actor is None: return redirect_to_login(request.get_full_path(),reverse("accounts:login"))
|
||
return conversation_poll_response(request,ticket,actor,include_internal=False)
|
||
@require_POST
|
||
def customer_read(request,public_id):
|
||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||
actor,_=customer_ticket_actor(request,ticket)
|
||
if actor is None: return JsonResponse({"ok":False},status=401)
|
||
try: message_ids=json.loads(request.body or b"{}").get("message_ids",[])
|
||
except (TypeError,ValueError): return JsonResponse({"ok":False,"error":"invalid_json"},status=400)
|
||
messages_qs=ticket.ticket_messages.filter(public_id__in=message_ids,is_internal=False).exclude(author=actor)
|
||
MessageReadReceipt.objects.bulk_create([MessageReadReceipt(message=item,user=actor) for item in messages_qs],ignore_conflicts=True)
|
||
from apps.notifications.services import cancel_staff_reply_sms_if_read
|
||
cancel_staff_reply_sms_if_read(ticket,actor)
|
||
return JsonResponse({"ok":True,"read":messages_qs.count()})
|
||
@require_POST
|
||
def customer_reply(request,public_id):
|
||
ticket=get_object_or_404(Ticket,public_id=public_id); form=ReplyForm(request.POST,request.FILES)
|
||
actor,_=customer_ticket_actor(request,ticket)
|
||
if actor is None: return redirect_to_login(request.get_full_path(),reverse("accounts:login"))
|
||
result="error"
|
||
if form.is_valid():
|
||
try: reply_ticket(ticket_id=ticket.pk,user=actor,body=form.cleaned_data.get("body", ""),files=request.FILES.getlist("files")); messages.success(request,"پاسخ شما با موفقیت در تیکت ثبت شد. در صورت نیاز میتوانید پاسخ دیگری اضافه کنید."); result="reply"
|
||
except (PermissionDenied,ValidationError) as exc: messages.error(request,str(exc))
|
||
else: messages.error(request,"پاسخ ثبت نشد. لطفاً متن یا پیوست را بررسی و دوباره تلاش کنید.")
|
||
return redirect(f'{reverse("tickets:detail",args=[public_id])}?reply_result={result}')
|
||
@staff_required
|
||
def staff_list(request):
|
||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||
qs=filter_staff_tickets(staff_ticket_rows(request.user),request.user,request.GET)
|
||
tab=request.GET.get("tab","")
|
||
tab_titles={"":"میز کار","new":"صف جدید","selected":"انتخابی من","mine":"تیکتهای من","shared":"اشتراکها","waiting_staff":"در انتظار اقدام من","waiting_customer":"در انتظار مشتری","resolved":"حلشدهها","closed":"بستهشدهها"}
|
||
if tab not in tab_titles: tab=""
|
||
unit_ids=TicketPolicy.unit_ids(request.user)
|
||
selected_tag_ids=[int(value) for value in request.GET.getlist("tag") if value.isdigit()]
|
||
selected_group_ids=[int(value) for value in request.GET.getlist("contact_group") if value.isdigit()]
|
||
categories=TicketCategory.objects.filter(is_active=True,unit_id__in=unit_ids) if unit_ids else TicketCategory.objects.filter(is_active=True)
|
||
experts=StaffProfile.objects.filter(is_active=True,user__unit_memberships__unit_id__in=unit_ids,user__unit_memberships__is_active=True).select_related("user").distinct() if unit_ids else StaffProfile.objects.filter(is_active=True).select_related("user")
|
||
context={"tickets":qs[:100],"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"delivery_types":Ticket.DeliveryType.choices,"stakeholder_types":CustomerProfile.StakeholderType.choices,"categories":categories,"experts":experts,"units":OrganizationalUnit.objects.filter(pk__in=unit_ids),"contact_groups":ContactGroup.objects.filter(unit_id__in=unit_ids,is_active=True),"selected_group_ids":selected_group_ids,"ticket_tags":TicketTag.objects.filter(is_active=True),"selected_tag_ids":selected_tag_ids,"counts":dashboard_counts(request.user),"active_tab":tab,"active_tab_title":tab_titles[tab]}
|
||
return render(request,"tickets/_staff_ticket_results.html" if request.headers.get("HX-Request") else "tickets/staff_list.html",context)
|
||
@staff_required
|
||
def staff_create(request):
|
||
unit_add_ids=_unit_ids_with_permission(request.user,"tickets.add_ticket")
|
||
if not request.user.has_perm("tickets.add_ticket") and not unit_add_ids: raise PermissionDenied
|
||
initial=None
|
||
if request.method=="GET" and request.GET.get("customer"):
|
||
preset=_customer_rows(user=request.user).filter(public_id=request.GET["customer"]).first()
|
||
if preset:
|
||
profile=getattr(preset,"customer_profile",None)
|
||
initial={"customer_id":preset.public_id,"customer_mobile":preset.mobile,"first_name":getattr(profile,"first_name","") or "","last_name":getattr(profile,"last_name","") or "","organization_name":getattr(profile,"organization_name","") or "","stakeholder_type":getattr(profile,"stakeholder_type",CustomerProfile.StakeholderType.CUSTOMER),"province":getattr(profile,"province","") or "","city":getattr(profile,"city","") or ""}
|
||
form=StaffCreateTicketForm(request.POST or None,request.FILES or None,initial=initial,user=request.user)
|
||
if request.method=="POST" and form.is_valid():
|
||
with transaction.atomic():
|
||
mobile=form.cleaned_data["customer_mobile"]
|
||
username=f"customer_{mobile[1:]}"
|
||
if User.objects.filter(username=username).exclude(mobile=mobile).exists():
|
||
username=f"{username}_{uuid4().hex[:6]}"
|
||
customer=form.cleaned_data.get("customer_user")
|
||
created=customer is None
|
||
if created:
|
||
customer=User.objects.create(mobile=mobile,username=username,account_type=User.AccountType.CUSTOMER)
|
||
if created: customer.set_unusable_password(); customer.save(update_fields=["password"])
|
||
profile,profile_created=CustomerProfile.objects.get_or_create(user=customer)
|
||
profile = CustomerProfile.objects.select_related("user").select_for_update().get(pk=profile.pk)
|
||
before = _contact_snapshot(profile) if not profile_created else None
|
||
for field in ("first_name","last_name","organization_name","stakeholder_type","province","city"):
|
||
value=form.cleaned_data.get(field)
|
||
if value: setattr(profile,field,value)
|
||
profile.save()
|
||
if form.cleaned_data.get("groups"): profile.groups.add(*form.cleaned_data["groups"])
|
||
if before is not None: _log_contact_edit(profile, request.user, before)
|
||
ticket=create_ticket(customer=customer,created_by=request.user,unit=form.cleaned_data.get("unit"),category=form.cleaned_data["category"],subject=form.cleaned_data["subject"],province=form.cleaned_data["province"],city=form.cleaned_data["city"],body=form.cleaned_data["body"],files=request.FILES.getlist("files"))
|
||
messages.success(request,"تیکت برای مشتری ثبت و پیامک لینک امن در صف ارسال قرار گرفت.")
|
||
return redirect("staff_tickets:detail",public_id=ticket.public_id)
|
||
return render(request,"tickets/staff_create.html",{"form":form,"staff_unit":TicketPolicy.primary_unit(request.user),"active_tab_title":"ثبت تیکت برای مشتری"})
|
||
|
||
def _customer_rows(search="", user=None):
|
||
# Legacy/imported CUSTOMER accounts may lack a profile. Keep them searchable
|
||
# so staff can open the editor and complete their contact information.
|
||
qs=User.objects.filter(
|
||
account_type=User.AccountType.CUSTOMER,
|
||
).select_related("customer_profile")
|
||
if search:
|
||
digits=search.translate(str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩","01234567890123456789"))
|
||
mobile_terms={digits}
|
||
try: mobile_terms.add(normalize_mobile(digits))
|
||
except ValueError: pass
|
||
mobile_query=Q()
|
||
for term in mobile_terms: mobile_query|=Q(mobile__icontains=term)
|
||
qs=qs.filter(mobile_query|Q(customer_profile__first_name__icontains=search)|Q(customer_profile__last_name__icontains=search)|Q(first_name__icontains=search)|Q(last_name__icontains=search)|Q(username__icontains=search))
|
||
visible_tickets = Q(customer_tickets__pk__in=staff_ticket_rows(user).values("pk")) if user is not None else Q()
|
||
return qs.annotate(
|
||
ticket_count=Count("customer_tickets",filter=visible_tickets,distinct=True),
|
||
active_count=Count("customer_tickets",filter=visible_tickets & ~Q(customer_tickets__status__in=[Ticket.Status.RESOLVED,Ticket.Status.CLOSED]),distinct=True),
|
||
waiting_staff_count=Count("customer_tickets",filter=visible_tickets & Q(customer_tickets__status__in=[Ticket.Status.NEW,Ticket.Status.WAITING_STAFF,Ticket.Status.REOPENED]),distinct=True),
|
||
resolved_count=Count("customer_tickets",filter=visible_tickets & Q(customer_tickets__status__in=[Ticket.Status.RESOLVED,Ticket.Status.CLOSED]),distinct=True),
|
||
).order_by("-active_count","-ticket_count","-date_joined")
|
||
|
||
@staff_required
|
||
def staff_customers(request):
|
||
target = reverse("staff_tickets:contacts")
|
||
return redirect(f"{target}?{request.GET.urlencode()}" if request.GET else target)
|
||
|
||
@staff_required
|
||
def staff_customer_search(request):
|
||
search=request.GET.get("q","").strip()
|
||
selected_id=request.GET.get("id","").strip()
|
||
if selected_id:
|
||
qs=_customer_rows(user=request.user).filter(public_id=selected_id)
|
||
elif len(search)>=2:
|
||
qs=_customer_rows(search, request.user)
|
||
else:
|
||
return JsonResponse({"results":[]})
|
||
results=[]
|
||
for customer in qs[:12]:
|
||
profile=getattr(customer,"customer_profile",None)
|
||
results.append({"id":str(customer.public_id),"mobile":customer.local_mobile,"name":str(profile) if profile else customer.local_mobile,"first_name":getattr(profile,"first_name","") or "","last_name":getattr(profile,"last_name","") or "","province":getattr(profile,"province","") or "","city":getattr(profile,"city","") or "","customer_type":profile.get_customer_type_display() if profile and profile.customer_type else "ثبت نشده","ticket_count":customer.ticket_count,"active_count":customer.active_count})
|
||
return JsonResponse({"results":results})
|
||
@staff_required
|
||
def staff_detail(request,public_id):
|
||
ticket=get_object_or_404(Ticket.objects.select_related("customer__customer_profile","category","owner__staff_profile","selected_expert__staff_profile","group_dispatch__unit","group_dispatch__contact_group"),public_id=public_id)
|
||
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
|
||
conversation,has_older,older_cursor=conversation_page(ticket.ticket_messages.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"))
|
||
mark_messages_read(conversation,request.user)
|
||
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(Q(message__ticket=ticket) | Q(ticket=ticket)).select_related("message").order_by("-created_at", "-pk"))
|
||
can_retry_sms=TicketPolicy.can_reply(request.user,ticket)
|
||
can_request_announcement=request.user.account_type==User.AccountType.STAFF and can_retry_sms
|
||
configuration=VoipConfiguration.load() if can_request_announcement else None
|
||
announcement_ready=bool(configuration and configuration.announcement_url and configuration.announcement_id and ticket.customer.is_active and ticket.customer.local_mobile)
|
||
for sms_job in sms_jobs:
|
||
sms_job.sms_retryable=can_retry_sms and _sms_job_is_retryable(sms_job)
|
||
latest=conversation[-1] if conversation else None
|
||
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,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"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_announcement_now(request, public_id):
|
||
ticket=get_object_or_404(Ticket.objects.select_related("customer"), public_id=public_id)
|
||
if request.user.account_type!=User.AccountType.STAFF or not TicketPolicy.can_reply(request.user,ticket):
|
||
raise PermissionDenied
|
||
configuration=VoipConfiguration.load()
|
||
if not configuration.announcement_url or not configuration.announcement_id:
|
||
messages.error(request,"تنظیمات اطلاعرسانی تلفنی کامل نیست.")
|
||
elif not ticket.customer.is_active or not ticket.customer.local_mobile:
|
||
messages.error(request,"شماره همراه مخاطب برای تماس در دسترس نیست.")
|
||
else:
|
||
job,created=schedule_immediate_announcement(ticket,request.user)
|
||
if created:
|
||
from apps.notifications.announcement_worker import process_due_announcements
|
||
process_due_announcements(job_id=job.pk)
|
||
job.refresh_from_db()
|
||
if job.status == AnnouncementJob.Status.SENT:
|
||
messages.success(request,"درخواست تماس توسط سرویس پذیرفته شد.")
|
||
elif job.attempts:
|
||
messages.warning(request,"سرویس تماس پاسخ نداد؛ تلاش مجدد در صف قرار گرفت.")
|
||
else:
|
||
messages.info(request,"درخواست تماس در حال پردازش است.")
|
||
else:
|
||
messages.info(request,"درخواست اطلاعرسانی تلفنی این تیکت در دقیقهٔ گذشته ثبت شده است.")
|
||
return redirect("staff_tickets:detail",public_id=public_id)
|
||
@staff_required
|
||
@require_POST
|
||
def staff_sms_retry(request,public_id,job_id):
|
||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||
if not TicketPolicy.can_reply(request.user,ticket): raise PermissionDenied
|
||
jobs=ticket.notificationjob_set.filter(pk=job_id,channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET")
|
||
count=_requeue_retryable_sms_jobs(jobs)
|
||
messages.success(request,"پیامک ناموفق دوباره در صف ارسال قرار گرفت." if count else "این پیامک در وضعیت قابل تلاش مجدد نیست.")
|
||
return redirect("staff_tickets:detail",public_id=public_id)
|
||
@staff_required
|
||
def staff_messages(request,public_id):
|
||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
|
||
conversation,has_older,older_cursor=conversation_page(ticket.ticket_messages.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"),request.GET.get("before"))
|
||
mark_messages_read(conversation,request.user)
|
||
return render(request,"tickets/_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"older_route":"staff_tickets:messages","viewer_user":request.user})
|
||
@staff_required
|
||
def staff_poll(request,public_id):
|
||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
|
||
return conversation_poll_response(request,ticket,request.user,include_internal=True)
|
||
@staff_required
|
||
@require_POST
|
||
def staff_claim(request,public_id):
|
||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||
try: claim_ticket(ticket.pk,request.user); messages.success(request,"تیکت به شما اختصاص یافت.")
|
||
except PermissionDenied as exc: messages.error(request,str(exc))
|
||
return redirect("staff_tickets:detail",public_id=public_id)
|
||
@staff_required
|
||
@require_POST
|
||
def staff_reply(request,public_id,note=False):
|
||
ticket=get_object_or_404(Ticket,public_id=public_id); form=StaffReplyForm(request.POST,request.FILES)
|
||
result="error"
|
||
if form.is_valid():
|
||
try:
|
||
reply_ticket(ticket_id=ticket.pk,user=request.user,body=form.cleaned_data.get("body", ""),files=request.FILES.getlist("files"),internal=note,mentioned_users=form.cleaned_data["mentioned_experts"])
|
||
result="note" if note else "reply"
|
||
messages.success(request,"یادداشت کارشناسی با موفقیت در پرونده ثبت شد." if note else "پاسخ رسمی با موفقیت در تیکت ثبت و برای مشتری ارسال شد. در صورت نیاز میتوانید پاسخ دیگری ثبت کنید.")
|
||
except (PermissionDenied,ValidationError) as exc: messages.error(request,str(exc))
|
||
else: messages.error(request,"پاسخ ثبت نشد. لطفاً متن، پیوست و اطلاعات فرم را بررسی کنید.")
|
||
return redirect(f'{reverse("staff_tickets:detail",args=[public_id])}?reply_result={result}')
|
||
@staff_required
|
||
@require_POST
|
||
def staff_transfer(request,public_id):
|
||
ticket=get_object_or_404(Ticket,public_id=public_id); form=TransferForm(request.POST)
|
||
if form.is_valid():
|
||
try: transfer_ticket(ticket.pk,request.user,form.cleaned_data["expert"]); messages.success(request,"تیکت منتقل شد.")
|
||
except PermissionDenied: messages.error(request,"اجازه انتقال این تیکت را ندارید.")
|
||
return redirect("staff_tickets:detail",public_id=public_id)
|
||
@staff_required
|
||
@require_POST
|
||
def staff_share(request,public_id):
|
||
ticket=get_object_or_404(Ticket,public_id=public_id); form=ShareForm(request.POST)
|
||
if form.is_valid():
|
||
try: share_ticket(ticket,request.user,form.cleaned_data["expert"],form.cleaned_data["permission_level"]); messages.success(request,"دسترسی ایجاد شد.")
|
||
except PermissionDenied: messages.error(request,"اجازه اشتراک این تیکت را ندارید.")
|
||
return redirect("staff_tickets:detail",public_id=public_id)
|
||
|
||
@staff_required
|
||
@require_POST
|
||
def staff_revoke(request,public_id,user_id):
|
||
ticket=get_object_or_404(Ticket,public_id=public_id); target=get_object_or_404(User,public_id=user_id)
|
||
try: revoke_access(ticket,request.user,target); messages.success(request,"دسترسی کارشناس لغو شد.")
|
||
except PermissionDenied: messages.error(request,"اجازه لغو این دسترسی را ندارید.")
|
||
return redirect("staff_tickets:detail",public_id=public_id)
|
||
|
||
@staff_required
|
||
@require_POST
|
||
def staff_status(request,public_id):
|
||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||
try: change_status(ticket,request.user,request.POST.get("status")); messages.success(request,"وضعیت تغییر کرد.")
|
||
except (PermissionDenied,ValidationError) as exc: messages.error(request,str(exc))
|
||
return redirect("staff_tickets:detail",public_id=public_id)
|
||
|
||
@staff_required
|
||
@require_POST
|
||
def staff_priority(request,public_id):
|
||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||
try: change_priority(ticket,request.user,request.POST.get("priority")); messages.success(request,"اولویت تغییر کرد.")
|
||
except (PermissionDenied,ValidationError) as exc: messages.error(request,str(exc))
|
||
return redirect("staff_tickets:detail",public_id=public_id)
|
||
|
||
@staff_required
|
||
@require_POST
|
||
def staff_delete(request, public_id):
|
||
ticket = get_object_or_404(Ticket, public_id=public_id)
|
||
try:
|
||
from .services import delete_ticket
|
||
delete_ticket(ticket_id=ticket.pk, user=request.user)
|
||
messages.success(request, "تیکت و تمام پیامها و فایلهای آن برای همیشه حذف شد.")
|
||
return redirect("staff_tickets:list")
|
||
except PermissionDenied as exc:
|
||
messages.error(request, str(exc))
|
||
return redirect("staff_tickets:detail", public_id=public_id)
|
||
|
||
|
||
@staff_required
|
||
@require_http_methods(["GET", "POST"])
|
||
def staff_dispatch_create(request):
|
||
form = GroupDispatchForm(request.POST or None, request.FILES or None, user=request.user)
|
||
if request.method == "POST" and form.is_valid():
|
||
try:
|
||
dispatch, _ = dispatch_group_tickets(
|
||
user=request.user,
|
||
unit=form.cleaned_data["unit"],
|
||
contact_group=form.cleaned_data["contact_group"],
|
||
category=form.cleaned_data["category"],
|
||
subject=form.cleaned_data["subject"],
|
||
body=form.cleaned_data["body"],
|
||
operation_type=form.cleaned_data["operation_type"],
|
||
tags=form.cleaned_data["tags"],
|
||
files=request.FILES.getlist("files"),
|
||
)
|
||
messages.success(request, "برای هر مخاطب یک تیکت مستقل ساخته و پیامک آن در صف قرار گرفت.")
|
||
return redirect("staff_tickets:dispatch_detail", public_id=dispatch.public_id)
|
||
except (PermissionDenied, ValidationError) as exc:
|
||
form.add_error(None, str(exc))
|
||
create_unit_ids = {
|
||
unit.pk for unit in OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user), is_active=True)
|
||
if any(DispatchPolicy.can_create(request.user, unit, kind) for kind in GroupDispatch.OperationType.values)
|
||
}
|
||
if not create_unit_ids:
|
||
raise PermissionDenied
|
||
base_dispatches = _visible_dispatches(request.user)
|
||
metrics_row = base_dispatches.aggregate(total=Count("id"), recipients=Sum("recipient_count"))
|
||
metrics = {
|
||
"total": metrics_row["total"] or 0,
|
||
"recipients": metrics_row["recipients"] or 0,
|
||
"sent": base_dispatches.filter(status=GroupDispatch.Status.SENT).count(),
|
||
"attention": base_dispatches.filter(status__in=(GroupDispatch.Status.PARTIAL, GroupDispatch.Status.QUEUED)).count(),
|
||
}
|
||
dispatches = base_dispatches.select_related("unit", "contact_group", "created_by")
|
||
q = request.GET.get("q", "").strip()
|
||
if q:
|
||
dispatches = dispatches.filter(Q(subject__icontains=q) | Q(contact_group__name__icontains=q) | Q(created_by__username__icontains=q))
|
||
status = request.GET.get("status", "")
|
||
if status in GroupDispatch.Status.values:
|
||
dispatches = dispatches.filter(status=status)
|
||
unit = request.GET.get("unit", "")
|
||
if unit.isdigit() and base_dispatches.filter(unit_id=unit).exists():
|
||
dispatches = dispatches.filter(unit_id=unit)
|
||
group = request.GET.get("group", "")
|
||
if group.isdigit():
|
||
dispatches = dispatches.filter(contact_group_id=group)
|
||
created_from = parse_date(request.GET.get("created_from", ""))
|
||
created_to = parse_date(request.GET.get("created_to", ""))
|
||
if created_from:
|
||
dispatches = dispatches.filter(created_at__date__gte=created_from)
|
||
if created_to:
|
||
dispatches = dispatches.filter(created_at__date__lte=created_to)
|
||
page = Paginator(dispatches.order_by("-created_at"), 12).get_page(request.GET.get("page"))
|
||
for item in page.object_list:
|
||
refresh_group_dispatch_status(item.pk)
|
||
units = OrganizationalUnit.objects.filter(pk__in=create_unit_ids, is_active=True).order_by("name")
|
||
groups = ContactGroup.objects.filter(unit_id__in=create_unit_ids, is_active=True).select_related("unit").order_by("unit__name", "name")
|
||
return render(request, "tickets/staff_dispatch_create.html", {
|
||
"form": form, "dispatches": page.object_list, "page": page, "metrics": metrics,
|
||
"units": units, "groups": groups, "dispatch_statuses": GroupDispatch.Status.choices,
|
||
"active_staff_section": "dispatches", "active_tab_title": "ارسال گروهی و ردیابی",
|
||
})
|
||
|
||
|
||
@staff_required
|
||
def staff_dispatch_detail(request, public_id):
|
||
dispatch = get_object_or_404(
|
||
GroupDispatch.objects.select_related("unit", "contact_group", "created_by"),
|
||
public_id=public_id,
|
||
)
|
||
if not DispatchPolicy.can_view(request.user, dispatch):
|
||
raise PermissionDenied
|
||
tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "ticket_messages", "notificationjob_set").order_by("customer_id"))
|
||
delivery_failures = {
|
||
NotificationJob.DeliveryStatus.UNDELIVERED,
|
||
NotificationJob.DeliveryStatus.EXPIRED,
|
||
NotificationJob.DeliveryStatus.BLOCKED,
|
||
NotificationJob.DeliveryStatus.UNKNOWN,
|
||
NotificationJob.DeliveryStatus.ERROR,
|
||
}
|
||
now = timezone.now()
|
||
for ticket in tickets:
|
||
ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS and job.event_type == SMSTemplate.Event.STAFF_TICKET_CREATED), None)
|
||
ticket.sms_retryable = bool(ticket.sms_job and (ticket.sms_job.status == NotificationJob.Status.FAILED or (ticket.sms_job.delivery_status in delivery_failures and not ticket.sms_job.delivery_next_check_at)))
|
||
ticket.latest_customer_response = next((message for message in reversed(list(ticket.ticket_messages.all())) if message.author_id == ticket.customer_id), None)
|
||
links = list(ticket.magic_links.all())
|
||
ticket.magic_total_views = sum(link.view_count for link in links)
|
||
ticket.magic_link_count = len(links)
|
||
ticket.magic_active_count = sum(not link.revoked_at and link.expires_at > now for link in links)
|
||
ticket.magic_last_used_at = max((link.used_at for link in links if link.used_at), default=None)
|
||
job = ticket.sms_job
|
||
if not job:
|
||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب job پیامک پیدا نشد.", "neutral"
|
||
elif job.status == NotificationJob.Status.FAILED:
|
||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", f"ارسال خودکار پس از {1 + settings.SMS_MAX_RETRIES} تلاش متوقف شده است.", "danger"
|
||
elif job.status == NotificationJob.Status.RETRYING:
|
||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "worker پیامک را در موعد بعدی دوباره ارسال میکند.", "warning"
|
||
elif job.status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING):
|
||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ارسال", "پیامک هنوز به درگاه تحویل نشده است.", "info"
|
||
elif job.delivery_status == NotificationJob.DeliveryStatus.DELIVERED:
|
||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل به گوشی تأیید شد", "اپراتور دریافت پیامک توسط مخاطب را تأیید کرده است.", "success"
|
||
elif job.delivery_status in (NotificationJob.DeliveryStatus.PENDING, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.CHECK_FAILED) and job.delivery_next_check_at:
|
||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست و worker استعلام را ادامه میدهد.", "warning"
|
||
elif job.delivery_status == NotificationJob.DeliveryStatus.BLOCKED:
|
||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "دریافت پیامک مسدود است", "شماره مخاطب دریافت پیامک تبلیغاتی را بسته است.", "danger"
|
||
elif job.delivery_status == NotificationJob.DeliveryStatus.EXPIRED:
|
||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "مهلت تحویل پایان یافت", "اپراتور پیامک را در بازه مجاز تحویل نداده است.", "danger"
|
||
elif job.delivery_status in (NotificationJob.DeliveryStatus.ERROR, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED):
|
||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل تأیید نشد", "بازه پیگیری پایان یافته یا درگاه خطای قطعی برگردانده است.", "danger"
|
||
else:
|
||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "پذیرفتهشده توسط درگاه", "شناسه ارسال ثبت شده و در انتظار نتیجه دلیوری است.", "info"
|
||
sms_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, channel=NotificationJob.Channel.SMS, event_type=SMSTemplate.Event.STAFF_TICKET_CREATED)
|
||
sms_counts = {row["status"]: row["count"] for row in sms_jobs.values("status").annotate(count=Count("id"))}
|
||
sent = sms_counts.get(NotificationJob.Status.SENT, 0)
|
||
failed = sms_jobs.filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT, delivery_status__in=delivery_failures, delivery_next_check_at__isnull=True)).count()
|
||
delivered = sms_jobs.filter(delivery_status=NotificationJob.DeliveryStatus.DELIVERED).count()
|
||
retrying = sms_counts.get(NotificationJob.Status.RETRYING, 0)
|
||
tracking = sms_jobs.filter(delivery_next_check_at__isnull=False).count()
|
||
opened = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch, view_count__gt=0).values("ticket_id").distinct().count()
|
||
total_views = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch).aggregate(total=Sum("view_count"))["total"] or 0
|
||
replied = dispatch.tickets.filter(last_customer_message_at__isnull=False).count()
|
||
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"])
|
||
pending = sum(sms_counts.get(status, 0) for status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING, NotificationJob.Status.RETRYING))
|
||
delivery_percent = round((delivered / dispatch.recipient_count) * 100) if dispatch.recipient_count else 0
|
||
metrics = {"sent": sent, "delivered": delivered, "failed": failed, "retrying": retrying, "tracking": tracking, "pending": pending, "opened": opened, "not_opened": max(dispatch.recipient_count-opened,0), "total_views": total_views, "replied": replied, "delivery_percent": delivery_percent}
|
||
open_ticket_count = sum(ticket.status not in (Ticket.Status.RESOLVED, Ticket.Status.CLOSED) for ticket in tickets)
|
||
can_resolve_dispatch = request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, dispatch.unit, "tickets.change_ticket")
|
||
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "open_ticket_count": open_ticket_count, "can_resolve_dispatch": can_resolve_dispatch, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches"})
|
||
|
||
|
||
@staff_required
|
||
@require_POST
|
||
def staff_dispatch_resolve(request, public_id):
|
||
dispatch = get_object_or_404(GroupDispatch, public_id=public_id)
|
||
try:
|
||
count = resolve_group_dispatch_tickets(dispatch=dispatch, user=request.user)
|
||
messages.success(request, f"{count} تیکت این ارسال گروهی حلشده علامتگذاری شد." if count else "همه تیکتهای این ارسال گروهی از قبل حل یا بسته شدهاند.")
|
||
except PermissionDenied as exc:
|
||
messages.error(request, str(exc))
|
||
return_ticket = request.POST.get("return_ticket", "").strip()
|
||
if return_ticket and dispatch.tickets.filter(public_id=return_ticket).exists():
|
||
return redirect("staff_tickets:detail", public_id=return_ticket)
|
||
return redirect("staff_tickets:dispatch_detail", public_id=public_id)
|
||
|
||
|
||
@staff_required
|
||
@require_POST
|
||
def staff_dispatch_retry(request, public_id):
|
||
dispatch=get_object_or_404(GroupDispatch,public_id=public_id)
|
||
if not DispatchPolicy.can_view(request.user,dispatch) or not DispatchPolicy.can_create(request.user,dispatch.unit,dispatch.operation_type): raise PermissionDenied
|
||
jobs=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED)
|
||
job_id=request.POST.get("job_id","").strip()
|
||
if job_id:
|
||
if not job_id.isdigit():
|
||
messages.error(request,"شناسه پیامک برای تلاش مجدد معتبر نیست.")
|
||
return redirect("staff_tickets:dispatch_detail",public_id=public_id)
|
||
jobs=jobs.filter(pk=int(job_id))
|
||
count=_requeue_retryable_sms_jobs(jobs)
|
||
refresh_group_dispatch_status(dispatch.pk)
|
||
messages.success(request,f"{count} ارسال ناموفق دوباره در صف قرار گرفت." if count else "ارسال ناموفقی برای تلاش مجدد وجود ندارد.")
|
||
return redirect("staff_tickets:dispatch_detail",public_id=public_id)
|
||
|
||
|
||
def _unit_ids_with_permission(user, permission):
|
||
from apps.staff.models import OrganizationalUnit
|
||
units = OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(user), is_active=True)
|
||
return {unit.pk for unit in units if user.is_superuser or user.has_perm(permission) or TicketPolicy.has_unit_permission(user, unit, permission)}
|
||
|
||
|
||
def _visible_dispatches(user):
|
||
if user.is_superuser:
|
||
return GroupDispatch.objects.all()
|
||
visibility = Q(pk__in=[])
|
||
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
|
||
for operation_type in GroupDispatch.OperationType.values:
|
||
if DispatchPolicy.can_view_all(user, operation_type):
|
||
visibility |= Q(operation_type=operation_type)
|
||
continue
|
||
view_all_units = [item.unit_id for item in memberships if DispatchPolicy.can_view_all(user, operation_type, item.unit)]
|
||
create_units = [item.unit_id for item in memberships if DispatchPolicy.can_create(user, item.unit, operation_type)]
|
||
visibility |= Q(operation_type=operation_type, unit_id__in=view_all_units)
|
||
visibility |= Q(operation_type=operation_type, unit_id__in=create_units, created_by=user)
|
||
return GroupDispatch.objects.filter(visibility)
|
||
|
||
|
||
CONTACT_AUDIT_FIELDS = {
|
||
"mobile": "شماره همراه",
|
||
"first_name": "نام",
|
||
"last_name": "نام خانوادگی",
|
||
"organization_name": "نام شرکت / سازمان",
|
||
"stakeholder_type": "نوع مخاطب",
|
||
"province": "استان",
|
||
"city": "شهر",
|
||
}
|
||
|
||
|
||
def _contact_snapshot(profile):
|
||
values = {key: profile.user.mobile if key == "mobile" else getattr(profile, key) for key in CONTACT_AUDIT_FIELDS}
|
||
values["groups"] = set(profile.groups.values_list("pk", flat=True))
|
||
return values
|
||
|
||
|
||
def _log_contact_edit(profile, actor, before):
|
||
after = _contact_snapshot(profile)
|
||
changes = [
|
||
{"field": label, "before": str(before[key]), "after": str(after[key])}
|
||
for key, label in CONTACT_AUDIT_FIELDS.items() if before[key] != after[key]
|
||
]
|
||
if before["groups"] != after["groups"]:
|
||
names = dict(ContactGroup.objects.filter(pk__in=before["groups"] | after["groups"]).values_list("pk", "name"))
|
||
changes.append({
|
||
"field": "گروهها",
|
||
"before": "، ".join(sorted(names.get(pk, str(pk)) for pk in before["groups"])),
|
||
"after": "، ".join(sorted(names.get(pk, str(pk)) for pk in after["groups"])),
|
||
})
|
||
if changes:
|
||
ContactEditLog.objects.create(
|
||
contact=profile,
|
||
contact_public_id=profile.user.public_id,
|
||
actor=actor,
|
||
actor_name=(actor.get_full_name() or actor.username)[:150],
|
||
changes=changes,
|
||
)
|
||
|
||
|
||
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):
|
||
contact = get_object_or_404(User, public_id=public_id, account_type=User.AccountType.CUSTOMER)
|
||
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 contact.mobile:
|
||
return JsonResponse({"success": False, "message": "شماره مخاطب ثبت نشده است."}, status=400)
|
||
result = place_voip_call(agent=request.user, customer=contact, 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)
|
||
for membership in user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit")
|
||
)
|
||
|
||
|
||
@staff_required
|
||
def staff_contacts(request):
|
||
search = request.GET.get("q", "").strip()
|
||
stakeholder = request.GET.get("stakeholder_type", "")
|
||
group = request.GET.get("group", "")
|
||
province = request.GET.get("province", "").strip()
|
||
visible_unit_ids = _unit_ids_with_permission(request.user, "customers.view_customerprofile")
|
||
visible_groups = ContactGroup.objects.filter(unit_id__in=visible_unit_ids).select_related("unit")
|
||
qs = _customer_rows(search, request.user).prefetch_related(Prefetch("customer_profile__groups", queryset=visible_groups, to_attr="visible_groups"))
|
||
if stakeholder in CustomerProfile.StakeholderType.values:
|
||
qs = qs.filter(customer_profile__stakeholder_type=stakeholder)
|
||
if group.isdigit():
|
||
qs = qs.filter(customer_profile__groups__id=group, customer_profile__groups__unit_id__in=visible_unit_ids)
|
||
if province:
|
||
qs = qs.filter(customer_profile__province=province)
|
||
contacts = list(qs.distinct()[:200])
|
||
base_profiles = CustomerProfile.objects.filter(user_id__in=[item.pk for item in contacts])
|
||
metrics = {
|
||
"total": len(contacts),
|
||
"customers": base_profiles.filter(stakeholder_type=CustomerProfile.StakeholderType.CUSTOMER).count() + sum(not hasattr(item, "customer_profile") for item in contacts),
|
||
"suppliers": base_profiles.filter(stakeholder_type=CustomerProfile.StakeholderType.SUPPLIER).count(),
|
||
"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"), "voip_call_methods": _voip_call_methods(request.user), "active_staff_section": "contacts", "active_tab_title": "دفترچه مخاطبان"}
|
||
return render(request, "tickets/staff_contacts.html", context)
|
||
|
||
|
||
@staff_required
|
||
@require_http_methods(["GET", "POST"])
|
||
def staff_contact_create(request):
|
||
if not _has_any_unit_permission(request.user, "customers.add_customerprofile"):
|
||
raise PermissionDenied
|
||
manageable_unit_ids = _unit_ids_with_permission(request.user, "customers.add_customerprofile")
|
||
form = StaffContactForm(request.POST or None, user=request.user, unit_ids=manageable_unit_ids)
|
||
if request.method == "POST" and form.is_valid():
|
||
with transaction.atomic():
|
||
mobile = form.cleaned_data["mobile"]
|
||
user = User.objects.create(username=f"contact_{mobile[1:]}_{uuid4().hex[:5]}", mobile=mobile, account_type=User.AccountType.CUSTOMER)
|
||
user.set_unusable_password(); user.save(update_fields=["password"])
|
||
profile = CustomerProfile.objects.create(user=user, **{key: form.cleaned_data[key] for key in ("first_name", "last_name", "organization_name", "stakeholder_type", "province", "city")})
|
||
profile.groups.set(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": "ثبت مخاطب بیرونی", "form_help": "مشتری، تامینکننده یا باربری را بدون ساخت رمز عبور ثبت کنید."})
|
||
|
||
|
||
@staff_required
|
||
@require_http_methods(["GET", "POST"])
|
||
def staff_contact_edit(request, public_id):
|
||
contact = get_object_or_404(User, public_id=public_id, account_type=User.AccountType.CUSTOMER)
|
||
profile, _ = CustomerProfile.objects.get_or_create(user=contact, defaults={"first_name": contact.first_name[:100], "last_name": contact.last_name[:100]})
|
||
manageable_unit_ids = _unit_ids_with_permission(request.user, "customers.change_customerprofile")
|
||
form = StaffContactForm(request.POST or None, user=request.user, instance=profile, unit_ids=manageable_unit_ids)
|
||
if request.method == "POST" and form.is_valid():
|
||
with transaction.atomic():
|
||
profile = CustomerProfile.objects.select_related("user").select_for_update().get(pk=profile.pk)
|
||
before = _contact_snapshot(profile)
|
||
profile.user.mobile = form.cleaned_data["mobile"]; profile.user.save(update_fields=["mobile"])
|
||
for key in ("first_name", "last_name", "organization_name", "stakeholder_type", "province", "city"):
|
||
setattr(profile, key, form.cleaned_data[key])
|
||
profile.save()
|
||
accessible = ContactGroup.objects.filter(unit_id__in=manageable_unit_ids)
|
||
profile.groups.remove(*accessible)
|
||
profile.groups.add(*form.cleaned_data["groups"])
|
||
_log_contact_edit(profile, request.user, before)
|
||
messages.success(request, "اطلاعات مخاطب بروزرسانی شد.")
|
||
return redirect("staff_tickets:contacts")
|
||
return render(request, "tickets/staff_entity_form.html", {"form": form, "contact_edit_logs": profile.edit_logs.select_related("actor")[:30], "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
|
||
@require_http_methods(["GET", "POST"])
|
||
def staff_groups(request, group_id=None):
|
||
visible_unit_ids = _unit_ids_with_permission(request.user, "customers.view_contactgroup")
|
||
editable_unit_ids = _unit_ids_with_permission(request.user, "customers.change_contactgroup" if group_id else "customers.add_contactgroup")
|
||
if not visible_unit_ids and not editable_unit_ids: raise PermissionDenied
|
||
groups = ContactGroup.objects.filter(unit_id__in=visible_unit_ids | editable_unit_ids).select_related("unit").annotate(contact_count=Count("contacts", distinct=True))
|
||
instance = get_object_or_404(groups, pk=group_id) if group_id else None
|
||
form = ContactGroupForm(request.POST or None, user=request.user, instance=instance, unit_ids=editable_unit_ids)
|
||
if request.method == "POST" and form.is_valid():
|
||
unit = form.cleaned_data["unit"]
|
||
permission = "customers.change_contactgroup" if instance else "customers.add_contactgroup"
|
||
if not (request.user.is_superuser or TicketPolicy.has_unit_permission(request.user, unit, permission)):
|
||
raise PermissionDenied
|
||
form.save(); messages.success(request, "گروه مخاطبان ذخیره شد.")
|
||
return redirect("staff_tickets:groups")
|
||
group_metrics = {"total": groups.count(), "active": groups.filter(is_active=True).count(), "memberships": sum(item.contact_count for item in groups)}
|
||
return render(request, "tickets/staff_groups.html", {"form": form, "groups": groups, "group_metrics": group_metrics, "can_edit": bool(editable_unit_ids), "active_staff_section": "contacts", "active_tab_title": "گروههای مخاطبان"})
|
||
|
||
|
||
@staff_required
|
||
def staff_magic_audit(request):
|
||
global_access = request.user.is_superuser or request.user.has_perm("accounts.view_all_magic_links")
|
||
if not global_access and not _has_any_unit_permission(request.user, "accounts.view_ticketmagiclink"):
|
||
raise PermissionDenied
|
||
links = TicketMagicLink.objects.select_related("ticket", "customer", "created_by", "created_unit").prefetch_related("access_logs")
|
||
if not global_access:
|
||
links = links.filter(created_unit_id__in=TicketPolicy.unit_ids(request.user))
|
||
q = request.GET.get("q", "").strip()
|
||
if q:
|
||
links = links.filter(Q(ticket__ticket_number__icontains=q) | Q(recipient_name__icontains=q) | Q(recipient_mobile__icontains=q))
|
||
unit=request.GET.get("unit","")
|
||
if unit.isdigit(): links=links.filter(created_unit_id=unit)
|
||
status=request.GET.get("status","")
|
||
now=timezone.now()
|
||
if status=="active": links=links.filter(revoked_at__isnull=True,expires_at__gt=now)
|
||
elif status=="opened": links=links.filter(view_count__gt=0)
|
||
elif status=="unopened": links=links.filter(view_count=0)
|
||
elif status=="expired": links=links.filter(revoked_at__isnull=True,expires_at__lte=now)
|
||
elif status=="revoked": links=links.filter(revoked_at__isnull=False)
|
||
created_from=parse_date(request.GET.get("created_from","")); created_to=parse_date(request.GET.get("created_to",""))
|
||
if created_from: links=links.filter(created_at__date__gte=created_from)
|
||
if created_to: links=links.filter(created_at__date__lte=created_to)
|
||
page=Paginator(links.order_by("-created_at"),50).get_page(request.GET.get("page"))
|
||
from apps.staff.models import OrganizationalUnit
|
||
units=OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user),is_active=True)
|
||
return render(request, "tickets/staff_magic_audit.html", {"links": page.object_list,"page":page,"units":units,"global_access":global_access,"can_revoke":request.user.is_superuser or request.user.has_perm("accounts.change_ticketmagiclink") or _has_any_unit_permission(request.user,"accounts.change_ticketmagiclink"),"now":now,"active_staff_section":"audit","active_tab_title":"ممیزی لینکهای جادویی"})
|
||
|
||
|
||
@staff_required
|
||
@require_POST
|
||
def staff_magic_revoke(request, link_id):
|
||
link=get_object_or_404(TicketMagicLink.objects.select_related("created_unit"),pk=link_id)
|
||
permitted=request.user.is_superuser or request.user.has_perm("accounts.change_ticketmagiclink") or TicketPolicy.has_unit_permission(request.user,link.created_unit,"accounts.change_ticketmagiclink")
|
||
if not permitted: raise PermissionDenied
|
||
if link.revoked_at is None:
|
||
link.revoked_at=timezone.now(); link.save(update_fields=["revoked_at"])
|
||
messages.success(request,"لینک جادویی فوراً باطل شد.")
|
||
else: messages.info(request,"این لینک قبلاً باطل شده است.")
|
||
return redirect("staff_tickets:magic_audit")
|
||
|
||
|
||
@staff_required
|
||
@require_http_methods(["GET", "POST"])
|
||
def staff_sms_templates(request, template_id=None):
|
||
visible_unit_ids = _unit_ids_with_permission(request.user, "notifications.view_smstemplate")
|
||
editable_unit_ids = _unit_ids_with_permission(request.user, "notifications.change_smstemplate" if template_id else "notifications.add_smstemplate")
|
||
can_view_globals = request.user.is_superuser or request.user.has_perm("notifications.view_smstemplate")
|
||
if not visible_unit_ids and not editable_unit_ids and not can_view_globals: raise PermissionDenied
|
||
templates = SMSTemplate.objects.filter(
|
||
Q(unit_id__in=visible_unit_ids | editable_unit_ids) | Q(unit__isnull=True)
|
||
).select_related("unit").order_by("unit__name", "event_type")
|
||
instance = get_object_or_404(templates.exclude(unit__isnull=True), pk=template_id) if template_id else None
|
||
form = UnitSMSTemplateForm(request.POST or None, user=request.user, instance=instance, unit_ids=editable_unit_ids)
|
||
if request.method == "POST" and form.is_valid():
|
||
unit = form.cleaned_data["unit"]
|
||
permission = "notifications.change_smstemplate" if instance else "notifications.add_smstemplate"
|
||
if not (request.user.is_superuser or TicketPolicy.has_unit_permission(request.user, unit, permission)):
|
||
raise PermissionDenied
|
||
form.save(); messages.success(request, "الگوی پیامک واحد ذخیره شد.")
|
||
return redirect("staff_tickets:sms_templates")
|
||
return render(request, "tickets/staff_sms_templates.html", {"form": form, "templates": templates, "allowed_tags": SMSTemplate._meta.get_field("allowed_tags").default(), "active_staff_section": "templates", "active_tab_title": "الگوهای پیامک"})
|