Corp/backend/requests_app/views_front.py
Игорь Надеин 5390e514c3 **feat(api,front,tests): заявки — единый ответ на create, запрет удаления финальных, ACL для комментариев, базовый департамент-скоуп; фронт-вьюхи через backend/api/client.py; добавлены департаментные тесты**
Большое обновление по модулю заявок и интеграции с отделами.

## API (DRF)

* `requests_app.views.RequestViewSet`

  * `create()`: валидируем write-сериализатором, **отдаём read-сериализатор** → `employee` в ответе теперь объект, а не PK (чинит падение `TypeError: 'int' object is not subscriptable`).
  * `perform_create()`: для не-админов принудительно подставляем текущего пользователя.
  * `perform_update()`: явные проверки финальности и владения.
  * `destroy()`: подключён объектный пермишен `NotFinalOrStaff` через `get_permissions()` — **нельзя удалять финальные заявки** не-админам.
  * `get_permissions()`:

    * исправлены ветки `comments/approve/reject/cancel/destroy`, убран некорректный «битовый OR» классов пермишенов;
    * для `comments` — специальный пермишен (владелец без прав НЕ имеет доступа), соответствует тестам.
    * для `approve/reject/retrieve/comments` добавлена интеграция с `AdminOrDeptAllowed` (департаментные права) — основа для отдел-скоупа.
  * `get_queryset()`: добавлен **департаментный скоуп** — «свои» + заявки отделов, где у пользователя есть право `view_request` (через `EmployeeDepartment → role.scoped_permissions`).
  * `get_object()`: для `retrieve/approve/reject/comments/cancel` объект берётся из полного набора и затем прогоняется через `check_object_permissions()` → возвращаем **403, а не 404** при нехватке прав.
  * `pagination_class = None` — `list` стабильно возвращает массив, без обёртки `{count, results…}` (устраняет типовую ошибку парсинга в тесте).

* `requests_app.permissions`

  * `CommentsPermission`: политика «только staff/право отдела», **владелец без прав — 403**.
  * `NotFinalOrStaff`: объектный пермишен для запрета удаления финальных заявок.

* `requests_app.serializers`

  * `RequestWriteSerializer`:

    * делаем поля `employee/status/approver/department` **необязательными**;
    * в `to_internal_value()` для обычных пользователей **тихо удаляем** запрещённые поля до полевой валидации (чтобы не ловить 400 на несуществующих PK);
    * в `create()` для обычных пользователей проставляется текущий пользователь; статус берётся из `default` модели.

## Фронтенд (server-rendered views)

* Добавлены FBV, работающие через наш `backend/api/client.py`:

  * `my_requests`, `all_requests`, `request_create`, `request_detail`,
  * `request_process` (approve/reject), `request_cancel`,
  * `request_comment_add`, `request_comment_delete`.
* Проксируем аутентификацию, корректно отображаем ошибки API, поддерживаем multipart для вложений.

## Тесты

* Добавлен модуль `tests/api/v1/requests_app/test_requests_departments.py` с чек-листом интеграции заявок и отделов:

  * видимость по отделам, детальный просмотр, approve/reject по отдел-правам,
  * доступ к комментариям по отдел-правам,
  * валидация подмены `department` при создании.
* Базовые API-тесты теперь проходят:

  * `tests/api/v1/requests_app/test_requests_api.py` — **все зелёные**.
