Игорь Надеин cfc76e43b7 **feat(employees): Position & Department API, доработка прав головы отдела, усиление пермишенов и полный набор тестов**
### Что сделано

**Position API**

* Добавлен `PositionViewSet` с CRUD и экшенами:

  * `POST /positions/{id}/set_groups`
  * `POST /positions/{id}/add_groups`
  * `POST /positions/{id}/remove_groups`
  * `GET  /positions/{id}/permissions` (агрегирует permissions из связанных групп)
* Чтение: `IsAuthenticated`; запись: staff/superuser **или** наличие модельных прав (`employees.add|change|delete_position`), экшены групп — кастомное право `employees.assign_position_groups`.
* Выставление требуемого права через `view.required_perm_code` в `get_permissions()`.
* Поиск/сортировка: `?search=`, `?ordering=`.
* **Отключена пагинация** (возвращаем plain list, как ожидают тесты).
* Сериализатор включает `groups` и `groups_verbose`.
* Роутер: зарегистрирован `positions` в `api:api_v1`.

**Кастомный пермишен для Position**

* В `Position.Meta.permissions` добавлен `("assign_position_groups", "Can assign auth groups to positions")`.
* В `employees.apps.EmployeesConfig.ready()` добавлен `post_migrate`-хук для гарантированного создания этого пермишена (страхует тестовую БД).

**DepartmentRole API**

* Добавлен `DepartmentRoleViewSet` (CRUD + экшены управления правами роли):

  * `GET  /department-roles/{id}/perms`
  * `POST /department-roles/{id}/set_perms`
  * `POST /department-roles/{id}/add_perms`
  * `POST /department-roles/{id}/remove_perms`
* Фильтр: `?department=<id>` (+ безопасная обработка нечислового значения → пустой список).
* Права:

  * CRUD: требуется `manage_department` (через главу отдела, роль с правом, staff/superuser **или** глобальное модельное право `employees.manage_department`);
  * управление правами роли: `assign_department_role` (аналогично).
* Сериализатор `DepartmentRoleSerializer` с полями `permissions` и `permissions_verbose`.

**Пермишены и проверки доступа**

* `IsDeptManagerForWrite`:

  * Научили работать с объектами, у которых есть `obj.department` (например, `DepartmentRole`).
  * Добавили поддержку **глобальных** модельных прав (`user.has_perm("employees.<code>")`), чтобы уполномоченные пользователи могли управлять **любым** отделом.
* `IsStaffOrHasModelPerm`:

  * Перед `user.has_perm(...)` сбрасываем кэш прав (`_perm_cache`, `_user_perm_cache`, `_group_perm_cache`) — важно для тестов, где права выдают в ходе сценария.

**Department.save(): членство и права главы**

* Перевели создание/активацию линка `EmployeeDepartment` для **нового** главы на **синхронный** режим (без `on_commit`) при **смене** главы.
* **Не** создаём линк на первичном создании отдела с уже заданным `head` (чтобы не ловить `UNIQUE` в кейсах, где тесты добавляют линк вручную).
* При смене главы:

  * для **нового** главы — гарантируем наличие активной ссылки (`is_active=True`, `date_from` при необходимости);
  * для **старого** главы:

    * если `head -> None` — гарантируем наличие ссылки и деактивируем её (`is_active=False`, проставляем `date_to`);
    * если смена `A -> B` — гарантируем, что ссылка старого главы существует и остаётся активной (бизнес-логика и тесты).
* Таким образом, права «главы» корректно переходят и отражаются в ссылках членства.

**Маршрутизация**

* Зарегистрированы в `api/v1/urls.py`:

  * `positions`
  * `department-roles`

**Тесты**

* **positions:** полный набор на права, экшены групп, поиск/сортировку, валидацию payload’ов; фикс хелпера телефонов (уникальные номера).
* **department-roles:** основной набор + расширенный (`extra`) на:

  * роли с правами внутри отдела (manage/assign),
  * глобальные модельные права (доступ к любому отделу),
  * кросс-доступ (роль в отделе A не даёт прав в отделе B),
  * семантику `set_perms` (полная замена, дедуп), идемпотентность `add/remove`,
  * валидацию payload’ов, 404 при удалении несуществующей роли.
