first
This commit is contained in:
commit
fa9d154db9
260 files changed
+17643
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,39 @@
|
||||
from django.contrib import admin
|
||||
from .models import GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketCategory,TicketEvent,TicketReadState,TicketTag
|
||||
@admin.register(Ticket)
|
||||
class TicketAdmin(admin.ModelAdmin):
|
||||
list_display=("ticket_number","subject","owning_unit","delivery_type","status","priority","owner","last_activity_at")
|
||||
list_filter=("owning_unit","assigned_unit","delivery_type","status","priority","category","tags")
|
||||
search_fields=("ticket_number","subject","customer__mobile","customer__customer_profile__organization_name")
|
||||
filter_horizontal=("tags",)
|
||||
|
||||
|
||||
class DispatchTicketInline(admin.TabularInline):
|
||||
model = Ticket
|
||||
extra = 0
|
||||
fields = ("ticket_number", "customer", "status", "last_activity_at")
|
||||
readonly_fields = fields
|
||||
can_delete = False
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(GroupDispatch)
|
||||
class GroupDispatchAdmin(admin.ModelAdmin):
|
||||
list_display = ("subject", "unit", "contact_group", "status", "recipient_count", "dispatched_at")
|
||||
list_filter = ("unit", "status", "contact_group")
|
||||
search_fields = ("subject", "created_by__username")
|
||||
readonly_fields = ("recipient_count", "dispatched_at")
|
||||
inlines = (DispatchTicketInline,)
|
||||
|
||||
|
||||
@admin.register(TicketCategory)
|
||||
class TicketCategoryAdmin(admin.ModelAdmin):
|
||||
list_display=("title","unit","sort_order","show_in_customer_form","is_active")
|
||||
list_filter=("unit","show_in_customer_form","is_active")
|
||||
search_fields=("title","description","search_keywords","unit__name","unit__search_keywords")
|
||||
list_editable=("is_active","show_in_customer_form","sort_order")
|
||||
|
||||
|
||||
admin.site.register([SLAConfig,TicketAccessGrant,TicketEvent,TicketReadState,TicketTag])
|
||||
@@ -0,0 +1,2 @@
|
||||
from django.apps import AppConfig
|
||||
class TicketsConfig(AppConfig): name = "apps.tickets"
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Canonical customer-facing ticket routing catalog used by bootstrap."""
|
||||
|
||||
UNIT_CONTENT = {
|
||||
"sales": {
|
||||
"description": "خرید، فروش، موجودی کالا، سفارش، بارگیری، ارسال، تحویل و خدمات مشتریان",
|
||||
"search_keywords": "فروش خرید سفارش کالا محصول قیمت پیش فاکتور موجودی بار بارگیری ارسال حمل تحویل نمایندگی شکایت مرجوعی",
|
||||
},
|
||||
"finance": {
|
||||
"description": "پرداخت، فاکتور، صورتحساب، مانده حساب، چک، مالیات و تسویه حساب",
|
||||
"search_keywords": "مالی حسابداری پرداخت واریز فیش رسید فاکتور صورتحساب مانده بدهی بستانکاری چک مالیات تسویه",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
CATEGORY_CATALOG = {
|
||||
"sales": (
|
||||
(10, "price", "قیمت، پیشفاکتور و خرید", "دریافت قیمت روز، پیشفاکتور یا راهنمایی برای خرید محصول.", "قیمت نرخ بها خرید سفارش پیش فاکتور پیشفاکتور quotation استعلام قیمت لیست قیمت"),
|
||||
(20, "discount", "تخفیف و پیشنهاد فروش", "پرسش درباره تخفیف، جشنواره، پیشنهاد ویژه یا قیمت همکاری.", "تخفیف آفر جشنواره پیشنهاد ویژه قیمت همکاری درصد تخفیف فروش عمده کد تخفیف"),
|
||||
(30, "sales-terms", "شرایط فروش و ثبت سفارش", "اطلاع از شرایط فروش و ثبت، تغییر یا تأیید سفارش.", "شرایط فروش ثبت سفارش خرید سفارش جدید تغییر سفارش تایید سفارش تأیید سفارش لغو سفارش قرارداد فروش"),
|
||||
(40, "inventory", "موجودی و مشخصات کالا", "استعلام موجودی، زمان تأمین، مشخصات، مدل، رنگ یا ابعاد کالا.", "موجودی کالا ناموجود تامین تأمین محصول مشخصات فنی مدل رنگ سایز اندازه ابعاد کاتالوگ جایگزین"),
|
||||
(50, "order-follow-up", "پیگیری سفارش", "پیگیری وضعیت سفارش از زمان ثبت تا آمادهسازی و ارسال.", "پیگیری سفارش وضعیت سفارش کد سفارش شماره سفارش آماده سازی آمادهسازی پردازش سفارش کی آماده میشه چه زمانی"),
|
||||
(60, "loading-shipping-tracking", "پیگیری بارگیری و ارسال", "هماهنگی و پیگیری بارگیری، ارسال، حمل، راننده و رسیدن بار.", "پیگیری بار ارسال بار بارگیری حمل محموله راننده کامیون تریلی بارنامه کد رهگیری رهگیری ارسال وضعیت بارگیری زمان ارسال ارسال نشده کی میرسه کجاست"),
|
||||
(70, "delivery-issue", "تحویل، کسری یا آسیب کالا", "اعلام تأخیر، عدم تحویل، کسری، مغایرت یا آسیبدیدگی کالای تحویلی.", "تحویل بار تاخیر تأخیر نرسیده کسری کمبود مغایرت اشتباه خرابی آسیب شکسته ناقص مفقودی بسته بندی بستهبندی"),
|
||||
(80, "consulting", "مشاوره انتخاب محصول", "دریافت راهنمایی برای انتخاب محصول مناسب و مقایسه گزینهها.", "مشاوره راهنمای خرید انتخاب محصول مقایسه پیشنهاد مناسب کاربرد چه محصولی کدام مدل"),
|
||||
(90, "agency", "نمایندگی و همکاری تجاری", "درخواست نمایندگی، عاملیت فروش یا شروع همکاری تجاری.", "نمایندگی عاملیت همکاری تجاری شراکت فروشنده همکار پخش کننده پخشکننده قرارداد همکاری"),
|
||||
(100, "complaint", "مرجوعی، تعویض و شکایت", "درخواست مرجوعی یا تعویض کالا و ثبت شکایت از فرایند فروش یا تحویل.", "مرجوعی برگشت کالا تعویض شکایت اعتراض نارضایتی خدمات بد پیگیری شکایت خسارت"),
|
||||
(110, "delivery", "شرایط و هزینه ارسال", "پرسش پیش از خرید درباره روش، هزینه، محدوده و زمان ارسال.", "شرایط ارسال هزینه ارسال کرایه حمل هزینه حمل روش ارسال زمان تحویل ارسال شهرستان پست باربری تیپاکس"),
|
||||
(900, "sales-general", "عمومی فروش", "موضوعات فروش، سفارش و خدمات مشتریان که در دستههای دیگر قرار نمیگیرند.", "عمومی فروش سایر متفرقه سوال سؤال درخواست مشتری راهنمایی ارتباط با فروش"),
|
||||
),
|
||||
"finance": (
|
||||
(10, "invoice", "فاکتور و صورتحساب", "دریافت یا پیگیری فاکتور رسمی، صورتحساب و رونوشت اسناد فروش.", "فاکتور رسمی صورتحساب صورت حساب پیش فاکتور کپی فاکتور رونوشت شماره فاکتور صدور فاکتور اصلاح فاکتور"),
|
||||
(20, "payment-receipt", "ثبت و تأیید پرداخت", "ارسال فیش یا رسید واریز و پیگیری ثبت و تأیید پرداخت.", "پرداخت واریز فیش واریزی رسید تراکنش کارت به کارت حواله بانکی تایید پرداخت تأیید پرداخت ثبت پرداخت پول واریز کردم"),
|
||||
(30, "account-balance", "مانده حساب و گردش حساب", "دریافت مانده بدهکار یا بستانکار، گردش حساب و تطبیق حسابها.", "مانده حساب گردش حساب بدهی بدهکار بستانکار طلب صورت حساب صورتحساب حساب مشتری مغایرت حساب تطبیق حساب"),
|
||||
(40, "financial-documents", "اصلاح و تکمیل اسناد مالی", "ارسال، تکمیل یا اصلاح مدارک و اسناد موردنیاز واحد مالی.", "اسناد مالی مدارک مالی اصلاح سند سند حسابداری کد اقتصادی شناسه ملی اطلاعات شرکت پرونده مالی پیوست سند"),
|
||||
(50, "cheque-guarantee", "چک، ضمانت و تعهدات", "پیگیری تحویل، وصول، برگشت یا تعویض چک و اسناد ضمانتی.", "چک صیادی ثبت چک وصول چک برگشتی برگشت چک تعویض چک ضمانت ضمانتنامه ضمانتنامه سفته سررسید"),
|
||||
(60, "payment-refund", "استرداد وجه", "درخواست یا پیگیری بازگشت وجه پرداختشده.", "استرداد وجه عودت وجه برگشت پول بازپرداخت پس گرفتن پول واریز اشتباه پرداخت اضافه refund"),
|
||||
(70, "tax-vat", "مالیات و ارزش افزوده", "پرسش درباره مالیات، ارزش افزوده، سامانه مؤدیان و صورتحساب الکترونیکی.", "مالیات ارزش افزوده vat سامانه مودیان مؤدیان صورتحساب الکترونیکی شناسه یکتا گواهی مالیاتی فصل معاملات"),
|
||||
(80, "supplier-settlement", "تسویه تأمینکنندگان", "پیگیری صورتحساب، پرداخت و تسویه حساب فروشندگان و تأمینکنندگان.", "تامین کننده تأمین کننده تامینکننده تأمینکننده فروشنده طرف حساب تسویه تسویه حساب پرداخت تامین کننده صورت وضعیت صورتوضعیت"),
|
||||
(900, "finance-general", "سایر امور مالی و حسابداری", "موضوعات مالی و حسابداری که در دستههای دیگر قرار نمیگیرند.", "عمومی مالی حسابداری سایر متفرقه امور مالی سوال سؤال درخواست پیگیری مالی"),
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
app_name="tickets"
|
||||
urlpatterns=[path("",views.customer_list,name="list"),path("new/",views.customer_new,name="new"),path("new/options/",views.customer_new_options,name="new_options"),path("new/search/",views.customer_new_search,name="new_search"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("<uuid:public_id>/messages/older/",views.customer_messages,name="messages"),path("<uuid:public_id>/messages/poll/",views.customer_poll,name="poll"),path("<uuid:public_id>/messages/read/",views.customer_read,name="read"),path("<uuid:public_id>/",views.customer_detail,name="detail"),path("<uuid:public_id>/reply/",views.customer_reply,name="reply")]
|
||||
@@ -0,0 +1,246 @@
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from apps.staff.models import StaffProfile
|
||||
from .models import GroupDispatch,Ticket,TicketAccessGrant,TicketCategory,TicketTag
|
||||
from apps.customers.models import ContactGroup
|
||||
from apps.staff.models import OrganizationalUnit
|
||||
from apps.core.iran_locations import location_data, validate_location
|
||||
from apps.accounts.models import normalize_mobile
|
||||
from apps.accounts.models import User
|
||||
from apps.customers.models import CustomerProfile
|
||||
from apps.notifications.models import SMSTemplate
|
||||
from .policies import DispatchPolicy, TicketPolicy
|
||||
class MultipleFileInput(forms.ClearableFileInput): allow_multiple_selected=True
|
||||
class MultipleFileField(forms.FileField):
|
||||
def clean(self, data, initial=None):
|
||||
clean_one = super().clean
|
||||
return [clean_one(item, initial) for item in data] if isinstance(data, (list, tuple)) else ([clean_one(data, initial)] if data else [])
|
||||
class TicketForm(forms.Form):
|
||||
unit=forms.ModelChoiceField(label="واحد سازمانی",queryset=OrganizationalUnit.objects.none(),widget=forms.Select(attrs={"data-ticket-unit":""}))
|
||||
category=forms.ModelChoiceField(label="دستهبندی",queryset=TicketCategory.objects.none(),widget=forms.Select(attrs={"data-ticket-category":""})); subject=forms.CharField(label="موضوع",min_length=5,max_length=200,widget=forms.TextInput(attrs={"autocomplete":"off","data-ticket-subject":""})); province=forms.ChoiceField(label="استان",required=False,widget=forms.Select(attrs={"data-province-input":""})); city=forms.ChoiceField(label="شهر",required=False,widget=forms.Select(attrs={"data-city-input":""})); selected_expert=forms.ModelChoiceField(label="کارشناس دلخواه",required=False,queryset=StaffProfile.objects.none()); body=forms.CharField(label="شرح درخواست",required=False,widget=forms.Textarea); files=MultipleFileField(label="پیوستها",required=False,widget=MultipleFileInput)
|
||||
def __init__(self,*a,**kw):
|
||||
super().__init__(*a,**kw)
|
||||
units=OrganizationalUnit.objects.filter(is_active=True,accepts_external_tickets=True,show_in_customer_form=True).order_by("sort_order","name","pk")
|
||||
self.fields["unit"].queryset=units
|
||||
self.fields["unit"].required=units.exists()
|
||||
selected_unit=(self.data.get("unit") if self.is_bound else self.initial.get("unit"))
|
||||
if not selected_unit:
|
||||
selected_unit=units.values_list("pk",flat=True).first()
|
||||
self.initial["unit"]=selected_unit
|
||||
try: selected_unit_id=int(selected_unit)
|
||||
except (TypeError,ValueError): selected_unit_id=None
|
||||
self.fields["category"].queryset=TicketCategory.objects.filter(is_active=True,show_in_customer_form=True,unit_id=selected_unit_id).order_by("sort_order","title","pk")
|
||||
self.fields["selected_expert"].queryset=StaffProfile.objects.filter(is_active=True,is_customer_selectable=True,user__is_active=True).filter(Q(primary_unit_id=selected_unit_id)|Q(user__unit_memberships__unit_id=selected_unit_id,user__unit_memberships__is_active=True)).select_related("user").distinct()
|
||||
rows=location_data(); selected_province=(self.data.get("province") if self.is_bound else self.initial.get("province")) or ""
|
||||
cities=next((row["cities"] for row in rows if row["province"]==selected_province),[])
|
||||
self.fields["province"].choices=[("","انتخاب استان یا جستوجو...")]+[(row["province"],row["province"]) for row in rows]
|
||||
self.fields["city"].choices=[("","ابتدا استان را انتخاب کنید")]+[(value,value) for value in cities]
|
||||
submitted_city=self.data.get("city") if self.is_bound else ""
|
||||
if submitted_city and submitted_city not in cities: self.fields["city"].choices.append((submitted_city,submitted_city))
|
||||
def clean_selected_expert(self): return self.cleaned_data["selected_expert"].user if self.cleaned_data.get("selected_expert") else None
|
||||
def clean(self):
|
||||
data=super().clean()
|
||||
unit,category=data.get("unit"),data.get("category")
|
||||
if unit and category and category.unit_id != unit.pk: self.add_error("category","این دستهبندی متعلق به واحد انتخابشده نیست.")
|
||||
selected_expert=data.get("selected_expert")
|
||||
profile=getattr(selected_expert,"staff_profile",None) if selected_expert else None
|
||||
if unit and selected_expert and not ((profile and profile.primary_unit_id==unit.pk) or selected_expert.unit_memberships.filter(unit=unit,is_active=True).exists()): self.add_error("selected_expert","کارشناس انتخابشده عضو این واحد نیست.")
|
||||
province,city,error=validate_location(data.get("province"),data.get("city")); data["province"],data["city"]=province,city
|
||||
if error: self.add_error("city" if city else "province",error)
|
||||
if not data.get("body"," ").strip() and not self.files.getlist("files"): raise forms.ValidationError("متن یا پیوست الزامی است.")
|
||||
return data
|
||||
class ReplyForm(forms.Form):
|
||||
body=forms.CharField(label="متن",required=False,widget=forms.Textarea); files=MultipleFileField(label="پیوستها",required=False,widget=MultipleFileInput)
|
||||
def clean(self):
|
||||
data=super().clean()
|
||||
if not data.get("body","").strip() and not self.files.getlist("files"): raise forms.ValidationError("متن یا فایل الزامی است.")
|
||||
return data
|
||||
class StaffReplyForm(ReplyForm):
|
||||
mentioned_experts=forms.ModelMultipleChoiceField(label="اطلاع به کارشناسان",required=False,queryset=StaffProfile.objects.none(),widget=forms.SelectMultiple)
|
||||
def __init__(self,*a,**kw):
|
||||
super().__init__(*a,**kw); self.fields["mentioned_experts"].queryset=StaffProfile.objects.filter(is_active=True,user__is_active=True).select_related("user")
|
||||
def clean_mentioned_experts(self): return [profile.user for profile in self.cleaned_data["mentioned_experts"]]
|
||||
class TransferForm(forms.Form):
|
||||
expert=forms.ModelChoiceField(label="کارشناس جدید",queryset=StaffProfile.objects.none())
|
||||
def __init__(self,*a,**kw): super().__init__(*a,**kw); self.fields["expert"].queryset=StaffProfile.objects.filter(is_active=True,user__is_active=True).select_related("user")
|
||||
def clean_expert(self): return self.cleaned_data["expert"].user
|
||||
class ShareForm(TransferForm):
|
||||
permission_level=forms.ChoiceField(label="سطح دسترسی",choices=TicketAccessGrant.Level.choices)
|
||||
|
||||
class StaffCreateTicketForm(forms.Form):
|
||||
unit=forms.ModelChoiceField(label="واحد ایجادکننده",queryset=OrganizationalUnit.objects.none(),required=False,widget=forms.HiddenInput())
|
||||
customer_id=forms.UUIDField(required=False,widget=forms.HiddenInput(attrs={"data-customer-id":""}))
|
||||
customer_mobile=forms.CharField(label="شماره موبایل مشتری",max_length=16,widget=forms.TextInput(attrs={"dir":"ltr","inputmode":"tel"}))
|
||||
first_name=forms.CharField(label="نام مشتری",required=False,max_length=100); last_name=forms.CharField(label="نام خانوادگی مشتری",required=False,max_length=100)
|
||||
organization_name=forms.CharField(label="نام شرکت / سازمان",required=False,max_length=180)
|
||||
stakeholder_type=forms.ChoiceField(label="نوع مخاطب",choices=CustomerProfile.StakeholderType.choices,initial=CustomerProfile.StakeholderType.CUSTOMER,required=False)
|
||||
groups=forms.ModelMultipleChoiceField(label="گروههای مخاطب",queryset=ContactGroup.objects.none(),required=False,help_text="برای حفظ مخاطب در دفترچه واحد، حداقل یک گروه مناسب انتخاب کنید.")
|
||||
category=forms.ModelChoiceField(label="دستهبندی",queryset=TicketCategory.objects.none()); subject=forms.CharField(label="موضوع",min_length=5,max_length=200)
|
||||
province=forms.CharField(label="استان",required=False,max_length=100,widget=forms.TextInput(attrs={"list":"ticket-province-options","autocomplete":"off","data-province-input":""})); city=forms.CharField(label="شهر",required=False,max_length=100,widget=forms.TextInput(attrs={"list":"ticket-city-options","autocomplete":"off","data-city-input":""}))
|
||||
body=forms.CharField(label="شرح پیام کارشناس",widget=forms.Textarea); files=MultipleFileField(label="پیوستها",required=False,widget=MultipleFileInput)
|
||||
def __init__(self,*args,user=None,**kwargs):
|
||||
super().__init__(*args,**kwargs)
|
||||
self.user=user
|
||||
if user is None: return
|
||||
primary_unit=TicketPolicy.primary_unit(user)
|
||||
allowed = bool(primary_unit and primary_unit.is_active and (user.is_superuser or user.has_perm("tickets.add_ticket") or TicketPolicy.has_unit_permission(user,primary_unit,"tickets.add_ticket")))
|
||||
allowed_ids=[primary_unit.pk] if allowed else []
|
||||
self.fields["unit"].queryset=OrganizationalUnit.objects.filter(pk__in=allowed_ids)
|
||||
self.fields["unit"].initial=primary_unit.pk if allowed else None
|
||||
self.initial["unit"]=primary_unit.pk if allowed else None
|
||||
categories=TicketCategory.objects.filter(unit_id__in=allowed_ids,is_active=True)
|
||||
if not allowed_ids or not categories.exists():
|
||||
categories=TicketCategory.objects.filter(unit__isnull=True,is_active=True)
|
||||
self.fields["category"].queryset=categories.select_related("unit")
|
||||
self.fields["groups"].queryset=ContactGroup.objects.filter(unit_id__in=allowed_ids,is_active=True).select_related("unit")
|
||||
def clean_customer_mobile(self):
|
||||
try: mobile=normalize_mobile(self.cleaned_data["customer_mobile"])
|
||||
except ValueError as exc: raise forms.ValidationError(str(exc)) from exc
|
||||
existing=User.objects.filter(mobile=mobile).only("account_type").first()
|
||||
if existing and existing.account_type != User.AccountType.CUSTOMER:
|
||||
raise forms.ValidationError("این شماره متعلق به حساب کارشناسی است و نمیتواند بهعنوان مشتری انتخاب شود.")
|
||||
return mobile
|
||||
def clean(self):
|
||||
data=super().clean(); province,city,error=validate_location(data.get("province"),data.get("city")); data["province"],data["city"]=province,city
|
||||
if error: self.add_error("city" if city else "province",error)
|
||||
mobile=data.get("customer_mobile")
|
||||
selected=None
|
||||
if data.get("customer_id"):
|
||||
selected=User.objects.filter(public_id=data["customer_id"],account_type=User.AccountType.CUSTOMER).first()
|
||||
if not selected: self.add_error("customer_mobile","مشتری انتخابشده معتبر نیست؛ دوباره جستوجو کنید.")
|
||||
elif mobile and selected.mobile != mobile: self.add_error("customer_mobile","شماره موبایل با مشتری انتخابشده تطابق ندارد.")
|
||||
existing=User.objects.filter(mobile=mobile,account_type=User.AccountType.CUSTOMER).first() if mobile else None
|
||||
if selected and existing and selected.pk != existing.pk: self.add_error("customer_mobile","این شماره متعلق به مشتری دیگری است.")
|
||||
data["customer_user"]=selected or existing
|
||||
if not data["customer_user"]:
|
||||
if not (data.get("first_name") or "").strip(): self.add_error("first_name","برای مشتری جدید، نام را وارد کنید.")
|
||||
if not (data.get("last_name") or "").strip(): self.add_error("last_name","برای مشتری جدید، نام خانوادگی را وارد کنید.")
|
||||
unit=data.get("unit")
|
||||
category=data.get("category")
|
||||
primary_unit=TicketPolicy.primary_unit(self.user) if self.user else None
|
||||
if unit and (not primary_unit or unit.pk != primary_unit.pk): self.add_error("unit","واحد ایجادکننده باید واحد اصلی کاربر باشد.")
|
||||
if unit and category and category.unit_id and category.unit_id != unit.pk: self.add_error("category","این دستهبندی متعلق به واحد ایجادکننده نیست.")
|
||||
if self.user and self.fields["unit"].queryset.exists() and not unit: self.add_error("unit","واحد ایجادکننده مشخص نیست.")
|
||||
if unit and any(group.unit_id != unit.pk for group in data.get("groups",())): self.add_error("groups","همه گروهها باید متعلق به واحد ایجادکننده باشند.")
|
||||
return data
|
||||
|
||||
|
||||
class GroupDispatchForm(forms.Form):
|
||||
operation_type = forms.ChoiceField(label="نوع عملیات", choices=(), required=False)
|
||||
unit = forms.ModelChoiceField(label="واحد ارسالکننده", queryset=OrganizationalUnit.objects.none())
|
||||
contact_group = forms.ModelChoiceField(label="گروه مخاطبان", queryset=ContactGroup.objects.none())
|
||||
category = forms.ModelChoiceField(label="دستهبندی", queryset=TicketCategory.objects.none())
|
||||
subject = forms.CharField(label="موضوع استعلام یا پیام", min_length=5, max_length=200)
|
||||
body = forms.CharField(label="متن پیام", widget=forms.Textarea)
|
||||
files = MultipleFileField(label="پیوستهای مشترک", required=False, widget=MultipleFileInput, help_text="اسناد انتخابشده بهصورت مستقل داخل تیکت خصوصی هر مخاطب قرار میگیرند.")
|
||||
tags = forms.ModelMultipleChoiceField(label="برچسبها", queryset=TicketTag.objects.none(), required=False)
|
||||
|
||||
def __init__(self, *args, user, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user = user
|
||||
candidate_units = OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(user), is_active=True)
|
||||
operation_types = [value for value, label in GroupDispatch.OperationType.choices if any(DispatchPolicy.can_create(user, unit, value) for unit in candidate_units) or user.is_superuser]
|
||||
self.fields["operation_type"].choices = [item for item in GroupDispatch.OperationType.choices if item[0] in operation_types]
|
||||
selected_type = self.data.get("operation_type") if self.is_bound else self.initial.get("operation_type")
|
||||
if selected_type not in operation_types:
|
||||
selected_type = operation_types[0] if operation_types else None
|
||||
if selected_type:
|
||||
self.initial["operation_type"] = selected_type
|
||||
unit_ids = [unit.pk for unit in candidate_units if selected_type and DispatchPolicy.can_create(user, unit, selected_type)]
|
||||
self.fields["unit"].queryset = OrganizationalUnit.objects.filter(pk__in=unit_ids)
|
||||
self.fields["contact_group"].queryset = ContactGroup.objects.filter(unit_id__in=unit_ids, is_active=True).select_related("unit")
|
||||
selected_unit=self.data.get("unit") if self.is_bound else self.initial.get("unit")
|
||||
categories=TicketCategory.objects.filter(is_active=True, unit_id__in=unit_ids)
|
||||
if selected_unit: categories=categories.filter(unit_id=selected_unit)
|
||||
if selected_unit and not categories.exists():
|
||||
categories=TicketCategory.objects.filter(is_active=True, unit__isnull=True)
|
||||
self.fields["category"].queryset=categories.select_related("unit")
|
||||
self.fields["tags"].queryset = TicketTag.objects.filter(is_active=True)
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
if not data.get("operation_type") and GroupDispatch.OperationType.BULK in dict(self.fields["operation_type"].choices):
|
||||
data["operation_type"] = GroupDispatch.OperationType.BULK
|
||||
unit = data.get("unit")
|
||||
group = data.get("contact_group")
|
||||
if unit and group and group.unit_id != unit.pk:
|
||||
self.add_error("contact_group", "این گروه متعلق به واحد انتخابشده نیست.")
|
||||
category=data.get("category")
|
||||
if unit and category and category.unit_id and category.unit_id != unit.pk:
|
||||
self.add_error("category", "این دستهبندی متعلق به واحد انتخابشده نیست.")
|
||||
operation_type=data.get("operation_type")
|
||||
if unit and operation_type and not DispatchPolicy.can_create(self.user, unit, operation_type):
|
||||
self.add_error("operation_type", "اجازه ایجاد این نوع عملیات را ندارید.")
|
||||
return data
|
||||
|
||||
|
||||
class StaffContactForm(forms.Form):
|
||||
"""Create or update an external contact without giving it login credentials."""
|
||||
mobile = forms.CharField(label="شماره همراه", max_length=16, widget=forms.TextInput(attrs={"dir": "ltr", "inputmode": "tel"}))
|
||||
first_name = forms.CharField(label="نام", max_length=100)
|
||||
last_name = forms.CharField(label="نام خانوادگی", max_length=100, required=False)
|
||||
organization_name = forms.CharField(label="نام شرکت / سازمان", max_length=180, required=False)
|
||||
stakeholder_type = forms.ChoiceField(label="نوع مخاطب", choices=CustomerProfile.StakeholderType.choices)
|
||||
groups = forms.ModelMultipleChoiceField(label="عضویت در گروهها", queryset=ContactGroup.objects.none(), required=True, help_text="حداقل یک گروه برای تعیین مالکیت واحدی مخاطب انتخاب کنید.")
|
||||
province = forms.ChoiceField(label="استان", required=False)
|
||||
city = forms.ChoiceField(label="شهر", required=False)
|
||||
|
||||
def __init__(self, *args, user, instance=None, unit_ids=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.instance = instance
|
||||
rows = location_data()
|
||||
self.fields["province"].choices = [("", "انتخاب استان")] + [(row["province"], row["province"]) for row in rows]
|
||||
selected_province = (self.data.get("province") if self.is_bound else self.initial.get("province")) or getattr(instance, "province", "")
|
||||
selected_city = (self.data.get("city") if self.is_bound else self.initial.get("city")) or getattr(instance, "city", "")
|
||||
cities = next((row["cities"] for row in rows if row["province"] == selected_province), [])
|
||||
self.fields["city"].choices = [("", "ابتدا استان را انتخاب کنید")] + [(city, city) for city in cities]
|
||||
self.fields["province"].widget.attrs.update({"data-province-input": "", "autocomplete": "off"})
|
||||
self.fields["city"].widget.attrs.update({"data-city-input": "", "autocomplete": "off"})
|
||||
if selected_city and selected_city not in dict(self.fields["city"].choices):
|
||||
self.fields["city"].choices.append((selected_city, selected_city))
|
||||
unit_ids = set(unit_ids if unit_ids is not None else TicketPolicy.unit_ids(user))
|
||||
self.fields["groups"].queryset = ContactGroup.objects.filter(unit_id__in=unit_ids, is_active=True).select_related("unit")
|
||||
if instance and not self.is_bound:
|
||||
self.initial.update({"mobile": instance.user.mobile, "first_name": instance.first_name, "last_name": instance.last_name, "organization_name": instance.organization_name, "stakeholder_type": instance.stakeholder_type, "groups": instance.groups.filter(unit_id__in=unit_ids), "province": instance.province, "city": instance.city})
|
||||
|
||||
def clean_mobile(self):
|
||||
try:
|
||||
mobile = normalize_mobile(self.cleaned_data["mobile"])
|
||||
except ValueError as exc:
|
||||
raise forms.ValidationError(str(exc)) from exc
|
||||
current_user_id = getattr(getattr(self.instance, "user", None), "pk", None)
|
||||
if User.objects.filter(mobile=mobile).exclude(pk=current_user_id).exists():
|
||||
raise forms.ValidationError("این شماره همراه قبلاً ثبت شده است.")
|
||||
return mobile
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
province, city, error = validate_location(data.get("province"), data.get("city"))
|
||||
data["province"], data["city"] = province, city
|
||||
if error:
|
||||
self.add_error("city" if city else "province", error)
|
||||
return data
|
||||
|
||||
|
||||
class ContactGroupForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ContactGroup
|
||||
fields = ("unit", "name", "description", "is_active")
|
||||
labels = {"unit": "واحد مالک", "name": "نام گروه", "description": "توضیح", "is_active": "فعال"}
|
||||
|
||||
def __init__(self, *args, user, unit_ids=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["unit"].queryset = OrganizationalUnit.objects.filter(pk__in=unit_ids if unit_ids is not None else TicketPolicy.unit_ids(user), is_active=True)
|
||||
|
||||
|
||||
class UnitSMSTemplateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = SMSTemplate
|
||||
fields = ("unit", "event_type", "title", "body", "is_active")
|
||||
labels = {"unit": "واحد", "event_type": "رویداد", "title": "عنوان مدیریتی", "body": "متن پیامک", "is_active": "فعال"}
|
||||
widgets = {"body": forms.Textarea(attrs={"rows": 6})}
|
||||
|
||||
def __init__(self, *args, user, unit_ids=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["unit"].queryset = OrganizationalUnit.objects.filter(pk__in=unit_ids if unit_ids is not None else TicketPolicy.unit_ids(user), is_active=True)
|
||||
self.fields["unit"].required = True
|
||||
@@ -0,0 +1,130 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-05 07:03
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('messaging', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SLAConfig',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('priority', models.CharField(max_length=15, unique=True)),
|
||||
('first_response_minutes', models.PositiveIntegerField()),
|
||||
('resolution_minutes', models.PositiveIntegerField()),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('title', models.CharField(max_length=100)),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('icon', models.CharField(blank=True, max_length=50)),
|
||||
('sort_order', models.PositiveIntegerField(default=0)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['sort_order', 'title'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Ticket',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('ticket_number', models.CharField(editable=False, max_length=30, unique=True)),
|
||||
('subject', models.CharField(max_length=200, validators=[django.core.validators.MinLengthValidator(5)])),
|
||||
('status', models.CharField(choices=[('NEW', 'جدید'), ('OPEN', 'باز'), ('WAITING_CUSTOMER', 'در انتظار مشتری'), ('WAITING_STAFF', 'در انتظار کارشناس'), ('RESOLVED', 'حل\u200cشده'), ('CLOSED', 'بسته'), ('REOPENED', 'بازگشایی\u200cشده')], db_index=True, default='NEW', max_length=25)),
|
||||
('priority', models.CharField(choices=[('NORMAL', 'عادی'), ('IMPORTANT', 'مهم'), ('URGENT', 'فوری')], db_index=True, default='NORMAL', max_length=15)),
|
||||
('origin_visibility', models.CharField(choices=[('PUBLIC', 'عمومی'), ('SELECTED', 'کارشناس انتخابی')], default='PUBLIC', max_length=15)),
|
||||
('last_activity_at', models.DateTimeField(db_index=True)),
|
||||
('first_response_at', models.DateTimeField(blank=True, null=True)),
|
||||
('last_customer_message_at', models.DateTimeField(blank=True, null=True)),
|
||||
('last_staff_message_at', models.DateTimeField(blank=True, null=True)),
|
||||
('first_response_due_at', models.DateTimeField(blank=True, null=True)),
|
||||
('resolution_due_at', models.DateTimeField(blank=True, null=True)),
|
||||
('resolved_at', models.DateTimeField(blank=True, null=True)),
|
||||
('closed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('reopened_at', models.DateTimeField(blank=True, null=True)),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='customer_tickets', to=settings.AUTH_USER_MODEL)),
|
||||
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='owned_tickets', to=settings.AUTH_USER_MODEL)),
|
||||
('selected_expert', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='selected_tickets', to=settings.AUTH_USER_MODEL)),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='tickets.ticketcategory')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-last_activity_at'],
|
||||
'permissions': [('view_all_tickets', 'Can view all tickets'), ('claim_ticket', 'Can claim ticket'), ('transfer_ticket', 'Can transfer ticket'), ('share_ticket', 'Can share ticket'), ('change_ticket_priority', 'Can change ticket priority'), ('view_customer_history', 'Can view customer history')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketEvent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('event_type', models.CharField(choices=[('CREATED', 'ایجاد'), ('CLAIMED', 'برداشت'), ('TRANSFERRED', 'انتقال'), ('SHARED', 'اشتراک'), ('ACCESS_REVOKED', 'لغو دسترسی'), ('STATUS_CHANGED', 'تغییر وضعیت'), ('PRIORITY_CHANGED', 'تغییر اولویت'), ('REOPENED', 'بازگشایی'), ('RESOLVED', 'حل'), ('CLOSED', 'بستن')], max_length=30)),
|
||||
('metadata', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='tickets.ticket')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketReadState',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('last_read_at', models.DateTimeField()),
|
||||
('last_read_message', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='messaging.message')),
|
||||
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.ticket')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketAccessGrant',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('permission_level', models.CharField(choices=[('VIEW_ONLY', 'فقط مشاهده'), ('REPLY', 'پاسخ'), ('FULL', 'کامل')], default='FULL', max_length=15)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('revoked_at', models.DateTimeField(blank=True, null=True)),
|
||||
('granted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='granted_ticket_access', to=settings.AUTH_USER_MODEL)),
|
||||
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_grants', to='tickets.ticket')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_grants', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(condition=models.Q(('revoked_at__isnull', True)), fields=('ticket', 'user'), name='unique_active_ticket_grant')],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ticket',
|
||||
index=models.Index(fields=['status', 'last_activity_at'], name='tickets_tic_status_dbef56_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ticket',
|
||||
index=models.Index(fields=['owner', 'status'], name='tickets_tic_owner_i_70fd59_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ticket',
|
||||
index=models.Index(fields=['selected_expert', 'status'], name='tickets_tic_selecte_71a761_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='ticketreadstate',
|
||||
constraint=models.UniqueConstraint(fields=('ticket', 'user'), name='unique_ticket_read_state'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("tickets", "0001_initial")]
|
||||
operations = [migrations.AlterField(model_name="ticketreadstate", name="ticket", field=models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="read_states", to="tickets.ticket"))]
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("tickets", "0002_alter_ticketreadstate_ticket")]
|
||||
operations = [
|
||||
migrations.AddField(model_name="ticket", name="city", field=models.CharField(blank=True, max_length=100)),
|
||||
migrations.AddField(model_name="ticket", name="province", field=models.CharField(blank=True, max_length=100)),
|
||||
]
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-08 09:57
|
||||
|
||||
import apps.core.models
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customers', '0003_contactgroup_customerprofile_organization_name_and_more'),
|
||||
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
|
||||
('tickets', '0003_ticket_city_ticket_province'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TicketTag',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=80, unique=True)),
|
||||
('slug', models.SlugField(max_length=80, unique=True)),
|
||||
('color', models.CharField(default='#52677d', max_length=7)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'برچسب تیکت',
|
||||
'verbose_name_plural': 'برچسب\u200cهای تیکت',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='assigned_unit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assigned_tickets', to='staff.organizationalunit'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='created_tickets', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='delivery_type',
|
||||
field=models.CharField(choices=[('INDIVIDUAL', 'فردی'), ('GROUP', 'ارسال گروهی')], db_index=True, default='INDIVIDUAL', max_length=12),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='owning_unit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='owned_tickets', to='staff.organizationalunit'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GroupDispatch',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('subject', models.CharField(max_length=200, validators=[django.core.validators.MinLengthValidator(5)])),
|
||||
('body', models.TextField()),
|
||||
('status', models.CharField(choices=[('DRAFT', 'پیش\u200cنویس'), ('QUEUED', 'در صف ارسال'), ('SENT', 'ارسال\u200cشده'), ('PARTIAL', 'ارسال ناقص'), ('CANCELLED', 'لغوشده')], db_index=True, default='DRAFT', max_length=12)),
|
||||
('recipient_count', models.PositiveIntegerField(default=0)),
|
||||
('dispatched_at', models.DateTimeField(blank=True, null=True)),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='tickets.ticketcategory')),
|
||||
('contact_group', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dispatches', to='customers.contactgroup')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_group_dispatches', to=settings.AUTH_USER_MODEL)),
|
||||
('unit', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='group_dispatches', to='staff.organizationalunit')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ارسال گروهی',
|
||||
'verbose_name_plural': 'ارسال\u200cهای گروهی',
|
||||
'ordering': ['-created_at'],
|
||||
'permissions': [('dispatch_group_tickets', 'Can dispatch isolated tickets to a contact group')],
|
||||
},
|
||||
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='group_dispatch',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='tickets.groupdispatch'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='tags',
|
||||
field=models.ManyToManyField(blank=True, related_name='tickets', to='tickets.tickettag'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ticket',
|
||||
index=models.Index(fields=['owning_unit', 'status'], name='tickets_tic_owning__e629e3_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ticket',
|
||||
index=models.Index(fields=['delivery_type', 'last_activity_at'], name='tickets_tic_deliver_c53084_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
BASE_UNIT_PERMISSIONS = {
|
||||
"tickets": {"view_ticket", "add_ticket", "change_ticket", "claim_ticket", "transfer_ticket", "share_ticket", "change_ticket_priority", "dispatch_group_tickets"},
|
||||
"messaging": {"add_message", "view_message"},
|
||||
"customers": {"view_customerprofile", "add_customerprofile", "change_customerprofile", "view_contactgroup", "add_contactgroup", "change_contactgroup"},
|
||||
"notifications": {"view_smstemplate", "add_smstemplate", "change_smstemplate"},
|
||||
}
|
||||
AUDIT_ROLES = {"SALES_MANAGER", "CEO", "GENERAL_ADMIN"}
|
||||
|
||||
|
||||
def bootstrap_legacy_staff(apps, schema_editor):
|
||||
"""Give pre-RBAC staff a safe editable default unit during an in-place upgrade."""
|
||||
StaffProfile = apps.get_model("staff", "StaffProfile")
|
||||
if not StaffProfile.objects.exists():
|
||||
return
|
||||
OrganizationalUnit = apps.get_model("staff", "OrganizationalUnit")
|
||||
UnitMembership = apps.get_model("staff", "UnitMembership")
|
||||
Permission = apps.get_model("auth", "Permission")
|
||||
unit, _ = OrganizationalUnit.objects.get_or_create(
|
||||
slug="sales",
|
||||
defaults={"name": "فروش", "description": "واحد پیشفرض برای کارشناسان موجود پیش از راهاندازی RBAC"},
|
||||
)
|
||||
query = None
|
||||
from django.db.models import Q
|
||||
for app_label, codenames in BASE_UNIT_PERMISSIONS.items():
|
||||
part = Q(content_type__app_label=app_label, codename__in=codenames)
|
||||
query = part if query is None else query | part
|
||||
unit.permissions.add(*Permission.objects.filter(query))
|
||||
audit_permission = Permission.objects.filter(content_type__app_label="accounts", codename="view_ticketmagiclink").first()
|
||||
for profile in StaffProfile.objects.select_related("user"):
|
||||
if profile.primary_unit_id is None:
|
||||
profile.primary_unit_id = unit.pk
|
||||
profile.save(update_fields=["primary_unit"])
|
||||
membership, _ = UnitMembership.objects.get_or_create(user_id=profile.user_id, unit=unit, defaults={"is_primary": True, "is_active": True})
|
||||
changed = False
|
||||
if not membership.is_active: membership.is_active = True; changed = True
|
||||
if profile.primary_unit_id == unit.pk and not membership.is_primary: membership.is_primary = True; changed = True
|
||||
if changed: membership.save(update_fields=["is_active", "is_primary", "updated_at"])
|
||||
if audit_permission and (profile.role in AUDIT_ROLES or profile.user.is_superuser):
|
||||
membership.permissions.add(audit_permission)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0004_ticketmagiclink_created_unit_and_more"),
|
||||
("customers", "0004_contactgroup_unit_customerprofile_groups_and_more"),
|
||||
("notifications", "0006_alter_smstemplate_allowed_tags"),
|
||||
("staff", "0004_organizationalunit_staffprofile_primary_unit_and_more"),
|
||||
("tickets", "0004_tickettag_ticket_assigned_unit_ticket_created_by_and_more"),
|
||||
]
|
||||
operations = [migrations.RunPython(bootstrap_legacy_staff, migrations.RunPython.noop)]
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def assign_legacy_contacts(apps, schema_editor):
|
||||
OrganizationalUnit = apps.get_model("staff", "OrganizationalUnit")
|
||||
ContactGroup = apps.get_model("customers", "ContactGroup")
|
||||
CustomerProfile = apps.get_model("customers", "CustomerProfile")
|
||||
sales = OrganizationalUnit.objects.filter(slug="sales").first()
|
||||
if sales is None or not CustomerProfile.objects.exists():
|
||||
return
|
||||
group, _ = ContactGroup.objects.get_or_create(
|
||||
unit=sales,
|
||||
name="مخاطبان فروش",
|
||||
defaults={"description": "گروه پیشفرض مخاطبان موجود پیش از راهاندازی دفترچه سازمانی"},
|
||||
)
|
||||
group.contacts.add(*CustomerProfile.objects.filter(groups__isnull=True))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("tickets", "0005_bootstrap_legacy_staff_unit")]
|
||||
operations = [migrations.RunPython(assign_legacy_contacts, migrations.RunPython.noop)]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-09 05:43
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('staff', '0005_organizationalunit_accepts_external_tickets'),
|
||||
('tickets', '0006_assign_legacy_contacts_to_sales'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='ticketcategory',
|
||||
options={'ordering': ['unit__name', 'sort_order', 'title']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketcategory',
|
||||
name='unit',
|
||||
field=models.ForeignKey(blank=True, help_text='برای دسته\u200cهای قدیمی عمومی می\u200cتواند خالی باشد؛ دسته\u200cهای جدید باید واحد داشته باشند.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ticket_categories', to='staff.organizationalunit', verbose_name='واحد سازمانی'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def assign_categories(apps, schema_editor):
|
||||
Unit = apps.get_model("staff", "OrganizationalUnit")
|
||||
Category = apps.get_model("tickets", "TicketCategory")
|
||||
unit = Unit.objects.filter(is_active=True, slug="sales").first() or Unit.objects.filter(is_active=True).order_by("id").first()
|
||||
if unit:
|
||||
Category.objects.filter(unit__isnull=True).update(unit=unit)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("tickets", "0007_alter_ticketcategory_options_ticketcategory_unit")]
|
||||
operations = [migrations.RunPython(assign_categories, migrations.RunPython.noop)]
|
||||
@@ -0,0 +1,37 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
DEFAULT_UNITS = (
|
||||
("accounting", "حسابداری", (("financial-documents", "اسناد مالی"), ("payment-receipt", "فیش و رسید پرداخت"), ("invoice", "صورتحساب و فاکتور"))),
|
||||
("logistics", "بارگیری و لجستیک", (("shipping-quote", "استعلام حمل"), ("loading-status", "وضعیت بارگیری"), ("delivery-coordination", "هماهنگی تحویل"))),
|
||||
("warehouse", "انبار", (("inventory-request", "استعلام موجودی انبار"), ("warehouse-release", "مجوز خروج کالا"), ("warehouse-documents", "اسناد انبار"))),
|
||||
)
|
||||
|
||||
|
||||
def seed_units_and_categories(apps, schema_editor):
|
||||
Unit = apps.get_model("staff", "OrganizationalUnit")
|
||||
Category = apps.get_model("tickets", "TicketCategory")
|
||||
for unit_slug, unit_name, categories in DEFAULT_UNITS:
|
||||
unit, _ = Unit.objects.get_or_create(
|
||||
slug=unit_slug,
|
||||
defaults={"name": unit_name, "is_active": True, "accepts_external_tickets": True},
|
||||
)
|
||||
changed = []
|
||||
if not unit.is_active:
|
||||
unit.is_active = True
|
||||
changed.append("is_active")
|
||||
if not unit.accepts_external_tickets:
|
||||
unit.accepts_external_tickets = True
|
||||
changed.append("accepts_external_tickets")
|
||||
if changed:
|
||||
unit.save(update_fields=changed)
|
||||
for sort_order, (slug, title) in enumerate(categories, start=10):
|
||||
Category.objects.get_or_create(
|
||||
slug=slug,
|
||||
defaults={"unit": unit, "title": title, "sort_order": sort_order, "is_active": True},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("tickets", "0008_assign_categories_to_default_unit")]
|
||||
operations = [migrations.RunPython(seed_units_and_categories, migrations.RunPython.noop)]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-09 09:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0009_seed_customer_facing_units_and_categories'),
|
||||
('staff', '0006_alter_organizationalunit_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='ticketcategory',
|
||||
options={'ordering': ['unit__sort_order', 'unit__name', 'sort_order', 'title', 'pk']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketcategory',
|
||||
name='search_keywords',
|
||||
field=models.TextField(blank=True, help_text='مترادف\u200cها و عبارت\u200cهای رایج مشتری را وارد کنید.', verbose_name='کلیدواژه\u200cهای جستجو'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ticketcategory',
|
||||
name='sort_order',
|
||||
field=models.PositiveIntegerField(db_index=True, default=0, verbose_name='ترتیب نمایش'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-09 09:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0010_alter_ticketcategory_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ticketcategory',
|
||||
name='show_in_customer_form',
|
||||
field=models.BooleanField(default=True, help_text='در صورت خاموش بودن، این دسته در انتخاب و جستجوی مشتری دیده نمی\u200cشود.', verbose_name='نمایش در فرم مشتری'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,124 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
PEOPLE = [
|
||||
("kimiya_ghaffari", "کیمیا", "غفاری", "کیمیا غفاری", "SALES_EXPERT", "sales", "کارشناس فروش", "SELF"),
|
||||
("zahra_jamali", "زهرا", "جمالی", "زهرا جمالی", "SALES_EXPERT", "sales", "کارشناس فروش", "SELF"),
|
||||
("maryam_fotovat", "مریم", "فتوت", "مریم فتوت", "SALES_SUPERVISOR", "sales", "سرپرست فروش", "UNIT"),
|
||||
("morteza_sokhaeifard", "مرتضی", "سخائی فرد", "مرتضی سخائی فرد", "SALES_MANAGER", "sales", "مدیر فروش", "UNIT"),
|
||||
("mostafa_sokhaeifard", "مصطفی", "سخائی فرد", "مصطفی سخائی فرد", "CEO", "executive", "مدیرعامل", "UNIT"),
|
||||
("ms_sabzali", "", "سبزعلی", "خانم سبزعلی", "WAREHOUSE_EXPERT", "warehouse-loading", "کارشناس بارگیری انبار", "UNIT"),
|
||||
("mehdi_azami", "مهدی", "عظمی", "آقای مهدی عظمی", "WAREHOUSE_EXPERT", "warehouse-loading", "کارشناس بارگیری انبار", "UNIT"),
|
||||
("fereshteh_yavari", "فرشته", "یاوری", "خانم فرشته یاوری", "FINANCE_EXPERT", "finance", "کارشناس مالی / حسابداری", "UNIT"),
|
||||
("fatemeh_sokhaei", "فاطمه", "سخایی", "خانم فاطمه سخایی", "FINANCE_EXPERT", "finance", "کارشناس مالی / حسابداری", "UNIT"),
|
||||
("rana_nobakht", "رعنا", "نوبخت", "خانم رعنا نوبخت", "FINANCE_EXPERT", "finance", "کارشناس مالی / حسابداری", "UNIT"),
|
||||
("mehdi_liaqat", "مهدی", "لیاقت", "آقای مهدی لیاقت", "FINANCE_EXPERT", "finance", "کارشناس مالی / حسابداری", "UNIT"),
|
||||
("aliasghar_sokhaeifard", "علی اصغر", "سخائی فرد", "علی اصغر سخائی فرد", "FINANCE_EXPERT", "finance", "کارشناس مالی / حسابداری", "UNIT"),
|
||||
]
|
||||
|
||||
|
||||
def seed_organization(apps, schema_editor):
|
||||
ContentType = apps.get_model("contenttypes", "ContentType")
|
||||
Permission = apps.get_model("auth", "Permission")
|
||||
Group = apps.get_model("auth", "Group")
|
||||
User = apps.get_model("accounts", "User")
|
||||
Unit = apps.get_model("staff", "OrganizationalUnit")
|
||||
Membership = apps.get_model("staff", "UnitMembership")
|
||||
Profile = apps.get_model("staff", "StaffProfile")
|
||||
Category = apps.get_model("tickets", "TicketCategory")
|
||||
ContactGroup = apps.get_model("customers", "ContactGroup")
|
||||
|
||||
unit_specs = {
|
||||
"sales": ("فروش", "امور فروش و ارتباط با مشتری", True),
|
||||
"warehouse-loading": ("بارگیری انبار", "بارگیری، انبار و ارسال", True),
|
||||
"finance": ("مالی / حسابداری", "واحد مالی و حسابداری (یک مفهوم سازمانی)", True),
|
||||
"executive": ("مدیریت ارشد", "مدیریت عامل", False),
|
||||
}
|
||||
units = {}
|
||||
for order, (slug, (name, description, external)) in enumerate(unit_specs.items(), 10):
|
||||
unit, _ = Unit.objects.update_or_create(slug=slug, defaults={"name": name, "description": description, "is_active": True, "accepts_external_tickets": external, "show_in_customer_form": external, "sort_order": order})
|
||||
units[slug] = unit
|
||||
|
||||
category_specs = [("sales", "عمومی فروش", "sales-general"), ("warehouse-loading", "بارگیری و ارسال", "warehouse-loading-general"), ("finance", "مالی و حسابداری", "finance-general")]
|
||||
for slug, title, category_slug in category_specs:
|
||||
Category.objects.update_or_create(slug=category_slug, defaults={"title": title, "unit": units[slug], "is_active": True, "show_in_customer_form": True})
|
||||
ContactGroup.objects.get_or_create(unit=units[slug], name=f"مخاطبان {units[slug].name}")
|
||||
|
||||
permission_specs = {
|
||||
("tickets", "groupdispatch"): [
|
||||
("create_bulk_dispatch", "Can create bulk ticket dispatches"),
|
||||
("view_all_bulk_dispatches", "Can view all bulk ticket dispatches"),
|
||||
("create_inquiry", "Can create inquiries"),
|
||||
("view_all_inquiries", "Can view all inquiries"),
|
||||
],
|
||||
}
|
||||
for (app_label, model), rows in permission_specs.items():
|
||||
content_type, _ = ContentType.objects.get_or_create(app_label=app_label, model=model)
|
||||
for codename, name in rows:
|
||||
Permission.objects.get_or_create(content_type=content_type, codename=codename, defaults={"name": name})
|
||||
|
||||
permission_models = {
|
||||
"tickets": {"view_ticket": "ticket", "add_ticket": "ticket", "claim_ticket": "ticket", "view_all_tickets": "ticket", "create_bulk_dispatch": "groupdispatch", "view_all_bulk_dispatches": "groupdispatch", "create_inquiry": "groupdispatch", "view_all_inquiries": "groupdispatch"},
|
||||
"messaging": {"add_message": "message"},
|
||||
"customers": {"view_customerprofile": "customerprofile", "add_customerprofile": "customerprofile", "change_customerprofile": "customerprofile", "view_contactgroup": "contactgroup", "add_contactgroup": "contactgroup", "change_contactgroup": "contactgroup"},
|
||||
}
|
||||
|
||||
def permission(app_label, codename):
|
||||
content_type, _ = ContentType.objects.get_or_create(app_label=app_label, model=permission_models[app_label][codename])
|
||||
item, _ = Permission.objects.get_or_create(content_type=content_type, codename=codename, defaults={"name": codename.replace("_", " ").title()})
|
||||
return item
|
||||
|
||||
unit_permissions = [
|
||||
("tickets", "view_ticket"), ("tickets", "add_ticket"), ("tickets", "claim_ticket"),
|
||||
("messaging", "add_message"),
|
||||
("customers", "view_customerprofile"), ("customers", "add_customerprofile"), ("customers", "change_customerprofile"),
|
||||
("customers", "view_contactgroup"), ("customers", "add_contactgroup"), ("customers", "change_contactgroup"),
|
||||
]
|
||||
for slug in ("sales", "warehouse-loading", "finance"):
|
||||
units[slug].permissions.add(*[permission(*item) for item in unit_permissions])
|
||||
|
||||
role_groups = {
|
||||
"SALES_EXPERT": "Sales Expert", "SALES_SUPERVISOR": "Sales Supervisor", "SALES_MANAGER": "Sales Manager",
|
||||
"WAREHOUSE_EXPERT": "Warehouse Expert", "FINANCE_EXPERT": "Finance Expert", "CEO": "CEO",
|
||||
}
|
||||
for username, first_name, last_name, display_name, role, unit_slug, job_title, scope in PEOPLE:
|
||||
user, _ = User.objects.update_or_create(username=username, defaults={"first_name": first_name, "last_name": last_name, "account_type": "STAFF", "is_active": True, "password": "!"})
|
||||
profile, _ = Profile.objects.update_or_create(user=user, defaults={"primary_unit": units[unit_slug], "role": role, "display_name": display_name, "first_name": first_name, "last_name": last_name, "job_title": job_title, "is_active": True})
|
||||
membership, _ = Membership.objects.update_or_create(user=user, unit=units[unit_slug], defaults={"is_primary": True, "is_active": True, "ticket_scope": scope})
|
||||
Membership.objects.filter(user=user).exclude(pk=membership.pk).delete()
|
||||
group, _ = Group.objects.get_or_create(name=role_groups[role])
|
||||
user.groups.add(group)
|
||||
|
||||
sabzali = User.objects.get(username="ms_sabzali")
|
||||
sabzali_membership = Membership.objects.get(user=sabzali, unit=units["warehouse-loading"])
|
||||
sabzali_membership.permissions.add(permission("tickets", "create_bulk_dispatch"), permission("tickets", "create_inquiry"))
|
||||
|
||||
ceo = User.objects.get(username="mostafa_sokhaeifard")
|
||||
ceo.user_permissions.add(
|
||||
permission("tickets", "view_all_tickets"), permission("tickets", "add_ticket"),
|
||||
permission("tickets", "create_bulk_dispatch"), permission("tickets", "view_all_bulk_dispatches"),
|
||||
permission("tickets", "create_inquiry"), permission("tickets", "view_all_inquiries"),
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0005_magiclinkaccesslog_magic_opened_idx_and_more"),
|
||||
("customers", "0004_contactgroup_unit_customerprofile_groups_and_more"),
|
||||
("messaging", "0003_messagereadreceipt"),
|
||||
("staff", "0008_unitmembership_ticket_scope_and_roles"),
|
||||
("tickets", "0011_ticketcategory_show_in_customer_form"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="groupdispatch",
|
||||
name="operation_type",
|
||||
field=models.CharField(choices=[("BULK", "ارسال گروهی تیکت"), ("INQUIRY", "استعلام")], db_index=True, default="BULK", max_length=12),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="groupdispatch",
|
||||
options={"ordering": ["-created_at"], "permissions": [("dispatch_group_tickets", "Can dispatch isolated tickets to a contact group"), ("create_bulk_dispatch", "Can create bulk ticket dispatches"), ("view_all_bulk_dispatches", "Can view all bulk ticket dispatches"), ("create_inquiry", "Can create inquiries"), ("view_all_inquiries", "Can view all inquiries")], "verbose_name": "ارسال گروهی", "verbose_name_plural": "ارسالهای گروهی"},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def merge_legacy_accounting_unit(apps, schema_editor):
|
||||
Unit = apps.get_model("staff", "OrganizationalUnit")
|
||||
Membership = apps.get_model("staff", "UnitMembership")
|
||||
StaffProfile = apps.get_model("staff", "StaffProfile")
|
||||
TicketCategory = apps.get_model("tickets", "TicketCategory")
|
||||
Ticket = apps.get_model("tickets", "Ticket")
|
||||
GroupDispatch = apps.get_model("tickets", "GroupDispatch")
|
||||
ContactGroup = apps.get_model("customers", "ContactGroup")
|
||||
CustomerProfile = apps.get_model("customers", "CustomerProfile")
|
||||
SMSTemplate = apps.get_model("notifications", "SMSTemplate")
|
||||
TicketMagicLink = apps.get_model("accounts", "TicketMagicLink")
|
||||
|
||||
legacy = Unit.objects.filter(slug="accounting").first()
|
||||
canonical = Unit.objects.filter(slug="finance").first()
|
||||
if legacy is None:
|
||||
return
|
||||
if canonical is None:
|
||||
legacy.slug = "finance"
|
||||
legacy.name = "مالی / حسابداری"
|
||||
legacy.description = "واحد مالی و حسابداری (یک مفهوم سازمانی)"
|
||||
legacy.is_active = True
|
||||
legacy.accepts_external_tickets = True
|
||||
legacy.show_in_customer_form = True
|
||||
legacy.save()
|
||||
return
|
||||
|
||||
canonical.permissions.add(*legacy.permissions.all())
|
||||
TicketCategory.objects.filter(unit=legacy).update(unit=canonical)
|
||||
Ticket.objects.filter(owning_unit=legacy).update(owning_unit=canonical)
|
||||
Ticket.objects.filter(assigned_unit=legacy).update(assigned_unit=canonical)
|
||||
GroupDispatch.objects.filter(unit=legacy).update(unit=canonical)
|
||||
TicketMagicLink.objects.filter(created_unit=legacy).update(created_unit=canonical)
|
||||
StaffProfile.objects.filter(primary_unit=legacy).update(primary_unit=canonical)
|
||||
|
||||
for membership in Membership.objects.filter(unit=legacy):
|
||||
existing = Membership.objects.filter(user_id=membership.user_id, unit=canonical).first()
|
||||
if existing is None:
|
||||
membership.unit = canonical
|
||||
membership.save(update_fields=["unit", "updated_at"])
|
||||
continue
|
||||
existing.permissions.add(*membership.permissions.all())
|
||||
existing.is_active = existing.is_active or membership.is_active
|
||||
existing.is_primary = existing.is_primary or membership.is_primary
|
||||
if membership.ticket_scope == "UNIT":
|
||||
existing.ticket_scope = "UNIT"
|
||||
existing.save(update_fields=["is_active", "is_primary", "ticket_scope", "updated_at"])
|
||||
membership.delete()
|
||||
|
||||
for group in ContactGroup.objects.filter(unit=legacy):
|
||||
existing = ContactGroup.objects.filter(unit=canonical, name=group.name).first()
|
||||
if existing is None:
|
||||
group.unit = canonical
|
||||
group.save(update_fields=["unit", "updated_at"])
|
||||
continue
|
||||
for profile in CustomerProfile.objects.filter(groups=group):
|
||||
profile.groups.add(existing)
|
||||
GroupDispatch.objects.filter(contact_group=group).update(contact_group=existing)
|
||||
group.delete()
|
||||
|
||||
for template in SMSTemplate.objects.filter(unit=legacy):
|
||||
if SMSTemplate.objects.filter(unit=canonical, event_type=template.event_type).exists():
|
||||
template.delete()
|
||||
else:
|
||||
template.unit = canonical
|
||||
template.save(update_fields=["unit", "updated_at"])
|
||||
|
||||
legacy.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("tickets", "0012_groupdispatch_operation_permissions_and_seed_org")]
|
||||
operations = [migrations.RunPython(merge_legacy_accounting_unit, migrations.RunPython.noop)]
|
||||
@@ -0,0 +1,76 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def merge_unit(apps, legacy_slug, canonical_slug, canonical_name, canonical_description):
|
||||
Unit = apps.get_model("staff", "OrganizationalUnit")
|
||||
Membership = apps.get_model("staff", "UnitMembership")
|
||||
StaffProfile = apps.get_model("staff", "StaffProfile")
|
||||
TicketCategory = apps.get_model("tickets", "TicketCategory")
|
||||
Ticket = apps.get_model("tickets", "Ticket")
|
||||
GroupDispatch = apps.get_model("tickets", "GroupDispatch")
|
||||
ContactGroup = apps.get_model("customers", "ContactGroup")
|
||||
CustomerProfile = apps.get_model("customers", "CustomerProfile")
|
||||
SMSTemplate = apps.get_model("notifications", "SMSTemplate")
|
||||
TicketMagicLink = apps.get_model("accounts", "TicketMagicLink")
|
||||
|
||||
legacy = Unit.objects.filter(slug=legacy_slug).first()
|
||||
canonical = Unit.objects.filter(slug=canonical_slug).first()
|
||||
if legacy is None:
|
||||
return
|
||||
if canonical is None:
|
||||
legacy.slug = canonical_slug
|
||||
legacy.name = canonical_name
|
||||
legacy.description = canonical_description
|
||||
legacy.is_active = True
|
||||
legacy.accepts_external_tickets = True
|
||||
legacy.show_in_customer_form = True
|
||||
legacy.save()
|
||||
return
|
||||
|
||||
canonical.permissions.add(*legacy.permissions.all())
|
||||
TicketCategory.objects.filter(unit=legacy).update(unit=canonical)
|
||||
Ticket.objects.filter(owning_unit=legacy).update(owning_unit=canonical)
|
||||
Ticket.objects.filter(assigned_unit=legacy).update(assigned_unit=canonical)
|
||||
GroupDispatch.objects.filter(unit=legacy).update(unit=canonical)
|
||||
TicketMagicLink.objects.filter(created_unit=legacy).update(created_unit=canonical)
|
||||
StaffProfile.objects.filter(primary_unit=legacy).update(primary_unit=canonical)
|
||||
for membership in Membership.objects.filter(unit=legacy):
|
||||
existing = Membership.objects.filter(user_id=membership.user_id, unit=canonical).first()
|
||||
if existing is None:
|
||||
membership.unit = canonical
|
||||
membership.save(update_fields=["unit", "updated_at"])
|
||||
continue
|
||||
existing.permissions.add(*membership.permissions.all())
|
||||
existing.is_active = existing.is_active or membership.is_active
|
||||
existing.is_primary = existing.is_primary or membership.is_primary
|
||||
if membership.ticket_scope == "UNIT":
|
||||
existing.ticket_scope = "UNIT"
|
||||
existing.save(update_fields=["is_active", "is_primary", "ticket_scope", "updated_at"])
|
||||
membership.delete()
|
||||
for group in ContactGroup.objects.filter(unit=legacy):
|
||||
existing = ContactGroup.objects.filter(unit=canonical, name=group.name).first()
|
||||
if existing is None:
|
||||
group.unit = canonical
|
||||
group.save(update_fields=["unit", "updated_at"])
|
||||
continue
|
||||
for profile in CustomerProfile.objects.filter(groups=group):
|
||||
profile.groups.add(existing)
|
||||
GroupDispatch.objects.filter(contact_group=group).update(contact_group=existing)
|
||||
group.delete()
|
||||
for template in SMSTemplate.objects.filter(unit=legacy):
|
||||
if SMSTemplate.objects.filter(unit=canonical, event_type=template.event_type).exists():
|
||||
template.delete()
|
||||
else:
|
||||
template.unit = canonical
|
||||
template.save(update_fields=["unit", "updated_at"])
|
||||
legacy.delete()
|
||||
|
||||
|
||||
def merge_legacy_warehouse_units(apps, schema_editor):
|
||||
merge_unit(apps, "logistics", "warehouse-loading", "بارگیری انبار", "بارگیری، انبار و ارسال")
|
||||
merge_unit(apps, "warehouse", "warehouse-loading", "بارگیری انبار", "بارگیری، انبار و ارسال")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("tickets", "0013_merge_legacy_accounting_unit")]
|
||||
operations = [migrations.RunPython(merge_legacy_warehouse_units, migrations.RunPython.noop)]
|
||||
@@ -0,0 +1,69 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
UNIT_CONTENT = {
|
||||
"sales": ("خرید، فروش، موجودی کالا، سفارش، بارگیری، ارسال، تحویل و خدمات مشتریان", "فروش خرید سفارش کالا محصول قیمت پیش فاکتور موجودی بار بارگیری ارسال حمل تحویل نمایندگی شکایت مرجوعی"),
|
||||
"finance": ("پرداخت، فاکتور، صورتحساب، مانده حساب، چک، مالیات و تسویه حساب", "مالی حسابداری پرداخت واریز فیش رسید فاکتور صورتحساب مانده بدهی بستانکاری چک مالیات تسویه"),
|
||||
}
|
||||
|
||||
CATEGORIES = {
|
||||
"sales": (
|
||||
(10, "price", "قیمت، پیشفاکتور و خرید", "دریافت قیمت روز، پیشفاکتور یا راهنمایی برای خرید محصول.", "قیمت نرخ بها خرید سفارش پیش فاکتور پیشفاکتور quotation استعلام قیمت لیست قیمت"),
|
||||
(20, "discount", "تخفیف و پیشنهاد فروش", "پرسش درباره تخفیف، جشنواره، پیشنهاد ویژه یا قیمت همکاری.", "تخفیف آفر جشنواره پیشنهاد ویژه قیمت همکاری درصد تخفیف فروش عمده کد تخفیف"),
|
||||
(30, "sales-terms", "شرایط فروش و ثبت سفارش", "اطلاع از شرایط فروش و ثبت، تغییر یا تأیید سفارش.", "شرایط فروش ثبت سفارش خرید سفارش جدید تغییر سفارش تایید سفارش تأیید سفارش لغو سفارش قرارداد فروش"),
|
||||
(40, "inventory", "موجودی و مشخصات کالا", "استعلام موجودی، زمان تأمین، مشخصات، مدل، رنگ یا ابعاد کالا.", "موجودی کالا ناموجود تامین تأمین محصول مشخصات فنی مدل رنگ سایز اندازه ابعاد کاتالوگ جایگزین"),
|
||||
(50, "order-follow-up", "پیگیری سفارش", "پیگیری وضعیت سفارش از زمان ثبت تا آمادهسازی و ارسال.", "پیگیری سفارش وضعیت سفارش کد سفارش شماره سفارش آماده سازی آمادهسازی پردازش سفارش کی آماده میشه چه زمانی"),
|
||||
(60, "loading-shipping-tracking", "پیگیری بارگیری و ارسال", "هماهنگی و پیگیری بارگیری، ارسال، حمل، راننده و رسیدن بار.", "پیگیری بار ارسال بار بارگیری حمل محموله راننده کامیون تریلی بارنامه کد رهگیری رهگیری ارسال وضعیت بارگیری زمان ارسال ارسال نشده کی میرسه کجاست"),
|
||||
(70, "delivery-issue", "تحویل، کسری یا آسیب کالا", "اعلام تأخیر، عدم تحویل، کسری، مغایرت یا آسیبدیدگی کالای تحویلی.", "تحویل بار تاخیر تأخیر نرسیده کسری کمبود مغایرت اشتباه خرابی آسیب شکسته ناقص مفقودی بسته بندی بستهبندی"),
|
||||
(80, "consulting", "مشاوره انتخاب محصول", "دریافت راهنمایی برای انتخاب محصول مناسب و مقایسه گزینهها.", "مشاوره راهنمای خرید انتخاب محصول مقایسه پیشنهاد مناسب کاربرد چه محصولی کدام مدل"),
|
||||
(90, "agency", "نمایندگی و همکاری تجاری", "درخواست نمایندگی، عاملیت فروش یا شروع همکاری تجاری.", "نمایندگی عاملیت همکاری تجاری شراکت فروشنده همکار پخش کننده پخشکننده قرارداد همکاری"),
|
||||
(100, "complaint", "مرجوعی، تعویض و شکایت", "درخواست مرجوعی یا تعویض کالا و ثبت شکایت از فرایند فروش یا تحویل.", "مرجوعی برگشت کالا تعویض شکایت اعتراض نارضایتی خدمات بد پیگیری شکایت خسارت"),
|
||||
(110, "delivery", "شرایط و هزینه ارسال", "پرسش پیش از خرید درباره روش، هزینه، محدوده و زمان ارسال.", "شرایط ارسال هزینه ارسال کرایه حمل هزینه حمل روش ارسال زمان تحویل ارسال شهرستان پست باربری تیپاکس"),
|
||||
(900, "sales-general", "عمومی فروش", "موضوعات فروش، سفارش و خدمات مشتریان که در دستههای دیگر قرار نمیگیرند.", "عمومی فروش سایر متفرقه سوال سؤال درخواست مشتری راهنمایی ارتباط با فروش"),
|
||||
),
|
||||
"finance": (
|
||||
(10, "invoice", "فاکتور و صورتحساب", "دریافت یا پیگیری فاکتور رسمی، صورتحساب و رونوشت اسناد فروش.", "فاکتور رسمی صورتحساب صورت حساب پیش فاکتور کپی فاکتور رونوشت شماره فاکتور صدور فاکتور اصلاح فاکتور"),
|
||||
(20, "payment-receipt", "ثبت و تأیید پرداخت", "ارسال فیش یا رسید واریز و پیگیری ثبت و تأیید پرداخت.", "پرداخت واریز فیش واریزی رسید تراکنش کارت به کارت حواله بانکی تایید پرداخت تأیید پرداخت ثبت پرداخت پول واریز کردم"),
|
||||
(30, "account-balance", "مانده حساب و گردش حساب", "دریافت مانده بدهکار یا بستانکار، گردش حساب و تطبیق حسابها.", "مانده حساب گردش حساب بدهی بدهکار بستانکار طلب صورت حساب صورتحساب حساب مشتری مغایرت حساب تطبیق حساب"),
|
||||
(40, "financial-documents", "اصلاح و تکمیل اسناد مالی", "ارسال، تکمیل یا اصلاح مدارک و اسناد موردنیاز واحد مالی.", "اسناد مالی مدارک مالی اصلاح سند سند حسابداری کد اقتصادی شناسه ملی اطلاعات شرکت پرونده مالی پیوست سند"),
|
||||
(50, "cheque-guarantee", "چک، ضمانت و تعهدات", "پیگیری تحویل، وصول، برگشت یا تعویض چک و اسناد ضمانتی.", "چک صیادی ثبت چک وصول چک برگشتی برگشت چک تعویض چک ضمانت ضمانتنامه ضمانتنامه سفته سررسید"),
|
||||
(60, "payment-refund", "استرداد وجه", "درخواست یا پیگیری بازگشت وجه پرداختشده.", "استرداد وجه عودت وجه برگشت پول بازپرداخت پس گرفتن پول واریز اشتباه پرداخت اضافه refund"),
|
||||
(70, "tax-vat", "مالیات و ارزش افزوده", "پرسش درباره مالیات، ارزش افزوده، سامانه مؤدیان و صورتحساب الکترونیکی.", "مالیات ارزش افزوده vat سامانه مودیان مؤدیان صورتحساب الکترونیکی شناسه یکتا گواهی مالیاتی فصل معاملات"),
|
||||
(80, "supplier-settlement", "تسویه تأمینکنندگان", "پیگیری صورتحساب، پرداخت و تسویه حساب فروشندگان و تأمینکنندگان.", "تامین کننده تأمین کننده تامینکننده تأمینکننده فروشنده طرف حساب تسویه تسویه حساب پرداخت تامین کننده صورت وضعیت صورتوضعیت"),
|
||||
(900, "finance-general", "سایر امور مالی و حسابداری", "موضوعات مالی و حسابداری که در دستههای دیگر قرار نمیگیرند.", "عمومی مالی حسابداری سایر متفرقه امور مالی سوال سؤال درخواست پیگیری مالی"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def seed_catalog(apps, schema_editor):
|
||||
Unit = apps.get_model("staff", "OrganizationalUnit")
|
||||
Category = apps.get_model("tickets", "TicketCategory")
|
||||
units = {}
|
||||
for slug, (description, keywords) in UNIT_CONTENT.items():
|
||||
name = "فروش" if slug == "sales" else "مالی / حسابداری"
|
||||
order = 10 if slug == "sales" else 30
|
||||
unit, _ = Unit.objects.update_or_create(
|
||||
slug=slug,
|
||||
defaults={"name": name, "description": description, "search_keywords": keywords, "sort_order": order, "is_active": True, "accepts_external_tickets": True, "show_in_customer_form": True},
|
||||
)
|
||||
units[slug] = unit
|
||||
Unit.objects.filter(slug="warehouse-loading").update(show_in_customer_form=False)
|
||||
for unit_slug, rows in CATEGORIES.items():
|
||||
for sort_order, slug, title, description, keywords in rows:
|
||||
Category.objects.update_or_create(
|
||||
slug=slug,
|
||||
defaults={"unit": units[unit_slug], "title": title, "description": description, "search_keywords": keywords, "sort_order": sort_order, "is_active": True, "show_in_customer_form": True},
|
||||
)
|
||||
warehouse = Unit.objects.filter(slug="warehouse-loading").first()
|
||||
if warehouse is not None:
|
||||
Category.objects.update_or_create(
|
||||
slug="warehouse-loading-general",
|
||||
defaults={"unit": warehouse, "title": "عملیات بارگیری انبار", "description": "هماهنگی داخلی عملیات انبار و بارگیری.", "search_keywords": "انبار بارگیری عملیات داخلی", "sort_order": 10, "is_active": True, "show_in_customer_form": False},
|
||||
)
|
||||
Category.objects.filter(slug="other").update(show_in_customer_form=False)
|
||||
Category.objects.filter(slug="e2e-sms").update(show_in_customer_form=False)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("tickets", "0014_merge_legacy_warehouse_units")]
|
||||
operations = [migrations.RunPython(seed_catalog, migrations.RunPython.noop)]
|
||||
Whitespace-only changes.
@@ -0,0 +1,140 @@
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from apps.core.models import PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel
|
||||
|
||||
class TicketCategory(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
persian_text_fields = {"title": False, "description": True, "search_keywords": True}
|
||||
unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.PROTECT,null=True,blank=True,related_name="ticket_categories",verbose_name="واحد سازمانی",help_text="برای دستههای قدیمی عمومی میتواند خالی باشد؛ دستههای جدید باید واحد داشته باشند.")
|
||||
title=models.CharField(max_length=100); slug=models.SlugField(unique=True); description=models.TextField(blank=True); search_keywords=models.TextField(blank=True,verbose_name="کلیدواژههای جستجو",help_text="مترادفها و عبارتهای رایج مشتری را وارد کنید."); icon=models.CharField(max_length=50, blank=True); sort_order=models.PositiveIntegerField(default=0,db_index=True,verbose_name="ترتیب نمایش"); is_active=models.BooleanField(default=True); show_in_customer_form=models.BooleanField(default=True,verbose_name="نمایش در فرم مشتری",help_text="در صورت خاموش بودن، این دسته در انتخاب و جستجوی مشتری دیده نمیشود.")
|
||||
class Meta: ordering=["unit__sort_order", "unit__name", "sort_order", "title", "pk"]
|
||||
def __str__(self): return f"{self.unit.name} — {self.title}" if self.unit_id else self.title
|
||||
class SLAConfig(models.Model):
|
||||
priority=models.CharField(max_length=15, unique=True); first_response_minutes=models.PositiveIntegerField(); resolution_minutes=models.PositiveIntegerField(); is_active=models.BooleanField(default=True)
|
||||
|
||||
|
||||
class TicketTag(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
persian_text_fields = {"name": False}
|
||||
name = models.CharField(max_length=80, unique=True)
|
||||
slug = models.SlugField(max_length=80, unique=True)
|
||||
color = models.CharField(max_length=7, default="#52677d")
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
verbose_name = "برچسب تیکت"
|
||||
verbose_name_plural = "برچسبهای تیکت"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class GroupDispatch(PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel):
|
||||
"""One bulk operation whose recipients each receive an isolated ticket."""
|
||||
|
||||
persian_text_fields = {"subject": False, "body": True}
|
||||
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "DRAFT", "پیشنویس"
|
||||
QUEUED = "QUEUED", "در صف ارسال"
|
||||
SENT = "SENT", "ارسالشده"
|
||||
PARTIAL = "PARTIAL", "ارسال ناقص"
|
||||
CANCELLED = "CANCELLED", "لغوشده"
|
||||
|
||||
class OperationType(models.TextChoices):
|
||||
BULK = "BULK", "ارسال گروهی تیکت"
|
||||
INQUIRY = "INQUIRY", "استعلام"
|
||||
|
||||
unit = models.ForeignKey(
|
||||
"staff.OrganizationalUnit",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="group_dispatches",
|
||||
)
|
||||
operation_type = models.CharField(max_length=12, choices=OperationType.choices, default=OperationType.BULK, db_index=True)
|
||||
contact_group = models.ForeignKey(
|
||||
"customers.ContactGroup",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="dispatches",
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="created_group_dispatches",
|
||||
)
|
||||
category = models.ForeignKey(TicketCategory, on_delete=models.PROTECT)
|
||||
subject = models.CharField(max_length=200, validators=[MinLengthValidator(5)])
|
||||
body = models.TextField()
|
||||
status = models.CharField(max_length=12, choices=Status.choices, default=Status.DRAFT, db_index=True)
|
||||
recipient_count = models.PositiveIntegerField(default=0)
|
||||
dispatched_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
permissions = [
|
||||
("dispatch_group_tickets", "Can dispatch isolated tickets to a contact group"),
|
||||
("create_bulk_dispatch", "Can create bulk ticket dispatches"),
|
||||
("view_all_bulk_dispatches", "Can view all bulk ticket dispatches"),
|
||||
("create_inquiry", "Can create inquiries"),
|
||||
("view_all_inquiries", "Can view all inquiries"),
|
||||
]
|
||||
verbose_name = "ارسال گروهی"
|
||||
verbose_name_plural = "ارسالهای گروهی"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.subject} ({self.contact_group})"
|
||||
|
||||
|
||||
class Ticket(PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel):
|
||||
persian_text_fields = {"subject": False, "province": False, "city": False}
|
||||
class Status(models.TextChoices): NEW="NEW","جدید"; OPEN="OPEN","باز"; WAITING_CUSTOMER="WAITING_CUSTOMER","در انتظار مشتری"; WAITING_STAFF="WAITING_STAFF","در انتظار کارشناس"; RESOLVED="RESOLVED","حلشده"; CLOSED="CLOSED","بسته"; REOPENED="REOPENED","بازگشاییشده"
|
||||
class Priority(models.TextChoices): NORMAL="NORMAL","عادی"; IMPORTANT="IMPORTANT","مهم"; URGENT="URGENT","فوری"
|
||||
class Visibility(models.TextChoices): PUBLIC="PUBLIC","عمومی"; SELECTED="SELECTED","کارشناس انتخابی"
|
||||
class DeliveryType(models.TextChoices):
|
||||
INDIVIDUAL = "INDIVIDUAL", "فردی"
|
||||
GROUP = "GROUP", "ارسال گروهی"
|
||||
ticket_number=models.CharField(max_length=30, unique=True, editable=False)
|
||||
customer=models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="customer_tickets")
|
||||
created_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,null=True,blank=True,related_name="created_tickets")
|
||||
owning_unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.PROTECT,null=True,blank=True,related_name="owned_tickets")
|
||||
assigned_unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.PROTECT,null=True,blank=True,related_name="assigned_tickets")
|
||||
delivery_type=models.CharField(max_length=12,choices=DeliveryType.choices,default=DeliveryType.INDIVIDUAL,db_index=True)
|
||||
group_dispatch=models.ForeignKey(GroupDispatch,on_delete=models.PROTECT,null=True,blank=True,related_name="tickets")
|
||||
tags=models.ManyToManyField(TicketTag,blank=True,related_name="tickets")
|
||||
category=models.ForeignKey(TicketCategory, on_delete=models.PROTECT, related_name="tickets")
|
||||
subject=models.CharField(max_length=200, validators=[MinLengthValidator(5)])
|
||||
province=models.CharField(max_length=100, blank=True)
|
||||
city=models.CharField(max_length=100, blank=True)
|
||||
status=models.CharField(max_length=25, choices=Status.choices, default=Status.NEW, db_index=True)
|
||||
priority=models.CharField(max_length=15, choices=Priority.choices, default=Priority.NORMAL, db_index=True)
|
||||
selected_expert=models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="selected_tickets")
|
||||
owner=models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="owned_tickets")
|
||||
origin_visibility=models.CharField(max_length=15, choices=Visibility.choices, default=Visibility.PUBLIC)
|
||||
last_activity_at=models.DateTimeField(db_index=True); first_response_at=models.DateTimeField(null=True, blank=True); last_customer_message_at=models.DateTimeField(null=True, blank=True); last_staff_message_at=models.DateTimeField(null=True, blank=True); first_response_due_at=models.DateTimeField(null=True, blank=True); resolution_due_at=models.DateTimeField(null=True, blank=True); resolved_at=models.DateTimeField(null=True, blank=True); closed_at=models.DateTimeField(null=True, blank=True); reopened_at=models.DateTimeField(null=True, blank=True)
|
||||
class Meta:
|
||||
ordering=["-last_activity_at"]
|
||||
permissions=[("view_all_tickets","Can view all tickets"),("claim_ticket","Can claim ticket"),("transfer_ticket","Can transfer ticket"),("share_ticket","Can share ticket"),("change_ticket_priority","Can change ticket priority"),("view_customer_history","Can view customer history")]
|
||||
indexes=[models.Index(fields=["status","last_activity_at"]), models.Index(fields=["owner","status"]), models.Index(fields=["selected_expert","status"]), models.Index(fields=["owning_unit","status"]), models.Index(fields=["delivery_type","last_activity_at"])]
|
||||
def save(self,*args,**kwargs):
|
||||
if not self.ticket_number:
|
||||
from django.utils import timezone
|
||||
self.ticket_number=f"TK-{timezone.localdate().year}-{uuid.uuid4().hex[:8].upper()}"
|
||||
super().save(*args,**kwargs)
|
||||
def __str__(self): return f"{self.ticket_number} - {self.subject}"
|
||||
@property
|
||||
def assigned_expert_name(self):
|
||||
expert=self.owner or self.selected_expert
|
||||
profile=getattr(expert,"staff_profile",None) if expert else None
|
||||
return getattr(profile,"display_name","") or "صف عمومی"
|
||||
class TicketAccessGrant(models.Model):
|
||||
class Level(models.TextChoices): VIEW_ONLY="VIEW_ONLY","فقط مشاهده"; REPLY="REPLY","پاسخ"; FULL="FULL","کامل"
|
||||
ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="access_grants"); user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,related_name="ticket_grants"); granted_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,related_name="granted_ticket_access"); permission_level=models.CharField(max_length=15,choices=Level.choices,default=Level.FULL); created_at=models.DateTimeField(auto_now_add=True); revoked_at=models.DateTimeField(null=True,blank=True)
|
||||
class Meta:
|
||||
constraints=[models.UniqueConstraint(fields=["ticket","user"],condition=Q(revoked_at__isnull=True),name="unique_active_ticket_grant")]
|
||||
class TicketEvent(models.Model):
|
||||
class Type(models.TextChoices): CREATED="CREATED","ایجاد"; CLAIMED="CLAIMED","برداشت"; TRANSFERRED="TRANSFERRED","انتقال"; SHARED="SHARED","اشتراک"; ACCESS_REVOKED="ACCESS_REVOKED","لغو دسترسی"; STATUS_CHANGED="STATUS_CHANGED","تغییر وضعیت"; PRIORITY_CHANGED="PRIORITY_CHANGED","تغییر اولویت"; REOPENED="REOPENED","بازگشایی"; RESOLVED="RESOLVED","حل"; CLOSED="CLOSED","بستن"
|
||||
ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="events"); event_type=models.CharField(max_length=30,choices=Type.choices); actor=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,null=True); metadata=models.JSONField(default=dict,blank=True); created_at=models.DateTimeField(auto_now_add=True)
|
||||
class TicketReadState(models.Model):
|
||||
ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="read_states"); user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE); last_read_at=models.DateTimeField(); last_read_message=models.ForeignKey("messaging.Message",on_delete=models.SET_NULL,null=True,blank=True)
|
||||
class Meta: constraints=[models.UniqueConstraint(fields=["ticket","user"],name="unique_ticket_read_state")]
|
||||
@@ -0,0 +1,163 @@
|
||||
from django.db.models import Q
|
||||
|
||||
from .models import TicketAccessGrant
|
||||
|
||||
|
||||
class TicketPolicy:
|
||||
@staticmethod
|
||||
def is_customer(user): return getattr(user,"account_type",None)=="CUSTOMER"
|
||||
@staticmethod
|
||||
def unit_ids(user):
|
||||
if not user.is_authenticated:
|
||||
return set()
|
||||
if user.is_superuser:
|
||||
from apps.staff.models import OrganizationalUnit
|
||||
return set(OrganizationalUnit.objects.filter(is_active=True).values_list("id", flat=True))
|
||||
return set(
|
||||
user.unit_memberships.filter(is_active=True, unit__is_active=True)
|
||||
.values_list("unit_id", flat=True)
|
||||
)
|
||||
@staticmethod
|
||||
def primary_unit(user):
|
||||
profile = getattr(user, "staff_profile", None)
|
||||
if profile and profile.primary_unit_id:
|
||||
return profile.primary_unit
|
||||
membership = user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit").order_by("-is_primary", "id").first()
|
||||
return membership.unit if membership else None
|
||||
@classmethod
|
||||
def unit_scope(cls, user, unit):
|
||||
if not user.is_authenticated or unit is None:
|
||||
return None
|
||||
if user.is_superuser or user.has_perm("tickets.view_all_tickets"):
|
||||
return "GLOBAL"
|
||||
return user.unit_memberships.filter(is_active=True, unit=unit).values_list("ticket_scope", flat=True).first()
|
||||
@classmethod
|
||||
def has_unit_permission(cls, user, unit, permission):
|
||||
"""Resolve a permission dynamically from unit and membership configuration."""
|
||||
if not user.is_authenticated or unit is None:
|
||||
return False
|
||||
app_label, codename = permission.split(".", 1)
|
||||
memberships = user.unit_memberships.filter(is_active=True, unit=unit)
|
||||
return memberships.filter(
|
||||
Q(unit__permissions__content_type__app_label=app_label, unit__permissions__codename=codename)
|
||||
| Q(permissions__content_type__app_label=app_label, permissions__codename=codename)
|
||||
).exists()
|
||||
@classmethod
|
||||
def can_access_unit(cls, user, unit, permission="tickets.view_ticket"):
|
||||
return bool(
|
||||
user.is_authenticated
|
||||
and (
|
||||
user.is_superuser
|
||||
or user.has_perm("tickets.view_all_tickets")
|
||||
or cls.has_unit_permission(user, unit, permission)
|
||||
)
|
||||
)
|
||||
@classmethod
|
||||
def viewable_unit_ids(cls, user):
|
||||
if not user.is_authenticated:
|
||||
return set()
|
||||
if user.is_superuser or user.has_perm("tickets.view_all_tickets"):
|
||||
return set(user.unit_memberships.filter(is_active=True).values_list("unit_id", flat=True))
|
||||
return {
|
||||
membership.unit_id
|
||||
for membership in user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit")
|
||||
if cls.has_unit_permission(user, membership.unit, "tickets.view_ticket")
|
||||
}
|
||||
@classmethod
|
||||
def can_view(cls,user,ticket):
|
||||
if not user.is_authenticated: return False
|
||||
if cls.is_customer(user): return ticket.customer_id==user.id
|
||||
if user.is_superuser or user.has_perm("tickets.view_all_tickets"): return True
|
||||
if ticket.created_by_id == user.id or ticket.selected_expert_id == user.id or ticket.owner_id == user.id: return True
|
||||
if ticket.access_grants.filter(user=user,revoked_at__isnull=True).exists(): return True
|
||||
unit = ticket.assigned_unit or ticket.owning_unit
|
||||
if unit and cls.can_access_unit(user, unit):
|
||||
scope = cls.unit_scope(user, unit)
|
||||
if scope == "UNIT": return True
|
||||
return bool(ticket.origin_visibility == "PUBLIC" and ticket.owner_id is None and ticket.selected_expert_id is None)
|
||||
return bool(not ticket.owning_unit_id and ticket.origin_visibility == "PUBLIC" and ticket.owner_id is None and ticket.selected_expert_id is None and user.has_perm("tickets.view_ticket"))
|
||||
@classmethod
|
||||
def viewable_q(cls, user):
|
||||
"""Database equivalent of can_view; use it for every staff ticket collection."""
|
||||
if user.is_superuser or user.has_perm("tickets.view_all_tickets"):
|
||||
return Q()
|
||||
direct = Q(created_by=user) | Q(selected_expert=user) | Q(owner=user) | Q(access_grants__user=user, access_grants__revoked_at__isnull=True)
|
||||
memberships = user.unit_memberships.filter(is_active=True, unit__is_active=True)
|
||||
permitted_ids = {
|
||||
item.unit_id for item in memberships.select_related("unit")
|
||||
if cls.has_unit_permission(user, item.unit, "tickets.view_ticket")
|
||||
}
|
||||
unit_wide_ids = set(memberships.filter(ticket_scope="UNIT", unit_id__in=permitted_ids).values_list("unit_id", flat=True))
|
||||
unit_wide = Q(owning_unit_id__in=unit_wide_ids) | Q(assigned_unit_id__in=unit_wide_ids)
|
||||
limited_ids = permitted_ids - unit_wide_ids
|
||||
public_unassigned = (
|
||||
(Q(owning_unit_id__in=limited_ids) | Q(assigned_unit_id__in=limited_ids))
|
||||
& Q(origin_visibility="PUBLIC", selected_expert__isnull=True)
|
||||
)
|
||||
legacy = Q(owning_unit__isnull=True, origin_visibility="PUBLIC", selected_expert__isnull=True) if user.has_perm("tickets.view_ticket") else Q(pk__in=[])
|
||||
return direct | unit_wide | public_unassigned | legacy
|
||||
@classmethod
|
||||
def can_reply(cls,user,ticket):
|
||||
if not cls.can_view(user,ticket): return False
|
||||
if cls.is_customer(user): return ticket.customer_id==user.id
|
||||
if cls.has_unit_permission(user,ticket.assigned_unit or ticket.owning_unit,"messaging.add_message"): return True
|
||||
if user.has_perm("tickets.view_all_tickets") or ticket.owner_id==user.id or ticket.selected_expert_id==user.id or ticket.created_by_id==user.id: return True
|
||||
if ticket.owner_id is None and ticket.selected_expert_id is None and user.has_perm("messaging.add_message"): return True
|
||||
if not ticket.owning_unit_id and ticket.owner_id is None and ticket.selected_expert_id is None and user.has_perm("tickets.view_ticket"): return True
|
||||
return ticket.access_grants.filter(user=user,revoked_at__isnull=True,permission_level__in=[TicketAccessGrant.Level.REPLY,TicketAccessGrant.Level.FULL]).exists()
|
||||
@classmethod
|
||||
def can_claim(cls,user,ticket):
|
||||
permitted = user.has_perm("tickets.claim_ticket") or cls.has_unit_permission(user, ticket.assigned_unit or ticket.owning_unit, "tickets.claim_ticket")
|
||||
return permitted and cls.can_view(user, ticket) and ticket.owner_id is None and ticket.selected_expert_id is None
|
||||
@classmethod
|
||||
def can_transfer(cls,user,ticket):
|
||||
permitted=user.has_perm("tickets.transfer_ticket") or cls.has_unit_permission(user,ticket.owning_unit,"tickets.transfer_ticket")
|
||||
return permitted and cls.can_view(user,ticket) and (ticket.owner_id==user.id or (ticket.owner_id is None and ticket.selected_expert_id==user.id) or user.has_perm("tickets.view_all_tickets"))
|
||||
@classmethod
|
||||
def can_share(cls,user,ticket):
|
||||
permitted=user.has_perm("tickets.share_ticket") or cls.has_unit_permission(user,ticket.owning_unit,"tickets.share_ticket")
|
||||
return permitted and cls.can_view(user,ticket) and (ticket.owner_id==user.id or (ticket.owner_id is None and ticket.selected_expert_id==user.id) or user.has_perm("tickets.view_all_tickets"))
|
||||
@classmethod
|
||||
def can_delete(cls, user, ticket):
|
||||
if not user.is_authenticated or cls.is_customer(user):
|
||||
return False
|
||||
if user.is_superuser:
|
||||
return True
|
||||
if ticket.owning_unit_id:
|
||||
return cls.has_unit_permission(user,ticket.owning_unit,"tickets.delete_ticket") and cls.can_view(user,ticket)
|
||||
profile = getattr(user, "staff_profile", None)
|
||||
return bool(profile and profile.role != profile.Role.SALES_EXPERT and user.has_perm("tickets.delete_ticket") and cls.can_view(user,ticket))
|
||||
|
||||
|
||||
class DispatchPolicy:
|
||||
CREATE_PERMISSIONS = {
|
||||
"BULK": "tickets.create_bulk_dispatch",
|
||||
"INQUIRY": "tickets.create_inquiry",
|
||||
}
|
||||
VIEW_ALL_PERMISSIONS = {
|
||||
"BULK": "tickets.view_all_bulk_dispatches",
|
||||
"INQUIRY": "tickets.view_all_inquiries",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def can_create(cls, user, unit, operation_type):
|
||||
permission = cls.CREATE_PERMISSIONS.get(operation_type)
|
||||
if not permission or not user.is_authenticated or unit is None:
|
||||
return False
|
||||
allowed = user.is_superuser or user.has_perm(permission) or TicketPolicy.has_unit_permission(user, unit, permission)
|
||||
if operation_type == "BULK":
|
||||
allowed = allowed or user.has_perm("tickets.dispatch_group_tickets") or TicketPolicy.has_unit_permission(user, unit, "tickets.dispatch_group_tickets")
|
||||
return bool(allowed)
|
||||
|
||||
@classmethod
|
||||
def can_view_all(cls, user, operation_type, unit=None):
|
||||
permission = cls.VIEW_ALL_PERMISSIONS.get(operation_type)
|
||||
if not permission or not user.is_authenticated:
|
||||
return False
|
||||
return bool(user.is_superuser or user.has_perm(permission) or (unit and TicketPolicy.has_unit_permission(user, unit, permission)))
|
||||
|
||||
@classmethod
|
||||
def can_view(cls, user, dispatch):
|
||||
if dispatch.created_by_id == user.id:
|
||||
return cls.can_create(user, dispatch.unit, dispatch.operation_type)
|
||||
return cls.can_view_all(user, dispatch.operation_type, dispatch.unit)
|
||||
@@ -0,0 +1,70 @@
|
||||
from django.db.models import Case, F, IntegerField, Q, Value, When
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date
|
||||
from .models import Ticket
|
||||
from .policies import TicketPolicy
|
||||
def customer_tickets(user): return Ticket.objects.filter(customer=user).select_related("category","owner__staff_profile","selected_expert__staff_profile")
|
||||
def staff_ticket_rows(user):
|
||||
qs=Ticket.objects.select_related("customer__customer_profile","category","owner__staff_profile","selected_expert__staff_profile","owning_unit","assigned_unit","group_dispatch")
|
||||
if user.is_superuser or user.has_perm("tickets.view_all_tickets"): return qs
|
||||
return qs.filter(TicketPolicy.viewable_q(user)).distinct()
|
||||
|
||||
def filter_staff_tickets(qs, user, params):
|
||||
search=params.get("q", "").strip()
|
||||
if search:
|
||||
qs=qs.filter(Q(ticket_number__icontains=search)|Q(subject__icontains=search)|Q(customer__mobile__icontains=search)|Q(customer__customer_profile__first_name__icontains=search)|Q(customer__customer_profile__last_name__icontains=search))
|
||||
if params.get("status") in Ticket.Status.values: qs=qs.filter(status=params["status"])
|
||||
if params.get("priority") in Ticket.Priority.values: qs=qs.filter(priority=params["priority"])
|
||||
if params.get("category", "").isdigit(): qs=qs.filter(category_id=params["category"])
|
||||
if params.get("owner", "").isdigit(): qs=qs.filter(owner_id=params["owner"])
|
||||
if params.get("unit", "").isdigit(): qs=qs.filter(Q(owning_unit_id=params["unit"])|Q(assigned_unit_id=params["unit"]))
|
||||
if params.get("owning_unit", "").isdigit(): qs=qs.filter(owning_unit_id=params["owning_unit"])
|
||||
if params.get("assigned_unit", "").isdigit(): qs=qs.filter(assigned_unit_id=params["assigned_unit"])
|
||||
if params.get("delivery_type") in Ticket.DeliveryType.values: qs=qs.filter(delivery_type=params["delivery_type"])
|
||||
stakeholder_type=params.get("stakeholder_type", "")
|
||||
if stakeholder_type: qs=qs.filter(customer__customer_profile__stakeholder_type=stakeholder_type)
|
||||
recipient=params.get("recipient", "").strip()
|
||||
if recipient: qs=qs.filter(Q(customer__mobile__icontains=recipient)|Q(customer__customer_profile__first_name__icontains=recipient)|Q(customer__customer_profile__last_name__icontains=recipient)|Q(customer__customer_profile__organization_name__icontains=recipient))
|
||||
group_ids=[value for value in params.getlist("contact_group") if value.isdigit()] if hasattr(params,"getlist") else []
|
||||
if group_ids: qs=qs.filter(Q(group_dispatch__contact_group_id__in=group_ids)|Q(customer__customer_profile__groups__id__in=group_ids))
|
||||
tag_ids=[value for value in params.getlist("tag") if value.isdigit()] if hasattr(params,"getlist") else []
|
||||
if tag_ids: qs=qs.filter(tags__id__in=tag_ids)
|
||||
created_from=parse_date(params.get("created_from", ""))
|
||||
created_to=parse_date(params.get("created_to", ""))
|
||||
if created_from: qs=qs.filter(created_at__date__gte=created_from)
|
||||
if created_to: qs=qs.filter(created_at__date__lte=created_to)
|
||||
updated_from=parse_date(params.get("updated_from", ""))
|
||||
updated_to=parse_date(params.get("updated_to", ""))
|
||||
if updated_from: qs=qs.filter(updated_at__date__gte=updated_from)
|
||||
if updated_to: qs=qs.filter(updated_at__date__lte=updated_to)
|
||||
tab=params.get("tab", "")
|
||||
if tab=="new": qs=qs.filter(status=Ticket.Status.NEW,owner__isnull=True,selected_expert__isnull=True)
|
||||
elif tab=="selected": qs=qs.filter(selected_expert=user,owner__isnull=True)
|
||||
elif tab=="mine": qs=qs.filter(owner=user)
|
||||
elif tab=="shared": qs=qs.filter(access_grants__user=user,access_grants__revoked_at__isnull=True)
|
||||
elif tab=="waiting_staff": qs=qs.filter(status__in=[Ticket.Status.WAITING_STAFF,Ticket.Status.REOPENED])
|
||||
elif tab=="waiting_customer": qs=qs.filter(status=Ticket.Status.WAITING_CUSTOMER)
|
||||
elif tab=="resolved": qs=qs.filter(status=Ticket.Status.RESOLVED)
|
||||
elif tab=="closed": qs=qs.filter(status=Ticket.Status.CLOSED)
|
||||
if params.get("unread"):
|
||||
qs=qs.filter(Q(read_states__user=user,read_states__last_read_at__lt=F("last_activity_at"))|Q(read_states__isnull=True))
|
||||
qs=qs.distinct()
|
||||
if tab in ("", "waiting_staff"):
|
||||
return qs.annotate(
|
||||
_action_rank=Case(
|
||||
When(status__in=[Ticket.Status.NEW,Ticket.Status.WAITING_STAFF,Ticket.Status.REOPENED],then=Value(0)),
|
||||
When(status=Ticket.Status.OPEN,then=Value(1)),
|
||||
When(status=Ticket.Status.WAITING_CUSTOMER,then=Value(2)),
|
||||
default=Value(3),output_field=IntegerField(),
|
||||
),
|
||||
).order_by("_action_rank","-last_activity_at","-created_at","-id")
|
||||
return qs.order_by("-last_activity_at","-created_at","-id")
|
||||
|
||||
def dashboard_counts(user):
|
||||
qs=staff_ticket_rows(user)
|
||||
return {
|
||||
"new":qs.filter(status=Ticket.Status.NEW,owner__isnull=True,selected_expert__isnull=True).count(),
|
||||
"mine":qs.filter(owner=user).count(),
|
||||
"waiting":qs.filter(status__in=[Ticket.Status.WAITING_STAFF,Ticket.Status.REOPENED]).count(),
|
||||
"overdue":qs.filter(Q(first_response_at__isnull=True,first_response_due_at__lt=timezone.now())|Q(resolved_at__isnull=True,resolution_due_at__lt=timezone.now())).count(),
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
from datetime import timedelta
|
||||
from django.core.exceptions import PermissionDenied,ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
from apps.attachments.services import save_attachments
|
||||
from apps.core.persian_text import normalize_persian_text
|
||||
from apps.messaging.models import Message,MessageMention
|
||||
from apps.notifications.models import NotificationJob
|
||||
from apps.notifications.services import cancel_staff_reply_sms,enqueue,schedule_customer_reply_bale,schedule_staff_reply_sms
|
||||
from .models import GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketEvent
|
||||
from .policies import DispatchPolicy, TicketPolicy
|
||||
|
||||
def _primary_unit(user):
|
||||
profile = getattr(user, "staff_profile", None)
|
||||
if profile and profile.primary_unit_id:
|
||||
return profile.primary_unit
|
||||
membership = user.unit_memberships.filter(is_active=True, unit__is_active=True).order_by("-is_primary", "id").select_related("unit").first()
|
||||
return membership.unit if membership else None
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_ticket(*,customer,category,subject,selected_expert=None,body="",files=(),province="",city="",created_by=None,unit=None,group_dispatch=None,tags=()):
|
||||
subject=normalize_persian_text(subject)
|
||||
body=normalize_persian_text(body,multiline=True)
|
||||
province=normalize_persian_text(province)
|
||||
city=normalize_persian_text(city)
|
||||
if not body.strip() and not files: raise ValidationError("متن یا فایل پیام اولیه الزامی است.")
|
||||
now=timezone.now(); sla=SLAConfig.objects.filter(priority=Ticket.Priority.NORMAL,is_active=True).first(); actor=created_by or customer
|
||||
staff_initiated=getattr(actor,"account_type","")=="STAFF"
|
||||
owning_unit = unit or category.unit or (_primary_unit(actor) if staff_initiated else _primary_unit(selected_expert) if selected_expert else None)
|
||||
if category.unit_id and (not owning_unit or category.unit_id != owning_unit.pk): raise ValidationError("دستهبندی انتخابشده متعلق به این واحد سازمانی نیست.")
|
||||
if not staff_initiated and owning_unit and (not owning_unit.is_active or not owning_unit.accepts_external_tickets or not owning_unit.show_in_customer_form): raise ValidationError("این واحد در حال حاضر پذیرش مستقیم تیکت ندارد.")
|
||||
if not staff_initiated and not category.show_in_customer_form: raise ValidationError("این دستهبندی در فرم مشتری قابل انتخاب نیست.")
|
||||
ticket=Ticket(customer=customer,created_by=actor,owning_unit=owning_unit,assigned_unit=owning_unit,category=category,subject=subject,province=province,city=city,selected_expert=None if staff_initiated else selected_expert,owner=actor if staff_initiated else None,status=Ticket.Status.WAITING_CUSTOMER if staff_initiated else Ticket.Status.NEW,origin_visibility=Ticket.Visibility.SELECTED if staff_initiated or selected_expert else Ticket.Visibility.PUBLIC,delivery_type=Ticket.DeliveryType.GROUP if group_dispatch else Ticket.DeliveryType.INDIVIDUAL,group_dispatch=group_dispatch,last_activity_at=now,last_customer_message_at=None if staff_initiated else now,last_staff_message_at=now if staff_initiated else None,first_response_at=now if staff_initiated else None)
|
||||
if sla: ticket.first_response_due_at=now+timedelta(minutes=sla.first_response_minutes); ticket.resolution_due_at=now+timedelta(minutes=sla.resolution_minutes)
|
||||
ticket.full_clean(); ticket.save(); ticket.tags.set(tags); message=Message.objects.create(ticket=ticket,author=actor,body=body); save_attachments(message,files,actor); TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.CREATED,actor=actor,metadata={"staff_initiated":staff_initiated,"group_dispatch_id":group_dispatch.pk if group_dispatch else None,"unit_id":owning_unit.pk if owning_unit else None})
|
||||
if staff_initiated:
|
||||
enqueue(NotificationJob.Channel.SMS,"STAFF_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:staff-created",customer=customer)
|
||||
else:
|
||||
enqueue(NotificationJob.Channel.SMS,"CUSTOMER_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:customer-created",customer=customer)
|
||||
enqueue(NotificationJob.Channel.BALE,"NEW_CUSTOMER_TICKET",ticket,f"ticket:{ticket.pk}:bale-group",payload={"ticket_number":ticket.ticket_number})
|
||||
return ticket
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body, tags=(), files=(), operation_type=GroupDispatch.OperationType.BULK):
|
||||
"""Create one private ticket per contact and queue one SMS per ticket."""
|
||||
if contact_group.unit_id != unit.pk:
|
||||
raise ValidationError("گروه مخاطبان متعلق به واحد انتخابشده نیست.")
|
||||
if category.unit_id and category.unit_id != unit.pk:
|
||||
raise ValidationError("دستهبندی متعلق به واحد انتخابشده نیست.")
|
||||
if operation_type not in GroupDispatch.OperationType.values or not DispatchPolicy.can_create(user, unit, operation_type):
|
||||
raise PermissionDenied("اجازه ایجاد این نوع عملیات در این واحد را ندارید.")
|
||||
contacts = list(
|
||||
contact_group.contacts.filter(user__is_active=True, user__mobile__isnull=False)
|
||||
.exclude(user__mobile="")
|
||||
.select_related("user")
|
||||
.order_by("user_id")
|
||||
)
|
||||
if not contacts:
|
||||
raise ValidationError("گروه انتخابشده مخاطب فعال دارای شماره همراه ندارد.")
|
||||
dispatch = GroupDispatch.objects.create(
|
||||
unit=unit,
|
||||
operation_type=operation_type,
|
||||
contact_group=contact_group,
|
||||
created_by=user,
|
||||
category=category,
|
||||
subject=normalize_persian_text(subject),
|
||||
body=normalize_persian_text(body, multiline=True),
|
||||
status=GroupDispatch.Status.QUEUED,
|
||||
recipient_count=len(contacts),
|
||||
)
|
||||
tickets=[]
|
||||
for contact in contacts:
|
||||
for uploaded in files:
|
||||
if hasattr(uploaded,"seek"): uploaded.seek(0)
|
||||
tickets.append(create_ticket(
|
||||
customer=contact.user,
|
||||
created_by=user,
|
||||
unit=unit,
|
||||
category=category,
|
||||
subject=subject,
|
||||
body=body,
|
||||
group_dispatch=dispatch,
|
||||
tags=tags,
|
||||
files=files,
|
||||
))
|
||||
# Delivery is asynchronous. The reporting view promotes this to SENT only
|
||||
# after every SMS job has actually succeeded.
|
||||
dispatch.status = GroupDispatch.Status.QUEUED
|
||||
dispatch.dispatched_at = timezone.now()
|
||||
dispatch.save(update_fields=["status", "dispatched_at", "updated_at"])
|
||||
return dispatch, tickets
|
||||
|
||||
|
||||
def refresh_group_dispatch_status(dispatch_id):
|
||||
"""Persist delivery state independently of whether a report page is opened."""
|
||||
dispatch=GroupDispatch.objects.filter(pk=dispatch_id).first()
|
||||
if not dispatch: return None
|
||||
counts={row["status"]:row["count"] for row in NotificationJob.objects.filter(ticket__group_dispatch_id=dispatch_id,channel=NotificationJob.Channel.SMS,event_type="STAFF_TICKET_CREATED").values("status").annotate(count=Count("id"))}
|
||||
sent=counts.get(NotificationJob.Status.SENT,0)
|
||||
failed=counts.get(NotificationJob.Status.FAILED,0)
|
||||
if dispatch.recipient_count and sent>=dispatch.recipient_count: status=GroupDispatch.Status.SENT
|
||||
elif failed: status=GroupDispatch.Status.PARTIAL
|
||||
else: status=GroupDispatch.Status.QUEUED
|
||||
if dispatch.status!=status:
|
||||
GroupDispatch.objects.filter(pk=dispatch_id).update(status=status,updated_at=timezone.now())
|
||||
dispatch.status=status
|
||||
return dispatch
|
||||
|
||||
@transaction.atomic
|
||||
def claim_ticket(ticket_id,user):
|
||||
ticket=Ticket.objects.select_for_update().get(pk=ticket_id)
|
||||
if not TicketPolicy.can_claim(user,ticket):
|
||||
owner=getattr(getattr(ticket.owner,"staff_profile",None),"display_name","") if ticket.owner else "کارشناس دیگری"
|
||||
raise PermissionDenied(f"این تیکت لحظاتی قبل توسط «{owner}» برداشته شد.")
|
||||
ticket.owner=user
|
||||
if ticket.status==Ticket.Status.NEW: ticket.status=Ticket.Status.OPEN
|
||||
ticket.save(update_fields=["owner","status","updated_at"]); TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.CLAIMED,actor=user); return ticket
|
||||
|
||||
@transaction.atomic
|
||||
def transfer_ticket(ticket_id,user,new_owner):
|
||||
ticket=Ticket.objects.select_for_update().get(pk=ticket_id)
|
||||
if not TicketPolicy.can_transfer(user,ticket): raise PermissionDenied
|
||||
old=ticket.owner or ticket.selected_expert; ticket.owner=new_owner; ticket.selected_expert=None; ticket.save(update_fields=["owner","selected_expert","updated_at"]); ticket.access_grants.filter(user=old,revoked_at__isnull=True).update(revoked_at=timezone.now()) if old else None
|
||||
TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.TRANSFERRED,actor=user,metadata={"from":old.pk if old else None,"to":new_owner.pk}); enqueue(NotificationJob.Channel.BALE,"TRANSFER",ticket,f"ticket:{ticket.pk}:transfer:{new_owner.pk}:{timezone.now().timestamp()}",staff_user=new_owner); return ticket
|
||||
|
||||
@transaction.atomic
|
||||
def share_ticket(ticket,user,target,level=TicketAccessGrant.Level.FULL):
|
||||
if not TicketPolicy.can_share(user,ticket): raise PermissionDenied
|
||||
grant=ticket.access_grants.filter(user=target,revoked_at__isnull=True).first()
|
||||
if grant:
|
||||
grant.permission_level=level; grant.granted_by=user; grant.save(update_fields=["permission_level","granted_by"])
|
||||
else:
|
||||
grant=TicketAccessGrant.objects.create(ticket=ticket,user=target,granted_by=user,permission_level=level)
|
||||
TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.SHARED,actor=user,metadata={"user":target.pk,"level":level}); enqueue(NotificationJob.Channel.BALE,"SHARE",ticket,f"ticket:{ticket.pk}:share:{target.pk}:{grant.pk}",staff_user=target); return grant
|
||||
|
||||
@transaction.atomic
|
||||
def revoke_access(ticket,user,target):
|
||||
if not TicketPolicy.can_share(user,ticket): raise PermissionDenied
|
||||
count=ticket.access_grants.filter(user=target,revoked_at__isnull=True).update(revoked_at=timezone.now()); TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.ACCESS_REVOKED,actor=user,metadata={"user":target.pk}); return count
|
||||
|
||||
@transaction.atomic
|
||||
def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_users=()):
|
||||
ticket=Ticket.objects.select_for_update().get(pk=ticket_id)
|
||||
if not TicketPolicy.can_reply(user,ticket): raise PermissionDenied
|
||||
if internal and (TicketPolicy.is_customer(user) or not user.has_perm("messaging.add_internal_note")): raise PermissionDenied
|
||||
body=normalize_persian_text(body,multiline=True)
|
||||
if not body and not files: raise ValidationError("متن یا فایل پیام الزامی است.")
|
||||
msg=Message.objects.create(ticket=ticket,author=user,body=body,message_type=Message.Type.INTERNAL_NOTE if internal else Message.Type.PUBLIC,is_internal=internal); save_attachments(msg,files,user)
|
||||
for target in mentioned_users:
|
||||
if not TicketPolicy.can_view(target,ticket):
|
||||
if TicketPolicy.can_share(user,ticket): share_ticket(ticket,user,target)
|
||||
else: raise PermissionDenied("کارشناس Mentionشده به تیکت دسترسی ندارد.")
|
||||
MessageMention.objects.get_or_create(message=msg,mentioned_user=target); enqueue(NotificationJob.Channel.BALE,"MENTION",ticket,f"message:{msg.pk}:mention:{target.pk}",staff_user=target,payload={"excerpt":body[:150]})
|
||||
now=timezone.now(); ticket.last_activity_at=now
|
||||
if not internal:
|
||||
if TicketPolicy.is_customer(user):
|
||||
cancel_staff_reply_sms(ticket,"customer_replied")
|
||||
ticket.last_customer_message_at=now
|
||||
reopened=ticket.status in [Ticket.Status.RESOLVED,Ticket.Status.CLOSED]
|
||||
if reopened: ticket.status=Ticket.Status.REOPENED; ticket.reopened_at=now; TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.REOPENED,actor=user)
|
||||
elif ticket.status != Ticket.Status.NEW: ticket.status=Ticket.Status.WAITING_STAFF
|
||||
if reopened:
|
||||
enqueue(NotificationJob.Channel.BALE,"CUSTOMER_TICKET_REOPENED",ticket,f"message:{msg.pk}:bale-reopened",customer=ticket.customer,payload={"message_id":msg.pk,"message_count":1})
|
||||
else:
|
||||
schedule_customer_reply_bale(ticket,msg)
|
||||
else:
|
||||
ticket.last_staff_message_at=now; ticket.status=Ticket.Status.WAITING_CUSTOMER
|
||||
if not ticket.first_response_at: ticket.first_response_at=now
|
||||
schedule_staff_reply_sms(ticket,msg)
|
||||
ticket.save(); return msg
|
||||
|
||||
@transaction.atomic
|
||||
def delete_ticket(*, ticket_id, user):
|
||||
"""Permanently remove a ticket, its conversation, and stored attachments."""
|
||||
ticket = Ticket.objects.select_for_update().get(pk=ticket_id)
|
||||
if not TicketPolicy.can_delete(user, ticket):
|
||||
raise PermissionDenied("اجازه حذف کامل این تیکت را ندارید.")
|
||||
attachments = []
|
||||
for message in ticket.ticket_messages.prefetch_related("attachments"):
|
||||
attachments.extend((attachment.file.storage, attachment.file.name) for attachment in message.attachments.all() if attachment.file.name)
|
||||
from apps.attachments.models import Attachment
|
||||
Attachment.objects.filter(message__ticket=ticket).delete()
|
||||
ticket.ticket_messages.all().delete()
|
||||
ticket.delete()
|
||||
for storage, name in attachments:
|
||||
transaction.on_commit(lambda storage=storage, name=name: storage.delete(name))
|
||||
|
||||
@transaction.atomic
|
||||
def change_status(ticket,user,status):
|
||||
permitted=user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(user,ticket.owning_unit,"tickets.change_ticket")
|
||||
if not (TicketPolicy.can_view(user,ticket) and permitted): raise PermissionDenied
|
||||
if status not in Ticket.Status.values: raise ValidationError("وضعیت نامعتبر است.")
|
||||
old=ticket.status; now=timezone.now(); ticket.status=status
|
||||
if status==Ticket.Status.RESOLVED: ticket.resolved_at=now
|
||||
if status==Ticket.Status.CLOSED: ticket.closed_at=now
|
||||
ticket.save(); TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.STATUS_CHANGED,actor=user,metadata={"from":old,"to":status})
|
||||
|
||||
@transaction.atomic
|
||||
def change_priority(ticket,user,priority):
|
||||
permitted=user.has_perm("tickets.change_ticket_priority") or TicketPolicy.has_unit_permission(user,ticket.owning_unit,"tickets.change_ticket_priority")
|
||||
if not (TicketPolicy.can_view(user,ticket) and permitted): raise PermissionDenied
|
||||
if priority not in Ticket.Priority.values: raise ValidationError("اولویت نامعتبر است.")
|
||||
old=ticket.priority; ticket.priority=priority; ticket.save(update_fields=["priority","updated_at"])
|
||||
TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.PRIORITY_CHANGED,actor=user,metadata={"from":old,"to":priority})
|
||||
@@ -0,0 +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")]
|
||||
@@ -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": "الگوهای پیامک"})
|
||||
Reference in new issue
Block a user