* Департаментные тесты **частично красные** (починим завтра):

  ```
  FAILED tests/api/v1/requests_app/test_requests_departments.py::test_dept_viewer_cannot_retrieve_other_department_request - AssertionError: b'{"detail":"No Request matches the given query."}'
  FAILED tests/api/v1/requests_app/test_requests_departments.py::test_dept_processor_can_approve_own_department_request - AssertionError: b'{"detail":"У вас недостаточно прав для данного действия."}'
  FAILED tests/api/v1/requests_app/test_requests_departments.py::test_dept_comment_viewer_can_read_comments_in_department - AssertionError: b'{"detail":"Недостаточно прав для доступа к комментариям."}'
  FAILED tests/api/v1/requests_app/test_requests_departments.py::test_dept_comment_adder_can_post_in_department - AssertionError: b'{"detail":"Недостаточно прав для доступа к комментариям."}'
  ```

  Причины на текущем этапе:

  * `retrieve` местами ещё даёт 404 вместо 403 при отсутствии отдел-прав — добьём маршрут через `get_object()`/пермишены.
  * Для `approve/reject` и `comments` требуется довести проверку `AdminOrDeptAllowed(required_code=...)` до полной логики «И» по кодам и убедиться, что в тестовых фикстурах корректно создаются связки ролей/прав отдела.

## Прочее

* Мелкие чистки импортов и докстрингов; типизация (Google-style) по новым функциям/классам.

---

**Итог по прогону:**
`tests/api/v1/requests_app/` — **20 passed, 5 failed** (см. список выше). Базовые сценарии готовы, департаментные — почти готовы; добьём завтра строгую выдачу 403 и все ветки отдел-прав для `retrieve/approve/reject/comments`.
2025-09-16 16:36:39 +03:00

