253 lines
21 KiB
Python
253 lines
21 KiB
Python
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="گروههای موردنظر را روشن یا خاموش کنید؛ حداقل یک گروه باید باقی بماند.",
|
|
widget=forms.CheckboxSelectMultiple(attrs={"data-contact-group-option": ""}),
|
|
)
|
|
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
|