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="شماره همراه سرپرست پنجم") 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)