Игорь Надеин befbe9b6b0 feat(requests_api): CRUD и экшены заявок + корректная видимость списков; комментарии; фильтры
- Добавлен RequestViewSet:
  - list/retrieve — чтение с учётом ролей; для админов переключатель ?view=mine / ?mine=1|true|yes|on
  - create/update/destroy — логика «владелец до финала, иначе — права»
  - кастомные экшены: POST /{id}/approve|reject|cancel/
  - подресурс: GET/POST /{id}/comments/
  - фильтры: ?type=..., ?status=...
- Единый возврат read-сериализатора в экшенах (context=self.get_serializer_context()).
- Типизация: get_serializer_class -> type[BaseSerializer]; list[Any] -> list[...]; мелкая чистка импортов.

fix(serializers): корректная проверка прав и защита полей в write-сериализаторе

- RequestWriteSerializer.validate: вместо user.has_perms([...]) (AND) — явное OR:
  is_staff OR has_perm(change_request) OR has_perm(can_process_requests)
- Создание обычным пользователем принудительно проставляет employee=request.user.
- Обновление запрещено для финальных заявок без прав (ValidationError).

refactor(views): контекст в сериализаторы, нормализация query-параметров, мелкая чистка

- В экшенах approve/reject/cancel/comments используется RequestReadSerializer(..., context=...).
- Нормализация параметра ?mine (поддержка 1/true/yes/on).
- Удалён неиспользуемый get_object_or_404.

test(requests_api): стартовый набор автотестов по чек-листу + правки

- Добавлены тесты видимости: админ (all/mine), менеджер с модельными правами, обычный пользователь (только свои).
- CRUD, экшены approve/reject/cancel, и блок комментариев.
- Фикс неверного URL в фильтрах (пропущенный '?'): now /requests/?view=mine&...
- Комментарии: тесты приведены к реальной политике пермишена
  (см. ниже про AdminOrActionOrModelPerms).

test(fixtures): устойчивые фабрики пользователей и заявок

- grant_model_perm: удалён user.save(update_fields=[]), так как пустой update_fields валит Django 5.x; сохранение не нужно для M2M.
- make_user: уникальный email по умолчанию + send_activation_email=False, авто-телефон при наличии поля.
- _detect_phone_field: узкий catch исключений (AttributeError, FieldDoesNotExist).
- make_request: при финальном статусе автоматически проставляет approver (иначе падал CHECK request_approver_required_on_decision).

docs: докстринги (Google-style) и типизация по всему добавленному коду.

BREAKING/NOTE (perms):
- Во вью используется AdminOrActionOrModelPerms. Если для кастомного экшена нет явного кода,
  то:
  - SAFE методы требуют view_<model> на queryset-модель (для /requests — requests_app.view_request),
  - POST падает на DjangoModelPermissions и требует add_<model> (requests_app.add_request).
- Это влияет на доступ к /requests/{id}/comments/: без явного маппинга потребуется
  requests_app.view_request (GET) и requests_app.add_request (POST),
  даже если выдали права на RequestComment. Чтобы использовать именно права на комментарии,
  нужно задать required_perms_by_action={"comments": "requests_app.view_requestcomment"} (и/или add_requestcomment).

--------------------------------
Оставшиеся падения тестов и что с ними сделать
--------------------------------

1) ❌ `tests/api/v1/documents/test_documents_api.py::TestSerialization::test_file_url_downloadable_in_dev` → 404 (ожидали 200)
   - Не затрагивали в этом MR. Проверить:
     - настройки DEV для статики/медиа (MEDIA_URL/DEBUG/urls serve),
     - выдачу file.url сериализатором документов (не прячется ли в DEV),
     - router/namespace для скачивания файла.
   - TODO (отдельный MR): включить dev-раздачу или адаптировать тест под текущую схему.

2) ❌ Серия 403 по requests_api:
   - `test_list_regular_user_sees_only_own`
   - `test_list_manager_with_model_perm_sees_all`
   - `test_detail_regular_user_own_and_forbidden_foreign`
   - `test_detail_admin_and_manager_can_see_any`
   - `test_create_regular_user_forces_employee_and_default_status`
   - `test_update_own_pending_ok_and_final_forbidden`
   - `test_delete_own_pending_ok_final_forbidden`
   - `test_actions_permissions_and_effects`

   Причина: сейчас на вью стоит AdminOrActionOrModelPerms, который без явного кода требует
   `view_request` даже для SAFE-методов. По продуктовой постановке нам нужно:
   «любой аутентифицированный — видит и работает только со своими (без model perms)».
   Варианты решения (нужно выбрать и применить в следующем MR):
   - Вернуть кастомный RequestAccessPermission (владелец без view_request видит свои).
   - Либо оставить AdminOrActionOrModelPerms, но добавить параллельно IsSelfOrStaff и/или
     задать required_perms_by_action под нужные action так, чтобы владелец проходил без view_request.
   - Для create: либо давать add_request всем аутентифицированным (нежелательно),
     либо обойтись кастомной проверкой на уровне вью как сейчас и гарантировать,
     что ответ формируется read-сериализатором (см. пункт 3).

