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.integrations.voip import place_voip_call from apps.staff.models import OrganizationalUnit, StaffProfile from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,ShareForm,StaffContactForm,StaffCreateTicketForm,StaffReplyForm,TicketForm,TransferForm,UnitSMSTemplateForm from .models import GroupDispatch,Ticket,TicketCategory,TicketReadState,TicketTag from .policies import DispatchPolicy, TicketPolicy from .selectors import customer_tickets,dashboard_counts,filter_staff_tickets,staff_ticket_rows from .services import change_priority,change_status,claim_ticket,create_ticket,dispatch_group_tickets,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,revoke_access,share_ticket,transfer_ticket MESSAGE_PAGE_SIZE=50 SMS_RETRYABLE_DELIVERY_STATUSES=( NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.EXPIRED, NotificationJob.DeliveryStatus.BLOCKED, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.ERROR, ) def _retryable_sms_jobs(jobs): return jobs.filter( Q(status=NotificationJob.Status.FAILED) | Q( status=NotificationJob.Status.SENT, delivery_status__in=SMS_RETRYABLE_DELIVERY_STATUSES, delivery_next_check_at__isnull=True, ) ) def _sms_job_is_retryable(job): return job.status == NotificationJob.Status.FAILED or ( job.status == NotificationJob.Status.SENT and job.delivery_status in SMS_RETRYABLE_DELIVERY_STATUSES and job.delivery_next_check_at is None ) def _requeue_retryable_sms_jobs(jobs): return _retryable_sms_jobs(jobs).update( status=NotificationJob.Status.PENDING, attempts=0, next_attempt_at=timezone.now(), last_error="", provider_message_id="", provider_status_code="", sent_at=None, delivery_status="", delivery_status_code="", delivery_description="", delivery_checked_at=None, delivery_check_attempts=0, delivery_next_check_at=None, delivery_is_final=False, ) def conversation_page(queryset,before=None): queryset=queryset.order_by("-created_at","-id") if before: cursor=get_object_or_404(queryset,public_id=before) queryset=queryset.filter(Q(created_at__lt=cursor.created_at)|Q(created_at=cursor.created_at,id__lt=cursor.id)) batch=list(queryset[:MESSAGE_PAGE_SIZE+1]); has_older=len(batch)>MESSAGE_PAGE_SIZE conversation=list(reversed(batch[:MESSAGE_PAGE_SIZE])) return conversation,has_older,(conversation[0].public_id if conversation and has_older else None) def mark_messages_read(conversation,user): MessageReadReceipt.objects.bulk_create( [MessageReadReceipt(message=item,user=user) for item in conversation if item.author_id != user.id], ignore_conflicts=True, ) def customer_required(view): @login_required def wrapped(request,*a,**kw): if request.user.account_type!=User.AccountType.CUSTOMER: raise PermissionDenied return view(request,*a,**kw) return wrapped def staff_required(view): @login_required(login_url="/staff/login/") def wrapped(request,*a,**kw): if request.user.account_type!=User.AccountType.STAFF: raise PermissionDenied return view(request,*a,**kw) return wrapped def customer_ticket_actor(request,ticket): if request.user.is_authenticated: if request.user.account_type!=User.AccountType.CUSTOMER or not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied return request.user,False link=get_ticket_magic_access(request,ticket) return (link.customer,True) if link else (None,False) def conversation_version(ticket,latest): source=f"{ticket.updated_at.isoformat()}:{latest.public_id if latest else ''}:{latest.updated_at.isoformat() if latest else ''}" return hashlib.sha256(source.encode()).hexdigest()[:20] def conversation_poll_response(request,ticket,actor,include_internal=False): qs=ticket.ticket_messages.all() if not include_internal: qs=qs.filter(is_internal=False) latest=qs.order_by("-created_at","-id").only("id","public_id","updated_at").first() version=conversation_version(ticket,latest) requested_cursor = request.GET.get("cursor", "") latest_cursor = str(latest.public_id) if latest else "" # A client can have the newest version token but still miss the DOM update # (navigation, aborted rendering, or a restored mobile page). Only report an # unchanged conversation when both independent checkpoints agree. if request.GET.get("version") == version and requested_cursor == latest_cursor: response=JsonResponse({"changed":False,"version":version}) response["Cache-Control"]="private, no-cache, no-store, must-revalidate" return response cursor=requested_cursor replace=not cursor or not latest or str(latest.public_id)==cursor messages_qs=qs.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile") if not replace: cursor_message=qs.filter(public_id=cursor).only("created_at","id").first() if cursor_message: messages_qs=messages_qs.filter(Q(created_at__gt=cursor_message.created_at)|Q(created_at=cursor_message.created_at,id__gt=cursor_message.id)).order_by("created_at","id") else: replace=True if replace: recent=list(messages_qs.order_by("-created_at","-id")[:MESSAGE_PAGE_SIZE]); conversation=list(reversed(recent)) else: conversation=list(messages_qs[:MESSAGE_PAGE_SIZE]) if include_internal: mark_messages_read(conversation,actor) html=render_to_string("tickets/_messages.html",{"conversation":conversation,"ticket":ticket,"viewer_user":actor},request=request) response=JsonResponse({"changed":True,"version":version,"cursor":str(latest.public_id) if latest else "","replace":replace,"html":html,"count":qs.count(),"status":ticket.get_status_display()}) response["Cache-Control"]="private, no-cache, no-store, must-revalidate" return response @customer_required def customer_list(request): profile,_=CustomerProfile.objects.get_or_create(user=request.user) labels={"first_name":"نام","last_name":"نام خانوادگی","province":"استان","city":"شهر"} missing_profile_fields=[label for field,label in labels.items() if not getattr(profile,field,"").strip()] return render(request,"tickets/customer_list.html",{"tickets":customer_tickets(request.user),"missing_profile_fields":missing_profile_fields}) @customer_required def customer_new(request): profile,_=CustomerProfile.objects.get_or_create(user=request.user) initial={"province":profile.province,"city":profile.city} if request.method=="GET" else None if initial is not None and OrganizationalUnit.objects.filter(pk=request.GET.get("unit"),is_active=True,accepts_external_tickets=True,show_in_customer_form=True).exists(): initial["unit"]=request.GET["unit"] form=TicketForm(request.POST or None,request.FILES or None,initial=initial) if request.method=="POST" and form.is_valid(): try: ticket=create_ticket(customer=request.user,unit=form.cleaned_data["unit"],category=form.cleaned_data["category"],subject=form.cleaned_data["subject"],province=form.cleaned_data["province"],city=form.cleaned_data["city"],selected_expert=form.cleaned_data["selected_expert"],body=form.cleaned_data.get("body", ""),files=request.FILES.getlist("files")); return redirect("tickets:detail",public_id=ticket.public_id) except ValidationError as exc: form.add_error(None,exc) selected_unit_id = form.data.get("unit") if form.is_bound else form.initial.get("unit") selected_unit = OrganizationalUnit.objects.filter(pk=selected_unit_id).first() if selected_unit_id else None return render(request,"tickets/customer_new.html",{"form":form,"selected_unit":selected_unit}) @customer_required def customer_new_options(request): unit=get_object_or_404(OrganizationalUnit,pk=request.GET.get("unit"),is_active=True,accepts_external_tickets=True,show_in_customer_form=True) categories=TicketCategory.objects.filter(unit=unit,is_active=True,show_in_customer_form=True).order_by("sort_order","title","pk") experts=StaffProfile.objects.filter(is_active=True,is_customer_selectable=True,user__is_active=True).filter(Q(primary_unit=unit)|Q(user__unit_memberships__unit=unit,user__unit_memberships__is_active=True)).select_related("user").distinct().order_by("sort_order","display_name") response = JsonResponse({"categories":[{"id":row.pk,"label":row.title} for row in categories],"experts":[{"id":row.pk,"name":row.display_name,"job_title":row.job_title or "کارشناس واحد","avatar_url":reverse("tickets:expert_avatar",args=[row.user.public_id])} for row in experts],"queue":{"unit_id":unit.pk,"label":f"صف عمومی {unit.name}"}}) response["Cache-Control"] = "private, no-cache, no-store, must-revalidate" return response def _search_text(value): value=normalize_persian_text(value or "").lower().replace("‌"," ") return " ".join(re.findall(r"[\wآ-ی]+",value)) SEARCH_ALIASES={"فیش":("پرداخت","واریز","رسید","مالی"),"واریزی":("پرداخت","رسید","مالی"),"قیمت":("استعلام","خرید","فروش"),"بار":("حمل","بارگیری","ارسال","تحویل"),"ارسال":("حمل","بارگیری","تحویل"),"موجودی":("انبار","کالا"),"فاکتور":("صورتحساب","مالی"),"شکایت":("اعتراض","پیگیری")} SEARCH_STOP_WORDS = {"از", "با", "به", "برای", "در", "را", "که", "من", "یک", "و", "یا"} def _meaningful_search_tokens(value): return { token for token in _search_text(value).split() if len(token) >= 2 and token not in SEARCH_STOP_WORDS } def _field_match_score(query_tokens, field_tokens, *, exact_weight, prefix_weight): score = 0 matched = set() for query_token in query_tokens: if query_token in field_tokens: score += exact_weight matched.add(query_token) elif len(query_token) >= 3 and any(token.startswith(query_token) for token in field_tokens): score += prefix_weight matched.add(query_token) return score, matched def _category_search_score(category, query): """Rank explicit category fields first; unit/description are supporting signals.""" query_tokens = _meaningful_search_tokens(query) expanded_tokens = set(query_tokens) for token in query_tokens: expanded_tokens.update(SEARCH_ALIASES.get(token, ())) alias_tokens = expanded_tokens - query_tokens title = _search_text(category.title) keywords = _search_text(category.search_keywords) description = _search_text(category.description) unit_text = _search_text( f"{category.unit.name} {category.unit.search_keywords}" ) title_tokens = set(title.split()) keyword_tokens = set(keywords.split()) description_tokens = set(description.split()) unit_tokens = set(unit_text.split()) score = 0 if query == title: score += 140 elif title.startswith(query): score += 95 elif query in title: score += 75 if query and query in keywords: score += 60 title_score, title_matches = _field_match_score( query_tokens, title_tokens, exact_weight=18, prefix_weight=12 ) keyword_score, keyword_matches = _field_match_score( query_tokens, keyword_tokens, exact_weight=12, prefix_weight=8 ) description_score, _ = _field_match_score( query_tokens, description_tokens, exact_weight=4, prefix_weight=2 ) unit_score, _ = _field_match_score( query_tokens, unit_tokens, exact_weight=2, prefix_weight=1 ) alias_title_score, _ = _field_match_score( alias_tokens, title_tokens, exact_weight=5, prefix_weight=3 ) alias_keyword_score, _ = _field_match_score( alias_tokens, keyword_tokens, exact_weight=3, prefix_weight=2 ) score += title_score + keyword_score + description_score + unit_score score += alias_title_score + alias_keyword_score direct_matches = title_matches | keyword_matches if query_tokens: coverage = len(direct_matches) / len(query_tokens) score += coverage * 24 if coverage == 1: score += 20 score += SequenceMatcher(None, query, title).ratio() * 6 return score @customer_required def customer_new_search(request): query=_search_text(request.GET.get("q")) if len(query)<2: return JsonResponse({"results":[]}) results=[] for category in TicketCategory.objects.filter(is_active=True,show_in_customer_form=True,unit__is_active=True,unit__accepts_external_tickets=True,unit__show_in_customer_form=True).select_related("unit"): score = _category_search_score(category, query) if score >= 10: results.append((score,category.unit.sort_order,category.sort_order,{"unit_id":category.unit_id,"unit":category.unit.name,"category_id":category.pk,"category":category.title,"description":category.description[:120]})) results.sort(key=lambda item:(-item[0],item[1],item[2],item[3]["unit"],item[3]["category"])) return JsonResponse({"results":[item for *_,item in results[:6]]}) @login_required def expert_avatar(request, public_id): from apps.attachments.delivery import signed_media_redirect from apps.staff.models import StaffProfile profiles = StaffProfile.objects.select_related("user").filter(user__public_id=public_id, is_active=True, user__is_active=True) if request.user.account_type == User.AccountType.CUSTOMER: profiles = profiles.filter(is_customer_selectable=True) elif request.user.account_type != User.AccountType.STAFF: raise PermissionDenied profile = get_object_or_404(profiles) if profile.avatar: if settings.USE_NGINX_MEDIA: return signed_media_redirect(profile.avatar.name) response=FileResponse(profile.avatar.open("rb"), content_type=mimetypes.guess_type(profile.avatar.name)[0] or "application/octet-stream") response["Cache-Control"]="private, no-cache, must-revalidate" return response initials = "".join(part[0] for part in profile.display_name.split()[:2]) or "ک" svg = f'{initials}' 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","group_dispatch__unit","group_dispatch__contact_group"),public_id=public_id) if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied conversation,has_older,older_cursor=conversation_page(ticket.ticket_messages.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")) mark_messages_read(conversation,request.user) last=conversation[-1] if conversation else None; TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last}) history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5] sms_jobs=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")) can_retry_sms=TicketPolicy.can_reply(request.user,ticket) for sms_job in sms_jobs: sms_job.sms_retryable=can_retry_sms and _sms_job_is_retryable(sms_job) latest=conversation[-1] if conversation else None group_dispatch = ticket.group_dispatch can_resolve_dispatch = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch) and (request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, group_dispatch.unit, "tickets.change_ticket"))) group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0 return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""}) @staff_required @require_POST def staff_sms_retry(request,public_id,job_id): ticket=get_object_or_404(Ticket,public_id=public_id) if not TicketPolicy.can_reply(request.user,ticket): raise PermissionDenied jobs=ticket.notificationjob_set.filter(pk=job_id,channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET") count=_requeue_retryable_sms_jobs(jobs) messages.success(request,"پیامک ناموفق دوباره در صف ارسال قرار گرفت." if count else "این پیامک در وضعیت قابل تلاش مجدد نیست.") return redirect("staff_tickets:detail",public_id=public_id) @staff_required def staff_messages(request,public_id): ticket=get_object_or_404(Ticket,public_id=public_id) if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied conversation,has_older,older_cursor=conversation_page(ticket.ticket_messages.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"),request.GET.get("before")) mark_messages_read(conversation,request.user) return render(request,"tickets/_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"older_route":"staff_tickets:messages","viewer_user":request.user}) @staff_required def staff_poll(request,public_id): ticket=get_object_or_404(Ticket,public_id=public_id) if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied return conversation_poll_response(request,ticket,request.user,include_internal=True) @staff_required @require_POST def staff_claim(request,public_id): ticket=get_object_or_404(Ticket,public_id=public_id) try: claim_ticket(ticket.pk,request.user); messages.success(request,"تیکت به شما اختصاص یافت.") except PermissionDenied as exc: messages.error(request,str(exc)) return redirect("staff_tickets:detail",public_id=public_id) @staff_required @require_POST def staff_reply(request,public_id,note=False): ticket=get_object_or_404(Ticket,public_id=public_id); form=StaffReplyForm(request.POST,request.FILES) result="error" if form.is_valid(): try: reply_ticket(ticket_id=ticket.pk,user=request.user,body=form.cleaned_data.get("body", ""),files=request.FILES.getlist("files"),internal=note,mentioned_users=form.cleaned_data["mentioned_experts"]) result="note" if note else "reply" messages.success(request,"یادداشت کارشناسی با موفقیت در پرونده ثبت شد." if note else "پاسخ رسمی با موفقیت در تیکت ثبت و برای مشتری ارسال شد. در صورت نیاز می‌توانید پاسخ دیگری ثبت کنید.") except (PermissionDenied,ValidationError) as exc: messages.error(request,str(exc)) else: messages.error(request,"پاسخ ثبت نشد. لطفاً متن، پیوست و اطلاعات فرم را بررسی کنید.") return redirect(f'{reverse("staff_tickets:detail",args=[public_id])}?reply_result={result}') @staff_required @require_POST def staff_transfer(request,public_id): ticket=get_object_or_404(Ticket,public_id=public_id); form=TransferForm(request.POST) if form.is_valid(): try: transfer_ticket(ticket.pk,request.user,form.cleaned_data["expert"]); messages.success(request,"تیکت منتقل شد.") except PermissionDenied: messages.error(request,"اجازه انتقال این تیکت را ندارید.") return redirect("staff_tickets:detail",public_id=public_id) @staff_required @require_POST def staff_share(request,public_id): ticket=get_object_or_404(Ticket,public_id=public_id); form=ShareForm(request.POST) if form.is_valid(): try: share_ticket(ticket,request.user,form.cleaned_data["expert"],form.cleaned_data["permission_level"]); messages.success(request,"دسترسی ایجاد شد.") except PermissionDenied: messages.error(request,"اجازه اشتراک این تیکت را ندارید.") return redirect("staff_tickets:detail",public_id=public_id) @staff_required @require_POST def staff_revoke(request,public_id,user_id): ticket=get_object_or_404(Ticket,public_id=public_id); target=get_object_or_404(User,public_id=user_id) try: revoke_access(ticket,request.user,target); messages.success(request,"دسترسی کارشناس لغو شد.") except PermissionDenied: messages.error(request,"اجازه لغو این دسترسی را ندارید.") return redirect("staff_tickets:detail",public_id=public_id) @staff_required @require_POST def staff_status(request,public_id): ticket=get_object_or_404(Ticket,public_id=public_id) try: change_status(ticket,request.user,request.POST.get("status")); messages.success(request,"وضعیت تغییر کرد.") except (PermissionDenied,ValidationError) as exc: messages.error(request,str(exc)) return redirect("staff_tickets:detail",public_id=public_id) @staff_required @require_POST def staff_priority(request,public_id): ticket=get_object_or_404(Ticket,public_id=public_id) try: change_priority(ticket,request.user,request.POST.get("priority")); messages.success(request,"اولویت تغییر کرد.") except (PermissionDenied,ValidationError) as exc: messages.error(request,str(exc)) return redirect("staff_tickets:detail",public_id=public_id) @staff_required @require_POST def staff_delete(request, public_id): ticket = get_object_or_404(Ticket, public_id=public_id) try: from .services import delete_ticket delete_ticket(ticket_id=ticket.pk, user=request.user) messages.success(request, "تیکت و تمام پیام‌ها و فایل‌های آن برای همیشه حذف شد.") return redirect("staff_tickets:list") except PermissionDenied as exc: messages.error(request, str(exc)) return redirect("staff_tickets:detail", public_id=public_id) @staff_required @require_http_methods(["GET", "POST"]) def staff_dispatch_create(request): form = GroupDispatchForm(request.POST or None, request.FILES or None, user=request.user) if request.method == "POST" and form.is_valid(): try: dispatch, _ = dispatch_group_tickets( user=request.user, unit=form.cleaned_data["unit"], contact_group=form.cleaned_data["contact_group"], category=form.cleaned_data["category"], subject=form.cleaned_data["subject"], body=form.cleaned_data["body"], operation_type=form.cleaned_data["operation_type"], tags=form.cleaned_data["tags"], files=request.FILES.getlist("files"), ) messages.success(request, "برای هر مخاطب یک تیکت مستقل ساخته و پیامک آن در صف قرار گرفت.") return redirect("staff_tickets:dispatch_detail", public_id=dispatch.public_id) except (PermissionDenied, ValidationError) as exc: form.add_error(None, str(exc)) create_unit_ids = { unit.pk for unit in OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user), is_active=True) if any(DispatchPolicy.can_create(request.user, unit, kind) for kind in GroupDispatch.OperationType.values) } if not create_unit_ids: raise PermissionDenied base_dispatches = _visible_dispatches(request.user) metrics_row = base_dispatches.aggregate(total=Count("id"), recipients=Sum("recipient_count")) metrics = { "total": metrics_row["total"] or 0, "recipients": metrics_row["recipients"] or 0, "sent": base_dispatches.filter(status=GroupDispatch.Status.SENT).count(), "attention": base_dispatches.filter(status__in=(GroupDispatch.Status.PARTIAL, GroupDispatch.Status.QUEUED)).count(), } dispatches = base_dispatches.select_related("unit", "contact_group", "created_by") q = request.GET.get("q", "").strip() if q: dispatches = dispatches.filter(Q(subject__icontains=q) | Q(contact_group__name__icontains=q) | Q(created_by__username__icontains=q)) status = request.GET.get("status", "") if status in GroupDispatch.Status.values: dispatches = dispatches.filter(status=status) unit = request.GET.get("unit", "") if unit.isdigit() and base_dispatches.filter(unit_id=unit).exists(): dispatches = dispatches.filter(unit_id=unit) group = request.GET.get("group", "") if group.isdigit(): dispatches = dispatches.filter(contact_group_id=group) created_from = parse_date(request.GET.get("created_from", "")) created_to = parse_date(request.GET.get("created_to", "")) if created_from: dispatches = dispatches.filter(created_at__date__gte=created_from) if created_to: dispatches = dispatches.filter(created_at__date__lte=created_to) page = Paginator(dispatches.order_by("-created_at"), 12).get_page(request.GET.get("page")) for item in page.object_list: refresh_group_dispatch_status(item.pk) units = OrganizationalUnit.objects.filter(pk__in=create_unit_ids, is_active=True).order_by("name") groups = ContactGroup.objects.filter(unit_id__in=create_unit_ids, is_active=True).select_related("unit").order_by("unit__name", "name") return render(request, "tickets/staff_dispatch_create.html", { "form": form, "dispatches": page.object_list, "page": page, "metrics": metrics, "units": units, "groups": groups, "dispatch_statuses": GroupDispatch.Status.choices, "active_staff_section": "dispatches", "active_tab_title": "ارسال گروهی و ردیابی", }) @staff_required def staff_dispatch_detail(request, public_id): dispatch = get_object_or_404( GroupDispatch.objects.select_related("unit", "contact_group", "created_by"), public_id=public_id, ) if not DispatchPolicy.can_view(request.user, dispatch): raise PermissionDenied tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "ticket_messages", "notificationjob_set").order_by("customer_id")) delivery_failures = { NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.EXPIRED, NotificationJob.DeliveryStatus.BLOCKED, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.ERROR, } now = timezone.now() for ticket in tickets: ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS and job.event_type == SMSTemplate.Event.STAFF_TICKET_CREATED), None) ticket.sms_retryable = bool(ticket.sms_job and (ticket.sms_job.status == NotificationJob.Status.FAILED or (ticket.sms_job.delivery_status in delivery_failures and not ticket.sms_job.delivery_next_check_at))) ticket.latest_customer_response = next((message for message in reversed(list(ticket.ticket_messages.all())) if message.author_id == ticket.customer_id), None) links = list(ticket.magic_links.all()) ticket.magic_total_views = sum(link.view_count for link in links) ticket.magic_link_count = len(links) ticket.magic_active_count = sum(not link.revoked_at and link.expires_at > now for link in links) ticket.magic_last_used_at = max((link.used_at for link in links if link.used_at), default=None) job = ticket.sms_job if not job: ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب job پیامک پیدا نشد.", "neutral" elif job.status == NotificationJob.Status.FAILED: ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", f"ارسال خودکار پس از {1 + settings.SMS_MAX_RETRIES} تلاش متوقف شده است.", "danger" elif job.status == NotificationJob.Status.RETRYING: ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "worker پیامک را در موعد بعدی دوباره ارسال می‌کند.", "warning" elif job.status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING): ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ارسال", "پیامک هنوز به درگاه تحویل نشده است.", "info" elif job.delivery_status == NotificationJob.DeliveryStatus.DELIVERED: ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل به گوشی تأیید شد", "اپراتور دریافت پیامک توسط مخاطب را تأیید کرده است.", "success" elif job.delivery_status in (NotificationJob.DeliveryStatus.PENDING, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.CHECK_FAILED) and job.delivery_next_check_at: ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست و worker استعلام را ادامه می‌دهد.", "warning" elif job.delivery_status == NotificationJob.DeliveryStatus.BLOCKED: ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "دریافت پیامک مسدود است", "شماره مخاطب دریافت پیامک تبلیغاتی را بسته است.", "danger" elif job.delivery_status == NotificationJob.DeliveryStatus.EXPIRED: ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "مهلت تحویل پایان یافت", "اپراتور پیامک را در بازه مجاز تحویل نداده است.", "danger" elif job.delivery_status in (NotificationJob.DeliveryStatus.ERROR, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED): ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل تأیید نشد", "بازه پیگیری پایان یافته یا درگاه خطای قطعی برگردانده است.", "danger" else: ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "پذیرفته‌شده توسط درگاه", "شناسه ارسال ثبت شده و در انتظار نتیجه دلیوری است.", "info" sms_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, channel=NotificationJob.Channel.SMS, event_type=SMSTemplate.Event.STAFF_TICKET_CREATED) sms_counts = {row["status"]: row["count"] for row in sms_jobs.values("status").annotate(count=Count("id"))} sent = sms_counts.get(NotificationJob.Status.SENT, 0) failed = sms_jobs.filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT, delivery_status__in=delivery_failures, delivery_next_check_at__isnull=True)).count() delivered = sms_jobs.filter(delivery_status=NotificationJob.DeliveryStatus.DELIVERED).count() retrying = sms_counts.get(NotificationJob.Status.RETRYING, 0) tracking = sms_jobs.filter(delivery_next_check_at__isnull=False).count() opened = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch, view_count__gt=0).values("ticket_id").distinct().count() total_views = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch).aggregate(total=Sum("view_count"))["total"] or 0 replied = dispatch.tickets.filter(last_customer_message_at__isnull=False).count() refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"]) pending = sum(sms_counts.get(status, 0) for status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING, NotificationJob.Status.RETRYING)) delivery_percent = round((delivered / dispatch.recipient_count) * 100) if dispatch.recipient_count else 0 metrics = {"sent": sent, "delivered": delivered, "failed": failed, "retrying": retrying, "tracking": tracking, "pending": pending, "opened": opened, "not_opened": max(dispatch.recipient_count-opened,0), "total_views": total_views, "replied": replied, "delivery_percent": delivery_percent} open_ticket_count = sum(ticket.status not in (Ticket.Status.RESOLVED, Ticket.Status.CLOSED) for ticket in tickets) can_resolve_dispatch = request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, dispatch.unit, "tickets.change_ticket") return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "open_ticket_count": open_ticket_count, "can_resolve_dispatch": can_resolve_dispatch, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches"}) @staff_required @require_POST def staff_dispatch_resolve(request, public_id): dispatch = get_object_or_404(GroupDispatch, public_id=public_id) try: count = resolve_group_dispatch_tickets(dispatch=dispatch, user=request.user) messages.success(request, f"{count} تیکت این ارسال گروهی حل‌شده علامت‌گذاری شد." if count else "همه تیکت‌های این ارسال گروهی از قبل حل یا بسته شده‌اند.") except PermissionDenied as exc: messages.error(request, str(exc)) return_ticket = request.POST.get("return_ticket", "").strip() if return_ticket and dispatch.tickets.filter(public_id=return_ticket).exists(): return redirect("staff_tickets:detail", public_id=return_ticket) return redirect("staff_tickets:dispatch_detail", public_id=public_id) @staff_required @require_POST def staff_dispatch_retry(request, public_id): dispatch=get_object_or_404(GroupDispatch,public_id=public_id) if not DispatchPolicy.can_view(request.user,dispatch) or not DispatchPolicy.can_create(request.user,dispatch.unit,dispatch.operation_type): raise PermissionDenied jobs=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED) job_id=request.POST.get("job_id","").strip() if job_id: if not job_id.isdigit(): messages.error(request,"شناسه پیامک برای تلاش مجدد معتبر نیست.") return redirect("staff_tickets:dispatch_detail",public_id=public_id) jobs=jobs.filter(pk=int(job_id)) count=_requeue_retryable_sms_jobs(jobs) refresh_group_dispatch_status(dispatch.pk) messages.success(request,f"{count} ارسال ناموفق دوباره در صف قرار گرفت." if count else "ارسال ناموفقی برای تلاش مجدد وجود ندارد.") return redirect("staff_tickets:dispatch_detail",public_id=public_id) def _unit_ids_with_permission(user, permission): from apps.staff.models import OrganizationalUnit units = OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(user), is_active=True) return {unit.pk for unit in units if user.is_superuser or user.has_perm(permission) or TicketPolicy.has_unit_permission(user, unit, permission)} def _visible_dispatches(user): if user.is_superuser: return GroupDispatch.objects.all() visibility = Q(pk__in=[]) memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit")) for operation_type in GroupDispatch.OperationType.values: if DispatchPolicy.can_view_all(user, operation_type): visibility |= Q(operation_type=operation_type) continue view_all_units = [item.unit_id for item in memberships if DispatchPolicy.can_view_all(user, operation_type, item.unit)] create_units = [item.unit_id for item in memberships if DispatchPolicy.can_create(user, item.unit, operation_type)] visibility |= Q(operation_type=operation_type, unit_id__in=view_all_units) visibility |= Q(operation_type=operation_type, unit_id__in=create_units, created_by=user) return GroupDispatch.objects.filter(visibility) def _contact_queryset(user): """Contacts visible through a permitted group or a viewable ticket. A customer created by login or ticket intake may not have a contact group yet. Keeping ticket-visible customers in this queryset makes the contact list and its edit endpoint use the same visibility rule. """ visible_groups = ContactGroup.objects.filter(unit_id__in=_unit_ids_with_permission(user, "customers.view_customerprofile")).select_related("unit") visible_customer_ids = staff_ticket_rows(user).values_list("customer_id", flat=True) return CustomerProfile.objects.filter( Q(groups__in=visible_groups) | Q(user_id__in=visible_customer_ids) ).select_related("user").prefetch_related( Prefetch("groups", queryset=visible_groups, to_attr="visible_groups") ).distinct() def _voip_call_methods(user): if not user.voip_call: return () methods = [] if user.voip_extension: methods.append("ip_phone") if user.voip_mobile: methods.append("mobile") return tuple(methods) @staff_required @require_POST def staff_contact_call(request, public_id): profile = get_object_or_404(_contact_queryset(request.user), user__public_id=public_id) if not request.user.voip_call: raise PermissionDenied try: data = json.loads(request.body or "{}") except json.JSONDecodeError: return JsonResponse({"success": False, "message": "درخواست تماس معتبر نیست."}, status=400) method = data.get("method") if method not in _voip_call_methods(request.user): return JsonResponse({"success": False, "message": "روش تماس در دسترس نیست."}, status=400) if not profile.user.mobile: return JsonResponse({"success": False, "message": "شماره مخاطب ثبت نشده است."}, status=400) result = place_voip_call(agent=request.user, customer=profile.user, method=method) if not result.success: return JsonResponse({"success": False, "message": "برقراری تماس ناموفق بود."}, status=502) return JsonResponse({"success": True, "message": "درخواست تماس ارسال شد."}) def _has_any_unit_permission(user, permission): return user.is_superuser or any( TicketPolicy.has_unit_permission(user, membership.unit, permission) for membership in user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit") ) @staff_required def staff_contacts(request): search = request.GET.get("q", "").strip() stakeholder = request.GET.get("stakeholder_type", "") group = request.GET.get("group", "") province = request.GET.get("province", "").strip() visible_unit_ids = _unit_ids_with_permission(request.user, "customers.view_customerprofile") 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"), "voip_call_methods": _voip_call_methods(request.user), "active_staff_section": "contacts", "active_tab_title": "دفترچه مخاطبان"} return render(request, "tickets/staff_contacts.html", context) @staff_required @require_http_methods(["GET", "POST"]) def staff_contact_create(request): if not _has_any_unit_permission(request.user, "customers.add_customerprofile"): raise PermissionDenied manageable_unit_ids = _unit_ids_with_permission(request.user, "customers.add_customerprofile") form = StaffContactForm(request.POST or None, user=request.user, unit_ids=manageable_unit_ids) if request.method == "POST" and form.is_valid(): with transaction.atomic(): mobile = form.cleaned_data["mobile"] user = User.objects.create(username=f"contact_{mobile[1:]}_{uuid4().hex[:5]}", mobile=mobile, account_type=User.AccountType.CUSTOMER) user.set_unusable_password(); user.save(update_fields=["password"]) profile = CustomerProfile.objects.create(user=user, **{key: form.cleaned_data[key] for key in ("first_name", "last_name", "organization_name", "stakeholder_type", "province", "city")}) profile.groups.set(form.cleaned_data["groups"]) messages.success(request, "مخاطب با عضویت‌های گروهی انتخاب‌شده ثبت شد.") return redirect("staff_tickets:contacts") return render(request, "tickets/staff_entity_form.html", {"form": form, "active_staff_section": "contacts", "active_tab_title": "مخاطب جدید", "form_title": "ثبت مخاطب بیرونی", "form_help": "مشتری، تامین‌کننده یا باربری را بدون ساخت رمز عبور ثبت کنید."}) @staff_required @require_http_methods(["GET", "POST"]) def staff_contact_edit(request, public_id): 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") editable_through_group = profile.groups.filter(unit_id__in=manageable_unit_ids).exists() editable_through_ticket = staff_ticket_rows(request.user).filter( Q(assigned_unit_id__in=manageable_unit_ids) | Q(assigned_unit__isnull=True, owning_unit_id__in=manageable_unit_ids), customer_id=profile.user_id, ).exists() if not (editable_through_group or editable_through_ticket): 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, "call_customer": profile.user, "voip_call_methods": _voip_call_methods(request.user), "active_staff_section": "contacts", "active_tab_title": "ویرایش مخاطب", "form_title": str(profile), "form_help": "عضویت همزمان مخاطب در چند گروه قابل مدیریت است."}) @staff_required @require_http_methods(["GET", "POST"]) def staff_groups(request, group_id=None): visible_unit_ids = _unit_ids_with_permission(request.user, "customers.view_contactgroup") editable_unit_ids = _unit_ids_with_permission(request.user, "customers.change_contactgroup" if group_id else "customers.add_contactgroup") if not visible_unit_ids and not editable_unit_ids: raise PermissionDenied groups = ContactGroup.objects.filter(unit_id__in=visible_unit_ids | editable_unit_ids).select_related("unit").annotate(contact_count=Count("contacts", distinct=True)) instance = get_object_or_404(groups, pk=group_id) if group_id else None form = ContactGroupForm(request.POST or None, user=request.user, instance=instance, unit_ids=editable_unit_ids) if request.method == "POST" and form.is_valid(): unit = form.cleaned_data["unit"] permission = "customers.change_contactgroup" if instance else "customers.add_contactgroup" if not (request.user.is_superuser or TicketPolicy.has_unit_permission(request.user, unit, permission)): raise PermissionDenied form.save(); messages.success(request, "گروه مخاطبان ذخیره شد.") return redirect("staff_tickets:groups") group_metrics = {"total": groups.count(), "active": groups.filter(is_active=True).count(), "memberships": sum(item.contact_count for item in groups)} return render(request, "tickets/staff_groups.html", {"form": form, "groups": groups, "group_metrics": group_metrics, "can_edit": bool(editable_unit_ids), "active_staff_section": "contacts", "active_tab_title": "گروه‌های مخاطبان"}) @staff_required def staff_magic_audit(request): global_access = request.user.is_superuser or request.user.has_perm("accounts.view_all_magic_links") if not global_access and not _has_any_unit_permission(request.user, "accounts.view_ticketmagiclink"): raise PermissionDenied links = TicketMagicLink.objects.select_related("ticket", "customer", "created_by", "created_unit").prefetch_related("access_logs") if not global_access: links = links.filter(created_unit_id__in=TicketPolicy.unit_ids(request.user)) q = request.GET.get("q", "").strip() if q: links = links.filter(Q(ticket__ticket_number__icontains=q) | Q(recipient_name__icontains=q) | Q(recipient_mobile__icontains=q)) unit=request.GET.get("unit","") if unit.isdigit(): links=links.filter(created_unit_id=unit) status=request.GET.get("status","") now=timezone.now() if status=="active": links=links.filter(revoked_at__isnull=True,expires_at__gt=now) elif status=="opened": links=links.filter(view_count__gt=0) elif status=="unopened": links=links.filter(view_count=0) elif status=="expired": links=links.filter(revoked_at__isnull=True,expires_at__lte=now) elif status=="revoked": links=links.filter(revoked_at__isnull=False) created_from=parse_date(request.GET.get("created_from","")); created_to=parse_date(request.GET.get("created_to","")) if created_from: links=links.filter(created_at__date__gte=created_from) if created_to: links=links.filter(created_at__date__lte=created_to) page=Paginator(links.order_by("-created_at"),50).get_page(request.GET.get("page")) from apps.staff.models import OrganizationalUnit units=OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user),is_active=True) return render(request, "tickets/staff_magic_audit.html", {"links": page.object_list,"page":page,"units":units,"global_access":global_access,"can_revoke":request.user.is_superuser or request.user.has_perm("accounts.change_ticketmagiclink") or _has_any_unit_permission(request.user,"accounts.change_ticketmagiclink"),"now":now,"active_staff_section":"audit","active_tab_title":"ممیزی لینک‌های جادویی"}) @staff_required @require_POST def staff_magic_revoke(request, link_id): link=get_object_or_404(TicketMagicLink.objects.select_related("created_unit"),pk=link_id) permitted=request.user.is_superuser or request.user.has_perm("accounts.change_ticketmagiclink") or TicketPolicy.has_unit_permission(request.user,link.created_unit,"accounts.change_ticketmagiclink") if not permitted: raise PermissionDenied if link.revoked_at is None: link.revoked_at=timezone.now(); link.save(update_fields=["revoked_at"]) messages.success(request,"لینک جادویی فوراً باطل شد.") else: messages.info(request,"این لینک قبلاً باطل شده است.") return redirect("staff_tickets:magic_audit") @staff_required @require_http_methods(["GET", "POST"]) def staff_sms_templates(request, template_id=None): visible_unit_ids = _unit_ids_with_permission(request.user, "notifications.view_smstemplate") editable_unit_ids = _unit_ids_with_permission(request.user, "notifications.change_smstemplate" if template_id else "notifications.add_smstemplate") can_view_globals = request.user.is_superuser or request.user.has_perm("notifications.view_smstemplate") if not visible_unit_ids and not editable_unit_ids and not can_view_globals: raise PermissionDenied templates = SMSTemplate.objects.filter( Q(unit_id__in=visible_unit_ids | editable_unit_ids) | Q(unit__isnull=True) ).select_related("unit").order_by("unit__name", "event_type") instance = get_object_or_404(templates.exclude(unit__isnull=True), pk=template_id) if template_id else None form = UnitSMSTemplateForm(request.POST or None, user=request.user, instance=instance, unit_ids=editable_unit_ids) if request.method == "POST" and form.is_valid(): unit = form.cleaned_data["unit"] permission = "notifications.change_smstemplate" if instance else "notifications.add_smstemplate" if not (request.user.is_superuser or TicketPolicy.has_unit_permission(request.user, unit, permission)): raise PermissionDenied form.save(); messages.success(request, "الگوی پیامک واحد ذخیره شد.") return redirect("staff_tickets:sms_templates") return render(request, "tickets/staff_sms_templates.html", {"form": form, "templates": templates, "allowed_tags": SMSTemplate._meta.get_field("allowed_tags").default(), "active_staff_section": "templates", "active_tab_title": "الگوهای پیامک"})