* **department\_head\_rights:** сценарии смены/снятия главы:

  * старый глава теряет права (если нет роли),
  * новому создаётся активная ссылка и права работают сразу,
  * снятие головы → деактивация ссылки + `date_to`,
  * у старого главы права сохраняются, если назначена роль с `manage_department`,
  * смена головы не деактивирует ссылку старого главы по умолчанию.
* Все тесты зелёные.

### Технические детали / файлы

* `employees/models.py` — правки `Position.Meta.permissions`, логика `Department.save()`.
* `employees/apps.py` — `post_migrate` для `assign_position_groups`.
* `employees/permissions.py` — обновлён `IsDeptManagerForWrite`, `IsStaffOrHasModelPerm`.
* `api/v1/employees/serializers.py` — `PositionSerializer`, `DepartmentRoleSerializer`.
* `api/v1/employees/views.py` — `PositionViewSet` (без пагинации), `DepartmentRoleViewSet` (CRUD + экшены), безопасный фильтр `department`.
* `api/v1/urls.py` — регистрация роутеров.
* `tests/api/v1/employees/` — новые файлы тестов и доработки существующих.

### Миграции/заметки

* Требуется миграция для добавления `assign_position_groups` в `Position.Meta.permissions`.
* `post_migrate` гарантирует наличие права в тестовой БД.
* После деплоя: `python manage.py migrate`.

---

Таким образом, добавлены полноценные API для **Position** и **DepartmentRole**, корректно выстроена модель прав (глава/роль/глобальные), а сценарии смены/снятия главы полностью покрыты тестами.
2025-08-25 17:01:58 +03:00

