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

No files matched your search

View File
Whitespace-only changes.
+39
View File
@@ -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])
+2
View File
@@ -0,0 +1,2 @@
from django.apps import AppConfig
class TicketsConfig(AppConfig): name = "apps.tickets"
+41
View File
@@ -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", "سایر امور مالی و حسابداری", "موضوعات مالی و حسابداری که در دسته‌های دیگر قرار نمی‌گیرند.", "عمومی مالی حسابداری سایر متفرقه امور مالی سوال سؤال درخواست پیگیری مالی"),
),
}
+4
View File
@@ -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")]
+246
View File
@@ -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
+130
View File
@@ -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)),
]
@@ -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)]
View File
Whitespace-only changes.
+140
View File
@@ -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")]
+163
View File
@@ -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)
+70
View File
@@ -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(),
}
+207
View File
@@ -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})
+4
View File
@@ -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")]
+766
View File
@@ -0,0 +1,766 @@
from django.contrib import messages
from django.conf import settings
from django.core.paginator import Paginator
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import redirect_to_login
import mimetypes
from django.core.exceptions import PermissionDenied,ValidationError
from django.http import FileResponse, HttpResponse, JsonResponse
from django.template.loader import render_to_string
from django.db import transaction
from django.db.models import Count, Prefetch, Q, Sum
from django.shortcuts import get_object_or_404,redirect,render
from django.urls import reverse
from django.utils import timezone
from django.utils.dateparse import parse_date
from django.views.decorators.http import require_http_methods, require_POST
from uuid import uuid4
import hashlib
import json
import re
from difflib import SequenceMatcher
from apps.accounts.models import TicketMagicLink, User, normalize_mobile
from apps.accounts.magic_access import get_ticket_magic_access
from apps.customers.models import ContactGroup, CustomerProfile
from apps.messaging.models import Message,MessageReadReceipt
from apps.notifications.models import NotificationJob, SMSTemplate
from apps.core.iran_locations import location_data
from apps.core.persian_text import normalize_persian_text
from apps.staff.models import OrganizationalUnit, StaffProfile
from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,ShareForm,StaffContactForm,StaffCreateTicketForm,StaffReplyForm,TicketForm,TransferForm,UnitSMSTemplateForm
from .models import GroupDispatch,Ticket,TicketCategory,TicketReadState,TicketTag
from .policies import DispatchPolicy, TicketPolicy
from .selectors import customer_tickets,dashboard_counts,filter_staff_tickets,staff_ticket_rows
from .services import change_priority,change_status,claim_ticket,create_ticket,dispatch_group_tickets,refresh_group_dispatch_status,reply_ticket,revoke_access,share_ticket,transfer_ticket
MESSAGE_PAGE_SIZE=50
def conversation_page(queryset,before=None):
queryset=queryset.order_by("-created_at","-id")
if before:
cursor=get_object_or_404(queryset,public_id=before)
queryset=queryset.filter(Q(created_at__lt=cursor.created_at)|Q(created_at=cursor.created_at,id__lt=cursor.id))
batch=list(queryset[:MESSAGE_PAGE_SIZE+1]); has_older=len(batch)>MESSAGE_PAGE_SIZE
conversation=list(reversed(batch[:MESSAGE_PAGE_SIZE]))
return conversation,has_older,(conversation[0].public_id if conversation and has_older else None)
def mark_messages_read(conversation,user):
MessageReadReceipt.objects.bulk_create(
[MessageReadReceipt(message=item,user=user) for item in conversation if item.author_id != user.id],
ignore_conflicts=True,
)
def customer_required(view):
@login_required
def wrapped(request,*a,**kw):
if request.user.account_type!=User.AccountType.CUSTOMER: raise PermissionDenied
return view(request,*a,**kw)
return wrapped
def staff_required(view):
@login_required(login_url="/staff/login/")
def wrapped(request,*a,**kw):
if request.user.account_type!=User.AccountType.STAFF: raise PermissionDenied
return view(request,*a,**kw)
return wrapped
def customer_ticket_actor(request,ticket):
if request.user.is_authenticated:
if request.user.account_type!=User.AccountType.CUSTOMER or not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
return request.user,False
link=get_ticket_magic_access(request,ticket)
return (link.customer,True) if link else (None,False)
def conversation_version(ticket,latest):
source=f"{ticket.updated_at.isoformat()}:{latest.public_id if latest else ''}:{latest.updated_at.isoformat() if latest else ''}"
return hashlib.sha256(source.encode()).hexdigest()[:20]
def conversation_poll_response(request,ticket,actor,include_internal=False):
qs=ticket.ticket_messages.all()
if not include_internal: qs=qs.filter(is_internal=False)
latest=qs.order_by("-created_at","-id").only("id","public_id","updated_at").first()
version=conversation_version(ticket,latest)
requested_cursor = request.GET.get("cursor", "")
latest_cursor = str(latest.public_id) if latest else ""
# A client can have the newest version token but still miss the DOM update
# (navigation, aborted rendering, or a restored mobile page). Only report an
# unchanged conversation when both independent checkpoints agree.
if request.GET.get("version") == version and requested_cursor == latest_cursor:
response=JsonResponse({"changed":False,"version":version})
response["Cache-Control"]="private, no-cache, no-store, must-revalidate"
return response
cursor=requested_cursor
replace=not cursor or not latest or str(latest.public_id)==cursor
messages_qs=qs.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")
if not replace:
cursor_message=qs.filter(public_id=cursor).only("created_at","id").first()
if cursor_message:
messages_qs=messages_qs.filter(Q(created_at__gt=cursor_message.created_at)|Q(created_at=cursor_message.created_at,id__gt=cursor_message.id)).order_by("created_at","id")
else:
replace=True
if replace:
recent=list(messages_qs.order_by("-created_at","-id")[:MESSAGE_PAGE_SIZE]); conversation=list(reversed(recent))
else:
conversation=list(messages_qs[:MESSAGE_PAGE_SIZE])
if include_internal: mark_messages_read(conversation,actor)
html=render_to_string("tickets/_messages.html",{"conversation":conversation,"ticket":ticket,"viewer_user":actor},request=request)
response=JsonResponse({"changed":True,"version":version,"cursor":str(latest.public_id) if latest else "","replace":replace,"html":html,"count":qs.count(),"status":ticket.get_status_display()})
response["Cache-Control"]="private, no-cache, no-store, must-revalidate"
return response
@customer_required
def customer_list(request):
profile,_=CustomerProfile.objects.get_or_create(user=request.user)
labels={"first_name":"نام","last_name":"نام خانوادگی","province":"استان","city":"شهر"}
missing_profile_fields=[label for field,label in labels.items() if not getattr(profile,field,"").strip()]
return render(request,"tickets/customer_list.html",{"tickets":customer_tickets(request.user),"missing_profile_fields":missing_profile_fields})
@customer_required
def customer_new(request):
profile,_=CustomerProfile.objects.get_or_create(user=request.user)
initial={"province":profile.province,"city":profile.city} if request.method=="GET" else None
if initial is not None and OrganizationalUnit.objects.filter(pk=request.GET.get("unit"),is_active=True,accepts_external_tickets=True,show_in_customer_form=True).exists():
initial["unit"]=request.GET["unit"]
form=TicketForm(request.POST or None,request.FILES or None,initial=initial)
if request.method=="POST" and form.is_valid():
try:
ticket=create_ticket(customer=request.user,unit=form.cleaned_data["unit"],category=form.cleaned_data["category"],subject=form.cleaned_data["subject"],province=form.cleaned_data["province"],city=form.cleaned_data["city"],selected_expert=form.cleaned_data["selected_expert"],body=form.cleaned_data.get("body", ""),files=request.FILES.getlist("files")); return redirect("tickets:detail",public_id=ticket.public_id)
except ValidationError as exc: form.add_error(None,exc)
selected_unit_id = form.data.get("unit") if form.is_bound else form.initial.get("unit")
selected_unit = OrganizationalUnit.objects.filter(pk=selected_unit_id).first() if selected_unit_id else None
return render(request,"tickets/customer_new.html",{"form":form,"selected_unit":selected_unit})
@customer_required
def customer_new_options(request):
unit=get_object_or_404(OrganizationalUnit,pk=request.GET.get("unit"),is_active=True,accepts_external_tickets=True,show_in_customer_form=True)
categories=TicketCategory.objects.filter(unit=unit,is_active=True,show_in_customer_form=True).order_by("sort_order","title","pk")
experts=StaffProfile.objects.filter(is_active=True,is_customer_selectable=True,user__is_active=True).filter(Q(primary_unit=unit)|Q(user__unit_memberships__unit=unit,user__unit_memberships__is_active=True)).select_related("user").distinct().order_by("sort_order","display_name")
response = JsonResponse({"categories":[{"id":row.pk,"label":row.title} for row in categories],"experts":[{"id":row.pk,"name":row.display_name,"job_title":row.job_title or "کارشناس واحد","avatar_url":reverse("tickets:expert_avatar",args=[row.user.public_id])} for row in experts],"queue":{"unit_id":unit.pk,"label":f"صف عمومی {unit.name}"}})
response["Cache-Control"] = "private, no-cache, no-store, must-revalidate"
return response
def _search_text(value):
value=normalize_persian_text(value or "").lower().replace("‌"," ")
return " ".join(re.findall(r"[\wآ-ی]+",value))
SEARCH_ALIASES={"فیش":("پرداخت","واریز","رسید","مالی"),"واریزی":("پرداخت","رسید","مالی"),"قیمت":("استعلام","خرید","فروش"),"بار":("حمل","بارگیری","ارسال","تحویل"),"ارسال":("حمل","بارگیری","تحویل"),"موجودی":("انبار","کالا"),"فاکتور":("صورتحساب","مالی"),"شکایت":("اعتراض","پیگیری")}
SEARCH_STOP_WORDS = {"از", "با", "به", "برای", "در", "را", "که", "من", "یک", "و", "یا"}
def _meaningful_search_tokens(value):
return {
token for token in _search_text(value).split()
if len(token) >= 2 and token not in SEARCH_STOP_WORDS
}
def _field_match_score(query_tokens, field_tokens, *, exact_weight, prefix_weight):
score = 0
matched = set()
for query_token in query_tokens:
if query_token in field_tokens:
score += exact_weight
matched.add(query_token)
elif len(query_token) >= 3 and any(token.startswith(query_token) for token in field_tokens):
score += prefix_weight
matched.add(query_token)
return score, matched
def _category_search_score(category, query):
"""Rank explicit category fields first; unit/description are supporting signals."""
query_tokens = _meaningful_search_tokens(query)
expanded_tokens = set(query_tokens)
for token in query_tokens:
expanded_tokens.update(SEARCH_ALIASES.get(token, ()))
alias_tokens = expanded_tokens - query_tokens
title = _search_text(category.title)
keywords = _search_text(category.search_keywords)
description = _search_text(category.description)
unit_text = _search_text(
f"{category.unit.name} {category.unit.search_keywords}"
)
title_tokens = set(title.split())
keyword_tokens = set(keywords.split())
description_tokens = set(description.split())
unit_tokens = set(unit_text.split())
score = 0
if query == title:
score += 140
elif title.startswith(query):
score += 95
elif query in title:
score += 75
if query and query in keywords:
score += 60
title_score, title_matches = _field_match_score(
query_tokens, title_tokens, exact_weight=18, prefix_weight=12
)
keyword_score, keyword_matches = _field_match_score(
query_tokens, keyword_tokens, exact_weight=12, prefix_weight=8
)
description_score, _ = _field_match_score(
query_tokens, description_tokens, exact_weight=4, prefix_weight=2
)
unit_score, _ = _field_match_score(
query_tokens, unit_tokens, exact_weight=2, prefix_weight=1
)
alias_title_score, _ = _field_match_score(
alias_tokens, title_tokens, exact_weight=5, prefix_weight=3
)
alias_keyword_score, _ = _field_match_score(
alias_tokens, keyword_tokens, exact_weight=3, prefix_weight=2
)
score += title_score + keyword_score + description_score + unit_score
score += alias_title_score + alias_keyword_score
direct_matches = title_matches | keyword_matches
if query_tokens:
coverage = len(direct_matches) / len(query_tokens)
score += coverage * 24
if coverage == 1:
score += 20
score += SequenceMatcher(None, query, title).ratio() * 6
return score
@customer_required
def customer_new_search(request):
query=_search_text(request.GET.get("q"))
if len(query)<2: return JsonResponse({"results":[]})
results=[]
for category in TicketCategory.objects.filter(is_active=True,show_in_customer_form=True,unit__is_active=True,unit__accepts_external_tickets=True,unit__show_in_customer_form=True).select_related("unit"):
score = _category_search_score(category, query)
if score >= 10:
results.append((score,category.unit.sort_order,category.sort_order,{"unit_id":category.unit_id,"unit":category.unit.name,"category_id":category.pk,"category":category.title,"description":category.description[:120]}))
results.sort(key=lambda item:(-item[0],item[1],item[2],item[3]["unit"],item[3]["category"]))
return JsonResponse({"results":[item for *_,item in results[:6]]})
@login_required
def expert_avatar(request, public_id):
from apps.attachments.delivery import signed_media_redirect
from apps.staff.models import StaffProfile
profiles = StaffProfile.objects.select_related("user").filter(user__public_id=public_id, is_active=True, user__is_active=True)
if request.user.account_type == User.AccountType.CUSTOMER:
profiles = profiles.filter(is_customer_selectable=True)
elif request.user.account_type != User.AccountType.STAFF:
raise PermissionDenied
profile = get_object_or_404(profiles)
if profile.avatar:
if settings.USE_NGINX_MEDIA:
return signed_media_redirect(profile.avatar.name)
response=FileResponse(profile.avatar.open("rb"), content_type=mimetypes.guess_type(profile.avatar.name)[0] or "application/octet-stream")
response["Cache-Control"]="private, no-cache, must-revalidate"
return response
initials = "".join(part[0] for part in profile.display_name.split()[:2]) or "ک"
svg = f'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160"><rect width="160" height="160" rx="80" fill="#e0f2fe"/><text x="80" y="98" text-anchor="middle" font-family="Tahoma" font-size="52" font-weight="bold" fill="#075985">{initials}</text></svg>'
response=HttpResponse(svg, content_type="image/svg+xml"); response["Cache-Control"]="private, no-cache, must-revalidate"; return response
def customer_detail(request,public_id):
ticket=get_object_or_404(Ticket.objects.select_related("category","owner__staff_profile","selected_expert__staff_profile"),public_id=public_id)
actor,magic_link_access=customer_ticket_actor(request,ticket)
if actor is None: return redirect_to_login(request.get_full_path(),reverse("accounts:login"))
qs=ticket.ticket_messages.filter(is_internal=False).select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")
conversation,has_older,older_cursor=conversation_page(qs)
latest=conversation[-1] if conversation else None
return render(request,"tickets/customer_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":ReplyForm(),"viewer_user":actor,"magic_link_access":magic_link_access,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
def customer_messages(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id)
actor,magic_link_access=customer_ticket_actor(request,ticket)
if actor is None: return redirect_to_login(request.get_full_path(),reverse("accounts:login"))
qs=ticket.ticket_messages.filter(is_internal=False).select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")
conversation,has_older,older_cursor=conversation_page(qs,request.GET.get("before"))
return render(request,"tickets/_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"older_route":"tickets:messages","viewer_user":actor,"magic_link_access":magic_link_access})
def customer_poll(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id)
actor,_=customer_ticket_actor(request,ticket)
if actor is None: return redirect_to_login(request.get_full_path(),reverse("accounts:login"))
return conversation_poll_response(request,ticket,actor,include_internal=False)
@require_POST
def customer_read(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id)
actor,_=customer_ticket_actor(request,ticket)
if actor is None: return JsonResponse({"ok":False},status=401)
try: message_ids=json.loads(request.body or b"{}").get("message_ids",[])
except (TypeError,ValueError): return JsonResponse({"ok":False,"error":"invalid_json"},status=400)
messages_qs=ticket.ticket_messages.filter(public_id__in=message_ids,is_internal=False).exclude(author=actor)
MessageReadReceipt.objects.bulk_create([MessageReadReceipt(message=item,user=actor) for item in messages_qs],ignore_conflicts=True)
from apps.notifications.services import cancel_staff_reply_sms_if_read
cancel_staff_reply_sms_if_read(ticket,actor)
return JsonResponse({"ok":True,"read":messages_qs.count()})
@require_POST
def customer_reply(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id); form=ReplyForm(request.POST,request.FILES)
actor,_=customer_ticket_actor(request,ticket)
if actor is None: return redirect_to_login(request.get_full_path(),reverse("accounts:login"))
result="error"
if form.is_valid():
try: reply_ticket(ticket_id=ticket.pk,user=actor,body=form.cleaned_data.get("body", ""),files=request.FILES.getlist("files")); messages.success(request,"پاسخ شما با موفقیت در تیکت ثبت شد. در صورت نیاز می‌توانید پاسخ دیگری اضافه کنید."); result="reply"
except (PermissionDenied,ValidationError) as exc: messages.error(request,str(exc))
else: messages.error(request,"پاسخ ثبت نشد. لطفاً متن یا پیوست را بررسی و دوباره تلاش کنید.")
return redirect(f'{reverse("tickets:detail",args=[public_id])}?reply_result={result}')
@staff_required
def staff_list(request):
from apps.staff.models import OrganizationalUnit, StaffProfile
qs=filter_staff_tickets(staff_ticket_rows(request.user),request.user,request.GET)
tab=request.GET.get("tab","")
tab_titles={"":"میز کار","new":"صف جدید","selected":"انتخابی من","mine":"تیکت‌های من","shared":"اشتراک‌ها","waiting_staff":"در انتظار اقدام من","waiting_customer":"در انتظار مشتری","resolved":"حل‌شده‌ها","closed":"بسته‌شده‌ها"}
if tab not in tab_titles: tab=""
unit_ids=TicketPolicy.unit_ids(request.user)
selected_tag_ids=[int(value) for value in request.GET.getlist("tag") if value.isdigit()]
selected_group_ids=[int(value) for value in request.GET.getlist("contact_group") if value.isdigit()]
categories=TicketCategory.objects.filter(is_active=True,unit_id__in=unit_ids) if unit_ids else TicketCategory.objects.filter(is_active=True)
experts=StaffProfile.objects.filter(is_active=True,user__unit_memberships__unit_id__in=unit_ids,user__unit_memberships__is_active=True).select_related("user").distinct() if unit_ids else StaffProfile.objects.filter(is_active=True).select_related("user")
context={"tickets":qs[:100],"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"delivery_types":Ticket.DeliveryType.choices,"stakeholder_types":CustomerProfile.StakeholderType.choices,"categories":categories,"experts":experts,"units":OrganizationalUnit.objects.filter(pk__in=unit_ids),"contact_groups":ContactGroup.objects.filter(unit_id__in=unit_ids,is_active=True),"selected_group_ids":selected_group_ids,"ticket_tags":TicketTag.objects.filter(is_active=True),"selected_tag_ids":selected_tag_ids,"counts":dashboard_counts(request.user),"active_tab":tab,"active_tab_title":tab_titles[tab]}
return render(request,"tickets/_staff_ticket_results.html" if request.headers.get("HX-Request") else "tickets/staff_list.html",context)
@staff_required
def staff_create(request):
unit_add_ids=_unit_ids_with_permission(request.user,"tickets.add_ticket")
if not request.user.has_perm("tickets.add_ticket") and not unit_add_ids: raise PermissionDenied
initial=None
if request.method=="GET" and request.GET.get("customer"):
preset=_customer_rows(user=request.user).filter(public_id=request.GET["customer"]).first()
if preset:
profile=getattr(preset,"customer_profile",None)
initial={"customer_id":preset.public_id,"customer_mobile":preset.mobile,"first_name":getattr(profile,"first_name","") or "","last_name":getattr(profile,"last_name","") or "","organization_name":getattr(profile,"organization_name","") or "","stakeholder_type":getattr(profile,"stakeholder_type",CustomerProfile.StakeholderType.CUSTOMER),"province":getattr(profile,"province","") or "","city":getattr(profile,"city","") or ""}
form=StaffCreateTicketForm(request.POST or None,request.FILES or None,initial=initial,user=request.user)
if request.method=="POST" and form.is_valid():
with transaction.atomic():
mobile=form.cleaned_data["customer_mobile"]
username=f"customer_{mobile[1:]}"
if User.objects.filter(username=username).exclude(mobile=mobile).exists():
username=f"{username}_{uuid4().hex[:6]}"
customer=form.cleaned_data.get("customer_user")
created=customer is None
if created:
customer=User.objects.create(mobile=mobile,username=username,account_type=User.AccountType.CUSTOMER)
if created: customer.set_unusable_password(); customer.save(update_fields=["password"])
profile,_=CustomerProfile.objects.get_or_create(user=customer)
for field in ("first_name","last_name","organization_name","stakeholder_type","province","city"):
value=form.cleaned_data.get(field)
if value: setattr(profile,field,value)
profile.save()
if form.cleaned_data.get("groups"): profile.groups.add(*form.cleaned_data["groups"])
ticket=create_ticket(customer=customer,created_by=request.user,unit=form.cleaned_data.get("unit"),category=form.cleaned_data["category"],subject=form.cleaned_data["subject"],province=form.cleaned_data["province"],city=form.cleaned_data["city"],body=form.cleaned_data["body"],files=request.FILES.getlist("files"))
messages.success(request,"تیکت برای مشتری ثبت و پیامک لینک امن در صف ارسال قرار گرفت.")
return redirect("staff_tickets:detail",public_id=ticket.public_id)
return render(request,"tickets/staff_create.html",{"form":form,"staff_unit":TicketPolicy.primary_unit(request.user),"active_tab_title":"ثبت تیکت برای مشتری"})
def _customer_rows(search="", user=None):
qs=User.objects.filter(account_type=User.AccountType.CUSTOMER).select_related("customer_profile")
has_unit_membership = user is not None and user.unit_memberships.filter(is_active=True, unit__is_active=True).exists()
if user is not None and has_unit_membership and not (user.is_superuser or user.has_perm("tickets.view_all_tickets")):
visible_customer_ids = staff_ticket_rows(user).values_list("customer_id", flat=True)
contact_unit_ids = _unit_ids_with_permission(user, "customers.view_customerprofile")
qs = qs.filter(Q(pk__in=visible_customer_ids) | Q(customer_profile__groups__unit_id__in=contact_unit_ids)).distinct()
if search:
digits=search.translate(str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩","01234567890123456789"))
mobile_terms={digits}
try: mobile_terms.add(normalize_mobile(digits))
except ValueError: pass
mobile_query=Q()
for term in mobile_terms: mobile_query|=Q(mobile__icontains=term)
qs=qs.filter(mobile_query|Q(customer_profile__first_name__icontains=search)|Q(customer_profile__last_name__icontains=search)|Q(username__icontains=search))
return qs.annotate(
ticket_count=Count("customer_tickets",distinct=True),
active_count=Count("customer_tickets",filter=~Q(customer_tickets__status__in=[Ticket.Status.RESOLVED,Ticket.Status.CLOSED]),distinct=True),
waiting_staff_count=Count("customer_tickets",filter=Q(customer_tickets__status__in=[Ticket.Status.NEW,Ticket.Status.WAITING_STAFF,Ticket.Status.REOPENED]),distinct=True),
resolved_count=Count("customer_tickets",filter=Q(customer_tickets__status__in=[Ticket.Status.RESOLVED,Ticket.Status.CLOSED]),distinct=True),
).order_by("-active_count","-ticket_count","-date_joined")
@staff_required
def staff_customers(request):
target = reverse("staff_tickets:contacts")
return redirect(f"{target}?{request.GET.urlencode()}" if request.GET else target)
@staff_required
def staff_customer_search(request):
search=request.GET.get("q","").strip()
selected_id=request.GET.get("id","").strip()
if selected_id:
qs=_customer_rows(user=request.user).filter(public_id=selected_id)
elif len(search)>=2:
qs=_customer_rows(search, request.user)
else:
return JsonResponse({"results":[]})
results=[]
for customer in qs[:12]:
profile=getattr(customer,"customer_profile",None)
results.append({"id":str(customer.public_id),"mobile":customer.local_mobile,"name":str(profile) if profile else customer.local_mobile,"first_name":getattr(profile,"first_name","") or "","last_name":getattr(profile,"last_name","") or "","province":getattr(profile,"province","") or "","city":getattr(profile,"city","") or "","customer_type":profile.get_customer_type_display() if profile and profile.customer_type else "ثبت نشده","ticket_count":customer.ticket_count,"active_count":customer.active_count})
return JsonResponse({"results":results})
@staff_required
def staff_detail(request,public_id):
ticket=get_object_or_404(Ticket.objects.select_related("customer__customer_profile","category","owner__staff_profile","selected_expert__staff_profile"),public_id=public_id)
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
conversation,has_older,older_cursor=conversation_page(ticket.ticket_messages.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"))
mark_messages_read(conversation,request.user)
last=conversation[-1] if conversation else None; TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last})
history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5]
latest=conversation[-1] if conversation else None
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"viewer_user":request.user,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
@staff_required
def staff_messages(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id)
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
conversation,has_older,older_cursor=conversation_page(ticket.ticket_messages.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"),request.GET.get("before"))
mark_messages_read(conversation,request.user)
return render(request,"tickets/_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"older_route":"staff_tickets:messages","viewer_user":request.user})
@staff_required
def staff_poll(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id)
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
return conversation_poll_response(request,ticket,request.user,include_internal=True)
@staff_required
@require_POST
def staff_claim(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id)
try: claim_ticket(ticket.pk,request.user); messages.success(request,"تیکت به شما اختصاص یافت.")
except PermissionDenied as exc: messages.error(request,str(exc))
return redirect("staff_tickets:detail",public_id=public_id)
@staff_required
@require_POST
def staff_reply(request,public_id,note=False):
ticket=get_object_or_404(Ticket,public_id=public_id); form=StaffReplyForm(request.POST,request.FILES)
result="error"
if form.is_valid():
try:
reply_ticket(ticket_id=ticket.pk,user=request.user,body=form.cleaned_data.get("body", ""),files=request.FILES.getlist("files"),internal=note,mentioned_users=form.cleaned_data["mentioned_experts"])
result="note" if note else "reply"
messages.success(request,"یادداشت کارشناسی با موفقیت در پرونده ثبت شد." if note else "پاسخ رسمی با موفقیت در تیکت ثبت و برای مشتری ارسال شد. در صورت نیاز می‌توانید پاسخ دیگری ثبت کنید.")
except (PermissionDenied,ValidationError) as exc: messages.error(request,str(exc))
else: messages.error(request,"پاسخ ثبت نشد. لطفاً متن، پیوست و اطلاعات فرم را بررسی کنید.")
return redirect(f'{reverse("staff_tickets:detail",args=[public_id])}?reply_result={result}')
@staff_required
@require_POST
def staff_transfer(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id); form=TransferForm(request.POST)
if form.is_valid():
try: transfer_ticket(ticket.pk,request.user,form.cleaned_data["expert"]); messages.success(request,"تیکت منتقل شد.")
except PermissionDenied: messages.error(request,"اجازه انتقال این تیکت را ندارید.")
return redirect("staff_tickets:detail",public_id=public_id)
@staff_required
@require_POST
def staff_share(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id); form=ShareForm(request.POST)
if form.is_valid():
try: share_ticket(ticket,request.user,form.cleaned_data["expert"],form.cleaned_data["permission_level"]); messages.success(request,"دسترسی ایجاد شد.")
except PermissionDenied: messages.error(request,"اجازه اشتراک این تیکت را ندارید.")
return redirect("staff_tickets:detail",public_id=public_id)
@staff_required
@require_POST
def staff_revoke(request,public_id,user_id):
ticket=get_object_or_404(Ticket,public_id=public_id); target=get_object_or_404(User,public_id=user_id)
try: revoke_access(ticket,request.user,target); messages.success(request,"دسترسی کارشناس لغو شد.")
except PermissionDenied: messages.error(request,"اجازه لغو این دسترسی را ندارید.")
return redirect("staff_tickets:detail",public_id=public_id)
@staff_required
@require_POST
def staff_status(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id)
try: change_status(ticket,request.user,request.POST.get("status")); messages.success(request,"وضعیت تغییر کرد.")
except (PermissionDenied,ValidationError) as exc: messages.error(request,str(exc))
return redirect("staff_tickets:detail",public_id=public_id)
@staff_required
@require_POST
def staff_priority(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id)
try: change_priority(ticket,request.user,request.POST.get("priority")); messages.success(request,"اولویت تغییر کرد.")
except (PermissionDenied,ValidationError) as exc: messages.error(request,str(exc))
return redirect("staff_tickets:detail",public_id=public_id)
@staff_required
@require_POST
def staff_delete(request, public_id):
ticket = get_object_or_404(Ticket, public_id=public_id)
try:
from .services import delete_ticket
delete_ticket(ticket_id=ticket.pk, user=request.user)
messages.success(request, "تیکت و تمام پیام‌ها و فایل‌های آن برای همیشه حذف شد.")
return redirect("staff_tickets:list")
except PermissionDenied as exc:
messages.error(request, str(exc))
return redirect("staff_tickets:detail", public_id=public_id)
@staff_required
@require_http_methods(["GET", "POST"])
def staff_dispatch_create(request):
form = GroupDispatchForm(request.POST or None, request.FILES or None, user=request.user)
if request.method == "POST" and form.is_valid():
try:
dispatch, _ = dispatch_group_tickets(
user=request.user,
unit=form.cleaned_data["unit"],
contact_group=form.cleaned_data["contact_group"],
category=form.cleaned_data["category"],
subject=form.cleaned_data["subject"],
body=form.cleaned_data["body"],
operation_type=form.cleaned_data["operation_type"],
tags=form.cleaned_data["tags"],
files=request.FILES.getlist("files"),
)
messages.success(request, "برای هر مخاطب یک تیکت مستقل ساخته و پیامک آن در صف قرار گرفت.")
return redirect("staff_tickets:dispatch_detail", public_id=dispatch.public_id)
except (PermissionDenied, ValidationError) as exc:
form.add_error(None, str(exc))
create_unit_ids = {
unit.pk for unit in OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user), is_active=True)
if any(DispatchPolicy.can_create(request.user, unit, kind) for kind in GroupDispatch.OperationType.values)
}
if not create_unit_ids:
raise PermissionDenied
base_dispatches = _visible_dispatches(request.user)
metrics_row = base_dispatches.aggregate(total=Count("id"), recipients=Sum("recipient_count"))
metrics = {
"total": metrics_row["total"] or 0,
"recipients": metrics_row["recipients"] or 0,
"sent": base_dispatches.filter(status=GroupDispatch.Status.SENT).count(),
"attention": base_dispatches.filter(status__in=(GroupDispatch.Status.PARTIAL, GroupDispatch.Status.QUEUED)).count(),
}
dispatches = base_dispatches.select_related("unit", "contact_group", "created_by")
q = request.GET.get("q", "").strip()
if q:
dispatches = dispatches.filter(Q(subject__icontains=q) | Q(contact_group__name__icontains=q) | Q(created_by__username__icontains=q))
status = request.GET.get("status", "")
if status in GroupDispatch.Status.values:
dispatches = dispatches.filter(status=status)
unit = request.GET.get("unit", "")
if unit.isdigit() and base_dispatches.filter(unit_id=unit).exists():
dispatches = dispatches.filter(unit_id=unit)
group = request.GET.get("group", "")
if group.isdigit():
dispatches = dispatches.filter(contact_group_id=group)
created_from = parse_date(request.GET.get("created_from", ""))
created_to = parse_date(request.GET.get("created_to", ""))
if created_from:
dispatches = dispatches.filter(created_at__date__gte=created_from)
if created_to:
dispatches = dispatches.filter(created_at__date__lte=created_to)
page = Paginator(dispatches.order_by("-created_at"), 12).get_page(request.GET.get("page"))
for item in page.object_list:
refresh_group_dispatch_status(item.pk)
units = OrganizationalUnit.objects.filter(pk__in=create_unit_ids, is_active=True).order_by("name")
groups = ContactGroup.objects.filter(unit_id__in=create_unit_ids, is_active=True).select_related("unit").order_by("unit__name", "name")
return render(request, "tickets/staff_dispatch_create.html", {
"form": form, "dispatches": page.object_list, "page": page, "metrics": metrics,
"units": units, "groups": groups, "dispatch_statuses": GroupDispatch.Status.choices,
"active_staff_section": "dispatches", "active_tab_title": "ارسال گروهی و ردیابی",
})
@staff_required
def staff_dispatch_detail(request, public_id):
dispatch = get_object_or_404(
GroupDispatch.objects.select_related("unit", "contact_group", "created_by"),
public_id=public_id,
)
if not DispatchPolicy.can_view(request.user, dispatch):
raise PermissionDenied
tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "ticket_messages", "notificationjob_set").order_by("customer_id"))
for ticket in tickets:
ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS), None)
ticket.latest_customer_response = next((message for message in reversed(list(ticket.ticket_messages.all())) if message.author_id == ticket.customer_id), None)
sms_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, channel=NotificationJob.Channel.SMS)
sms_counts = {row["status"]: row["count"] for row in sms_jobs.values("status").annotate(count=Count("id"))}
sent = sms_counts.get(NotificationJob.Status.SENT, 0)
failed = sms_counts.get(NotificationJob.Status.FAILED, 0)
opened = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch, view_count__gt=0).values("ticket_id").distinct().count()
replied = dispatch.tickets.filter(last_customer_message_at__isnull=False).count()
expected = dispatch.recipient_count
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"])
metrics = {"sent": sent, "failed": failed, "pending": max(expected - sent - failed, 0), "opened": opened, "replied": replied}
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "active_staff_section": "dispatches"})
@staff_required
@require_POST
def staff_dispatch_retry(request, public_id):
dispatch=get_object_or_404(GroupDispatch,public_id=public_id)
if not DispatchPolicy.can_view(request.user,dispatch) or not DispatchPolicy.can_create(request.user,dispatch.unit,dispatch.operation_type): raise PermissionDenied
count=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,status=NotificationJob.Status.FAILED).update(status=NotificationJob.Status.PENDING,attempts=0,next_attempt_at=timezone.now(),last_error="")
refresh_group_dispatch_status(dispatch.pk)
messages.success(request,f"{count} ارسال ناموفق دوباره در صف قرار گرفت." if count else "ارسال ناموفقی برای تلاش مجدد وجود ندارد.")
return redirect("staff_tickets:dispatch_detail",public_id=public_id)
def _unit_ids_with_permission(user, permission):
from apps.staff.models import OrganizationalUnit
units = OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(user), is_active=True)
return {unit.pk for unit in units if user.is_superuser or user.has_perm(permission) or TicketPolicy.has_unit_permission(user, unit, permission)}
def _visible_dispatches(user):
if user.is_superuser:
return GroupDispatch.objects.all()
visibility = Q(pk__in=[])
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
for operation_type in GroupDispatch.OperationType.values:
if DispatchPolicy.can_view_all(user, operation_type):
visibility |= Q(operation_type=operation_type)
continue
view_all_units = [item.unit_id for item in memberships if DispatchPolicy.can_view_all(user, operation_type, item.unit)]
create_units = [item.unit_id for item in memberships if DispatchPolicy.can_create(user, item.unit, operation_type)]
visibility |= Q(operation_type=operation_type, unit_id__in=view_all_units)
visibility |= Q(operation_type=operation_type, unit_id__in=create_units, created_by=user)
return GroupDispatch.objects.filter(visibility)
def _contact_queryset(user):
"""Contacts visible through at least one of the staff member's units."""
visible_groups = ContactGroup.objects.filter(unit_id__in=_unit_ids_with_permission(user, "customers.view_customerprofile")).select_related("unit")
return CustomerProfile.objects.filter(groups__in=visible_groups).select_related("user").prefetch_related(Prefetch("groups", queryset=visible_groups, to_attr="visible_groups")).distinct()
def _has_any_unit_permission(user, permission):
return user.is_superuser or any(
TicketPolicy.has_unit_permission(user, membership.unit, permission)
for membership in user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit")
)
@staff_required
def staff_contacts(request):
search = request.GET.get("q", "").strip()
stakeholder = request.GET.get("stakeholder_type", "")
group = request.GET.get("group", "")
province = request.GET.get("province", "").strip()
visible_unit_ids = _unit_ids_with_permission(request.user, "customers.view_customerprofile")
if not visible_unit_ids: raise PermissionDenied
visible_groups = ContactGroup.objects.filter(unit_id__in=visible_unit_ids).select_related("unit")
qs = _customer_rows(search, request.user).prefetch_related(Prefetch("customer_profile__groups", queryset=visible_groups, to_attr="visible_groups"))
if stakeholder in CustomerProfile.StakeholderType.values:
qs = qs.filter(customer_profile__stakeholder_type=stakeholder)
if group.isdigit():
qs = qs.filter(customer_profile__groups__id=group, customer_profile__groups__unit_id__in=visible_unit_ids)
if province:
qs = qs.filter(customer_profile__province=province)
contacts = list(qs.distinct()[:200])
base_profiles = CustomerProfile.objects.filter(user_id__in=[item.pk for item in contacts])
metrics = {
"total": len(contacts),
"customers": base_profiles.filter(stakeholder_type=CustomerProfile.StakeholderType.CUSTOMER).count(),
"suppliers": base_profiles.filter(stakeholder_type=CustomerProfile.StakeholderType.SUPPLIER).count(),
"carriers": base_profiles.filter(stakeholder_type=CustomerProfile.StakeholderType.CARRIER).count(),
"needs_action": sum(item.waiting_staff_count for item in contacts),
}
context = {"contacts": contacts, "metrics": metrics, "groups": visible_groups.annotate(contact_count=Count("contacts", distinct=True)), "stakeholder_types": CustomerProfile.StakeholderType.choices, "provinces": [row["province"] for row in location_data()], "can_add_contact": _has_any_unit_permission(request.user, "customers.add_customerprofile"), "active_staff_section": "contacts", "active_tab_title": "دفترچه مخاطبان"}
return render(request, "tickets/staff_contacts.html", context)
@staff_required
@require_http_methods(["GET", "POST"])
def staff_contact_create(request):
if not _has_any_unit_permission(request.user, "customers.add_customerprofile"):
raise PermissionDenied
manageable_unit_ids = _unit_ids_with_permission(request.user, "customers.add_customerprofile")
form = StaffContactForm(request.POST or None, user=request.user, unit_ids=manageable_unit_ids)
if request.method == "POST" and form.is_valid():
with transaction.atomic():
mobile = form.cleaned_data["mobile"]
user = User.objects.create(username=f"contact_{mobile[1:]}_{uuid4().hex[:5]}", mobile=mobile, account_type=User.AccountType.CUSTOMER)
user.set_unusable_password(); user.save(update_fields=["password"])
profile = CustomerProfile.objects.create(user=user, **{key: form.cleaned_data[key] for key in ("first_name", "last_name", "organization_name", "stakeholder_type", "province", "city")})
profile.groups.set(form.cleaned_data["groups"])
messages.success(request, "مخاطب با عضویت‌های گروهی انتخاب‌شده ثبت شد.")
return redirect("staff_tickets:contacts")
return render(request, "tickets/staff_entity_form.html", {"form": form, "active_staff_section": "contacts", "active_tab_title": "مخاطب جدید", "form_title": "ثبت مخاطب بیرونی", "form_help": "مشتری، تامین‌کننده یا باربری را بدون ساخت رمز عبور ثبت کنید."})
@staff_required
@require_http_methods(["GET", "POST"])
def staff_contact_edit(request, public_id):
profile = get_object_or_404(_contact_queryset(request.user), user__public_id=public_id)
manageable_unit_ids = _unit_ids_with_permission(request.user, "customers.change_customerprofile")
if not set(profile.groups.values_list("unit_id", flat=True)) & manageable_unit_ids:
raise PermissionDenied
form = StaffContactForm(request.POST or None, user=request.user, instance=profile, unit_ids=manageable_unit_ids)
if request.method == "POST" and form.is_valid():
with transaction.atomic():
profile.user.mobile = form.cleaned_data["mobile"]; profile.user.save(update_fields=["mobile"])
for key in ("first_name", "last_name", "organization_name", "stakeholder_type", "province", "city"):
setattr(profile, key, form.cleaned_data[key])
profile.save()
accessible = ContactGroup.objects.filter(unit_id__in=manageable_unit_ids)
profile.groups.remove(*accessible)
profile.groups.add(*form.cleaned_data["groups"])
messages.success(request, "اطلاعات مخاطب بروزرسانی شد.")
return redirect("staff_tickets:contacts")
return render(request, "tickets/staff_entity_form.html", {"form": form, "active_staff_section": "contacts", "active_tab_title": "ویرایش مخاطب", "form_title": str(profile), "form_help": "عضویت همزمان مخاطب در چند گروه قابل مدیریت است."})
@staff_required
@require_http_methods(["GET", "POST"])
def staff_groups(request, group_id=None):
visible_unit_ids = _unit_ids_with_permission(request.user, "customers.view_contactgroup")
editable_unit_ids = _unit_ids_with_permission(request.user, "customers.change_contactgroup" if group_id else "customers.add_contactgroup")
if not visible_unit_ids and not editable_unit_ids: raise PermissionDenied
groups = ContactGroup.objects.filter(unit_id__in=visible_unit_ids | editable_unit_ids).select_related("unit").annotate(contact_count=Count("contacts", distinct=True))
instance = get_object_or_404(groups, pk=group_id) if group_id else None
form = ContactGroupForm(request.POST or None, user=request.user, instance=instance, unit_ids=editable_unit_ids)
if request.method == "POST" and form.is_valid():
unit = form.cleaned_data["unit"]
permission = "customers.change_contactgroup" if instance else "customers.add_contactgroup"
if not (request.user.is_superuser or TicketPolicy.has_unit_permission(request.user, unit, permission)):
raise PermissionDenied
form.save(); messages.success(request, "گروه مخاطبان ذخیره شد.")
return redirect("staff_tickets:groups")
group_metrics = {"total": groups.count(), "active": groups.filter(is_active=True).count(), "memberships": sum(item.contact_count for item in groups)}
return render(request, "tickets/staff_groups.html", {"form": form, "groups": groups, "group_metrics": group_metrics, "can_edit": bool(editable_unit_ids), "active_staff_section": "contacts", "active_tab_title": "گروه‌های مخاطبان"})
@staff_required
def staff_magic_audit(request):
global_access = request.user.is_superuser or request.user.has_perm("accounts.view_all_magic_links")
if not global_access and not _has_any_unit_permission(request.user, "accounts.view_ticketmagiclink"):
raise PermissionDenied
links = TicketMagicLink.objects.select_related("ticket", "customer", "created_by", "created_unit").prefetch_related("access_logs")
if not global_access:
links = links.filter(created_unit_id__in=TicketPolicy.unit_ids(request.user))
q = request.GET.get("q", "").strip()
if q:
links = links.filter(Q(ticket__ticket_number__icontains=q) | Q(recipient_name__icontains=q) | Q(recipient_mobile__icontains=q))
unit=request.GET.get("unit","")
if unit.isdigit(): links=links.filter(created_unit_id=unit)
status=request.GET.get("status","")
now=timezone.now()
if status=="active": links=links.filter(revoked_at__isnull=True,expires_at__gt=now)
elif status=="opened": links=links.filter(view_count__gt=0)
elif status=="unopened": links=links.filter(view_count=0)
elif status=="expired": links=links.filter(revoked_at__isnull=True,expires_at__lte=now)
elif status=="revoked": links=links.filter(revoked_at__isnull=False)
created_from=parse_date(request.GET.get("created_from","")); created_to=parse_date(request.GET.get("created_to",""))
if created_from: links=links.filter(created_at__date__gte=created_from)
if created_to: links=links.filter(created_at__date__lte=created_to)
page=Paginator(links.order_by("-created_at"),50).get_page(request.GET.get("page"))
from apps.staff.models import OrganizationalUnit
units=OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user),is_active=True)
return render(request, "tickets/staff_magic_audit.html", {"links": page.object_list,"page":page,"units":units,"global_access":global_access,"can_revoke":request.user.is_superuser or request.user.has_perm("accounts.change_ticketmagiclink") or _has_any_unit_permission(request.user,"accounts.change_ticketmagiclink"),"now":now,"active_staff_section":"audit","active_tab_title":"ممیزی لینک‌های جادویی"})
@staff_required
@require_POST
def staff_magic_revoke(request, link_id):
link=get_object_or_404(TicketMagicLink.objects.select_related("created_unit"),pk=link_id)
permitted=request.user.is_superuser or request.user.has_perm("accounts.change_ticketmagiclink") or TicketPolicy.has_unit_permission(request.user,link.created_unit,"accounts.change_ticketmagiclink")
if not permitted: raise PermissionDenied
if link.revoked_at is None:
link.revoked_at=timezone.now(); link.save(update_fields=["revoked_at"])
messages.success(request,"لینک جادویی فوراً باطل شد.")
else: messages.info(request,"این لینک قبلاً باطل شده است.")
return redirect("staff_tickets:magic_audit")
@staff_required
@require_http_methods(["GET", "POST"])
def staff_sms_templates(request, template_id=None):
visible_unit_ids = _unit_ids_with_permission(request.user, "notifications.view_smstemplate")
editable_unit_ids = _unit_ids_with_permission(request.user, "notifications.change_smstemplate" if template_id else "notifications.add_smstemplate")
can_view_globals = request.user.is_superuser or request.user.has_perm("notifications.view_smstemplate")
if not visible_unit_ids and not editable_unit_ids and not can_view_globals: raise PermissionDenied
templates = SMSTemplate.objects.filter(
Q(unit_id__in=visible_unit_ids | editable_unit_ids) | Q(unit__isnull=True)
).select_related("unit").order_by("unit__name", "event_type")
instance = get_object_or_404(templates.exclude(unit__isnull=True), pk=template_id) if template_id else None
form = UnitSMSTemplateForm(request.POST or None, user=request.user, instance=instance, unit_ids=editable_unit_ids)
if request.method == "POST" and form.is_valid():
unit = form.cleaned_data["unit"]
permission = "notifications.change_smstemplate" if instance else "notifications.add_smstemplate"
if not (request.user.is_superuser or TicketPolicy.has_unit_permission(request.user, unit, permission)):
raise PermissionDenied
form.save(); messages.success(request, "الگوی پیامک واحد ذخیره شد.")
return redirect("staff_tickets:sms_templates")
return render(request, "tickets/staff_sms_templates.html", {"form": form, "templates": templates, "allowed_tags": SMSTemplate._meta.get_field("allowed_tags").default(), "active_staff_section": "templates", "active_tab_title": "الگوهای پیامک"})