- Добавлен 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, комментарии, фиксы фикстур и фабрик.
265 lines
9.7 KiB
Python
265 lines
9.7 KiB
Python
# 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}"
|