Files
customerTicket/apps/staff/models.py
T
2026-10-13 16:09:45 +03:30

216 lines
11 KiB
Python

from django.conf import settings
from django.contrib.auth.models import Permission
from django.core.exceptions import ValidationError
from django.db import models
from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel
class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
"""A business unit whose access rules are managed from Django Admin."""
persian_text_fields = {"name": False, "description": True, "search_keywords": True}
name = models.CharField(max_length=120, unique=True)
slug = models.SlugField(max_length=80, unique=True)
description = models.TextField(blank=True)
search_keywords = models.TextField(blank=True, verbose_name="کلیدواژه‌های جستجو", help_text="واژه‌ها و عبارت‌های مترادف را با فاصله یا ویرگول وارد کنید.")
sort_order = models.PositiveIntegerField(default=0, db_index=True, verbose_name="ترتیب نمایش")
permissions = models.ManyToManyField(
Permission,
blank=True,
related_name="organizational_units",
help_text="مجوزهایی که به تمام اعضای فعال این واحد داده می‌شود.",
)
is_active = models.BooleanField(default=True)
accepts_external_tickets = models.BooleanField(
default=True,
verbose_name="قابل انتخاب برای تیکت مشتری",
help_text="اگر غیرفعال باشد، مشتریان نمی‌توانند مستقیماً برای این واحد تیکت ثبت کنند.",
)
show_in_customer_form = models.BooleanField(default=True, verbose_name="نمایش در فرم مشتری", help_text="در صورت خاموش بودن، واحد در فرم ثبت تیکت و جستجوی مشتری نمایش داده نمی‌شود.")
bale_chat_id = models.CharField(
max_length=100,
blank=True,
verbose_name="شناسه گروه بله",
help_text="شناسه chat_id گروه بله این واحد؛ در صورت خالی بودن، اعلان گروهی بله برای این واحد ارسال نمی‌شود.",
)
supervisor_mobile_1 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست اول")
supervisor_mobile_2 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست دوم")
supervisor_mobile_3 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست سوم")
supervisor_mobile_4 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست چهارم")
supervisor_mobile_5 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست پنجم")
supervisor_sms_ticket_link_enabled = models.BooleanField(
default=False,
verbose_name="افزودن لینک تیکت به پیامک سرپرستان",
help_text="لینک کوتاه مشاهده و پاسخ با اعتبار ۷ روز؛ ورود کارشناسان و مجوز دسترسی به تیکت الزامی است.",
)
class Meta:
ordering = ["sort_order", "name", "pk"]
verbose_name = "واحد سازمانی"
verbose_name_plural = "واحدهای سازمانی"
def __str__(self):
return self.name
@property
def supervisor_mobiles(self):
"""Return configured supervisor mobiles once, in their configured order."""
return tuple(dict.fromkeys(filter(None, (
self.supervisor_mobile_1,
self.supervisor_mobile_2,
self.supervisor_mobile_3,
self.supervisor_mobile_4,
self.supervisor_mobile_5,
))))
def clean(self):
super().clean()
from apps.accounts.models import normalize_mobile
normalized = []
errors = {}
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5"):
value = getattr(self, field_name, "").strip()
if not value:
setattr(self, field_name, "")
continue
try:
value = normalize_mobile(value)
except ValueError as exc:
errors[field_name] = str(exc)
continue
setattr(self, field_name, value)
if value in normalized:
errors[field_name] = "این شماره همراه قبلاً برای همین واحد وارد شده است."
normalized.append(value)
if errors:
raise ValidationError(errors)
def save(self, *args, **kwargs):
from apps.accounts.models import normalize_mobile
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5"):
value = getattr(self, field_name, "").strip()
setattr(self, field_name, normalize_mobile(value) if value else "")
self.bale_chat_id = self.bale_chat_id.strip()
super().save(*args, **kwargs)
class UnitMembership(TimeStampedModel):
"""Dynamic per-user unit membership with optional extra permissions."""
class TicketScope(models.TextChoices):
SELF = "SELF", "عمومی، ایجادشده یا تخصیص‌یافته به خود"
UNIT = "UNIT", "تمام تیکت‌های واحد"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="unit_memberships",
)
unit = models.ForeignKey(
OrganizationalUnit,
on_delete=models.CASCADE,
related_name="memberships",
)
permissions = models.ManyToManyField(
Permission,
blank=True,
related_name="unit_memberships",
help_text="مجوزهای اضافه مخصوص این عضو در همین واحد.",
)
is_primary = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
ticket_scope = models.CharField(
max_length=12,
choices=TicketScope.choices,
default=TicketScope.SELF,
help_text="دامنه مشاهده تیکت‌ها در همین واحد؛ مجوز سراسری مستقل است.",
)
class Meta:
constraints = [
models.UniqueConstraint(fields=["user", "unit"], name="unique_unit_membership")
]
ordering = ["unit__name", "user__username"]
verbose_name = "عضویت واحد"
verbose_name_plural = "عضویت‌های واحد"
def __str__(self):
return f"{self.user} — {self.unit}"
class StaffProfile(PersianTextNormalizationMixin, TimeStampedModel):
class Role(models.TextChoices):
SALES_EXPERT = "SALES_EXPERT", "کارشناس فروش"
SALES_SUPERVISOR = "SALES_SUPERVISOR", "سرپرست فروش"
SALES_MANAGER = "SALES_MANAGER", "مدیر فروش"
WAREHOUSE_EXPERT = "WAREHOUSE_EXPERT", "کارشناس بارگیری انبار"
FINANCE_EXPERT = "FINANCE_EXPERT", "کارشناس مالی / حسابداری"
CEO = "CEO", "مدیرعامل"
GENERAL_ADMIN = "GENERAL_ADMIN", "مدیرکل Admin"
ROLE_GROUPS = {
Role.SALES_EXPERT: "Sales Expert",
Role.SALES_SUPERVISOR: "Sales Supervisor",
Role.SALES_MANAGER: "Sales Manager",
Role.WAREHOUSE_EXPERT: "Warehouse Expert",
Role.FINANCE_EXPERT: "Finance Expert",
Role.CEO: "CEO",
Role.GENERAL_ADMIN: "General Admin",
}
persian_text_fields = {"first_name": False, "last_name": False, "display_name": False, "job_title": False}
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="staff_profile")
primary_unit = models.ForeignKey(
OrganizationalUnit,
on_delete=models.PROTECT,
related_name="primary_staff",
null=True,
blank=True,
)
role = models.CharField(max_length=24, choices=Role.choices, default=Role.SALES_EXPERT, db_index=True)
display_name = models.CharField(max_length=150)
first_name = models.CharField(max_length=100, blank=True)
last_name = models.CharField(max_length=100, blank=True)
job_title = models.CharField(max_length=150, blank=True, default="کارشناس فروش")
avatar = models.ImageField(upload_to="staff/avatars/", blank=True)
mobile = models.CharField(max_length=16, blank=True)
bale_chat_id = models.CharField(max_length=100, blank=True)
bale_notifications_enabled = models.BooleanField(default=True)
is_customer_selectable = models.BooleanField(default=True)
sort_order = models.PositiveIntegerField(
default=0,
db_index=True,
verbose_name="ترتیب نمایش در انتخاب کارشناس",
help_text="عدد کمتر، کارشناس را زودتر در فهرست همان واحد نمایش می‌دهد.",
)
is_active = models.BooleanField(default=True)
class Meta: ordering = ["sort_order", "display_name"]
def __str__(self): return self.display_name
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
group_name = self.ROLE_GROUPS[self.role]
managed_names = tuple(self.ROLE_GROUPS.values())
self.user.groups.remove(*self.user.groups.filter(name__in=managed_names))
from django.contrib.auth.models import Group
group, _ = Group.objects.get_or_create(name=group_name)
self.user.groups.add(group)
if self.primary_unit_id:
membership, membership_created = UnitMembership.objects.get_or_create(
user=self.user,
unit=self.primary_unit,
defaults={"is_primary": True},
)
desired_scope = (
UnitMembership.TicketScope.UNIT
if self.role in {self.Role.SALES_SUPERVISOR, self.Role.SALES_MANAGER, self.Role.WAREHOUSE_EXPERT, self.Role.FINANCE_EXPERT, self.Role.CEO, self.Role.GENERAL_ADMIN}
else UnitMembership.TicketScope.SELF
)
if not membership.is_primary or not membership.is_active or (membership_created and membership.ticket_scope != desired_scope):
membership.is_primary = True
membership.is_active = True
if membership_created:
membership.ticket_scope = desired_scope
membership.save(update_fields=["is_primary", "is_active", "ticket_scope", "updated_at"])
self.user.unit_memberships.exclude(pk=membership.pk).filter(is_primary=True).update(is_primary=False)