1014 lines
39 KiB
Python
1014 lines
39 KiB
Python
# backend\employees\models.py
|
||
from common.emails import send_templated_mail
|
||
from django.contrib.auth.models import AbstractUser, BaseUserManager, Group
|
||
from django.core.exceptions import ValidationError
|
||
from django.db import IntegrityError, models, transaction
|
||
from django.db.models.functions import Lower
|
||
from django.utils import timezone
|
||
from django.utils.crypto import get_random_string
|
||
from phonenumber_field.modelfields import PhoneNumberField
|
||
from simple_history.models import HistoricalRecords
|
||
|
||
from .constants import (
|
||
ACTION_CHOICES,
|
||
ACTION_DISMISSED,
|
||
GENDER_CHOICES,
|
||
DeptPerm,
|
||
)
|
||
|
||
GUEST_ID_MIN = 900_000_000_000_000
|
||
|
||
|
||
def _sync_department_calendar_after_commit(department_id: int) -> None:
|
||
from scheduling.services import (
|
||
sync_department_calendar_binding_by_department_id,
|
||
)
|
||
|
||
sync_department_calendar_binding_by_department_id(department_id)
|
||
|
||
|
||
class DateRangeMixin(models.Model):
|
||
class Meta:
|
||
abstract = True
|
||
|
||
def clean(self):
|
||
super().clean()
|
||
if hasattr(self, "date_from") and hasattr(self, "date_to"):
|
||
if self.date_from and self.date_to and self.date_from >= self.date_to:
|
||
raise ValidationError("Дата начала должна быть раньше даты окончания")
|
||
|
||
|
||
class EmployeeQuerySet(models.QuerySet):
|
||
def visible_in_ui(self, *, include_ids=()):
|
||
"""Сотрудники для UI-каталогов с явным сохранением связанных записей.
|
||
|
||
``include_ids`` используется только карточками уже существующих сущностей:
|
||
скрытый сотрудник не попадает в общий выбор, но ранее сохранённая связь не
|
||
исчезает при чтении или повторном сохранении формы.
|
||
"""
|
||
include_ids = tuple(include_ids)
|
||
if not include_ids:
|
||
return self.filter(is_hidden_in_ui=False)
|
||
return self.filter(
|
||
models.Q(is_hidden_in_ui=False) | models.Q(pk__in=include_ids)
|
||
)
|
||
|
||
|
||
class EmployeeManager(BaseUserManager.from_queryset(EmployeeQuerySet)):
|
||
use_in_migrations = True
|
||
|
||
def create_user(self, email, password=None, **extra_fields):
|
||
send_activation_email = extra_fields.pop("send_activation_email", True)
|
||
if not email:
|
||
raise ValueError("Пользователь должен иметь email для подтверждения")
|
||
email = self.normalize_email(email).lower()
|
||
|
||
phone_number = extra_fields.pop("phone_number", None)
|
||
if not phone_number:
|
||
raise ValueError("Пользователь должен иметь номер телефона")
|
||
|
||
# Явные проверки для дружелюбной ошибки + защита от гонок ниже
|
||
if self.model.objects.filter(email__iexact=email).exists():
|
||
raise ValidationError(
|
||
{"email": "Пользователь с таким email уже существует"}
|
||
)
|
||
if self.model.objects.filter(phone_number=phone_number).exists():
|
||
raise ValidationError(
|
||
{"phone_number": "Пользователь с таким номером уже существует"}
|
||
)
|
||
|
||
try:
|
||
with transaction.atomic():
|
||
user = self.model(
|
||
email=email, phone_number=phone_number, **extra_fields
|
||
)
|
||
user.set_password(password)
|
||
# для обычного пользователя подтверждение по email
|
||
user.email_verified = False
|
||
user.email_activation_code = get_random_string(
|
||
6, allowed_chars="0123456789"
|
||
)
|
||
user.email_activation_sent_at = timezone.now()
|
||
user.save(using=self._db)
|
||
except IntegrityError as e:
|
||
# fallback — на случай гонки: маппим в поле-специфичную ошибку
|
||
msg = str(e).lower()
|
||
if "email" in msg or "uniq" in msg and "email" in msg:
|
||
raise ValidationError(
|
||
{"email": "Пользователь с таким email уже существует"}
|
||
)
|
||
if "phone" in msg or "phone_number" in msg:
|
||
raise ValidationError(
|
||
{"phone_number": ("Пользователь с таким номером уже существует")}
|
||
)
|
||
raise
|
||
|
||
if send_activation_email:
|
||
send_templated_mail(
|
||
subject="Подтверждение регистрации",
|
||
to=[user.email],
|
||
template_base="emails/registration_verify_code",
|
||
context={"code": user.email_activation_code, "user": user},
|
||
)
|
||
return user
|
||
|
||
def create_superuser(self, email, password=None, **extra_fields):
|
||
extra_fields.setdefault("is_staff", True)
|
||
extra_fields.setdefault("is_superuser", True)
|
||
extra_fields.setdefault("is_active", True)
|
||
extra_fields.setdefault("email_verified", True)
|
||
|
||
if extra_fields.get("is_staff") is not True:
|
||
raise ValueError("Суперпользователь должен иметь is_staff=True")
|
||
if extra_fields.get("is_superuser") is not True:
|
||
raise ValueError("Суперпользователь должен иметь is_superuser=True")
|
||
|
||
# для суперпользователя не отправляем код, просто создаём
|
||
user = self.model(
|
||
email=self.normalize_email(email).lower(),
|
||
phone_number=extra_fields.pop("phone_number", None)
|
||
or "+70000000000", # либо потребуйте явно
|
||
**extra_fields,
|
||
)
|
||
if not user.phone_number:
|
||
raise ValueError("Для суперпользователя также укажите phone_number")
|
||
user.set_password(password)
|
||
user.email_activation_code = None
|
||
user.email_activation_sent_at = None
|
||
user.save(using=self._db)
|
||
return user
|
||
|
||
def get_active(self):
|
||
"""
|
||
Возвращает list активных сотрудников: сначала выполняется
|
||
фильтрация в БД по полям, затем Python-фильтр для сложной
|
||
логики с actions.
|
||
"""
|
||
qs = (
|
||
self.get_queryset()
|
||
.filter(email_verified=True, is_active=True)
|
||
.prefetch_related("actions")
|
||
.order_by("-id")
|
||
)
|
||
return [
|
||
e for e in qs if e.is_actually_active
|
||
] # Финальный фильтр по actions в Python
|
||
|
||
|
||
class Position(models.Model):
|
||
"""
|
||
Справочник должностей. Одну должность могут иметь много сотрудников.
|
||
У каждой должности свой набор групп прав.
|
||
"""
|
||
|
||
name = models.CharField("Название должности", max_length=255, unique=True)
|
||
description = models.TextField("Описание", blank=True)
|
||
groups = models.ManyToManyField(
|
||
Group, blank=True, related_name="positions", verbose_name="Группы прав"
|
||
)
|
||
ldap_group_dn = models.CharField(
|
||
"DN агрегаторной группы должности в AD (POS_*)",
|
||
max_length=512,
|
||
blank=True,
|
||
default="",
|
||
help_text=(
|
||
"Напр.: CN=POS_engineer,OU=Position,OU=company,DC=robotail,DC=local"
|
||
),
|
||
)
|
||
history = HistoricalRecords()
|
||
|
||
class Meta:
|
||
verbose_name = "Должность"
|
||
verbose_name_plural = "Должности"
|
||
ordering = ["name"]
|
||
permissions = [
|
||
(
|
||
"assign_position_groups",
|
||
"Может назначать группы аутентификации для должностей",
|
||
),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return self.name
|
||
|
||
|
||
class Employee(AbstractUser):
|
||
username = models.CharField(
|
||
"LDAP Username (кэш)",
|
||
max_length=150,
|
||
blank=True,
|
||
null=True,
|
||
help_text="Кэшированный LDAP логин сотрудника",
|
||
)
|
||
|
||
gender = models.PositiveSmallIntegerField(
|
||
"Пол", choices=GENDER_CHOICES, default=0, blank=True
|
||
)
|
||
avatar = models.ImageField("Фото", upload_to="users/avatars", blank=True, null=True)
|
||
phone_number = PhoneNumberField("Номер телефона", max_length=100, unique=True)
|
||
patronymic = models.CharField("Отчество", max_length=100, blank=True)
|
||
birth_date = models.DateField("Дата рождения", blank=True, null=True)
|
||
position = models.ForeignKey(
|
||
Position,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="employees",
|
||
verbose_name="Должность",
|
||
)
|
||
email = models.EmailField("email address", unique=True)
|
||
telegram = models.CharField(
|
||
"Telegram Chat ID",
|
||
max_length=100,
|
||
blank=True,
|
||
help_text=(
|
||
"Ваш Telegram Chat ID для получения уведомлений "
|
||
"(найдите через @userinfobot)"
|
||
),
|
||
)
|
||
whatsapp = PhoneNumberField("WhatsApp", blank=True)
|
||
wechat = models.CharField("WeChat", max_length=100, blank=True)
|
||
attendance_aliases = models.JSONField(
|
||
"Алиасы посещаемости",
|
||
default=list,
|
||
blank=True,
|
||
help_text="Дополнительные идентификаторы сотрудника в LogStorm",
|
||
)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
skills = models.ManyToManyField("Skill", related_name="employees", blank=True)
|
||
is_active = models.BooleanField(default=False)
|
||
is_hidden_in_ui = models.BooleanField(
|
||
"Скрыт в интерфейсе",
|
||
default=False,
|
||
help_text=(
|
||
"Сотрудник сможет входить и пользоваться своими правами, но не "
|
||
"будет отображаться в каталогах, поиске и селекторах сотрудников."
|
||
),
|
||
)
|
||
sms_activation_code = models.CharField(
|
||
max_length=6, blank=True, null=True
|
||
) # Для будущей SMS-активации в bots (fallback к email)
|
||
email_verified = models.BooleanField("Email подтверждён", default=False)
|
||
email_activation_code = models.CharField(max_length=6, blank=True, null=True)
|
||
email_activation_sent_at = models.DateTimeField(
|
||
"Код подтверждения email отправлен",
|
||
blank=True,
|
||
null=True,
|
||
)
|
||
|
||
is_ldap_managed = models.BooleanField("Управляется LDAP", default=False)
|
||
|
||
objects = EmployeeManager()
|
||
|
||
USERNAME_FIELD = "email"
|
||
REQUIRED_FIELDS = ["first_name", "last_name", "phone_number"]
|
||
|
||
class Meta:
|
||
verbose_name = "Сотрудник"
|
||
verbose_name_plural = "Сотрудники"
|
||
ordering = ("last_name", "first_name")
|
||
indexes = [
|
||
models.Index(fields=["last_name", "first_name"]),
|
||
models.Index(fields=["email"]),
|
||
models.Index(fields=["phone_number"]),
|
||
models.Index(fields=["username"]),
|
||
models.Index(Lower("email"), name="employee_email_lower_idx"),
|
||
]
|
||
permissions = [
|
||
("manage_employee_skills", "Может управлять навыками сотрудников"),
|
||
(
|
||
"view_ldap_info",
|
||
"Может просматривать LDAP информацию сотрудников",
|
||
),
|
||
]
|
||
constraints = [
|
||
models.CheckConstraint(
|
||
name="employee_id_below_guest_range",
|
||
condition=models.Q(id__lt=GUEST_ID_MIN),
|
||
),
|
||
]
|
||
|
||
# Контактные поля telegram/whatsapp/wechat опциональны
|
||
# Валидация "хотя бы одно поле" удалена
|
||
|
||
def __str__(self):
|
||
return f"{self.last_name} {self.first_name}"
|
||
|
||
@property
|
||
def employment_status(self):
|
||
from employees.services.personnel_state import (
|
||
PERSONNEL_STATUS_NORMAL,
|
||
resolve_employee_personnel_state,
|
||
)
|
||
|
||
state = resolve_employee_personnel_state(self, timezone.localdate())
|
||
if state.status == PERSONNEL_STATUS_NORMAL:
|
||
return "Работает"
|
||
return state.label or "Нет данных"
|
||
|
||
@property
|
||
def departments(self):
|
||
# Отделы, где сотрудник активен
|
||
department_ids = EmployeeDepartment.objects.filter(
|
||
employee=self, is_active=True
|
||
).values_list("department_id", flat=True)
|
||
# Плюс отделы, где сотрудник начальник
|
||
head_ids = Department.objects.filter(head=self).values_list("id", flat=True)
|
||
return Department.objects.filter(
|
||
models.Q(id__in=department_ids) | models.Q(id__in=head_ids)
|
||
).distinct()
|
||
|
||
@property
|
||
def is_actually_active(self):
|
||
if not self.email_verified:
|
||
return False # Неактивен без подтверждения email
|
||
|
||
from employees.services.personnel_state import (
|
||
PERSONNEL_STATUS_NORMAL,
|
||
resolve_employee_personnel_state,
|
||
)
|
||
|
||
state = resolve_employee_personnel_state(self, timezone.localdate())
|
||
if state.status == PERSONNEL_STATUS_NORMAL:
|
||
return self.is_active
|
||
return state.status != ACTION_DISMISSED
|
||
|
||
def verify_email(self, code):
|
||
if self.email_activation_code == code:
|
||
self.email_verified = True
|
||
self.email_activation_code = None
|
||
self.email_activation_sent_at = None
|
||
self.is_active = True # Активируем после подтверждения
|
||
self.save()
|
||
return True
|
||
return False
|
||
|
||
def mark_ldap_managed(self, guid: str, dn: str | None = None) -> None:
|
||
if not guid:
|
||
raise ValueError("ldap_guid обязателен")
|
||
self.is_ldap_managed = True
|
||
if hasattr(self, "set_unusable_password"):
|
||
self.set_unusable_password()
|
||
self.save(update_fields=["is_ldap_managed", "password"])
|
||
|
||
st, _ = LdapSyncState.objects.get_or_create(
|
||
model="employee", object_pk=str(self.pk)
|
||
)
|
||
st.touch(ldap_guid=guid, ldap_dn=dn, sync_dir="ldap")
|
||
|
||
|
||
class EmployeeAction(models.Model):
|
||
employee = models.ForeignKey(
|
||
Employee, related_name="actions", on_delete=models.CASCADE
|
||
)
|
||
action = models.CharField("Кадровое событие", max_length=50, choices=ACTION_CHOICES)
|
||
source_request = models.ForeignKey(
|
||
"requests_app.Request",
|
||
related_name="employee_actions",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Источник-заявление",
|
||
)
|
||
date = models.DateTimeField("Дата действия")
|
||
date_to = models.DateField(
|
||
"Дата окончания временного события",
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
comment = models.TextField("Комментарий/причина", blank=True)
|
||
extra = models.JSONField("Дополнительно", blank=True, null=True)
|
||
history = HistoricalRecords()
|
||
|
||
class Meta:
|
||
verbose_name = "Кадровое событие"
|
||
verbose_name_plural = "Кадровые события"
|
||
ordering = ["-date"]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["source_request", "action"],
|
||
condition=models.Q(source_request__isnull=False),
|
||
name="uniq_employee_action_source_request_action",
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.employee} — {self.get_action_display()} — {self.date:%d.%m.%Y}"
|
||
|
||
|
||
class Department(models.Model):
|
||
name = models.CharField("Название отдела", max_length=255, unique=True)
|
||
description = models.TextField("Описание", blank=True)
|
||
head = models.ForeignKey(
|
||
Employee,
|
||
on_delete=models.SET_NULL,
|
||
verbose_name="Руководитель отдела",
|
||
related_name="headed_departments",
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
head_appointed_at = models.DateTimeField(
|
||
"Дата назначения руководителя", null=True, blank=True
|
||
)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
ldap_group_dn = models.CharField(
|
||
"DN агрегаторной группы отдела в AD (DEP_*)",
|
||
max_length=512,
|
||
blank=True,
|
||
default="",
|
||
help_text=("Напр.: CN=DEP_it,OU=Department,OU=company,DC=robotail,DC=local"),
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Отдел"
|
||
verbose_name_plural = "Отделы"
|
||
permissions = [
|
||
("change_department_head", "Может изменять руководителя отдела"),
|
||
("assign_department_role", "Может назначать роли в рамках отдела"),
|
||
("manage_department", "Может управлять отделом"),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
def save(self, *args, **kwargs):
|
||
old_head_id = None
|
||
is_create = self._state.adding
|
||
if self.pk:
|
||
old_head_id = (
|
||
Department.objects.filter(pk=self.pk)
|
||
.values_list("head_id", flat=True)
|
||
.first()
|
||
)
|
||
|
||
head_changed = old_head_id != self.head_id
|
||
|
||
if head_changed and self.head_id:
|
||
self.head_appointed_at = timezone.now()
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
if self.head_id:
|
||
try:
|
||
link, created = EmployeeDepartment.objects.get_or_create(
|
||
employee_id=self.head_id,
|
||
department_id=self.pk,
|
||
defaults={
|
||
"is_active": True,
|
||
"date_from": timezone.now().date(),
|
||
},
|
||
)
|
||
updates = {}
|
||
if not link.is_active:
|
||
updates["is_active"] = True
|
||
if not link.date_from:
|
||
updates["date_from"] = timezone.now().date()
|
||
if updates:
|
||
EmployeeDepartment.objects.filter(pk=link.pk).update(**updates)
|
||
except IntegrityError:
|
||
# На случай гонки
|
||
EmployeeDepartment.objects.filter(
|
||
employee_id=self.head_id, department_id=self.pk
|
||
).update(is_active=True, date_from=timezone.now().date())
|
||
|
||
# --- Обрабатываем бывшего руководителя ---
|
||
if (not is_create) and head_changed and old_head_id:
|
||
if self.head_id is None:
|
||
# Руководителя сняли: деактивируем его принадлежность
|
||
link, created = EmployeeDepartment.objects.get_or_create(
|
||
employee_id=old_head_id,
|
||
department_id=self.pk,
|
||
defaults={
|
||
"is_active": True,
|
||
"date_from": timezone.now().date(),
|
||
"date_to": timezone.now().date(),
|
||
},
|
||
)
|
||
if not created:
|
||
EmployeeDepartment.objects.filter(
|
||
employee_id=old_head_id, department_id=self.pk
|
||
).update(is_active=True, date_to=timezone.now().date())
|
||
else:
|
||
# Смена A -> B: по текущей логике бывший остаётся
|
||
# сотрудником отдела (активным).
|
||
# Если хотите наоборот — поменяйте is_active=True
|
||
# на False и добавьте
|
||
# date_to.
|
||
EmployeeDepartment.objects.get_or_create(
|
||
employee_id=old_head_id,
|
||
department_id=self.pk,
|
||
defaults={
|
||
"is_active": True,
|
||
"date_from": timezone.now().date(),
|
||
},
|
||
)
|
||
|
||
transaction.on_commit(
|
||
lambda department_id=self.pk: _sync_department_calendar_after_commit(
|
||
department_id
|
||
)
|
||
)
|
||
|
||
@property
|
||
def active_employees(self):
|
||
"""
|
||
Все активные сотрудники отдела (через EmployeeDepartment),
|
||
включая руководителя, если он существует и активен.
|
||
"""
|
||
qs = self.employeedepartment.filter(is_active=True).select_related("employee")
|
||
employees = [ed.employee for ed in qs if ed.employee.is_actually_active]
|
||
|
||
if self.head and self.head.is_actually_active and self.head not in employees:
|
||
employees.append(self.head)
|
||
|
||
return employees
|
||
|
||
@property
|
||
def new_employees(self):
|
||
"""
|
||
Новые сотрудники отдела за последний месяц (по дате присоединения).
|
||
"""
|
||
month_ago = timezone.now() - timezone.timedelta(days=30)
|
||
qs = self.employeedepartment.filter(
|
||
is_active=True, date_from__gte=month_ago
|
||
).select_related("employee")
|
||
|
||
employees = [ed.employee for ed in qs if ed.employee.is_actually_active]
|
||
|
||
# Добавляем руководителя, если он недавно назначен и активен
|
||
if (
|
||
self.head
|
||
and self.head_appointed_at
|
||
and self.head_appointed_at >= month_ago
|
||
and self.head.is_actually_active
|
||
and self.head not in employees
|
||
):
|
||
employees.append(self.head)
|
||
|
||
return employees
|
||
|
||
|
||
class DepartmentPermission(models.Model):
|
||
code = models.CharField(max_length=64, choices=DeptPerm.CHOICES, unique=True)
|
||
name = models.CharField(max_length=150)
|
||
|
||
def __str__(self):
|
||
return self.name or self.code
|
||
|
||
|
||
class DepartmentRole(models.Model):
|
||
"""Роль внутри конкретного отдела.
|
||
|
||
Права роли задаются начальником отдела.
|
||
"""
|
||
|
||
department = models.ForeignKey(
|
||
Department, on_delete=models.CASCADE, related_name="roles"
|
||
)
|
||
name = models.CharField(max_length=150)
|
||
scoped_permissions = models.ManyToManyField(
|
||
DepartmentPermission, blank=True, related_name="roles"
|
||
)
|
||
ldap_group_dn = models.CharField(
|
||
"DN агрегаторной группы роли в AD (ROLE_*)",
|
||
max_length=512,
|
||
blank=True,
|
||
default="",
|
||
help_text=("Напр.: CN=ROLE_it__oncall,OU=Role,OU=company,DC=robotail,DC=local"),
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Роль в отделе"
|
||
verbose_name_plural = "Роли в отделе"
|
||
unique_together = ("department", "name")
|
||
ordering = ("name", "department", "id")
|
||
|
||
def __str__(self):
|
||
return f"{self.department}: {self.name}"
|
||
|
||
|
||
class EmployeeDepartment(DateRangeMixin, models.Model):
|
||
employee = models.ForeignKey(
|
||
"Employee", on_delete=models.CASCADE, related_name="departments_links"
|
||
)
|
||
department = models.ForeignKey(
|
||
Department, on_delete=models.CASCADE, related_name="employeedepartment"
|
||
)
|
||
date_from = models.DateField("Дата начала работы", null=True, blank=True)
|
||
date_to = models.DateField("Дата окончания работы", null=True, blank=True)
|
||
is_active = models.BooleanField(default=True)
|
||
role = models.ForeignKey(
|
||
DepartmentRole,
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name="members",
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Принадлежность к отделу"
|
||
verbose_name_plural = "Принадлежности к отделам"
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["department_id", "employee_id"],
|
||
name="uniq_employee_per_department",
|
||
deferrable=models.Deferrable.DEFERRED,
|
||
),
|
||
]
|
||
indexes = [
|
||
models.Index(fields=["department_id", "is_active", "employee_id"]),
|
||
models.Index(fields=["employee_id", "department_id"]),
|
||
]
|
||
|
||
def __str__(self):
|
||
role_name = self.role.name if self.role else "сотрудник"
|
||
return f"{self.employee} в {self.department} ({role_name})"
|
||
|
||
|
||
class RoleAssignment(models.Model):
|
||
"""Назначение роли сотруднику.
|
||
|
||
Роль может быть назначена любому сотруднику компании,
|
||
независимо от его членства в отделе.
|
||
|
||
Attributes:
|
||
employee: Сотрудник, которому назначена роль.
|
||
role: Назначенная роль отдела.
|
||
assigned_by: Кто назначил роль (опционально).
|
||
assigned_at: Дата/время назначения.
|
||
is_active: Активно ли назначение.
|
||
"""
|
||
|
||
employee = models.ForeignKey(
|
||
"Employee",
|
||
on_delete=models.CASCADE,
|
||
related_name="role_assignments",
|
||
verbose_name="Сотрудник",
|
||
)
|
||
role = models.ForeignKey(
|
||
DepartmentRole,
|
||
on_delete=models.CASCADE,
|
||
related_name="assignments",
|
||
verbose_name="Роль",
|
||
)
|
||
assigned_by = models.ForeignKey(
|
||
"Employee",
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name="assigned_roles",
|
||
verbose_name="Назначил",
|
||
)
|
||
assigned_at = models.DateTimeField(
|
||
"Дата назначения",
|
||
auto_now_add=True,
|
||
)
|
||
is_active = models.BooleanField(
|
||
"Активно",
|
||
default=True,
|
||
db_index=True,
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Назначение роли"
|
||
verbose_name_plural = "Назначения ролей"
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["employee", "role"],
|
||
name="uniq_employee_role_assignment",
|
||
),
|
||
]
|
||
indexes = [
|
||
models.Index(fields=["role", "is_active"]),
|
||
models.Index(fields=["employee", "is_active"]),
|
||
]
|
||
ordering = ["-assigned_at"]
|
||
|
||
def __str__(self):
|
||
status = "✓" if self.is_active else "✗"
|
||
return f"{self.employee} → {self.role} [{status}]"
|
||
|
||
|
||
class Skill(models.Model):
|
||
name = models.CharField(max_length=100, unique=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Навык"
|
||
verbose_name_plural = "Навыки"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class EmployeeSkillProgress(models.Model):
|
||
"""Прогресс конкретного сотрудника по навыку.
|
||
|
||
Связь ``Employee.skills`` остаётся обратно совместимой, а эта модель
|
||
хранит игровое состояние и может быть восстановлена по журналу опыта.
|
||
"""
|
||
|
||
employee = models.ForeignKey(
|
||
Employee,
|
||
on_delete=models.CASCADE,
|
||
related_name="skill_progresses",
|
||
verbose_name="Сотрудник",
|
||
)
|
||
skill = models.ForeignKey(
|
||
Skill,
|
||
on_delete=models.CASCADE,
|
||
related_name="employee_progresses",
|
||
verbose_name="Навык",
|
||
)
|
||
experience = models.PositiveIntegerField("Опыт", default=0)
|
||
is_active = models.BooleanField("Показывается в профиле", default=True)
|
||
created_by = models.ForeignKey(
|
||
Employee,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="created_skill_progresses",
|
||
verbose_name="Добавил навык",
|
||
)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Прогресс навыка сотрудника"
|
||
verbose_name_plural = "Прогресс навыков сотрудников"
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["employee", "skill"],
|
||
name="uniq_employee_skill_progress",
|
||
),
|
||
]
|
||
indexes = [
|
||
models.Index(fields=["skill", "is_active", "experience"]),
|
||
models.Index(fields=["employee", "is_active"]),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.employee} — {self.skill}: {self.experience} XP"
|
||
|
||
|
||
class SkillExperienceEvent(models.Model):
|
||
class Source(models.TextChoices):
|
||
ENDORSEMENT = "endorsement", "Подтверждение сотрудником"
|
||
TASK = "task", "Завершённая задача"
|
||
TRAINING = "training", "Обучение"
|
||
CERTIFICATION = "certification", "Аттестация"
|
||
MANUAL = "manual", "Ручное начисление"
|
||
|
||
progress = models.ForeignKey(
|
||
EmployeeSkillProgress,
|
||
on_delete=models.CASCADE,
|
||
related_name="experience_events",
|
||
verbose_name="Прогресс навыка",
|
||
)
|
||
points = models.PositiveIntegerField("Баллы опыта")
|
||
source_type = models.CharField(
|
||
"Источник",
|
||
max_length=24,
|
||
choices=Source.choices,
|
||
)
|
||
source_id = models.CharField("ID источника", max_length=100, blank=True)
|
||
actor = models.ForeignKey(
|
||
Employee,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="skill_experience_events_given",
|
||
verbose_name="Автор начисления",
|
||
)
|
||
reason = models.TextField("Причина", blank=True)
|
||
metadata = models.JSONField("Дополнительные данные", default=dict, blank=True)
|
||
idempotency_key = models.CharField(
|
||
"Ключ идемпотентности",
|
||
max_length=180,
|
||
blank=True,
|
||
default="",
|
||
)
|
||
revoked_at = models.DateTimeField("Отозвано", null=True, blank=True)
|
||
revoked_by = models.ForeignKey(
|
||
Employee,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="skill_experience_events_revoked",
|
||
verbose_name="Кто отозвал",
|
||
)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Начисление опыта навыка"
|
||
verbose_name_plural = "Начисления опыта навыков"
|
||
ordering = ["-created_at", "-id"]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["idempotency_key"],
|
||
condition=~models.Q(idempotency_key=""),
|
||
name="uniq_skill_xp_idempotency_key",
|
||
),
|
||
]
|
||
indexes = [
|
||
models.Index(fields=["progress", "source_type", "revoked_at"]),
|
||
models.Index(fields=["actor", "source_type", "created_at"]),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.progress}: +{self.points} ({self.get_source_type_display()})"
|
||
|
||
|
||
# --- LDAP sync state -------------------------------------------------
|
||
|
||
|
||
class LdapSyncState(models.Model):
|
||
"""Единое состояние синхронизации LDAP для любых объектов.
|
||
|
||
Attributes:
|
||
model (str): Имя модели в Django:
|
||
'employee', 'department', 'group', 'dept_role', 'position'.
|
||
object_pk (str): Первичный ключ объекта строкой,
|
||
чтобы покрыть UUID и INT.
|
||
ldap_dn (str): Последний известный DN в LDAP.
|
||
ldap_guid (str | None): objectGUID из Active Directory.
|
||
last_ldap_modify_ts (datetime | None): Последний
|
||
modifyTimestamp/whenChanged из LDAP.
|
||
last_django_modify_ts (datetime | None): Последний updated_at
|
||
из Django на момент фиксации.
|
||
last_sync_dir (str): 'ldap'|'django'|'auto'|'manual' —
|
||
направление последнего успешного синка.
|
||
data_hash (str | None): Хэш значимых полей,
|
||
опциональный для ускоренной детекции изменений.
|
||
updated_at (datetime): Время обновления записи состояния.
|
||
|
||
Raises:
|
||
ValueError: При неверных значениях полей валидации.
|
||
На уровне БД дополнительных ограничений нет.
|
||
"""
|
||
|
||
model = models.CharField(max_length=32, db_index=True)
|
||
object_pk = models.CharField(max_length=64, db_index=True)
|
||
|
||
ldap_dn = models.TextField(blank=True, default="")
|
||
ldap_guid = models.CharField(max_length=64, null=True, blank=True, db_index=True)
|
||
|
||
last_ldap_modify_ts = models.DateTimeField(null=True, blank=True)
|
||
last_django_modify_ts = models.DateTimeField(null=True, blank=True)
|
||
|
||
last_sync_dir = models.CharField(max_length=8, default="ldap")
|
||
data_hash = models.CharField(max_length=64, null=True, blank=True)
|
||
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Состояние синхронизации LDAP"
|
||
verbose_name_plural = "Состояния синхронизации LDAP"
|
||
unique_together = (("model", "object_pk"),)
|
||
|
||
def touch(
|
||
self,
|
||
*,
|
||
ldap_dn: str | None = None,
|
||
ldap_guid: str | None = None,
|
||
last_ldap_modify_ts=None,
|
||
last_django_modify_ts=None,
|
||
sync_dir: str | None = None,
|
||
data_hash: str | None = None,
|
||
) -> None:
|
||
"""Обновляет поля состояния для записи.
|
||
|
||
Args:
|
||
ldap_dn (str | None): Новый DN.
|
||
ldap_guid (str | None): Новый GUID.
|
||
last_ldap_modify_ts: Новый штамп LDAP (UTC).
|
||
last_django_modify_ts: Новый штамп Django (UTC).
|
||
sync_dir (str | None): Направление:
|
||
'ldap'|'django'|'auto'|'manual'.
|
||
data_hash (str | None): Хэш полезных данных.
|
||
|
||
Raises:
|
||
ValueError: Если sync_dir не из допустимого набора.
|
||
"""
|
||
if sync_dir and sync_dir not in ("ldap", "django", "auto", "manual"):
|
||
raise ValueError("sync_dir должен быть 'ldap'|'django'|'auto'|'manual'")
|
||
if ldap_dn is not None:
|
||
self.ldap_dn = ldap_dn
|
||
if ldap_guid is not None:
|
||
self.ldap_guid = ldap_guid
|
||
if last_ldap_modify_ts is not None:
|
||
self.last_ldap_modify_ts = last_ldap_modify_ts
|
||
if last_django_modify_ts is not None:
|
||
self.last_django_modify_ts = last_django_modify_ts
|
||
if sync_dir:
|
||
self.last_sync_dir = sync_dir
|
||
if data_hash is not None:
|
||
self.data_hash = data_hash
|
||
self.updated_at = timezone.now()
|
||
self.save(
|
||
update_fields=[
|
||
"ldap_dn",
|
||
"ldap_guid",
|
||
"last_ldap_modify_ts",
|
||
"last_django_modify_ts",
|
||
"last_sync_dir",
|
||
"data_hash",
|
||
"updated_at",
|
||
]
|
||
)
|
||
|
||
|
||
class LdapSyncQueue(models.Model):
|
||
"""Очередь отложенных LDAP-операций для retry.
|
||
|
||
Когда сигнал не может записать данные в LDAP
|
||
(сервер недоступен, таймаут и т.д.), операция сохраняется
|
||
в эту очередь и повторяется через Celery.
|
||
"""
|
||
|
||
class Status(models.TextChoices):
|
||
PENDING = "pending", "Ожидает"
|
||
IN_PROGRESS = "in_progress", "Выполняется"
|
||
COMPLETED = "completed", "Завершено"
|
||
FAILED = "failed", "Ошибка (исчерпаны попытки)"
|
||
|
||
# Тип операции
|
||
operation = models.CharField(
|
||
max_length=64,
|
||
db_index=True,
|
||
help_text=("Имя операции: employee_save, department_delete, group_m2m и т.д."),
|
||
)
|
||
|
||
# Модель и объект
|
||
model_name = models.CharField(max_length=32, db_index=True)
|
||
object_pk = models.CharField(max_length=64, db_index=True)
|
||
|
||
# Сериализованные аргументы операции (JSON)
|
||
payload = models.JSONField(
|
||
default=dict,
|
||
help_text="JSON с аргументами для повтора операции",
|
||
)
|
||
|
||
# Статус
|
||
status = models.CharField(
|
||
max_length=16,
|
||
choices=Status.choices,
|
||
default=Status.PENDING,
|
||
db_index=True,
|
||
)
|
||
|
||
# Retry
|
||
attempts = models.PositiveIntegerField(default=0)
|
||
max_attempts = models.PositiveIntegerField(default=5)
|
||
next_retry_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||
last_error = models.TextField(blank=True, default="")
|
||
|
||
# Время
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Очередь LDAP синхронизации"
|
||
verbose_name_plural = "Очередь LDAP синхронизации"
|
||
ordering = ["created_at"]
|
||
indexes = [
|
||
models.Index(
|
||
fields=["status", "next_retry_at"],
|
||
name="ldap_queue_retry_idx",
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"[{self.status}] {self.operation} {self.model_name}:{self.object_pk}"
|
||
|
||
def schedule_retry(self, error: str):
|
||
"""Планирует следующую попытку с экспоненциальным backoff."""
|
||
self.attempts += 1
|
||
self.last_error = str(error)[:2000]
|
||
|
||
if self.attempts >= self.max_attempts:
|
||
self.status = self.Status.FAILED
|
||
else:
|
||
self.status = self.Status.PENDING
|
||
# Exponential backoff: 30s, 2m, 8m, 32m, ...
|
||
from datetime import timedelta
|
||
|
||
delay = timedelta(seconds=30 * (4**self.attempts))
|
||
self.next_retry_at = timezone.now() + delay
|
||
|
||
self.save(
|
||
update_fields=[
|
||
"attempts",
|
||
"last_error",
|
||
"status",
|
||
"next_retry_at",
|
||
"updated_at",
|
||
]
|
||
)
|
||
|
||
def mark_completed(self):
|
||
"""Отмечает операцию как успешно выполненную."""
|
||
self.status = self.Status.COMPLETED
|
||
self.save(update_fields=["status", "updated_at"])
|