# 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 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 активных сотрудников: сначала выполняется фильтрация в БД по полям, затем 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) 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 информацию сотрудников", ), ] 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.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()} — " f"{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 # --- 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} " f"{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"])