Corp/backend/search/views.py
Игорь Надеин 9529b3f268 **feat(feed, perms, tests): новая политика доступа для фида + полный набор автотестов**
Ключевые изменения

* Перевели доступ к объектам отдела (посты) на **ролевую модель внутри отдела**:

  * Используются `DepartmentRole.scoped_permissions` (M2M к `DepartmentPermission`).
  * Проверка прав идёт по **коду** из `DeptPerm` (`CREATE_POST`/`MANAGE_FEED`), а не по `auth.Permission`.
* **Company-посты** теперь контролируются **модельными правами** (`add_post`, `change_post`, `delete_post`) или статусом staff/superuser.
* Убраны остатки старой логики `publish_company_post` / `publish_department_post` (как глобальные права).

Пермишены / утилиты

* `user_has_dept_perm(user, dept, code)`:

  * true для staff/superuser и для руководителя отдела;
  * иначе проверка активной связи `EmployeeDepartment(is_active=True)` и наличия `role__scoped_permissions__code=...`.
  * ВАЖНО: используем поле **`code`**, не `codename`.
* `has_dept_perm(user, department_id, code)`:

  * те же правила, оптимизирован поиск (select\_related для роли).
* `AdminOrDeptAllowed`:

  * Корректно извлекает `department_id` из `kwargs`/`data`/`query_params` и из объекта (`obj.department_id/department.id`).
  * Если код не задан — SAFE-методы разрешены (чтение), небезопасные запрещены.
* `AdminOrActionOrModelPerms`:

  * staff/superuser — полный доступ;
  * поддержка явного `required_perm_code`/`required_perms_by_action`;
  * SAFE-методы требуют `view_<model>`, небезопасные — стандартная карта DjangoModelPermissions.
* `IsSelfOrStaff`/`IsSelfOrStaffOrModelPerms`:

  * Владелец объекта (по `author_id`/`author` и др.) или staff/superuser может изменять/удалять;
  * SAFE-методы — для авторизованных.

API: PostViewSet

* Обновлён `get_permissions()`:

  * `list/retrieve/create/like/unlike` → `IsAuthenticated`;
  * `update/partial_update/destroy` → `AdminOrDeptAllowed` с `required_code_map`:

    * `update/partial_update/destroy` → `DeptPerm.MANAGE_FEED`.
* Бизнес-проверки:

  * `perform_create`:

    * `type=department` → разрешено staff/superuser, head отдела, либо роль с `DeptPerm.CREATE_POST`;
    * `type=company` → нужны модельные права `add_post` (или staff).
    * `author` проставляется на `request.user`.
  * `perform_update/perform_destroy`:

    * dept-посты: staff/superuser, head, либо роль с `DeptPerm.MANAGE_FEED`;
    * company-посты: модельные права `change_post`/`delete_post` (или staff).
* Производительность:

  * Аннотация `is_liked` (через `Exists`).
  * Аннотация `last_comment_id` + батч-подтягивание последнего комментария в `list()`.
* Лайки:

  * `like/unlike` — идемпотентные; счётчик корректируется атомарно.

API: CommentViewSet

* `permission_classes = [IsAuthenticated, (IsSelfOrStaff | AdminOrActionOrModelPerms)]`.
* `perform_create` всегда ставит `author=request.user`.
* Фильтры `?post=`/`?author=` и сортировка `created_at ASC`.
* Обновлён `IsSelfOrStaff` для чтения настроек владельца с вью (например, `owner_id_attrs=("author_id",)`).

Тесты

* Добавлен/переписан комплект для новой модели доступа (фид):

  * **Аноним**: все эндпоинты → 401.
  * **Обычный пользователь**:

    * `GET posts/comments` → 200;
    * `POST comment`, `like/unlike` → 201/200;
    * `POST/PUT/PATCH/DELETE dept-post` → 403 (без роли/прав).
  * **Роль отдела**:

    * С `DeptPerm.CREATE_POST` — может создать пост **в своём отделе**; **в чужом** → 403.
    * С `DeptPerm.MANAGE_FEED` — может редактировать/удалять **в своём отделе**.
  * **Head отдела** — полный доступ к постам своего отдела.
  * **Staff/superuser** — полный доступ; `pin/unpin` только staff/superuser.
  * **Глобальные модельные права на company**:

    * `add_post`/`change_post`/`delete_post` → CRUD **только** для company-постов; dept-посты по-прежнему 403.
  * **Комментарии**:

    * Создание — любой аутентифицированный;
    * Изменение/удаление — автор или staff/superuser.
  * **Лайки**:

    * Идемпотентность `like/unlike`, корректная синхронизация `is_liked`.
  * **Валидация**:

    * `type=department` требует `department`;
    * роль в одном отделе не даёт создавать в другом → 403.
