This commit is contained in:
javad committed 2026-09-10 11:34:19 +03:30
commit fa9d154db9
260 files changed
+17643

No files matched your search

+766
View File
@@ -0,0 +1,766 @@
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):
qs=User.objects.filter(account_type=User.AccountType.CUSTOMER).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]
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,"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"))
for ticket in tickets:
ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS), None)
ticket.latest_customer_response = next((message for message in reversed(list(ticket.ticket_messages.all())) if message.author_id == ticket.customer_id), None)
sms_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, channel=NotificationJob.Channel.SMS)
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_counts.get(NotificationJob.Status.FAILED, 0)
opened = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch, view_count__gt=0).values("ticket_id").distinct().count()
replied = dispatch.tickets.filter(last_customer_message_at__isnull=False).count()
expected = dispatch.recipient_count
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"])
metrics = {"sent": sent, "failed": failed, "pending": max(expected - sent - failed, 0), "opened": opened, "replied": replied}
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "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
count=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,status=NotificationJob.Status.FAILED).update(status=NotificationJob.Status.PENDING,attempts=0,next_attempt_at=timezone.now(),last_error="")
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": "الگوهای پیامک"})