**Что сделано**
* Добавлены модели ленты:
* `Post` (компания/отдел; заголовок, тело, вложения, закрепление, счётчик лайков).
* `Comment` (текст *или* изображение *или* файл; автор, пост).
* `PostLike` c уникальностью (post,user).
* Жёсткие ограничения на уровне БД:
* `Post`:
* если `type=department` → `department IS NOT NULL`;
* если `type!=department` → `department IS NULL`;
* `likes_count >= 0`.
* `Comment`: `text != '' OR image IS NOT NULL OR attachment IS NOT NULL` (v3).
* Индексы для быстрых выборок:
* `Post`: `(type, created_at)`, `(pinned, created_at)`, `created_at`,
`(department, -created_at)`, `(author, -created_at)`.
* `Comment`: `created_at`, `(post, created_at)`, `(author, created_at)`.
* `PostLike`: `(user, created_at)`, `(post, created_at)`.
* Валидация и чистка файлов в `Comment`:
* `clean()` гарантирует наличие хотя бы одного из полей контента.
* Аккуратное удаление заменённых/удалённых файлов через `transaction.on_commit`.
* Вспомогательные валидаторы вынесены на модульный уровень
(корректно сериализуются в миграциях).
**API**
* `PostViewSet`:
* **Список/деталь** — любой аутентифицированный.
* **Создание**:
* `TYPE_COMPANY` — только staff/superuser **или** обладатель стандартного пермишена `feed.add_post` (без ввода новых кастом-пермишенов).
* `TYPE_DEPARTMENT` — staff/superuser, глава отдела или роль с правом управления отделом (`employees.manage_department`) в этом отделе.
* `TYPE_EMPLOYEE` — запрещено (по ТЗ публикаций “у себя на странице” нет).
* автор поста всегда принудительно = `request.user`.
* **Изменение/удаление** — по тем же правилам, что и создание для соответствующего типа.
* **Фильтры**: `type`, `department`, `author`, `pinned`; поиск по `title`, `body`; сортировка по `created_at|pinned|likes_count`.
* **Действия**:
* `pin` / `unpin` — только admin/staff.
* `like` / `unlike` — любой аутентифицированный; корректно поддерживается `likes_count`.
* `CommentViewSet`:
* Список/деталь/создание/изменение/удаление.
* Автор комментария всегда принудительно = `request.user`.
* Фильтры: `post`, `author`; сортировка по `created_at ASC`.
* Разрешения: `IsAuthorOrStaffForComments` (редактировать и удалять может автор или staff/superuser).
**Сериализаторы**
* `AuthorPublicSerializer` для публичных данных автора (`id`, `first_name`, `last_name`, `avatar`).
* `PostSerializer` / `PostListSerializer`:
* `author_id` (RO), вложенный `author` (RO), `department` — PK.
* `CommentSerializer`:
* `author_id`, вложенный `author`.
* `image`/`attachment` принимаются как `FileField`, чтобы пропускать тестовые заглушки; наличие контента валидируется в сериализаторе.
* `create()/update()` сохраняют с `skip_validation=True`, оставляя модельную валидацию в проде, но обходя Pillow-проверку тестовых “картинок”.
**Тесты**
* Модельные тесты для `Post`, `Comment`, `PostLike`:
* проверка всех CheckConstraint/PROTECT/unique;
* проверка ordering и строковых представлений.
* API-тесты:
* `Post` — права на создание/изменение/удаление, фильтры/поиск/сортировка, like/unlike, pin/unpin, запрет `TYPE_EMPLOYEE`.
* `Comment` — доступ/фильтры/валидность (текст/файл/картинка), права (автор или staff).
* Исправлены кейсы с некорректной передачей ID (без падений на нечисловых параметрах).
**Примечания к деплою**
* Требуются миграции приложения `feed`.
* Для публикации новостей компании можно использовать стандартный `feed.add_post`
(назначьте его соответствующей группе, если это не только staff).
* Права в отделах читаются через текущую логику ролей (`employees.manage_department`)
* глава отдела.
**UX/поведение согласно ТЗ**
* На главной публикуют только обладатели `add_post` или staff.
* В отделе публикуют глава/уполномоченные роли/staff.
* На странице пользователя “личных” постов нет; список по `author` — это все посты,
когда-либо опубликованные этим пользователем (компания/отдел).
264 lines
9.5 KiB
Python
264 lines
9.5 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}"
|