3) ❌ `test_create_admin_can_set_employee` → `TypeError: 'int' object is not subscriptable`
   - Причина: ответ на POST формируется write-сериализатором (поле employee как int),
     а тест ожидает структуру read-сериализатора (employee {...}).
   - TODO (следующий MR): переопределить create/update, чтобы в ответе всегда отдавался
     RequestReadSerializer(instance, context=...).

4) ❌ `test_comments_allowed_for_admin_and_manager`
   - Сейчас в тесте менеджеру выданы права на RequestComment, но per AdminOrActionOrModelPerms
     без маппинга экшена это игнорируется. Нужно либо:
     - выдать `requests_app.view_request` и `requests_app.add_request`, либо
     - в вью задать `required_perms_by_action={"comments": "requests_app.view_requestcomment"}` и на POST — add_requestcomment.
   - В этом MR тесты приведены к текущей логике (см. README к тестам), но код вью/пермишена ещё требует выбора стратегии.

--------------------------------
Дополнительно
--------------------------------
- Вверху тестового модуля добавлен `pytestmark = pytest.mark.django_db`.
- Исправлены разовые мелочи по типам и контексту сериализаторов.
- В README к тестам добавлены примечания, где поведение зависит от выбранной стратегии прав.

Refs: EUSRR-requests-api, пермишены AdminOrActionOrModelPerms, комментарии, фиксы фикстур и фабрик.
2025-09-15 16:59:12 +03:00

