Corp/backend/templates/requests_app/all_requests.html
Игорь Надеин 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

147 lines
6.8 KiB
HTML
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.

{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}Все заявления сотрудников{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-9 col-xl-8">
<!-- Заголовок -->
<div class="feed-header">
<i class="bi-clipboard2-check fs-2 text-primary"></i>
<h2 class="title mb-0">Заявления сотрудников</h2>
</div>
<!-- Фильтры -->
<div class="feed-new mb-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-12 col-sm-4">
<label class="form-label small text-secondary mb-1">Статус</label>
<select name="status" class="form-select">
<option value="" {% if not filters.status %}selected{% endif %}>Все</option>
<option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>На рассмотрении</option>
<option value="approved" {% if filters.status == 'approved' %}selected{% endif %}>Одобрено</option>
<option value="rejected" {% if filters.status == 'rejected' %}selected{% endif %}>Отклонено</option>
<option value="cancelled" {% if filters.status == 'cancelled' %}selected{% endif %}>Отменено</option>
<option value="draft" {% if filters.status == 'draft' %}selected{% endif %}>Черновик</option>
</select>
</div>
<div class="col-6 col-sm-4 col-md-3">
<label class="form-label small text-secondary mb-1">С даты</label>
<input type="date" name="from" class="form-control" value="{{ filters.from }}">
</div>
<div class="col-6 col-sm-4 col-md-3">
<label class="form-label small text-secondary mb-1">По дату</label>
<input type="date" name="to" class="form-control" value="{{ filters.to }}">
</div>
<div class="col-12 col-md-2">
<label class="form-label small text-secondary mb-1">Сортировка</label>
<select name="sort" class="form-select">
<option value="" {% if not filters.sort %}selected{% endif %}>По умолчанию</option>
<option value="-created_at" {% if filters.sort == '-created_at' %}selected{% endif %}>Новые сверху</option>
<option value="created_at" {% if filters.sort == 'created_at' %}selected{% endif %}>Старые сверху</option>
</select>
</div>
<div class="col-12 d-flex gap-2">
<button class="btn btn-primary"><i class="bi-funnel me-1"></i>Применить</button>
<a class="btn btn-ghost" href="?"><i class="bi-x-circle me-1"></i>Сбросить</a>
</div>
</form>
</div>
{% with items=requests|default:page.object_list %}
{% if items %}
<div class="feed-stream">
{% for req in items %}
<article class="feed-card">
<header class="feed-hd">
<div class="feed-ava" style="width:44px;height:44px;">
{% if req.employee.avatar %}
<img src="{{ req.employee.avatar_url }}" alt="">
{% else %}
<i class="bi-person"></i>
{% endif %}
</div>
<div class="feed-meta flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="{% url 'employees:employee_detail' req.employee.pk %}"
class="feed-author text-decoration-none">
{{ req.employee.get_full_name|default:req.employee.username }}
</a>
{% if req.department %}
<span class="badge bg-light text-dark border">
<i class="bi-diagram-3 me-1"></i>{{ req.department.name }}
</span>
{% endif %}
</div>
<div class="feed-sub mt-1">
<span class="text-secondary">Подано:</span>
{{ req.created_at|date:"d.m.Y H:i" }}
</div>
</div>
<!-- Бейдж статуса справа -->
<div class="ms-auto">
{% if req.status == 'pending' %}
<span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">На рассмотрении</span>
{% elif req.status == 'approved' %}
<span class="badge bg-success-subtle text-success-emphasis border border-success-subtle">Одобрено</span>
{% elif req.status == 'rejected' %}
<span class="badge bg-danger-subtle text-danger-emphasis border border-danger-subtle">Отклонено</span>
{% elif req.status == 'cancelled' %}
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle">Отменено</span>
{% else %}
<span class="badge bg-secondary">Черновик</span>
{% endif %}
</div>
</header>
<div class="feed-body pt-0">
<div class="mb-2">
<span class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle">
{{ req.get_type_display }}
</span>
</div>
<div class="small text-muted">
<span class="me-1 text-secondary">Период:</span>
{% if req.date_from or req.date_to %}
{{ req.date_from|date:"d.m.Y" }}{% if req.date_to %} — {{ req.date_to|date:"d.m.Y" }}{% endif %}
{% else %}
—
{% endif %}
</div>
</div>
<footer class="feed-actions">
<a href="{% url 'requests_app:request_process' req.pk %}"
class="btn btn-ghost d-inline-flex align-items-center gap-1">
<i class="bi-box-arrow-up-right"></i><span>Рассмотреть</span>
</a>
</footer>
</article>
{% endfor %}
</div>
{% if page %}
<div class="mt-4">
{% bootstrap_pagination page %}
</div>
{% endif %}
{% else %}
<div class="alert alert-info shadow-sm">Заявлений нет.</div>
{% endif %}
{% endwith %}
</div>
</div>
</div>
{% endblock %}