Corp/backend/employees/models.py

769 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# backend\employees\models.py
from common.emails import send_templated_mail
from django.contrib.auth.models import AbstractUser, BaseUserManager, Group, Permission
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
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 EmployeeManager(BaseUserManager):
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.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.save(using=self._db)
return user
def get_active(self):
"""
Возвращает list активных сотрудников, оптимизированный: предварительная фильтрация в DB по полям,
затем 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)
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)
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)
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 информацию сотрудников"),
]
# Контактные поля telegram/whatsapp/wechat опциональны
# Валидация "хотя бы одно поле" удалена
def __str__(self):
return f"{self.last_name} {self.first_name}"
@property
def employment_status(self):
last_action = self.actions.order_by("-date").first()
if last_action:
return last_action.get_action_display()
return "Нет данных"
@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
last_action = self.actions.order_by("-date").first()
if not last_action:
return self.is_active
return last_action.action != ACTION_DISMISSED
def verify_email(self, code):
if self.email_activation_code == code:
self.email_verified = True
self.email_activation_code = 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)
date = models.DateTimeField("Дата действия")
comment = models.TextField("Комментарий/причина", blank=True)
extra = models.JSONField("Дополнительно", blank=True, null=True)
history = HistoricalRecords()
class Meta:
verbose_name = "Кадровое событие"
verbose_name_plural = "Кадровые события"
ordering = ["-date"]
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(),
},
)
@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
# --- 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"])