265 lines
9.7 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/requests_app/models.py
import logging
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator
from django.db import models
from django.db.models import F, Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from .constants import MAX_ATTACHMENT_SIZE, SAFE_EXTENSIONS, SAFE_MIME_TYPES
logger = logging.getLogger(__name__)
def attachment_file_size(value):
"""Ограничение размера вложения (10 МБ)."""
if value and getattr(value, "size", 0) > MAX_ATTACHMENT_SIZE:
mb = MAX_ATTACHMENT_SIZE // (1024 * 1024)
raise ValidationError(
_("Размер файла не должен превышать %(mb)s МБ."), params={"mb": mb}
)
def attachment_mime_validator(value):
"""
Доп. проверка MIME-типа. Если python-magic отсутствует — пишем WARNING и
пропускаем проверку (понижает безопасность).
"""
if not value:
return
try:
import magic # type: ignore
except ImportError:
logger.warning(
"python-magic не установлен, проверка MIME-типов вложений отключена. "
"Это может снизить безопасность обработки файлов."
)
return
# читаем «шапку» файла
pos = value.file.tell()
head = value.file.read(2048)
value.file.seek(pos)
if not head:
raise ValidationError(_("Недопустимый файл: пустое содержимое."))
mime = magic.Magic(mime=True).from_buffer(head)
if not mime or mime not in SAFE_MIME_TYPES:
raise ValidationError(
_("Недопустимый тип файла: %(mime)s."), params={"mime": mime or "unknown"}
)
class Request(models.Model):
"""Заявление сотрудника: тип, даты, статус, согласование и вложение."""
TYPE_VACATION = "vacation"
TYPE_SICK = "sick_leave"
TYPE_TRANSFER = "transfer"
TYPE_DISMISSAL = "dismissal"
TYPE_OTHER = "other"
TYPE_CHOICES = [
(TYPE_VACATION, _("Отпуск")),
(TYPE_SICK, _("Больничный")),
(TYPE_TRANSFER, _("Перевод")),
(TYPE_DISMISSAL, _("Увольнение")),
(TYPE_OTHER, _("Другое")),
]
STATUS_DRAFT = "draft"
STATUS_PENDING = "pending"
STATUS_APPROVED = "approved"
STATUS_REJECTED = "rejected"
STATUS_CANCELLED = "cancelled"
STATUS_CHOICES = [
(STATUS_DRAFT, _("Черновик")),
(STATUS_PENDING, _("На рассмотрении")),
(STATUS_APPROVED, _("Одобрено")),
(STATUS_REJECTED, _("Отклонено")),
(STATUS_CANCELLED, _("Отменено")),
]
FINAL_STATUS_SET = {STATUS_APPROVED, STATUS_REJECTED, STATUS_CANCELLED}
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="requests",
verbose_name=_("Автор"),
)
approver = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
related_name="approved_requests",
null=True,
blank=True,
verbose_name=_("Согласующий"),
)
department = models.ForeignKey(
"employees.Department",
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name=_("Отдел"),
)
type = models.CharField(_("Тип заявления"), choices=TYPE_CHOICES, max_length=32)
title = models.CharField(_("Тема"), max_length=200, blank=True)
date_from = models.DateField(_("Дата начала"), null=True, blank=True)
date_to = models.DateField(_("Дата окончания"), null=True, blank=True)
comment = models.TextField(_("Комментарий"), blank=True)
status = models.CharField(
_("Статус"),
choices=STATUS_CHOICES,
max_length=16,
default=STATUS_PENDING,
)
attachment = models.FileField(
_("Вложение"),
upload_to="requests/attachments/%Y/%m/",
blank=True,
null=True,
validators=[
FileExtensionValidator(allowed_extensions=SAFE_EXTENSIONS),
attachment_file_size,
attachment_mime_validator,
],
)
created_at = models.DateTimeField(_("Создано"), auto_now_add=True)
updated_at = models.DateTimeField(_("Обновлено"), auto_now=True)
decided_at = models.DateTimeField(_("Решение принято"), null=True, blank=True)
class Meta:
permissions = [
("can_view_all_requests", "Можно просмотреть все заявления"),
("can_process_requests", "Может одобрять/отклонять заявления"),
]
verbose_name = _("Заявление")
verbose_name_plural = _("Заявления")
ordering = ["-created_at"]
# NOTE: проверь через EXPLAIN, не избыточны ли некоторые индексы для твоих кейсов
indexes = [
models.Index(fields=["status", "created_at"]),
models.Index(fields=["type", "created_at"]),
models.Index(fields=["employee", "created_at"]),
models.Index(
fields=["decided_at"],
name="req_decided_idx",
condition=Q(decided_at__isnull=False),
),
]
constraints = [
models.CheckConstraint(
name="request_date_range_valid",
condition=Q(date_to__isnull=True)
| Q(date_from__isnull=True)
| Q(date_from__lte=F("date_to")),
),
models.CheckConstraint(
name="request_approver_required_on_decision",
condition=Q(status__in=["approved", "rejected"], approver__isnull=False)
| Q(
status__in=["draft", "pending", "cancelled"], approver__isnull=True
),
),
]
def clean(self):
if self.type in {self.TYPE_VACATION, self.TYPE_SICK} and not (
self.date_from and self.date_to
):
raise ValidationError(
_("Для выбранного типа укажите даты начала и окончания.")
)
if (
self.type in {self.TYPE_TRANSFER, self.TYPE_DISMISSAL}
and not self.date_from
):
raise ValidationError(_("Для перевода/увольнения укажите дату начала."))
if self.approver_id and self.approver_id == self.employee_id:
raise ValidationError(_("Согласующий не может совпадать с автором заявки."))
@property
def display_title(self) -> str:
"""Заголовок для списков: берём title или генерим на лету."""
return self.title or f"{self.get_type_display()} — {self.employee}"
@property
def is_final(self):
return self.status in self.FINAL_STATUS_SET
def approve(self, by_user):
self.status = self.STATUS_APPROVED
self.approver = by_user
self.decided_at = timezone.now()
self.save(update_fields=["status", "approver", "decided_at", "updated_at"])
def reject(self, by_user):
self.status = self.STATUS_REJECTED
self.approver = by_user
self.decided_at = timezone.now()
self.save(update_fields=["status", "approver", "decided_at", "updated_at"])
def cancel(self):
self.status = self.STATUS_CANCELLED
if self.approver_id is not None:
self.approver = None
self.decided_at = timezone.now()
self.save(update_fields=["status", "approver", "decided_at", "updated_at"])
def save(self, *args, **kwargs):
"""
Страховка на случай изменения статуса напрямую:
- финальные статусы → ставим decided_at, если пусто;
- draft/pending/cancelled → чистим approver (в синхроне с БД-constraint).
"""
if self.status in self.FINAL_STATUS_SET and not self.decided_at:
self.decided_at = timezone.now()
if (
self.status
in {self.STATUS_DRAFT, self.STATUS_PENDING, self.STATUS_CANCELLED}
and self.approver_id
):
self.approver = None
super().save(*args, **kwargs)
def __str__(self):
return (
f"{self.get_type_display()} — {self.employee} ({self.get_status_display()})"
)
class RequestComment(models.Model):
request = models.ForeignKey(
Request,
on_delete=models.CASCADE,
related_name="comments",
verbose_name=_("Заявка"),
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
verbose_name=_("Автор"),
)
text = models.TextField(_("Комментарий"))
created_at = models.DateTimeField(_("Создано"), auto_now_add=True)
class Meta:
verbose_name = _("Комментарий к заявке")
verbose_name_plural = _("Комментарии к заявкам")
ordering = ["created_at"]
indexes = [models.Index(fields=["created_at"])]
def __str__(self):
return f"Комментарий {self.author} к заявке #{self.request_id}"