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.
+51
View File
@@ -0,0 +1,51 @@
from django.contrib import admin
from django.utils.html import format_html
from django.urls import reverse
from .forms import StaffProfileAdminForm
from .models import OrganizationalUnit, StaffProfile, UnitMembership
class UnitMembershipInline(admin.TabularInline):
model = UnitMembership
extra = 0
filter_horizontal = ("permissions",)
@admin.register(OrganizationalUnit)
class OrganizationalUnitAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at")
list_filter = ("show_in_customer_form", "accepts_external_tickets", "is_active")
search_fields = ("name", "slug")
list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active")
filter_horizontal = ("permissions",)
inlines = (UnitMembershipInline,)
@admin.register(UnitMembership)
class UnitMembershipAdmin(admin.ModelAdmin):
list_display = ("user", "unit", "ticket_scope", "is_primary", "is_active")
list_filter = ("unit", "ticket_scope", "is_primary", "is_active")
search_fields = ("user__username", "user__mobile", "unit__name")
filter_horizontal = ("permissions",)
@admin.register(StaffProfile)
class StaffProfileAdmin(admin.ModelAdmin):
form = StaffProfileAdminForm
list_display = ("display_name", "primary_unit", "role", "sort_order", "job_title", "mobile", "is_active", "is_customer_selectable")
list_editable = ("sort_order",)
list_filter = ("primary_unit", "role", "is_active", "is_customer_selectable", "bale_notifications_enabled")
search_fields = ("first_name", "last_name", "display_name", "mobile", "user__username")
fieldsets = (
("حساب کاربری", {"fields": ("user",)}),
("مشخصات کارشناس", {"fields": ("primary_unit", "role", "first_name", "last_name", "display_name", "job_title", "avatar_preview", "avatar", "mobile")}),
("اطلاع‌رسانی", {"fields": ("bale_chat_id", "bale_notifications_enabled")}),
("نمایش و دسترسی", {"fields": ("is_customer_selectable", "sort_order", "is_active")}),
)
readonly_fields = ("avatar_preview",)
@admin.display(description="تصویر فعلی")
def avatar_preview(self, obj):
if not obj or not obj.pk or not obj.avatar:
return "تصویری ثبت نشده است."
url = reverse("staff_tickets:expert_avatar", args=[obj.user.public_id])
return format_html('<img src="{}?v={}" alt="تصویر کارشناس" style="width:96px;height:96px;border-radius:16px;object-fit:cover;border:1px solid #ccd8e5">', url, obj.updated_at.timestamp())
+2
View File
@@ -0,0 +1,2 @@
from django.apps import AppConfig
class StaffConfig(AppConfig): name = "apps.staff"
+31
View File
@@ -0,0 +1,31 @@
from django import forms
from .models import StaffProfile
class StaffProfileAdminForm(forms.ModelForm):
"""Upload/replace avatars without exposing a direct public media URL."""
role = forms.ChoiceField(
choices=StaffProfile.Role.choices,
required=False,
initial=StaffProfile.Role.SALES_EXPERT,
label="نقش سازمانی",
)
avatar = forms.ImageField(
required=False,
widget=forms.FileInput(attrs={"accept": "image/jpeg,image/png,image/webp"}),
help_text="تصویر JPG، PNG یا WebP انتخاب کنید. تصویر جدید جایگزین تصویر فعلی می‌شود.",
)
class Meta:
model = StaffProfile
fields = "__all__"
def clean_avatar(self):
avatar = self.cleaned_data.get("avatar")
if avatar and avatar.size > 5 * 1024 * 1024:
raise forms.ValidationError("حجم تصویر پروفایل نباید بیشتر از ۵ مگابایت باشد.")
return avatar
def clean_role(self):
return self.cleaned_data.get("role") or StaffProfile.Role.SALES_EXPERT
+37
View File
@@ -0,0 +1,37 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='StaffProfile',
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)),
('display_name', models.CharField(max_length=150)),
('avatar', models.ImageField(blank=True, upload_to='staff/avatars/')),
('mobile', models.CharField(blank=True, max_length=16)),
('bale_chat_id', models.CharField(blank=True, max_length=100)),
('bale_notifications_enabled', models.BooleanField(default=True)),
('is_customer_selectable', models.BooleanField(default=True)),
('sort_order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='staff_profile', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['sort_order', 'display_name'],
},
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.1.1 on 2026-09-06 10:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('staff', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='staffprofile',
name='first_name',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='staffprofile',
name='job_title',
field=models.CharField(blank=True, default='کارشناس فروش', max_length=150),
),
migrations.AddField(
model_name='staffprofile',
name='last_name',
field=models.CharField(blank=True, max_length=100),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-09-07 11:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('staff', '0002_staffprofile_first_name_staffprofile_job_title_and_more'),
]
operations = [
migrations.AddField(
model_name='staffprofile',
name='role',
field=models.CharField(choices=[('SALES_EXPERT', 'کارشناس فروش'), ('SALES_SUPERVISOR', 'سرپرست فروش'), ('SALES_MANAGER', 'مدیر فروش'), ('CEO', 'مدیرعامل'), ('GENERAL_ADMIN', 'مدیرکل Admin')], db_index=True, default='SALES_EXPERT', max_length=24),
),
]
@@ -0,0 +1,61 @@
# Generated by Django 5.1.1 on 2026-09-08 09:57
import apps.core.models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('staff', '0003_staffprofile_role'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='OrganizationalUnit',
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=120, unique=True)),
('slug', models.SlugField(max_length=80, unique=True)),
('description', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)),
('permissions', models.ManyToManyField(blank=True, help_text='مجوزهایی که به تمام اعضای فعال این واحد داده می\u200cشود.', related_name='organizational_units', to='auth.permission')),
],
options={
'verbose_name': 'واحد سازمانی',
'verbose_name_plural': 'واحدهای سازمانی',
'ordering': ['name'],
},
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
),
migrations.AddField(
model_name='staffprofile',
name='primary_unit',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='primary_staff', to='staff.organizationalunit'),
),
migrations.CreateModel(
name='UnitMembership',
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)),
('is_primary', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
('permissions', models.ManyToManyField(blank=True, help_text='مجوزهای اضافه مخصوص این عضو در همین واحد.', related_name='unit_memberships', to='auth.permission')),
('unit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='staff.organizationalunit')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='unit_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'عضویت واحد',
'verbose_name_plural': 'عضویت\u200cهای واحد',
'ordering': ['unit__name', 'user__username'],
'constraints': [models.UniqueConstraint(fields=('user', 'unit'), name='unique_unit_membership')],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-09-09 05:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
]
operations = [
migrations.AddField(
model_name='organizationalunit',
name='accepts_external_tickets',
field=models.BooleanField(default=True, help_text='اگر غیرفعال باشد، مشتریان نمی\u200cتوانند مستقیماً برای این واحد تیکت ثبت کنند.', verbose_name='قابل انتخاب برای تیکت مشتری'),
),
]
@@ -0,0 +1,27 @@
# Generated by Django 5.1.1 on 2026-09-09 09:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('staff', '0005_organizationalunit_accepts_external_tickets'),
]
operations = [
migrations.AlterModelOptions(
name='organizationalunit',
options={'ordering': ['sort_order', 'name', 'pk'], 'verbose_name': 'واحد سازمانی', 'verbose_name_plural': 'واحدهای سازمانی'},
),
migrations.AddField(
model_name='organizationalunit',
name='search_keywords',
field=models.TextField(blank=True, help_text='واژه\u200cها و عبارت\u200cهای مترادف را با فاصله یا ویرگول وارد کنید.', verbose_name='کلیدواژه\u200cهای جستجو'),
),
migrations.AddField(
model_name='organizationalunit',
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 = [
('staff', '0006_alter_organizationalunit_options_and_more'),
]
operations = [
migrations.AddField(
model_name='organizationalunit',
name='show_in_customer_form',
field=models.BooleanField(default=True, help_text='در صورت خاموش بودن، واحد در فرم ثبت تیکت و جستجوی مشتری نمایش داده نمی\u200cشود.', verbose_name='نمایش در فرم مشتری'),
),
]
@@ -0,0 +1,36 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("staff", "0007_organizationalunit_show_in_customer_form")]
operations = [
migrations.AddField(
model_name="unitmembership",
name="ticket_scope",
field=models.CharField(
choices=[("SELF", "عمومی، ایجادشده یا تخصیص‌یافته به خود"), ("UNIT", "تمام تیکت‌های واحد")],
default="SELF",
help_text="دامنه مشاهده تیکت‌ها در همین واحد؛ مجوز سراسری مستقل است.",
max_length=12,
),
),
migrations.AlterField(
model_name="staffprofile",
name="role",
field=models.CharField(
choices=[
("SALES_EXPERT", "کارشناس فروش"),
("SALES_SUPERVISOR", "سرپرست فروش"),
("SALES_MANAGER", "مدیر فروش"),
("WAREHOUSE_EXPERT", "کارشناس بارگیری انبار"),
("FINANCE_EXPERT", "کارشناس مالی / حسابداری"),
("CEO", "مدیرعامل"),
("GENERAL_ADMIN", "مدیرکل Admin"),
],
db_index=True,
default="SALES_EXPERT",
max_length=24,
),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-09-10 05:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('staff', '0008_unitmembership_ticket_scope_and_roles'),
]
operations = [
migrations.AlterField(
model_name='staffprofile',
name='sort_order',
field=models.PositiveIntegerField(db_index=True, default=0, help_text='عدد کمتر، کارشناس را زودتر در فهرست همان واحد نمایش می\u200cدهد.', verbose_name='ترتیب نمایش در انتخاب کارشناس'),
),
]
View File
Whitespace-only changes.
+155
View File
@@ -0,0 +1,155 @@
from django.conf import settings
from django.contrib.auth.models import Permission
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="در صورت خاموش بودن، واحد در فرم ثبت تیکت و جستجوی مشتری نمایش داده نمی‌شود.")
class Meta:
ordering = ["sort_order", "name", "pk"]
verbose_name = "واحد سازمانی"
verbose_name_plural = "واحدهای سازمانی"
def __str__(self):
return self.name
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)