Corp/backend/templates/auth/logout.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

172 lines
5.9 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.

{# templates/registration/logout.html #}
{% extends "base.html" %}
{% load static %}
{% block title %}Выход{% endblock %}
{% block extra_css %}
<style>
/* Overlay поверх всего (и навбара тоже) */
.ios-overlay{
position: fixed; inset: 0; z-index: 1080;
background: color-mix(in srgb, var(--bs-body-bg), #000 35%);
backdrop-filter: blur(10px) saturate(110%);
display: grid; place-items: end center; /* bottom sheet по умолчанию */
}
@media (min-width: 992px){
.ios-overlay{ place-items: center; }
}
/* Bottom sheet / Card */
.ios-sheet{
width: 100%; max-width: 560px;
background: color-mix(in srgb, var(--bs-body-bg), #fff 6%);
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
border-radius: 16px 16px 0 0;
box-shadow: 0 12px 48px rgba(0,0,0,.28);
animation: slideUp .28s ease both;
}
@media (min-width: 992px){
.ios-sheet{
border-radius: 18px; animation: popIn .18s ease both;
}
}
/* Грип-бар как в iOS */
.ios-grip{
width: 36px; height: 4px; border-radius: 999px;
background: color-mix(in srgb, var(--bs-body-color), transparent 70%);
margin: .5rem auto 0.25rem auto;
}
/* Контент */
.ios-body{ padding: 1rem 1.25rem 1.25rem 1.25rem; }
@media (min-width: 992px){ .ios-body{ padding: 1.25rem 1.5rem 1.5rem 1.5rem; } }
.ios-title{
margin: .25rem 0 .25rem 0; text-align: center;
font-weight: 800; letter-spacing: .2px;
}
.ios-sub{
text-align: center; color: var(--bs-secondary-color);
margin: 0 0 1rem 0;
}
/* Аватар + иконка выхода */
.ios-ava{
width: 64px; height: 64px; border-radius: 999px; overflow: hidden;
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
background: var(--bs-tertiary-bg);
display: grid; place-items: center; margin: .25rem auto .75rem auto;
}
.ios-ava img{ width: 100%; height: 100%; object-fit: cover; display: block; }
.ios-ico{
width: 56px; height: 56px; border-radius: 14px;
background: color-mix(in srgb, var(--bs-danger), #000 12%);
color: #fff; display: grid; place-items: center; margin: .25rem auto .75rem auto;
}
/* Кнопки */
.ios-actions{ display: grid; gap: .6rem; }
.ios-btn{ min-height: 48px; border-radius: 12px; font-weight: 700; }
.ios-btn-danger{
background: var(--bs-danger); border-color: var(--bs-danger); color: #fff;
}
.ios-btn-danger:hover{ filter: brightness(1.02); }
.ios-btn-ghost{
background: color-mix(in srgb, var(--bs-body-bg), #fff 6%);
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
color: var(--bs-body-color);
}
.ios-btn-ghost:hover{ background: color-mix(in srgb, var(--bs-body-bg), #fff 9%); }
/* Анимации */
@keyframes slideUp{
from{ transform: translateY(16px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes popIn{
from{ transform: scale(.98); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
</style>
{% endblock %}
{% block content %}
{# Перекрываем основной контент полноэкранным диалогом #}
<div class="ios-overlay" role="dialog" aria-modal="true" aria-labelledby="logoutTitle" aria-describedby="logoutText">
<div class="ios-sheet">
<div class="ios-grip" aria-hidden="true"></div>
<div class="ios-body">
{# аватар если есть, иначе красивая иконка выхода #}
{% if user.avatar %}
<div class="ios-ava">
<img src="{{ user.avatar.url }}" alt="">
</div>
{% else %}
<div class="ios-ico">
<i class="bi-box-arrow-right fs-3"></i>
</div>
{% endif %}
<h1 id="logoutTitle" class="ios-title">Выйти из аккаунта?</h1>
<p id="logoutText" class="ios-sub">
{{ user.get_short_name|default:"Вы" }}, вы уверены, что хотите завершить сеанс?<br>
Вы всегда можете войти снова — данные не удаляются.
</p>
<form method="post" class="ios-actions" autocomplete="off">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next|default:'' }}">
<button type="submit" class="btn ios-btn ios-btn-danger" id="confirmBtn">
Выйти
</button>
<a href="javascript:void(0)" class="btn ios-btn ios-btn-ghost text-decoration-none text-center" id="cancelBtn">
Отмена
</a>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// UX: ESC = отмена, Enter на «Выйти», тап по фону = отмена
(function(){
const cancel = document.getElementById('cancelBtn');
const confirm = document.getElementById('confirmBtn');
const overlay = document.querySelector('.ios-overlay');
function goBack(){
// если есть referer/next — туда, иначе на главную
const params = new URLSearchParams(location.search);
const next = (params.get('next') || '').trim();
if (next) { location.href = next; return; }
if (document.referrer) { history.back(); return; }
location.href = "{% url 'auth_front:login' %}";
}
cancel?.addEventListener('click', goBack);
// клик по фону закрывает (но не по самой карточке)
overlay?.addEventListener('click', (e)=>{
if (e.target === overlay) goBack();
});
addEventListener('keydown', (e)=>{
if (e.key === 'Escape'){ e.preventDefault(); goBack(); }
if (e.key === 'Enter' && document.activeElement !== cancel){
// даём нативной форме отправиться
}
});
// автофокус на «Выйти»
setTimeout(()=> confirm?.focus(), 10);
})();
</script>
{% endblock %}