Files
customerTicket/apps/tickets/views.py
T
2026-09-14 15:48:13 +03:30

829 lines
59 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, 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 ContactGroup, CustomerProfile
from apps.messaging.models import Message,MessageReadReceipt
from apps.notifications.models import NotificationJob, SMSTemplate
from apps.core.iran_locations import location_data
from apps.core.persian_text import normalize_persian_text
from apps.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,revoke_access,share_ticket,transfer_ticket
MESSAGE_PAGE_SIZE=50
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,_=CustomerProfile.objects.get_or_create(user=customer)
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"])
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):
# A CUSTOMER account can temporarily exist without its one-to-one profile
# (legacy/imported or interrupted registrations). Such an account is not
# an address-book contact and would make the contacts template dereference
# a missing profile, especially for superusers whose scope includes all
# customers.
qs=User.objects.filter(
account_type=User.AccountType.CUSTOMER,
customer_profile__isnull=False,
).select_related("customer_profile")
has_unit_membership = user is not None and user.unit_memberships.filter(is_active=True, unit__is_active=True).exists()
if user is not None and has_unit_membership and not (user.is_superuser or user.has_perm("tickets.view_all_tickets")):
visible_customer_ids = staff_ticket_rows(user).values_list("customer_id", flat=True)
contact_unit_ids = _unit_ids_with_permission(user, "customers.view_customerprofile")
qs = qs.filter(Q(pk__in=visible_customer_ids) | Q(customer_profile__groups__unit_id__in=contact_unit_ids)).distinct()
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(username__icontains=search))
return qs.annotate(
ticket_count=Count("customer_tickets",distinct=True),
active_count=Count("customer_tickets",filter=~Q(customer_tickets__status__in=[Ticket.Status.RESOLVED,Ticket.Status.CLOSED]),distinct=True),
waiting_staff_count=Count("customer_tickets",filter=Q(customer_tickets__status__in=[Ticket.Status.NEW,Ticket.Status.WAITING_STAFF,Ticket.Status.REOPENED]),distinct=True),
resolved_count=Count("customer_tickets",filter=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"),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=ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")
latest=conversation[-1] if conversation else None
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),"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"viewer_user":request.user,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
@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}
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches"})
@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
retryable_delivery = [NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.EXPIRED, NotificationJob.DeliveryStatus.BLOCKED, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.ERROR]
jobs=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED).filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT,delivery_status__in=retryable_delivery,delivery_next_check_at__isnull=True))
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=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,
)
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)
def _contact_queryset(user):
"""Contacts visible through at least one of the staff member's units."""
visible_groups = ContactGroup.objects.filter(unit_id__in=_unit_ids_with_permission(user, "customers.view_customerprofile")).select_related("unit")
return CustomerProfile.objects.filter(groups__in=visible_groups).select_related("user").prefetch_related(Prefetch("groups", queryset=visible_groups, to_attr="visible_groups")).distinct()
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")
if not visible_unit_ids: raise PermissionDenied
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(),
"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"), "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):
profile = get_object_or_404(_contact_queryset(request.user), user__public_id=public_id)
manageable_unit_ids = _unit_ids_with_permission(request.user, "customers.change_customerprofile")
if not set(profile.groups.values_list("unit_id", flat=True)) & manageable_unit_ids:
raise PermissionDenied
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.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"])
messages.success(request, "اطلاعات مخاطب بروزرسانی شد.")
return redirect("staff_tickets:contacts")
return render(request, "tickets/staff_entity_form.html", {"form": form, "active_staff_section": "contacts", "active_tab_title": "ویرایش مخاطب", "form_title": str(profile), "form_help": "عضویت همزمان مخاطب در چند گروه قابل مدیریت است."})
@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": "الگوهای پیامک"})