### Что сделано
**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**, корректно выстроена модель прав (глава/роль/глобальные), а сценарии смены/снятия главы полностью покрыты тестами.
172 lines
5.9 KiB
HTML
172 lines
5.9 KiB
HTML
{# 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 %}
|