Corp/backend/employees/views_auth.py
Игорь Надеин 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
8.6 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.

from __future__ import annotations
from api.client import ApiClient, get_api_client
from django.contrib import messages
from django.contrib.auth import logout
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView as DjangoLoginView
from django.contrib.auth.views import \
PasswordResetCompleteView as DjangoPasswordResetCompleteView
from django.contrib.auth.views import \
PasswordResetConfirmView as DjangoPasswordResetConfirmView
from django.contrib.auth.views import \
PasswordResetDoneView as DjangoPasswordResetDoneView
from django.contrib.auth.views import \
PasswordResetView as DjangoPasswordResetView
from django.shortcuts import redirect, render
from django.urls import path, reverse
from django.utils.http import urlencode
from django.views import View
from django.views.decorators.http import require_http_methods
from .forms_auth import (EmailOrPhoneAuthenticationForm, RegistrationForm,
VerifyEmailForm)
def _apply_api_errors_to_form(form, data: dict):
"""Развешиваем ошибки из API по полям/неполям."""
if not isinstance(data, dict):
form.add_error(None, "Неизвестная ошибка сервера.")
return
if "ok" in data and not data.get("ok"):
form.add_error(
None, data.get("error") or data.get("detail") or "Ошибка запроса."
)
detail = data.get("detail")
if isinstance(detail, str):
form.add_error(None, detail)
elif isinstance(detail, (list, tuple)):
for msg in detail:
form.add_error(None, msg)
for field, errs in data.items():
if field in {"ok", "error", "detail"}:
continue
target = "phone_number" if field == "phone" else field
if target in form.fields:
if isinstance(errs, (list, tuple)):
for e in errs:
form.add_error(target, e)
else:
form.add_error(target, str(errs))
else:
if isinstance(errs, (list, tuple)):
for e in errs:
form.add_error(None, f"{field}: {e}")
else:
form.add_error(None, f"{field}: {errs}")
@require_http_methods(["GET", "POST"])
def register_view(request):
"""
Страница регистрации: показываем Django-форму и шлём валидные данные в /api/v1/auth/register/
(аватар пока локально, в API не отправляем — загрузим после верификации).
"""
api = get_api_client(request)
if request.method == "GET":
form = RegistrationForm()
return render(request, "auth/register.html", {"form": form})
# ВАЖНО: учитывать файлы (чтобы form.avatar был BoundField)
form = RegistrationForm(request.POST, request.FILES)
if not form.is_valid():
return render(request, "auth/register.html", {"form": form}, status=400)
payload = form.to_api_payload() # формирует json для API, включая password
resp = api.post("v1/auth/register/", json=payload)
if resp.status == 201:
messages.success(
request,
"Мы отправили код подтверждения на вашу почту. Введите его на следующем шаге.",
)
q = urlencode({"email": payload["email"]})
return redirect(f'{reverse("auth_front:verify_email")}?{q}')
data = resp.json or {}
if resp.status == 200 and data.get("ok") and data.get("pending_verification"):
messages.info(request, "Аккаунт уже создан, подтвердите email кодом из письма.")
q = urlencode({"email": payload["email"]})
return redirect(f'{reverse("auth_front:verify_email")}?{q}')
# Остальные ошибки — показать в форме
_apply_api_errors_to_form(form, data)
return render(request, "auth/register.html", {"form": form}, status=400)
@require_http_methods(["GET", "POST"])
def verify_email_view(request):
"""Ввод кода подтверждения. Использует /api/v1/auth/verify-email/."""
api = get_api_client(request)
if request.method == "GET":
form = VerifyEmailForm(initial={"email": request.GET.get("email", "")})
return render(request, "auth/verify_email.html", {"form": form})
form = VerifyEmailForm(request.POST)
if not form.is_valid():
return render(request, "auth/verify_email.html", {"form": form}, status=400)
payload = {"email": form.cleaned_data["email"], "code": form.cleaned_data["code"]}
r = api.post("v1/auth/verify-email/", json=payload)
if r.ok and (r.json or {}).get("ok"):
messages.success(request, "Email подтверждён! Теперь вы можете войти.")
return redirect("auth_front:login")
data = r.json or {}
err = data.get("error") or data.get("detail") or "Не удалось подтвердить email."
if err == "invalid_code":
err = "Неверный код подтверждения."
elif err == "user_not_found":
err = "Пользователь с таким email не найден."
form.add_error(None, err)
return render(request, "auth/verify_email.html", {"form": form}, status=400)
@require_http_methods(["POST"])
def resend_email_view(request):
"""Кнопка «Отправить код ещё раз» — /api/v1/auth/resend-email/."""
api = get_api_client(request)
email = (request.POST.get("email") or "").strip()
r = api.post("v1/auth/resend-email/", json={"email": email})
if r.ok and (r.json or {}).get("ok"):
messages.success(request, "Код подтверждения повторно отправлен на вашу почту.")
else:
data = r.json or {}
err = (
data.get("error")
or data.get("detail")
or "Не удалось отправить код повторно."
)
if err == "already_verified":
err = "Email уже подтверждён."
elif err == "user_not_found":
err = "Пользователь с таким email не найден."
messages.error(request, err)
q = urlencode({"email": email}) if email else ""
return redirect(f'{reverse("auth_front:verify_email")}{("?" + q) if q else ""}')
class EmailOrPhoneLoginView(DjangoLoginView):
"""
Страница входа: одно поле 'Email или телефон' + пароль.
После успешной Django-аутентификации — берём JWT у DRF и кладём в сессию.
"""
template_name = "auth/login.html"
authentication_form = EmailOrPhoneAuthenticationForm
def form_valid(self, form):
# 1) Django-логин
response = super().form_valid(form)
# 2) Получаем JWT от API и кладём его в сессию
user = self.request.user
password = form.cleaned_data.get("password")
client = ApiClient(self.request)
ok = client.login(user.email, password) # USERNAME_FIELD=email
if not ok:
messages.error(
self.request, "Не удалось получить токен API. Повторите вход позже."
)
return response
class ConfirmLogoutView(LoginRequiredMixin, View):
template_name = "auth/logout.html"
def get(self, request):
next_url = request.GET.get("next") or request.META.get("HTTP_REFERER") or ""
return render(request, self.template_name, {"next": next_url})
def post(self, request):
next_url = request.POST.get("next") or reverse("auth_front:login")
logout(request)
messages.success(request, "Вы вышли из аккаунта.")
return redirect(next_url)
# ===== Сброс пароля =====
class PasswordResetView(DjangoPasswordResetView):
template_name = "auth/password_reset_form.html"
email_template_name = "auth/password_reset_email.txt"
subject_template_name = "auth/password_reset_subject.txt"
success_url = "/auth/password-reset/done/"
class PasswordResetDoneView(DjangoPasswordResetDoneView):
template_name = "auth/password_reset_done.html"
class PasswordResetConfirmView(DjangoPasswordResetConfirmView):
template_name = "auth/password_reset_confirm.html"
success_url = "/auth/reset/done/"
class PasswordResetCompleteView(DjangoPasswordResetCompleteView):
template_name = "auth/password_reset_complete.html"