Игорь Надеин 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

281 lines
8.8 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/admin.py
from django import forms
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import ReadOnlyPasswordHashField, AdminPasswordChangeForm
from django.contrib.auth.models import Group, Permission
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from simple_history.admin import SimpleHistoryAdmin
from .models import (
Employee,
EmployeeAction,
Department,
DepartmentRole,
EmployeeDepartment,
Position,
Skill,
)
# =========================
# Формы для кастомного User (Employee)
# =========================
class EmployeeCreationForm(forms.ModelForm):
"""
Создание сотрудника в админке: логин — email, без username.
"""
password1 = forms.CharField(label="Пароль", widget=forms.PasswordInput)
password2 = forms.CharField(label="Подтверждение пароля", widget=forms.PasswordInput)
class Meta:
model = Employee
fields = ("email", "first_name", "last_name", "phone_number")
def clean_password2(self):
p1 = self.cleaned_data.get("password1") or ""
p2 = self.cleaned_data.get("password2") or ""
if not p1:
raise forms.ValidationError("Пароль обязателен")
if p1 != p2:
raise forms.ValidationError("Пароли не совпадают")
return p2
def save(self, commit=True):
user = super().save(commit=False)
user.set_password(self.cleaned_data["password1"])
# При создании через админку обычно активируем и помечаем e-mail подтверждённым
if not user.is_active:
user.is_active = True
if not user.email_verified:
user.email_verified = True
if commit:
user.save()
return user
class EmployeeChangeForm(forms.ModelForm):
"""
Редактирование сотрудника: пароль только для чтения (смена пароля — отдельной кнопкой).
"""
password = ReadOnlyPasswordHashField(
label="Хэш пароля",
help_text=(
"Пароль хранится в виде хэша — посмотреть его невозможно. "
"Используйте кнопку «Изменить пароль» выше."
),
required=False,
)
class Meta:
model = Employee
fields = (
"email",
"password",
"first_name",
"last_name",
"patronymic",
"gender",
"birth_date",
"avatar",
"phone_number",
"telegram",
"whatsapp",
"wechat",
"position",
"skills",
"is_active",
"email_verified",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
"last_login",
)
def clean_password(self):
# Возвращаем исходное значение (хэш), даже если пользователь ничего не ввёл
return self.initial.get("password")
# =========================
# Инлайны
# =========================
class EmployeeDepartmentInline(admin.TabularInline):
"""
Принадлежности сотрудника к отделам (видно из карточки сотрудника).
"""
model = EmployeeDepartment
fk_name = "employee"
extra = 0
autocomplete_fields = ("department", "role")
fields = ("department", "role", "is_active", "date_from", "date_to")
show_change_link = True
class DepartmentMembershipInline(admin.TabularInline):
"""
Состав отдела — список участников прямо в карточке отдела.
"""
model = EmployeeDepartment
fk_name = "department"
extra = 0
autocomplete_fields = ("employee", "role")
fields = ("employee", "role", "is_active", "date_from", "date_to")
show_change_link = True
# =========================
# Админки моделей
# =========================
@admin.register(Employee)
class EmployeeAdmin(DjangoUserAdmin):
"""
Кастомный UserAdmin для модели Employee.
Включает стандартную кнопку «Изменить пароль».
"""
add_form = EmployeeCreationForm
form = EmployeeChangeForm
model = Employee
# Включаем стандартную форму смены пароля и шаблон,
# который показывает ссылку «Изменить пароль» на форме изменения пользователя.
change_password_form = AdminPasswordChangeForm
change_form_template = "admin/employees/employee/change_form.html"
list_display = (
"email",
"last_name",
"first_name",
"phone_number",
"position",
"is_active",
"email_verified",
"is_staff",
)
list_filter = ("is_active", "email_verified", "is_staff", "is_superuser", "position", "groups")
search_fields = ("email", "first_name", "last_name", "phone_number")
ordering = ("last_name", "first_name")
list_select_related = ("position",)
filter_horizontal = ("groups", "user_permissions", "skills")
readonly_fields = ("last_login", "created_at", "updated_at")
fieldsets = (
(None, {"fields": ("email", "password")}),
("Личная информация", {
"fields": (
"first_name",
"last_name",
"patronymic",
"gender",
"birth_date",
"avatar",
)
}),
("Контакты", {
"fields": ("phone_number", "telegram", "whatsapp", "wechat")
}),
("Работа", {
"fields": ("position", "skills")
}),
("Права и статусы", {
"fields": (
"is_active",
"email_verified",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
)
}),
("Служебное", {"fields": ("last_login", "created_at", "updated_at")}),
)
add_fieldsets = (
(None, {
"classes": ("wide",),
"fields": (
"email",
"first_name",
"last_name",
"phone_number",
"whatsapp",
"position",
"password1",
"password2",
"is_active",
"email_verified",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
),
}),
)
inlines = [EmployeeDepartmentInline,]
@admin.register(Position)
class PositionAdmin(SimpleHistoryAdmin):
list_display = ("name", "description")
search_fields = ("name", "description")
filter_horizontal = ("groups",)
@admin.register(EmployeeAction)
class EmployeeActionAdmin(SimpleHistoryAdmin):
list_display = ("employee", "action", "date")
list_filter = ("action",)
search_fields = ("employee__first_name", "employee__last_name", "comment")
autocomplete_fields = ("employee",)
date_hierarchy = "date"
@admin.register(Department)
class DepartmentAdmin(admin.ModelAdmin):
list_display = ("name", "head", "created_at")
list_filter = ("head",)
search_fields = ("name",)
autocomplete_fields = ("head",)
readonly_fields = ("created_at", "head_appointed_at")
inlines = [DepartmentMembershipInline]
@admin.register(DepartmentRole)
class DepartmentRoleAdmin(admin.ModelAdmin):
list_display = ("name", "department")
list_filter = ("department",)
search_fields = ("name", "department__name")
autocomplete_fields = ("department",)
filter_horizontal = ("scoped_permissions",)
@admin.register(EmployeeDepartment)
class EmployeeDepartmentAdmin(admin.ModelAdmin):
list_display = ("employee", "department", "role", "is_active", "date_from", "date_to")
list_filter = ("is_active", "department", "role")
search_fields = ("employee__first_name", "employee__last_name", "department__name")
autocomplete_fields = ("employee", "department", "role")
date_hierarchy = "date_from"
@admin.register(Skill)
class SkillAdmin(admin.ModelAdmin):
list_display = ("name",)
search_fields = ("name",)
# Полезно иметь Permission в админке
@admin.register(Permission)
class PermissionAdmin(admin.ModelAdmin):
list_display = ("name", "codename", "content_type")
list_filter = ("content_type",)
search_fields = ("name", "codename")