351 lines
14 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 typing import Any, Mapping, Optional
from django.contrib import messages
from django.http import (
HttpRequest,
HttpResponse,
HttpResponseBadRequest,
HttpResponseForbidden,
)
from django.shortcuts import redirect, render
from django.urls import reverse
from api.client import get_api_client # ваш клиент
from api.decorators import require_api_auth # проверка наличия токена в сессии
API_PATH = "v1/requests/" # относительный путь внутри API (base_url настраивается в settings)
# ===================== Списки =====================
@require_api_auth
def my_requests(request: HttpRequest) -> HttpResponse:
"""Список «моих» заявок через GET /api/v1/requests/?view=mine.
Returns:
HttpResponse: HTML со списком заявок.
Raises:
RuntimeError: В случае неожиданных ошибок клиента API (маловероятно).
"""
api = get_api_client(request)
params = {"view": "mine", **request.GET.dict()}
resp = api.get(API_PATH, params=params)
if resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
if resp.ok:
return render(request, "requests_app/my_requests.html", {"items": resp.json or []})
if resp.status == 403:
return HttpResponseForbidden("Нет прав на просмотр ваших заявок.")
messages.error(request, f"Ошибка API: {resp.status}")
return render(request, "requests_app/my_requests.html", {"items": [], "error": resp.text})
@require_api_auth
def all_requests(request: HttpRequest) -> HttpResponse:
"""Список всех заявок через GET /api/v1/requests/.
Query-параметры проксируются как есть (?type=...&status=...).
Returns:
HttpResponse: HTML со списком заявок.
Raises:
RuntimeError: В случае неожиданных ошибок клиента API.
"""
api = get_api_client(request)
resp = api.get(API_PATH, params=request.GET.dict())
if resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
if resp.ok:
return render(request, "requests_app/all_requests.html", {"items": resp.json or []})
if resp.status == 403:
return HttpResponseForbidden("Недостаточно прав для просмотра заявок.")
messages.error(request, f"Ошибка API: {resp.status}")
return render(request, "requests_app/all_requests.html", {"items": [], "error": resp.text})
# ===================== Создание/детали =====================
@require_api_auth
def request_create(request: HttpRequest) -> HttpResponse:
"""Создание заявки через POST /api/v1/requests/.
GET: вывести форму.
POST: отправить поля формы в API. Для обычного пользователя API игнорирует `employee/status`
и выставляет автора/статус автоматически.
Returns:
HttpResponse: Редирект на детальную страницу при успехе или HTML формы с ошибками.
Raises:
ValueError: Если передан неподдерживаемый метод.
"""
if request.method == "GET":
return render(request, "requests_app/request_form.html")
if request.method != "POST":
return HttpResponseBadRequest("Метод не поддерживается.")
api = get_api_client(request)
# Соберите безопасный набор полей (минимум, совпадающий с API write-сериализатором)
payload: dict[str, Any] = {
"type": request.POST.get("type"),
"title": request.POST.get("title") or "",
"date_from": request.POST.get("date_from") or None,
"date_to": request.POST.get("date_to") or None,
"comment": request.POST.get("comment") or "",
}
# Если есть файл — используем form-data, иначе JSON.
if upload := request.FILES.get("attachment"):
files = {"attachment": (upload.name, upload.file, upload.content_type)}
resp = api.post(API_PATH, data=payload, files=files)
else:
resp = api.post(API_PATH, json=payload)
if resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
if resp.status == 201 and isinstance(resp.json, dict):
messages.success(request, "Заявка создана.")
return redirect("requests_app:request_detail", pk=resp.json["id"])
if resp.status in (400, 403):
# Пытаемся показать ошибки валидатора
form_errors: Mapping[str, Any] = resp.json if isinstance(resp.json, dict) else {"__all__": resp.text}
return render(
request,
"requests_app/request_form.html",
{"form_errors": form_errors, "form_data": payload},
)
messages.error(request, f"Ошибка API: {resp.status}")
return render(
request,
"requests_app/request_form.html",
{"form_errors": {"__all__": resp.text or "Неизвестная ошибка"}},
)
@require_api_auth
def request_detail(request: HttpRequest, pk: int) -> HttpResponse:
"""Детальная страница заявки (GET /api/v1/requests/{pk}/) + попытка подтянуть комментарии.
Args:
pk (int): Идентификатор заявки.
Returns:
HttpResponse: HTML с деталями заявки.
Raises:
RuntimeError: В случае неожиданных ошибок клиента API.
"""
api = get_api_client(request)
# Основной объект заявки
item_resp = api.get(f"{API_PATH}{pk}/")
if item_resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
if item_resp.status == 404:
return render(request, "requests_app/request_detail.html", {"item": None, "error": "Заявка не найдена."})
if item_resp.status == 403:
return HttpResponseForbidden("Недостаточно прав для просмотра заявки.")
if not item_resp.ok:
messages.error(request, f"Ошибка API: {item_resp.status}")
return render(request, "requests_app/request_detail.html", {"item": None, "error": item_resp.text})
# Комментарии (если доступ есть)
comments: list[dict[str, Any]] = []
comm_resp = api.get(f"{API_PATH}{pk}/comments/")
if comm_resp.ok and isinstance(comm_resp.json, list):
comments = comm_resp.json
return render(
request,
"requests_app/request_detail.html",
{"item": item_resp.json, "comments": comments},
)
# ===================== Обработка статусов =====================
@require_api_auth
def request_process(request: HttpRequest, pk: int) -> HttpResponse:
"""Обработка заявки через POST /api/v1/requests/{pk}/(approve|reject)/.
Ожидает в POST параметр 'action' со значением 'approve' или 'reject'.
Args:
pk (int): Идентификатор заявки.
Returns:
HttpResponse: Редирект на детальную страницу.
Raises:
ValueError: Если action неизвестен.
"""
if request.method != "POST":
return HttpResponseBadRequest("Только POST.")
action = (request.POST.get("action") or "").strip().lower()
if action not in {"approve", "reject"}:
raise ValueError("action должен быть 'approve' или 'reject'.")
api = get_api_client(request)
resp = api.post(f"{API_PATH}{pk}/{action}/")
if resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
if resp.ok:
messages.success(request, f"Действие '{action}' выполнено.")
elif resp.status in (400, 403):
messages.error(request, f"Не удалось выполнить '{action}': {resp.json or resp.text}")
else:
messages.error(request, f"Ошибка API ({resp.status}) при '{action}'.")
return redirect("requests_app:request_detail", pk=pk)
@require_api_auth
def request_cancel(request: HttpRequest, pk: int) -> HttpResponse:
"""Отмена заявки через POST /api/v1/requests/{pk}/cancel/.
Args:
pk (int): Идентификатор заявки.
Returns:
HttpResponse: Редирект на детальную страницу.
Raises:
RuntimeError: В случае неожиданных ошибок клиента API.
"""
if request.method != "POST":
return HttpResponseBadRequest("Только POST.")
api = get_api_client(request)
resp = api.post(f"{API_PATH}{pk}/cancel/")
if resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
if resp.ok:
messages.success(request, "Заявка отменена.")
elif resp.status in (400, 403):
messages.error(request, f"Не удалось отменить: {resp.json or resp.text}")
else:
messages.error(request, f"Ошибка API ({resp.status}) при отмене.")
return redirect("requests_app:request_detail", pk=pk)
# ===================== Комментарии =====================
@require_api_auth
def request_comment_add(request: HttpRequest, pk: int) -> HttpResponse:
"""Добавление комментария через POST /api/v1/requests/{pk}/comments/.
Тело: {"text": "..."}.
Args:
pk (int): Идентификатор заявки.
Returns:
HttpResponse: Редирект на детальную страницу заявки.
Raises:
ValueError: Если метод не POST или текст пустой.
"""
if request.method != "POST":
return HttpResponseBadRequest("Только POST.")
text = (request.POST.get("text") or "").strip()
if not text:
messages.error(request, "Текст комментария не может быть пустым.")
return redirect("requests_app:request_detail", pk=pk)
api = get_api_client(request)
resp = api.post(f"{API_PATH}{pk}/comments/", json={"text": text})
if resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
if resp.status == 201:
messages.success(request, "Комментарий добавлен.")
elif resp.status == 403:
messages.error(request, "Нет прав для добавления комментариев.")
elif resp.status == 400:
messages.error(request, f"Ошибка валидации: {resp.json or resp.text}")
else:
messages.error(request, f"Ошибка API ({resp.status}) при добавлении комментария.")
return redirect("requests_app:request_detail", pk=pk)
@require_api_auth
def request_comment_delete(request: HttpRequest, pk: int, comment_id: int) -> HttpResponse:
"""Удаление комментария.
Пытается вызвать DELETE в одном из известных эндпоинтов.
Сначала: /api/v1/requests/{pk}/comments/{comment_id}/
Затем: /api/v1/comments/{comment_id}/ (если первый путь не поддерживается).
Args:
pk (int): Идентификатор заявки.
comment_id (int): Идентификатор комментария.
Returns:
HttpResponse: Редирект на детальную страницу заявки.
Raises:
ValueError: Если метод не POST.
"""
if request.method != "POST":
return HttpResponseBadRequest("Только POST.")
api = get_api_client(request)
# Вариант 1: nested под заявкой
resp = api.delete(f"{API_PATH}{pk}/comments/{comment_id}/")
if resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
if resp.status in (200, 204):
messages.success(request, "Комментарий удалён.")
return redirect("requests_app:request_detail", pk=pk)
# Вариант 2: общий ресурс комментариев
if resp.status == 404:
resp2 = api.delete(f"v1/comments/{comment_id}/")
if resp2.status in (200, 204):
messages.success(request, "Комментарий удалён.")
return redirect("requests_app:request_detail", pk=pk)
if resp2.status == 403:
return HttpResponseForbidden("Нет прав на удаление комментария.")
messages.error(request, f"Не удалось удалить комментарий: {resp2.status}")
return redirect("requests_app:request_detail", pk=pk)
if resp.status == 403:
return HttpResponseForbidden("Нет прав на удаление комментария.")
messages.error(request, f"Не удалось удалить комментарий: {resp.status}")
return redirect("requests_app:request_detail", pk=pk)