Большое обновление по модулю заявок и интеграции с отделами.
## 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`.
351 lines
14 KiB
Python
351 lines
14 KiB
Python
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)
|