* Тестовые хелперы:

  * `_ensure_permission(model, codename, name)` — создаёт/возвращает `auth.Permission` для company-кейсов;
  * `_ensure_dept_permission(code)` и `_give_dept_role_with_perms(...)` — для департаментных прав (через `DepartmentPermission`/`DepartmentRole.scoped_permissions`).

Fixed / багфиксы

* `FieldError: Unsupported lookup 'codename'…` — заменён на фильтр по `scoped_permissions__code`.
* `IntegrityError: NOT NULL constraint failed: feed_post.author_id` — в `perform_create` постов/комментариев author задаётся принудительно.
* Несоответствие тестам по правам чтения/изменения комментариев — исправлено через корректный пермишен владельца.

Файлы (основные)

* `api/v1/permissions.py`: `AdminOrDeptAllowed`, `AdminOrActionOrModelPerms`, `IsSelfOrStaff`(+вариант `IsSelfOrStaffOrModelPerms`), `user_has_dept_perm`, `has_dept_perm`.
* `api/v1/feed/views.py`: `PostViewSet`, `CommentViewSet` (правки прав, `perform_*`, аннотации, like/unlike).
* `tests/api/v1/feed/test_posts.py`, `tests/api/v1/feed/test_comments.py` (и/или `test_feed_api.py`): полный набор сценариев под новую политику.
* Хелперы тестов: функции создания отделов, прав и ролей, генераторы payload.

Итог

* Политика доступа фида приведена к спецификации: **dept-права — через роли внутри отдела**, **company — через модельные права**.
* Исправлены ошибки в проверках прав и авторстве, покрыто автотестами (CRUD, лайки, комменты, валидация, пермишены).
* Производительность чтения ленты улучшена (аннотации `is_liked`, `last_comment_id` без N+1).
2025-09-11 17:56:33 +03:00

484 lines
16 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.

