fix some bugs

This commit is contained in:
javad committed 2026-09-15 09:50:17 +03:30
1 parent b8b3aba855
commit 963fd839dc
22 files changed
+570 -33

No files matched your search

@@ -23,10 +23,15 @@ def _magic_link(job, reason):
token,link = TicketMagicLink.issue_with_record(job.ticket, reason=reason, created_by=job.staff_user or job.ticket.owner or job.ticket.created_by)
return link, f"{settings.PUBLIC_BASE_URL}/t/{token}/"
def _sms_context(job,magic_link):
profile=getattr(job.customer,"customer_profile",None)
def _sms_context(job,magic_link=""):
recipient = job.customer or job.ticket.customer
profile=getattr(recipient,"customer_profile",None)
unit=job.ticket.owning_unit
return {"magic_link":magic_link,"first_name":getattr(profile,"first_name","") or "","last_name":getattr(profile,"last_name","") or "","recipient_name":" ".join(filter(None,[getattr(profile,"first_name","") or "",getattr(profile,"last_name","") or ""])),"unit_name":unit.name if unit else "","company_name":getattr(settings,"COMPANY_NAME","لوله گستر گلپایگان"),"ticket_number":job.ticket.ticket_number,"subject":job.ticket.subject,"province":job.ticket.province or getattr(profile,"province","") or "","city":job.ticket.city or getattr(profile,"city","") or "","expert_name":job.ticket.assigned_expert_name}
customer_profile=getattr(job.ticket.customer,"customer_profile",None)
first_name=getattr(profile,"first_name","") or ""
last_name=getattr(profile,"last_name","") or ""
recipient_name=" ".join(filter(None,[first_name,last_name])) or "ثبت نشده"
return {"magic_link":magic_link,"first_name":first_name,"last_name":last_name,"recipient_name":recipient_name,"unit_name":unit.name if unit else "","company_name":getattr(settings,"COMPANY_NAME","لوله گستر گلپایگان"),"ticket_number":job.ticket.ticket_number,"subject":job.ticket.subject,"province":job.ticket.province or getattr(customer_profile,"province","") or "","city":job.ticket.city or getattr(customer_profile,"city","") or "","expert_name":job.ticket.assigned_expert_name,"customer_mobile":job.ticket.customer.local_mobile or "ثبت نشده","category_name":job.ticket.category.title}
def _render_sms(event_type,context,unit=None):
from string import Formatter
@@ -65,10 +70,7 @@ def _send_sms(job):
recipient = job.payload.get("recipient_mobile", "")
if not recipient:
raise ValueError("Unit supervisor mobile is not configured")
subject = " ".join(job.ticket.subject.split())[:80] or "بدون موضوع"
category = " ".join(job.ticket.category.title.split())[:50]
customer_mobile = job.ticket.customer.local_mobile or "ثبت نشده"
message = f"تیکت جدید {job.ticket.ticket_number}\nتلفن: {customer_mobile}\nموضوع: {subject}\nدسته: {category}"
message = _render_sms(job.event_type, _sms_context(job), job.ticket.owning_unit)
return get_sms_provider().send_message(recipient, message), issued
if job.event_type == "CUSTOMER_TICKET_CREATED":
issued, url = _magic_link(job, "customer_created_ticket")
@@ -0,0 +1,56 @@
from django.db import migrations, models
EVENT = "UNIT_SUPERVISOR_NEW_TICKET"
TITLE = "اطلاع تیکت جدید به سرپرستان واحد"
BODY = "تیکت جدید {ticket_number}\nتلفن: {customer_mobile}\nموضوع: {subject}\nدسته: {category_name}\nارجاع: {expert_name}"
TAGS = [
"magic_link", "first_name", "last_name", "recipient_name", "unit_name",
"company_name", "ticket_number", "subject", "province", "city",
"expert_name", "customer_mobile", "category_name",
]
def seed_supervisor_template(apps, schema_editor):
sms_template = apps.get_model("notifications", "SMSTemplate")
sms_template.objects.get_or_create(
unit=None,
event_type=EVENT,
defaults={"title": TITLE, "body": BODY, "allowed_tags": TAGS, "is_active": True},
)
for template in sms_template.objects.all().iterator():
allowed_tags = list(template.allowed_tags or [])
updated_tags = list(dict.fromkeys([*allowed_tags, "customer_mobile", "category_name"]))
if updated_tags != allowed_tags:
template.allowed_tags = updated_tags
template.save(update_fields=["allowed_tags"])
def remove_supervisor_template(apps, schema_editor):
apps.get_model("notifications", "SMSTemplate").objects.filter(
unit__isnull=True, event_type=EVENT, title=TITLE, body=BODY
).delete()
class Migration(migrations.Migration):
dependencies = [
("notifications", "0011_notification_delivery_tracking"),
("staff", "0011_organizationalunit_supervisor_mobile_4"),
]
operations = [
migrations.AlterField(
model_name="smstemplate",
name="event_type",
field=models.CharField(
choices=[
("CUSTOMER_TICKET_CREATED", "ثبت تیکت توسط مشتری"),
("STAFF_TICKET_CREATED", "ثبت تیکت توسط کارشناس"),
("STAFF_REPLY", "پاسخ کارشناس"),
(EVENT, TITLE),
],
max_length=50,
),
),
migrations.RunPython(seed_supervisor_template, remove_supervisor_template),
]
+2 -1
View File
@@ -5,7 +5,7 @@ from apps.core.models import PersianTextNormalizationMixin
def sms_template_tags():
return ["magic_link", "first_name", "last_name", "recipient_name", "unit_name", "company_name", "ticket_number", "subject", "province", "city", "expert_name"]
return ["magic_link", "first_name", "last_name", "recipient_name", "unit_name", "company_name", "ticket_number", "subject", "province", "city", "expert_name", "customer_mobile", "category_name"]
class SMSTemplate(PersianTextNormalizationMixin, models.Model):
persian_text_fields={"title":False,"body":True}
@@ -13,6 +13,7 @@ class SMSTemplate(PersianTextNormalizationMixin, models.Model):
CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری"
STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس"
STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس"
UNIT_SUPERVISOR_NEW_TICKET="UNIT_SUPERVISOR_NEW_TICKET","اطلاع تیکت جدید به سرپرستان واحد"
unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.CASCADE,null=True,blank=True,related_name="sms_templates",help_text="خالی یعنی الگوی عمومی و fallback.")
event_type=models.CharField(max_length=50,choices=Event.choices)
title=models.CharField(max_length=120)
+2 -2
View File
@@ -15,13 +15,13 @@ class UnitMembershipInline(admin.TabularInline):
class OrganizationalUnitAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "bale_chat_id", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at")
list_filter = ("show_in_customer_form", "accepts_external_tickets", "is_active")
search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3")
search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4")
list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active")
filter_horizontal = ("permissions",)
inlines = (UnitMembershipInline,)
fieldsets = (
("مشخصات واحد", {"fields": ("name", "slug", "description", "search_keywords", "sort_order")}),
("اطلاع‌رسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3")}),
("اطلاع‌رسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4")}),
("نمایش و دسترسی", {"fields": ("permissions", "show_in_customer_form", "accepts_external_tickets", "is_active")}),
)
@@ -0,0 +1,13 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("staff", "0010_organizationalunit_notification_destinations")]
operations = [
migrations.AddField(
model_name="organizationalunit",
name="supervisor_mobile_4",
field=models.CharField(blank=True, max_length=16, verbose_name="شماره همراه سرپرست چهارم"),
),
]
+4 -2
View File
@@ -36,6 +36,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
supervisor_mobile_1 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست اول")
supervisor_mobile_2 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست دوم")
supervisor_mobile_3 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست سوم")
supervisor_mobile_4 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست چهارم")
class Meta:
ordering = ["sort_order", "name", "pk"]
@@ -52,6 +53,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
self.supervisor_mobile_1,
self.supervisor_mobile_2,
self.supervisor_mobile_3,
self.supervisor_mobile_4,
))))
def clean(self):
@@ -60,7 +62,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
normalized = []
errors = {}
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3"):
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4"):
value = getattr(self, field_name, "").strip()
if not value:
setattr(self, field_name, "")
@@ -80,7 +82,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
def save(self, *args, **kwargs):
from apps.accounts.models import normalize_mobile
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3"):
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4"):
value = getattr(self, field_name, "").strip()
setattr(self, field_name, normalize_mobile(value) if value else "")
self.bale_chat_id = self.bale_chat_id.strip()
+40
View File
@@ -112,6 +112,46 @@ def refresh_group_dispatch_status(dispatch_id):
dispatch.status=status
return dispatch
@transaction.atomic
def resolve_group_dispatch_tickets(*, dispatch, user):
"""Resolve every non-final ticket created by one group dispatch."""
dispatch = GroupDispatch.objects.select_for_update().select_related("unit").get(pk=dispatch.pk)
permitted = user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(
user, dispatch.unit, "tickets.change_ticket"
)
if not (DispatchPolicy.can_view(user, dispatch) and permitted):
raise PermissionDenied("اجازه حل‌کردن تیکت‌های این ارسال گروهی را ندارید.")
now = timezone.now()
tickets = list(
dispatch.tickets.select_for_update().exclude(
status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)
)
)
if not tickets:
return 0
previous_statuses = {ticket.pk: ticket.status for ticket in tickets}
for ticket in tickets:
ticket.status = Ticket.Status.RESOLVED
ticket.resolved_at = now
ticket.updated_at = now
Ticket.objects.bulk_update(tickets, ["status", "resolved_at", "updated_at"])
TicketEvent.objects.bulk_create([
TicketEvent(
ticket=ticket,
event_type=TicketEvent.Type.STATUS_CHANGED,
actor=user,
metadata={
"from": previous_statuses[ticket.pk],
"to": Ticket.Status.RESOLVED,
"group_dispatch_id": dispatch.pk,
},
)
for ticket in tickets
])
return len(tickets)
@transaction.atomic
def claim_ticket(ticket_id,user):
ticket=Ticket.objects.select_for_update().get(pk=ticket_id)
+1 -1
View File
@@ -1,4 +1,4 @@
from django.urls import path
from . import views
app_name="staff_tickets"
urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts/<uuid:public_id>/edit/",views.staff_contact_edit,name="contact_edit"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups/<int:group_id>/",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links/<int:link_id>/revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates/<int:template_id>/",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches/<uuid:public_id>/",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches/<uuid:public_id>/retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets/<uuid:public_id>/messages/older/",views.staff_messages,name="messages"),path("tickets/<uuid:public_id>/messages/poll/",views.staff_poll,name="poll"),path("tickets/<uuid:public_id>/",views.staff_detail,name="detail"),path("tickets/<uuid:public_id>/claim/",views.staff_claim,name="claim"),path("tickets/<uuid:public_id>/reply/",views.staff_reply,name="reply"),path("tickets/<uuid:public_id>/note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets/<uuid:public_id>/transfer/",views.staff_transfer,name="transfer"),path("tickets/<uuid:public_id>/share/",views.staff_share,name="share"),path("tickets/<uuid:public_id>/revoke/<uuid:user_id>/",views.staff_revoke,name="revoke"),path("tickets/<uuid:public_id>/status/",views.staff_status,name="status"),path("tickets/<uuid:public_id>/priority/",views.staff_priority,name="priority"),path("tickets/<uuid:public_id>/delete/",views.staff_delete,name="delete")]
urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts/<uuid:public_id>/edit/",views.staff_contact_edit,name="contact_edit"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups/<int:group_id>/",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links/<int:link_id>/revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates/<int:template_id>/",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches/<uuid:public_id>/",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches/<uuid:public_id>/retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches/<uuid:public_id>/resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets/<uuid:public_id>/messages/older/",views.staff_messages,name="messages"),path("tickets/<uuid:public_id>/messages/poll/",views.staff_poll,name="poll"),path("tickets/<uuid:public_id>/",views.staff_detail,name="detail"),path("tickets/<uuid:public_id>/claim/",views.staff_claim,name="claim"),path("tickets/<uuid:public_id>/reply/",views.staff_reply,name="reply"),path("tickets/<uuid:public_id>/note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets/<uuid:public_id>/transfer/",views.staff_transfer,name="transfer"),path("tickets/<uuid:public_id>/share/",views.staff_share,name="share"),path("tickets/<uuid:public_id>/revoke/<uuid:user_id>/",views.staff_revoke,name="revoke"),path("tickets/<uuid:public_id>/status/",views.staff_status,name="status"),path("tickets/<uuid:public_id>/priority/",views.staff_priority,name="priority"),path("tickets/<uuid:public_id>/delete/",views.staff_delete,name="delete")]
+24 -4
View File
@@ -31,7 +31,7 @@ from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,ShareForm,StaffC
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
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
def conversation_page(queryset,before=None):
queryset=queryset.order_by("-created_at","-id")
@@ -389,7 +389,7 @@ def staff_customer_search(request):
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)
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)
@@ -397,7 +397,10 @@ def staff_detail(request,public_id):
history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5]
sms_jobs=ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")
latest=conversation[-1] if conversation else None
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"viewer_user":request.user,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
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,"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)
@@ -612,7 +615,24 @@ def staff_dispatch_detail(request, public_id):
pending = sum(sms_counts.get(status, 0) for status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING, NotificationJob.Status.RETRYING))
delivery_percent = round((delivered / dispatch.recipient_count) * 100) if dispatch.recipient_count else 0
metrics = {"sent": sent, "delivered": delivered, "failed": failed, "retrying": retrying, "tracking": tracking, "pending": pending, "opened": opened, "not_opened": max(dispatch.recipient_count-opened,0), "total_views": total_views, "replied": replied, "delivery_percent": delivery_percent}
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches"})
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