Files
customerTicket/apps/tickets/views.py
T

1036 lines
70 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, Max, 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 django.views.decorators.cache import never_cache
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,StaffContactForm,StaffCreateTicketForm,StaffReplyForm,TicketForm,TransferForm,UnitSMSTemplateForm
from .models import GroupDispatch,MessageShare,Ticket,TicketCategory,TicketTag
from .forms import StaffMessageShareForm, StaffTicketShareForm
from .sharing import mark_ticket_share_opened, require_share_authority, revoke_staff_share, share_staff_message, share_staff_ticket, sharing_context, staff_message_context
from .policies import DispatchPolicy, TicketPolicy
from .staff_conversation import limited_poll, message_context, shared_ticket_rows, visible_messages
from .selectors import customer_tickets,dashboard_counts,filter_staff_tickets,staff_ticket_rows
from .services import change_priority,change_status,claim_ticket,create_ticket,create_group_dispatch,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,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 and not item.deleted_at],
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,include_internal=False):
query=ticket.ticket_messages.all()
if not include_internal: query=query.filter(is_internal=False)
edits=query.aggregate(edited=Max('edited_at'),deleted=Max('deleted_at'))
from .dispatch_responses import response_control
control = response_control(ticket.group_dispatch) if ticket.group_dispatch_id else None
revision=hashlib.sha256(repr((edits, control.opened_at if control else None)).encode()).hexdigest()[:20]
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]+':'+revision
def conversation_poll_response(request,ticket,actor,include_internal=False):
from .dispatch_responses import filter_response_messages
qs=filter_response_messages(ticket.ticket_messages.all(), actor)
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,include_internal=include_internal)
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 or request.GET.get('version','').partition(':')[2]!=version.partition(':')[2]
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])
context={"conversation":conversation,"ticket":ticket,"viewer_user":actor}
template="tickets/_messages.html"
if include_internal:
context.update(message_context(ticket,actor,conversation))
template="tickets/_staff_messages.html"
html=render_to_string(template,context,request=request)
delivered = conversation[-1] if conversation else latest
response=JsonResponse({"changed":True,"version":version,"cursor":str(delivered.public_id) if delivered 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
@transaction.atomic
def customer_read(request,public_id):
from apps.messaging.read_receipts import message_read_ids
ticket=get_object_or_404(Ticket.objects.select_for_update(),public_id=public_id)
actor,_=customer_ticket_actor(request,ticket)
if actor is None: return JsonResponse({"ok":False},status=401)
try: message_ids=message_read_ids(request)
except ValidationError: return JsonResponse({"ok":False,"error":"invalid_message_ids"},status=400)
acknowledged=list(ticket.ticket_messages.filter(public_id__in=message_ids,is_internal=False,deleted_at__isnull=True))
mark_messages_read(acknowledged,actor)
from apps.notifications.services import cancel_staff_reply_sms_if_read
cancel_staff_reply_sms_if_read(ticket,actor)
return JsonResponse({"ok":True,"read":sum(item.author_id != actor.pk for item in acknowledged),"read_ids":[str(item.public_id) for item in acknowledged]})
@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=shared_ticket_rows(request.user,request.GET) if request.GET.get("tab") == "shared" else 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],"unread_message_shares":MessageShare.objects.filter(user=request.user,revoked_at__isnull=True,first_viewed_at__isnull=True,message__deleted_at__isnull=True).count()}
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
@never_cache
def staff_detail(request,public_id):
from apps.payments import policies as payment_policies, selectors as payment_selectors
from apps.payments.models import PaymentNotice
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)
visible=visible_messages(ticket,request.user)
conversation,has_older,older_cursor=conversation_page(visible.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"))
mark_ticket_share_opened(ticket,request.user)
message_sharing=message_context(ticket,request.user,conversation)
history=Ticket.objects.filter(TicketPolicy.viewable_q(request.user), customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at").distinct()[: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_id__in=visible.values("pk")) | 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
message_sharing["can_view_dispatch"] = bool(group_dispatch and DispatchPolicy.can_view(request.user, 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
customer_notices = payment_selectors.notices_with_totals(
payment_policies.visible(PaymentNotice.objects.filter(customer=ticket.customer), request.user)
).order_by('-created_at', '-pk')[:3]
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":StaffTicketShareForm(actor=request.user),"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,"customer_notices":customer_notices,"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,include_internal=True),"poll_cursor":latest.public_id if latest else "",**message_sharing,**sharing_context(ticket,request.user)})
@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)
conversation,has_older,older_cursor=conversation_page(visible_messages(ticket,request.user).select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"),request.GET.get("before"))
return render(request,"tickets/_staff_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"viewer_user":request.user,**message_context(ticket,request.user,conversation)})
@staff_required
def staff_poll(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id)
visible_messages(ticket,request.user)
if not TicketPolicy.can_view(request.user,ticket): return limited_poll(request,ticket)
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,message_id=None):
from .sharing_views import submit_share_form
ticket=get_object_or_404(Ticket,public_id=public_id)
require_share_authority(request.user,ticket)
if message_id is not None:
selected_message=get_object_or_404(Message,public_id=message_id,ticket=ticket)
form=StaffMessageShareForm(request.POST,actor=request.user)
save=lambda: share_staff_message(selected_message,request.user,form.cleaned_data["expert"],form.cleaned_data["note"])
success="فقط همین پیام و پیوست‌های آن در صندوق اشتراک‌های کارشناس قرار گرفت."
else:
form=StaffTicketShareForm(request.POST,actor=request.user)
save=lambda: share_staff_ticket(ticket,request.user,form.cleaned_data["expert"],form.cleaned_data["permission_level"],form.cleaned_data["note"])
success="اشتراک تیکت ثبت شد؛ وضعیت مشاهده در سوابق قابل پیگیری است."
submit_share_form(request,form,save,success)
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)
require_share_authority(request.user,ticket)
grant=ticket.access_grants.filter(user__public_id=user_id,revoked_at__isnull=True).first()
if grant:
revoke_staff_share(grant,request.user)
messages.success(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):
from .dispatch_presentation import dispatch_form_options
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)
}
base_dispatches = _visible_dispatches(request.user)
if not create_unit_ids and (request.method == "POST" or not (DispatchPolicy.can_list(request.user) or base_dispatches.exists())):
raise PermissionDenied
form = GroupDispatchForm(request.POST or None, request.FILES or None, user=request.user)
if request.method == "POST" and form.is_valid():
try:
dispatch, _ = create_group_dispatch(
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"],
response_mode=form.cleaned_data["response_mode"],
notify_opening_sms=form.cleaned_data["notify_opening_sms"],
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))
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,
"dispatch_options": dispatch_form_options(request.user, units, groups),
"can_create_dispatch": bool(create_unit_ids),
"history_units": OrganizationalUnit.objects.filter(pk__in=base_dispatches.values("unit_id")),
"history_groups": ContactGroup.objects.filter(pk__in=base_dispatches.values("contact_group_id")),
"active_staff_section": "dispatches", "active_tab_title": "ارسال گروهی و ردیابی",
})
@staff_required
def staff_dispatch_detail(request, public_id):
from .dispatch_responses import response_control, can_open_responses, populate_report_responses
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
control = response_control(dispatch)
locked = bool(control and control.mode == "SEALED" and not control.opened_at)
tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "notificationjob_set").order_by("customer_id"))
populate_report_responses(dispatch, tickets, locked)
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)))
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 = "در صف ثبت نشده", "برای این مخاطب سابقه ارسال پیامک ثبت نشده است.", "neutral"
elif job.status == NotificationJob.Status.FAILED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", "ارسال خودکار متوقف شده است؛ علت خطا و امکان تلاش مجدد را در ادامه بررسی کنید.", "danger"
elif job.status == NotificationJob.Status.RETRYING:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "سامانه پیامک را در موعد بعدی دوباره ارسال می‌کند.", "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 = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست؛ پیگیری خودکار تحویل ادامه دارد.", "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 = sum(bool(ticket.response_count) for ticket in tickets)
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")
opening_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, event_type="DISPATCH_RESPONSES_OPENED").values("status").annotate(count=Count("pk"))
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", "response_control": control, "responses_locked": locked, "can_open_responses": can_open_responses(request.user, dispatch), "opening_jobs": list(opening_jobs)})
@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):
return DispatchPolicy.visible_dispatches(user)
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.tags_for_event(form['event_type'].value()), "sms_tag_map": {event: SMSTemplate.tags_for_event(event) for event in SMSTemplate.Event.values}, "active_staff_section": "templates", "active_tab_title": "الگوهای پیامک"})