261 lines
9.0 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/forms.py
import base64
from mimetypes import guess_type
from django import forms
from django.contrib.auth.forms import (AuthenticationForm, UserChangeForm,
UserCreationForm)
from django.core.exceptions import ValidationError
from employees.models import Employee
from phonenumber_field.formfields import PhoneNumberField
from .models import Department, Employee, EmployeeDepartment, Skill
# =========================
# Регистрация
# =========================
def _file_to_data_uri(f) -> str:
"""
Конвертирует загруженный файл (InMemoryUploadedFile) в data URI: data:<mime>;base64,<...>
Совместимо с твоим Base64ImageField на бэкенде.
"""
try:
content = f.read()
if hasattr(f, "seek"):
f.seek(0)
except Exception:
return ""
mime = (
getattr(f, "content_type", None)
or guess_type(getattr(f, "name", ""))[0]
or "image/jpeg"
)
b64 = base64.b64encode(content).decode("ascii")
return f"data:{mime};base64,{b64}"
class EmailOrPhoneAuthenticationForm(AuthenticationForm):
username = forms.CharField(
label="Email или телефон",
widget=forms.TextInput(
attrs={
"autofocus": True,
"placeholder": "user@example.com или +7 999 123-45-67",
}
),
)
password = forms.CharField(
label="Пароль",
strip=False,
widget=forms.PasswordInput(attrs={"autocomplete": "current-password"}),
)
class RegistrationForm(forms.Form):
# Обязательные поля
first_name = forms.CharField(max_length=150, label="Имя")
last_name = forms.CharField(max_length=150, label="Фамилия")
phone_number = forms.CharField(max_length=100, label="Номер телефона")
email = forms.EmailField(label="Email")
birth_date = forms.DateField(
label="Дата рождения", widget=forms.DateInput(attrs={"type": "date"})
)
# Пароли — как в шаблоне
password1 = forms.CharField(label="Пароль", widget=forms.PasswordInput)
password2 = forms.CharField(label="Повторите пароль", widget=forms.PasswordInput)
# Контакты: нужен хотя бы один
telegram = forms.CharField(required=False, label="Telegram")
whatsapp = forms.CharField(required=False, label="WhatsApp")
wechat = forms.CharField(required=False, label="WeChat")
# Аватар (опционально)
avatar = forms.ImageField(required=False, label="Аватар")
# Опциональные поля модели
patronymic = forms.CharField(required=False, label="Отчество")
gender = forms.IntegerField(required=False, label="Пол (0/1/2)")
position = forms.IntegerField(required=False, label="ID должности")
# skills — список ID через запятую (для простоты)
skills = forms.CharField(required=False, label="Навыки (ID через запятую)")
def clean(self):
data = super().clean()
# хотя бы один контакт
if not (data.get("telegram") or data.get("whatsapp") or data.get("wechat")):
raise ValidationError(
"Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram"
)
# пароли совпадают
p1 = data.get("password1")
p2 = data.get("password2")
if p1 or p2:
if p1 != p2:
raise ValidationError("Пароли не совпадают.")
else:
raise ValidationError("Введите пароль и его подтверждение.")
return data
def skills_ids(self) -> list[int]:
raw = (self.cleaned_data.get("skills") or "").strip()
if not raw:
return []
out: list[int] = []
for chunk in raw.split(","):
chunk = chunk.strip()
if not chunk:
continue
try:
out.append(int(chunk))
except ValueError:
pass
return out
def to_api_payload(self) -> dict:
"""
Payload для POST /api/v1/auth/register/.
Включает avatar как data URI, если загружен.
"""
cd = self.cleaned_data
payload = {
"first_name": cd["first_name"],
"last_name": cd["last_name"],
"phone_number": cd["phone_number"],
"email": cd["email"],
"birth_date": cd["birth_date"].isoformat(),
"password": cd["password1"],
"telegram": cd.get("telegram") or "",
"whatsapp": cd.get("whatsapp") or "",
"wechat": cd.get("wechat") or "",
}
# доп. поля, если заданы
if cd.get("patronymic"):
payload["patronymic"] = cd["patronymic"]
if cd.get("gender") is not None:
payload["gender"] = cd["gender"]
if cd.get("position") is not None:
payload["position"] = cd["position"]
ids = self.skills_ids()
if ids:
payload["skills"] = ids
# avatar → base64 data URI
if cd.get("avatar"):
payload["avatar"] = _file_to_data_uri(cd["avatar"])
return payload
def save(self, commit=True):
"""
Прямое создание пользователя (если не ходим в API).
Создаём через менеджер, аватар передаём как файл.
"""
data = self.cleaned_data
user = Employee.objects.create_user(
email=data["email"].strip().lower(),
password=data["password1"],
phone_number=data["phone_number"],
first_name=data["first_name"],
last_name=data["last_name"],
birth_date=data.get("birth_date"),
telegram=data.get("telegram", ""),
whatsapp=data.get("whatsapp", ""),
wechat=data.get("wechat", ""),
avatar=data.get("avatar") or None,
)
return user
class VerifyEmailForm(forms.Form):
email = forms.EmailField(label="Email")
code = forms.CharField(max_length=32, label="Код из письма")
# =========================
# Отделы
# =========================
class DepartmentEditForm(forms.Form):
name = forms.CharField(max_length=255, label="Название")
description = forms.CharField(
required=False, widget=forms.Textarea, label="Описание"
)
class SetHeadForm(forms.Form):
# просто номер id; мы не тянем ORM
head_id = forms.IntegerField(
required=False, label="ID руководителя (пусто — снять)"
)
class SetMemberRoleForm(forms.Form):
employee_id = forms.IntegerField(label="ID сотрудника")
role_id = forms.IntegerField(required=False, label="ID роли (пусто — снять)")
is_active = forms.BooleanField(required=False, label="Активен в отделе")
class DepartmentForm(forms.ModelForm):
class Meta:
model = Department
fields = ["name", "description", "head"]
class InviteToDepartmentForm(forms.Form):
employee = forms.ModelChoiceField(
queryset=Employee.objects.none(),
label="Сотрудник",
widget=forms.Select(attrs={"class": "form-select"}),
)
def __init__(self, department, *args, **kwargs):
super().__init__(*args, **kwargs)
qs = (
Employee.objects.all()
# НЕ показываем тех, кто уже активен в отделе
.exclude(
departments_links__department=department,
departments_links__is_active=True,
)
# по желанию: не показывать руководителя, если он уже назначен
.exclude(id=department.head_id if department.head_id else None).order_by(
"last_name", "first_name"
)
)
self.fields["employee"].queryset = qs
class DepartmentMemberRoleForm(forms.ModelForm):
class Meta:
model = EmployeeDepartment
fields = ["role", "is_active"]
labels = {
"role": "Роль в отделе",
"is_active": "Активен в отделе",
}
widgets = {
"role": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "Например: Аналитик, Тимлид, Наставник",
"autocomplete": "off",
}
),
"is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}),
}
# =========================
# CRUD формы
# =========================
class SkillForm(forms.ModelForm):
class Meta:
model = Skill
fields = ["name"]