From 88e546b7fa953e8c091f52fb0c97a2f7c966a0ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 21 Oct 2025 07:35:23 +0300 Subject: [PATCH] Enforce single department per employee with unique constraint and validation --- backend/employees/models.py | 123 ++++++++++++------------------------ 1 file changed, 39 insertions(+), 84 deletions(-) diff --git a/backend/employees/models.py b/backend/employees/models.py index fecbfada..0fff8db0 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -8,6 +8,7 @@ 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 django.db.models import Q # для условий в уникальных ограничениях from .constants import ACTION_CHOICES, ACTION_DISMISSED, GENDER_CHOICES, DeptPerm @@ -229,14 +230,13 @@ class Employee(AbstractUser): @property def departments(self): - # Отделы, где сотрудник активен - department_ids = EmployeeDepartment.objects.filter( + """Возвращает максимум один активный отдел (по новой логике).""" + dept_ids = EmployeeDepartment.objects.filter( employee=self, is_active=True - ).values_list("department_id", flat=True) - # Плюс отделы, где сотрудник начальник + ).values_list("department_id", flat=True)[:1] 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) + Q(id__in=dept_ids) | Q(id__in=head_ids) ).distinct() @property @@ -343,16 +343,29 @@ class Department(models.Model): super().save(*args, **kwargs) + # --- Обновляем принадлежность текущего руководителя --- if self.head_id: - try: - link, created = EmployeeDepartment.objects.get_or_create( + link = EmployeeDepartment.objects.filter(employee_id=self.head_id).first() + if link and link.department_id != self.pk: + # Переназначаем отдел + EmployeeDepartment.objects.filter(pk=link.pk).update( + department_id=self.pk, + is_active=True, + date_to=None, + ) + if not link.date_from: + EmployeeDepartment.objects.filter(pk=link.pk).update( + date_from=timezone.now().date() + ) + elif not link: + EmployeeDepartment.objects.create( employee_id=self.head_id, department_id=self.pk, - defaults={ - "is_active": True, - "date_from": timezone.now().date(), - }, + is_active=True, + date_from=timezone.now().date(), ) + else: + # Обновляем статус если нужно updates = {} if not link.is_active: updates["is_active"] = True @@ -360,78 +373,12 @@ class Department(models.Model): 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 + if (not is_create) and head_changed and old_head_id and old_head_id != self.head_id: + # Бывший руководитель остаётся в старом отделе (по требованию можно деактивировать) + # Здесь ничего не делаем, так как запись уже существует и не должна меняться. + pass class DepartmentPermission(models.Model): @@ -493,9 +440,8 @@ class EmployeeDepartment(DateRangeMixin, models.Model): verbose_name_plural = "Принадлежности к отделам" constraints = [ models.UniqueConstraint( - fields=["department_id", "employee_id"], - name="uniq_employee_per_department", - deferrable=models.Deferrable.DEFERRED, + fields=["employee_id"], + name="uniq_department_per_employee", ), ] indexes = [ @@ -503,6 +449,15 @@ class EmployeeDepartment(DateRangeMixin, models.Model): models.Index(fields=["employee_id", "department_id"]), ] + def clean(self): + super().clean() + # Запрещаем более одной активной принадлежности (и вообще единственную запись по сотруднику) + existing = EmployeeDepartment.objects.filter(employee_id=self.employee_id) + if self.pk: + existing = existing.exclude(pk=self.pk) + if existing.exists(): + raise ValidationError({"employee": "Сотрудник уже состоит в другом отделе"}) + def __str__(self): role_name = self.role.name if self.role else "сотрудник" return f"{self.employee} в {self.department} ({role_name})"