Corp/backend/employees/models.py
Игорь Надеин 3f0ff230ca **feat: управление отделами и ролями (API + шаблоны) + правки тестов**
### Backend / API

* **DepartmentViewSet**

  * Вернул поведение поиска/сортировки как в старой реализации: `?search=` по имени/описанию/ФИО руководителя; `?ordering=name|id` (по умолчанию `name`).
  * Добавил аннотацию **`employees_count`**: считает уникальных активных участников по связям `EmployeeDepartment`, и **прибавляет руководителя**, если он не входит в активные линкы.
  * `partial_update`: смена руководителя требует `change_department_head` **только если значение реально меняется**; остальное редактируется по `manage_department`.
  * `set_head`:

    * `POST /departments/{id}/set_head` — установка нового руководителя (200 + сериализованный отдел).
    * `head_id=null|""` — снятие руководителя (204).
    * Валидация: **разрешаем** назначение любому существующему и активному (`is_active=True`) пользователю; поле `email_verified` не блокирует назначение.
  * `set_member_role`:

    * `POST /departments/{id}/set_member_role` — создание/обновление линка участника с отделом.
    * Валидирует, что `role_id` принадлежит текущему отделу.
    * Возвращает JSON строго в формате, который ждут тесты: `{"employee_id", "role_id", "is_active"}`.

* **DepartmentRoleViewSet**

  * Доступ на изменение ролей отделов только при скоуп-праве **`assign_department_role`** (через `AdminOrDeptAllowed`).
  * Новые эндпоинты:

    * `GET /department-roles/perm_choices/` — отдает справочник прав из `DeptPerm.CHOICES`; при необходимости создаёт записи в БД (id + code + name).
    * `GET /department-roles/{id}/perms/` — текущие права роли (id, code, name).
    * `POST /department-roles/{id}/set_perms` — полная замена прав по `permission_ids` **или** `permission_codes`.
  * Использование константного набора прав **`DeptPerm`**: `MANAGE`, `CHANGE_HEAD`, `ASSIGN_ROLE` (+ `CHOICES`).

* **Сериализаторы**

  * `DepartmentSerializer`: `head` — вложенно (кратко), `head_id` — write-only (`PrimaryKeyRelatedField`), `employees_count` — read-only (берётся из аннотации).
  * `EmployeeBriefSerializer` — для краткого отображения руководителя и кандидатов.

* **Права**

  * Везде применён общий пермишен `AdminOrDeptAllowed(required_code=...)` для скоуп-прав по отделу:

    * `manage_department` — редактирование/удаление отдела,
    * `change_department_head` — назначение/снятие руководителя,
    * `assign_department_role` — операции с ролями и назначениями участникам.

### Frontend / Templates

* **Новый частичный шаблон** `backend/templates/employees/_department_controls.html`

  * Разбит на **3 блока**:

    1. *Управление отделом* (название/описание) — доступно при `can_manage`.
    2. *Роли отдела* — доступно при `can_assign_roles`.

       * Кнопка «Создать роль» открывает панель с формой + модал выбора прав.
       * Редактирование существующей роли: выпадающий список ролей, изменение названия и прав.
       * **Одна кнопка «Сохранить изменения»**: JS сам определяет, что изменилось, и вызывает нужные операции (`op=update` и/или `op=set_perms`) последовательно.
       * Отдельно — «Удалить роль».
       * Выбор прав выполнен через модальные окна, как в блоке с должностями в `_employee_edit.html`; визуал выбранных прав — «чипсы».
    3. *Назначение руководителя* — доступно при `can_change_head` (выпадающий список кандидатов).
  * Разметка и стили согласованы с `_employee_edit.html` (карточки, ios-аккордеон, кнопки, модалки). Дополнительный CSS **не** вводился — всё опирается на базовые стили проекта и Bootstrap.
* **`department_detail` (view для фронта)**:

  * Сначала грузит отдел, затем роли, сотрудников и посты.
  * Собирает `head_choices` и `links` для шаблона.
  * Подтягивает `dept_perm_choices` из `GET /api/v1/department-roles/perm_choices/`.

### Тесты

* Полностью переписаны/обновлены:

  * `tests/api/v1/employees/test_departments.py`

    * Поиск/сортировка по имени.
    * `employees_count` считает head, если его нет в активных линках.
    * `set_head`: валидирует активность; снятие — 204; изменения head без доп. права — 403; сохранение того же head — 200.
    * `set_member_role`: «счастливый путь», запрет чужих ролей, формат ответа.
  * `tests/api/v1/employees/test_department_roles.py` и `test_department_roles_extra.py`

    * Список с фильтром по отделу, стабильный `ordering`.
    * Создание/обновление/удаление с учётом скоуп-прав.
    * `perm_choices`, `perms`, `set_perms` (валидаторы ids/codes).
  * `tests/api/v1/employees/test_department_head_rights.py`

    * Руководитель может править отдел без явных ролей.
    * Руководитель может менять head и **теряет** права после снятия.
* Фиксы фикстур:

  * `make_user` всегда создаёт пользователя с валидным телефоном, `email` в нижнем регистре, отключена отсылка писем; приводим к активному состоянию, чтобы не мешать правам.
* Убраны ошибки:

  * `NameError: User is not defined` → везде используется `get_user_model()`.
  * `NoReverseMatch` в шаблоне → корректные URL’ы: `employees:department_edit`, `employees:department_set_head`, `employees:edit_department_role`.

### Прочее

* Повсеместно добавлены докстринги для новых классов и функций (соответствие внутреннему гайдлайну).
* Без миграций данных/схемы.

**Итог:** добавлены управляемые через API роли отделов с выбором прав (скоуп-прав), унифицированы проверки доступа, восстановлено ожидаемое поведение списка/деталей отделов, подготовлены UI-блоки в едином стиле с «должностями», обновлены и позеленели тесты (остальные регрессии устранены).
2025-09-03 17:17:44 +03:00

485 lines
20 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="Группы прав"
)
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 = None
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", max_length=100, blank=True)
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)
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(Lower("email"), name="employee_email_lower_idx"),
]
permissions = [
("manage_employee_skills", "Может управлять навыками сотрудников"),
]
def clean(self):
super().clean()
if not (self.whatsapp or self.telegram or self.wechat):
raise ValidationError(
"Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram",
code="contact_required",
)
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
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)
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": False,
"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=False, 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"
)
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 Skill(models.Model):
name = models.CharField(max_length=100, unique=True)
class Meta:
verbose_name = "Навык"
verbose_name_plural = "Навыки"
def __str__(self):
return self.name