Игорь Надеин 2fd3b1e562 feat(feed): посты и комментарии — модели, ограничения, API, сериализаторы и тесты
**Что сделано**

* Добавлены модели ленты:

  * `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` — это все посты,
  когда-либо опубликованные этим пользователем (компания/отдел).
2025-08-26 13:13:16 +03:00

134 lines
4.9 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/calendar_app/models.py
from django.contrib.auth import get_user_model
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
User = get_user_model()
class Recurrence(models.TextChoices):
ONE_TIME = "one_time", _("Одноразовое")
ANNUAL = "annual", _("Ежегодно")
class CompanyEvent(models.Model):
title = models.CharField(_("Название"), max_length=200)
description = models.TextField(_("Описание"), blank=True)
date = models.DateField(_("Дата события"))
recurrence = models.CharField(
_("Повторение"),
max_length=20,
choices=Recurrence.choices,
default=Recurrence.ONE_TIME,
)
color = models.CharField(
_("Цвет (hex)"), max_length=7, blank=True, help_text="#RRGGBB"
)
location = models.CharField(_("Локация"), max_length=200, blank=True)
created_by = models.ForeignKey(
User,
verbose_name=_("Создал"),
on_delete=models.SET_NULL, # ← ожидаемое поведение при удалении автора
null=True,
blank=True,
related_name="company_events_created",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("Событие компании")
verbose_name_plural = _("События компании")
ordering = ["recurrence", "date"]
indexes = [
models.Index(fields=["date"]),
models.Index(
fields=["recurrence", "date"]
), # ← для сортировок по Meta.ordering
]
def __str__(self):
return f"{self.title} — {self.date:%d.%m.%Y}"
def get_absolute_url(self):
return reverse("calendar_app:company_calendar")
class DepartmentEvent(models.Model):
department = models.ForeignKey(
"employees.Department", # ← строковая ссылка без импорта
on_delete=models.CASCADE,
related_name="events",
verbose_name=_("Отдел"),
)
title = models.CharField(_("Название"), max_length=200)
description = models.TextField(_("Описание"), blank=True)
start_date = models.DateField(_("Дата начала"))
end_date = models.DateField(_("Дата окончания"), null=True, blank=True)
all_day = models.BooleanField(_("Весь день"), default=True)
recurrence = models.CharField(
_("Повторение"),
max_length=20,
choices=Recurrence.choices,
default=Recurrence.ONE_TIME,
)
color = models.CharField(
_("Цвет (hex)"), max_length=7, blank=True, help_text="#RRGGBB"
)
location = models.CharField(_("Локация"), max_length=200, blank=True)
created_by = models.ForeignKey(
User,
verbose_name=_("Создал"),
on_delete=models.SET_NULL, # ← автор может быть удалён
null=True,
blank=True,
related_name="department_events_created",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("Событие отдела")
verbose_name_plural = _("События отделов")
ordering = ["department", "start_date"]
indexes = [
models.Index(fields=["department", "start_date"]),
models.Index(
fields=["department", "recurrence", "start_date"]
), # ← фильтр/сорт по отделу+типу
models.Index(fields=["start_date", "end_date"]),
]
constraints = [
models.CheckConstraint(
name="dept_event_date_range_valid",
condition=(
models.Q(end_date__isnull=True)
| models.Q(start_date__isnull=True)
| models.Q(start_date__lte=models.F("end_date"))
),
),
]
permissions = [
("manage_department_events", "Может создавать/обновлять/ удалять события отдела"),
]
def __str__(self):
if self.end_date:
return f"{self.department}: {self.title} ({self.start_date:%d.%m.%Y}–{self.end_date:%d.%m.%Y})"
return f"{self.department}: {self.title} ({self.start_date:%d.%m.%Y})"
def clean(self):
# UX-валидация до попытки сохранить в БД
if self.end_date and self.start_date and self.end_date < self.start_date:
from django.core.exceptions import ValidationError
raise ValidationError(
{"end_date": _("Дата окончания не может быть раньше даты начала.")}
)
def get_absolute_url(self):
return reverse("calendar_app:department_calendar", args=[self.department_id])