Corp/backend/templates/requests_app/request_process.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

216 lines
9.7 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 i18n %}
{# ====== Страница обработки заявления HR/Руководителем ====== #}
{% 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-clipboard-check fs-2 text-primary"></i>
<h2 class="title mb-0">Рассмотрение заявления</h2>
<div class="ms-auto">
<a href="{% url 'requests_app:all_requests' %}" class="btn btn-ghost">
<i class="bi-arrow-left"></i> К списку
</a>
</div>
</div>
{# — Карточка заявки: кто подал, статус, тип, даты, комментарий, вложение — #}
<article class="feed-card mb-3">
<header class="feed-hd">
<div class="feed-ava" style="width:48px;height:48px;">
{% if request_obj.employee.avatar %}
<img src="{{ request_obj.employee.avatar_url }}" alt="Аватар сотрудника"
loading="lazy">
{% 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' request_obj.employee.pk %}"
class="feed-author text-decoration-none">
{{ request_obj.employee.get_full_name|default:request_obj.employee.username }}
</a>
{# Бейдж статуса заявки #}
{% if request_obj.status == 'pending' %}
<span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">На рассмотрении</span>
{% elif request_obj.status == 'approved' %}
<span class="badge bg-success-subtle text-success-emphasis border border-success-subtle">Одобрено</span>
{% elif request_obj.status == 'rejected' %}
<span class="badge bg-danger-subtle text-danger-emphasis border border-danger-subtle">Отклонено</span>
{% elif request_obj.status == 'cancelled' %}
<span class="badge bg-dark-subtle text-dark-emphasis border border-dark-subtle">Отменено</span>
{% elif request_obj.status == 'draft' %}
<span class="badge bg-light text-body border">Черновик</span>
{% else %}
<span class="badge bg-light text-body border">Неизвестно</span>
{% endif %}
{# Бейдж типа заявки #}
<span class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle">
{{ request_obj.get_type_display }}
</span>
</div>
<div class="feed-sub mt-1">
{# Дата создания — ISO в datetime для машин, формат для людей #}
<time datetime="{{ request_obj.created_at|date:'c' }}">
{{ request_obj.created_at|date:"d.m.Y H:i" }}
</time>
{# Период, если указан #}
{% if request_obj.date_from or request_obj.date_to %}
· Период:
{{ request_obj.date_from|date:'d.m.Y' }}
{% if request_obj.date_to %} — {{ request_obj.date_to|date:'d.m.Y' }}{% endif %}
{% endif %}
</div>
</div>
</header>
<div class="feed-body pt-0">
{# Комментарий сотрудника (если есть) #}
<div class="small text-muted mb-2">
<span class="text-secondary">Комментарий сотрудника:</span>
{{ request_obj.comment|default:"—" }}
</div>
{# Вложение сотрудника (если есть) #}
{% if request_obj.attachment %}
<a class="btn btn-ghost d-inline-flex align-items-center gap-1"
href="{{ request_obj.attachment.url }}" target="_blank"
rel="noopener noreferrer" download>
<i class="bi-paperclip"></i><span>Вложение сотрудника</span>
</a>
{% endif %}
</div>
</article>
{# — Инфоблок при финальном статусе — #}
{% if request_obj.is_final %}
<div class="alert alert-warning d-flex align-items-start gap-2">
<i class="bi-exclamation-triangle-fill"></i>
<div>
По заявлению установлен финальный статус (изменить статус нельзя).
Вы можете добавить служебный комментарий и/или вложение.
</div>
</div>
{% endif %}
{# — Форма решения: статус + служебный комментарий/вложение — #}
<article class="feed-card mb-3">
<header class="feed-hd">
<div class="feed-ava" style="width:44px;height:44px;">
<i class="bi-check2-circle"></i>
</div>
<div class="feed-meta">
<div class="feed-author">Принять решение</div>
<div class="feed-sub">Статус + служебный комментарий/вложение</div>
</div>
</header>
<div class="feed-body pt-0">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{# Неформатные ошибки формы (например, запрет смены финального статуса) #}
{% if form.non_field_errors %}
<div class="alert alert-danger mb-2">
{{ form.non_field_errors }}
</div>
{% endif %}
{# Поля формы через django-bootstrap5 #}
{% bootstrap_form form %}
<div class="d-flex gap-2 mt-2">
<button type="submit" class="btn btn-primary">
<i class="bi-check-circle"></i> Сохранить решение
</button>
<a href="{% url 'requests_app:all_requests' %}" class="btn btn-ghost">
Отмена
</a>
</div>
</form>
</div>
</article>
{# — Комментарии по заявлению — #}
<article class="feed-card" id="comments">
<header class="feed-hd">
<div class="feed-ava" style="width:44px;height:44px;">
<i class="bi-chat-dots"></i>
</div>
<div class="feed-meta">
<div class="feed-author">Комментарии</div>
<div class="feed-sub">
{# Избегаем неправильных русских окончаний — просто покажем число #}
{% with cnt=request_obj.comments.count %}
Комментариев: {{ cnt }}
{% endwith %}
</div>
</div>
</header>
<div class="feed-body pt-0">
{# Список комментариев. Используем {% empty %}, чтобы корректно показать «нет комментариев» #}
<div class="d-flex flex-column gap-2 mb-3">
{% for c in request_obj.comments.all %}
<div class="bg-body-tertiary border rounded p-2">
<div class="small d-flex justify-content-between align-items-start">
<div>
<span class="fw-semibold">
{{ c.author.get_full_name|default:c.author.username }}
</span>
<span class="text-secondary">·</span>
<time class="text-muted">{{ c.created_at|date:"d.m.Y H:i" }}</time>
</div>
<form method="post"
action="{% url 'requests_app:request_comment_delete' request_obj.pk c.pk %}?next={{ request.get_full_path|urlencode }}#comments"
onsubmit="return confirm('Удалить комментарий №{{ c.pk }}?');">
{% csrf_token %}
<button class="btn btn-sm btn-ghost text-danger" type="submit" title="Удалить">
<i class="bi-trash"></i>
</button>
</form>
</div>
<div class="small mt-1">{{ c.text|linebreaksbr }}</div>
</div>
{% empty %}
<div class="text-secondary small mb-3">Комментариев пока нет.</div>
{% endfor %}
</div>
<form class="mt-2"
method="post"
action="{% url 'requests_app:request_comment_add' request_obj.pk %}?next={{ request.get_full_path|urlencode }}#comments">
{% csrf_token %}
<div class="input-group">
<textarea name="text" class="form-control" rows="1"
placeholder="Оставить комментарий…"
maxlength="{{ max_comment_len|default:1000 }}"></textarea>
<button class="btn btn-primary" type="submit">
<i class="bi-send"></i>
</button>
</div>
</form>
</div>
</article>
</div>
</div>
</div>
{% endblock %}