# backend/search/views.py
from __future__ import annotations
from typing import Any, Dict, List, Literal, TypedDict, Optional, Tuple
from django.contrib.auth.decorators import login_required
from django.core.exceptions import FieldError
from django.db.models import Q, QuerySet
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.utils.html import escape
from feed.models import Post
from employees.models import Employee, Department
from requests_app.models import Request
class SearchItem(TypedDict):
"""Элемент единой выдачи поиска.
Attributes:
model_name (Literal["post","employee","department","request","chat","message","event"]):
Тип найденной сущности.
object (Any): Экземпляр модели, который будет отрисован в шаблоне.
"""
model_name: Literal[
"post", "employee", "department", "request", "chat", "message", "event"
]
object: Any
# ------------------------ ВСПОМОГАТЕЛЬНЫЕ УТИЛИТЫ ------------------------
def _is_hr(user: Employee) -> bool:
"""Проверяет расширенные права на просмотр заявлений.
Args:
user (Employee): Текущий пользователь.
Returns:
bool: True, если пользователь может видеть все заявления.
Notes:
Используются пермишены из requests_app.
"""
return user.has_perm("requests_app.can_view_all_requests") or user.has_perm(
"requests_app.can_process_requests"
)
def _try_extend_q(model_cls: type, base_q: Q, lookups: List[Tuple[str, Any]]) -> Q:
"""Пытается добавить условия в Q, аккуратно игнорируя несуществующие поля.
Для каждого пути из lookups пытаемся выполнить пустой фильтр, чтобы проверить валидность.
Неудачные пути (FieldError) игнорируем.
Args:
model_cls (type): Класс модели, к которой будет применён Q.
base_q (Q): Исходное условие.
lookups (List[Tuple[str, Any]]): Список пар (путь_к_полю, значение).
Returns:
Q: Итоговое условие с добавленными (валидными) частями.
"""
q = Q(base_q)
from django.db.models import QuerySet as _QS # локальный импорт для mypy
qs: _QS = model_cls.objects.all()
for path, value in lookups:
try:
# проверяем валидность пути: попытаемся составить запрос
_ = qs.filter(**{path: value}).values("pk")[:1]
q |= Q(**{path: value})
except FieldError:
continue
return q
def _order_by_first_valid(qs: QuerySet, fields: List[str]) -> QuerySet:
"""Сортирует QS по первому валидному полю, иначе возвращает как есть.
Args:
qs (QuerySet): Исходный QS.
fields (List[str]): Список полей приоритета сортировки.
Returns:
QuerySet: Отсортированный QS или оригинал.
"""
for f in fields:
try:
test = qs.order_by(f)
# прогрев запроса для раннего исключения FieldError
_ = test.values("pk")[:0]
return test
except FieldError:
continue
return qs
# ----------------------------- ОСНОВНЫЕ ПОИСКИ -----------------------------
def _search_posts(query: str) -> QuerySet[Post]:
"""Поиск по постам (title/body).
Args:
query (str): Строка запроса.
Returns:
QuerySet[Post]: Отсортированный QS.
"""
if not query:
return Post.objects.none()
return (
Post.objects.filter(Q(title__icontains=query) | Q(body__icontains=query))
.only("id", "title", "body", "created_at")
.order_by("-created_at")
)
def _search_employees(query: str) -> QuerySet[Employee]:
"""Поиск по сотрудникам (ФИО/контакты + принадлежность к отделам + навыки).
Ищем:
- ФИО, email, телефон
- Принадлежность к отделам (по названию отдела) через EmployeeDepartment
- Навыки (по названию навыка)
Args:
query (str): Строка запроса.
Returns:
QuerySet[Employee]: Отсортированный QS без дублей.
Raises:
ValueError: Если query пустая строка после trim (обрабатывается возвратом .none()).
"""
if not query:
return Employee.objects.none()
fio_contacts = (
Q(last_name__icontains=query)
| Q(first_name__icontains=query)
| Q(patronymic__icontains=query)
| Q(email__icontains=query)
| Q(phone_number__icontains=query)
)
# 🔹 ВАЖНО: у вас связь через EmployeeDepartment → departments_links
dept_lookups = [
("departments_links__department__name__icontains", query),
]
# Навыки — прямая M2M
skill_lookups = [
("skills__name__icontains", query),
]
q = _try_extend_q(Employee, fio_contacts, dept_lookups)
q = _try_extend_q(Employee, q, skill_lookups)
return (
Employee.objects.filter(q)
.distinct()
.select_related("position")
.only(
"id",
"last_name",
"first_name",
"patronymic",
"email",
"phone_number",
"position__name",
)
.order_by("last_name", "first_name")
)
def _search_departments(query: str) -> QuerySet[Department]:
"""Поиск по отделам (название/описание/руководитель + сотрудники отдела).
Ищем:
- name / description
- head (ФИО)
- сотрудники отдела (ФИО/e-mail/телефон) — через Department.employeedepartment → employee
Args:
query (str): Строка запроса.
Returns:
QuerySet[Department]: Отсортированный QS без дублей.
"""
if not query:
return Department.objects.none()
base = Q(name__icontains=query) | Q(description__icontains=query)
base |= (
Q(head__last_name__icontains=query)
| Q(head__first_name__icontains=query)
| Q(head__patronymic__icontains=query)
)
# 🔹 ВАЖНО: правильный reverse related_name — "employeedepartment"
member_fields = [
"last_name__icontains",
"first_name__icontains",
"patronymic__icontains",
"email__icontains",
"phone_number__icontains",
]
member_lookups = [
(f"employeedepartment__employee__{f}", query) for f in member_fields
]
q = _try_extend_q(Department, base, member_lookups)
return (
Department.objects.filter(q)
.distinct()
.select_related("head")
.only("id", "name", "description", "head")
.order_by("name")
)
def _search_events(query: str) -> QuerySet[Any]:
"""Поиск по календарным событиям (ваша модель: calendar_app.CalendarEvent).
Args:
query (str): Строка запроса.
Returns:
QuerySet[Any]: QS CalendarEvent или пустой QS при отсутствии приложения.
"""
try:
from calendar_app.models import CalendarEvent as EventModel # type: ignore
except Exception:
return Post.objects.none()
if not query:
return EventModel.objects.none()
q = Q(title__icontains=query) | Q(description__icontains=query)
q |= Q(department__name__icontains=query)
# других связей (owner/participants) в вашей модели нет
qs = EventModel.objects.filter(q).distinct()
# Поля дат у вас: start_date/start_time (+ end_*). Сортируем по началу.
qs = qs.order_by("-start_date", "-start_time", "-id")
try:
qs = qs.select_related("department")
except Exception:
pass
return qs
def _search_requests(query: str, user: Employee, is_hr: bool) -> QuerySet[Request]:
"""Поиск по заявлениям с учётом прав.
Args:
query (str): Строка запроса.
user (Employee): Текущий пользователь.
is_hr (bool): Признак расширенных прав.
Returns:
QuerySet[Request]: Отсортированный QS.
Raises:
ValueError: Если user не передан.
"""
if not user:
raise ValueError("user is required")
base = (
Request.objects.filter(
Q(title__icontains=query)
| Q(employee__last_name__icontains=query)
| Q(employee__first_name__icontains=query)
| Q(employee__patronymic__icontains=query)
| Q(department__name__icontains=query)
)
if query
else Request.objects.none()
)
if not is_hr:
base = base.filter(employee=user)
# Если используем select_related по FK — перечисляем их в only(...)
return (
base.select_related("employee", "department", "approver")
.only(
"id",
"title",
"status",
"employee",
"department",
"approver",
"created_at",
)
.order_by("-created_at")
)
def _search_chats(query: str) -> QuerySet[Any]:
"""Поиск по чатам (название/участники/отдел).
Args:
query (str): Строка запроса.
Returns:
QuerySet[Any]: QS Chat или пустой QS, если приложение чатов отсутствует.
Notes:
Безопасно обрабатывает разные схемы (name/department/participants).
"""
try:
from communications.models import Chat # type: ignore
except Exception:
return Post.objects.none() # тип «пустой» QS (любой модели)
if not query:
return Chat.objects.none()
q = Q()
# Попробуем разные пути без падения
q = _try_extend_q(
Chat,
q,
[
("name__icontains", query),
("title__icontains", query),
("department__name__icontains", query),
("participants__last_name__icontains", query),
("participants__first_name__icontains", query),
("participants__patronymic__icontains", query),
("participants__email__icontains", query),
],
)
qs = Chat.objects.filter(q).distinct()
qs = _order_by_first_valid(qs, ["-updated_at", "-last_message_at", "-id"])
# мягкая оптимизация
try:
qs = qs.select_related("department")
except FieldError:
pass
try:
qs = qs.prefetch_related("participants")
except FieldError:
pass
return qs
def _search_messages(query: str) -> QuerySet[Any]:
"""Поиск по сообщениям чатов (контент/автор/чат).
Args:
query (str): Строка запроса.
Returns:
QuerySet[Any]: QS Message или пустой QS, если приложение чатов отсутствует.
"""
try:
from communications.models import Message # type: ignore
except Exception:
return Post.objects.none()
if not query:
return Message.objects.none()
q = Q(content__icontains=query)
q = _try_extend_q(
Message,
q,
[
("author__last_name__icontains", query),
("author__first_name__icontains", query),
("author__patronymic__icontains", query),
("chat__name__icontains", query),
("chat__title__icontains", query),
("chat__department__name__icontains", query),
],
)
qs = Message.objects.filter(q).distinct()
qs = _order_by_first_valid(qs, ["-created_at", "-id"])
try:
qs = qs.select_related("author", "chat")
except FieldError:
pass
return qs
# -------------------------------- ВЬЮХА --------------------------------
@login_required
def search_view(request: HttpRequest) -> HttpResponse:
"""Единый поиск по системе (посты, сотрудники, отделы, заявления, чаты, сообщения, события).
Args:
request (HttpRequest): Запрос (`GET["q"]`).
Returns:
HttpResponse: Рендер `templates/search/results.html` с плоским списком.
Контекст шаблона:
- query: str — исходная строка запроса
- results: List[SearchItem] — плоский список элементов
- counts: Dict[str, int] — счётчики по типам:
keys: post, employee, department, request, chat, message, event
- total: int — сумма всех счётчиков
"""
raw_q = request.GET.get("q", "") or ""
query = escape(raw_q.strip())
items: List[SearchItem] = []
counts: Dict[str, int] = {
"post": 0,
"employee": 0,
"department": 0,
"request": 0,
"chat": 0,
"message": 0,
"event": 0,
}
if query:
# --- Посты
post_qs = _search_posts(query)
counts["post"] = post_qs.count()
items.extend({"model_name": "post", "object": p} for p in post_qs[:10])
# --- Сотрудники (ФИО/контакты + отделы + навыки)
emp_qs = _search_employees(query)
counts["employee"] = emp_qs.count()
items.extend({"model_name": "employee", "object": e} for e in emp_qs[:10])
# --- Отделы (в т.ч. по сотрудникам)
dept_qs = _search_departments(query)
counts["department"] = dept_qs.count()
items.extend({"model_name": "department", "object": d} for d in dept_qs[:10])
# --- Заявления (учёт прав)
is_hr = _is_hr(request.user) # type: ignore[arg-type]
req_qs = _search_requests(query, request.user, is_hr) # type: ignore[arg-type]
counts["request"] = req_qs.count()
items.extend({"model_name": "request", "object": r} for r in req_qs[:10])
# --- Чаты
chat_qs = _search_chats(query)
# Если приложение отсутствует — chat_qs будет пустым QS другой модели, count()=0
try:
counts["chat"] = chat_qs.count()
except Exception:
counts["chat"] = 0
items.extend({"model_name": "chat", "object": c} for c in chat_qs[:10])
# --- Сообщения
msg_qs = _search_messages(query)
try:
counts["message"] = msg_qs.count()
except Exception:
counts["message"] = 0
items.extend({"model_name": "message", "object": m} for m in msg_qs[:10])
# --- Календарные события
evt_qs = _search_events(query)
try:
counts["event"] = evt_qs.count()
except Exception:
counts["event"] = 0
items.extend({"model_name": "event", "object": ev} for ev in evt_qs[:10])
ctx = {
"query": query,
"results": items,
"counts": counts,
"total": sum(counts.values()),
}
return render(request, "search/results.html", ctx)