Corp/backend/feed/migrations/0001_initial.py
Игорь Надеин 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

132 lines
7.8 KiB
Python

# Generated by Django 5.2.4 on 2025-08-26 07:37
import django.core.validators
import django.db.models.deletion
import feed.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('employees', '0019_delete_education'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Post',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200, verbose_name='Заголовок')),
('body', models.TextField(verbose_name='Текст')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('pinned', models.BooleanField(default=False, verbose_name='Закреплено')),
('type', models.CharField(choices=[('company', 'Новость компании'), ('department', 'Новость отдела'), ('employee', 'Публикация сотрудника')], max_length=20, verbose_name='Тип публикации')),
('image', models.ImageField(blank=True, null=True, upload_to='feed/images/%Y/%m/', verbose_name='Изображение')),
('attachment', models.FileField(blank=True, null=True, upload_to='feed/attachments/%Y/%m/', verbose_name='Вложение')),
('likes_count', models.PositiveIntegerField(db_index=True, default=0, verbose_name='Лайки')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL, verbose_name='Автор')),
('department', models.ForeignKey(blank=True, help_text='Для новостей отдела укажите отдел. Для других типов оставьте пустым.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='posts', to='employees.department', verbose_name='Отдел')),
],
options={
'verbose_name': 'Публикация',
'verbose_name_plural': 'Публикации',
'ordering': ['-pinned', '-created_at'],
},
),
migrations.CreateModel(
name='Comment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.TextField(blank=True, default='', verbose_name='Комментарий')),
('image', models.ImageField(blank=True, null=True, upload_to='feed/comments/images/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'gif', 'webp']), feed.models.FileSizeValidator(10)], verbose_name='Изображение')),
('attachment', models.FileField(blank=True, null=True, upload_to='feed/comments/attachments/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'zip', 'rar', '7z']), feed.models.FileSizeValidator(25)], verbose_name='Вложение')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL, verbose_name='Автор')),
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='feed.post', verbose_name='Публикация')),
],
options={
'verbose_name': 'Комментарий',
'verbose_name_plural': 'Комментарии',
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='PostLike',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='feed.post', verbose_name='Пост')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_likes', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Лайк',
'verbose_name_plural': 'Лайки',
},
),
migrations.AddIndex(
model_name='post',
index=models.Index(fields=['type', 'created_at'], name='feed_post_type_b00891_idx'),
),
migrations.AddIndex(
model_name='post',
index=models.Index(fields=['pinned', 'created_at'], name='feed_post_pinned_e86c9a_idx'),
),
migrations.AddIndex(
model_name='post',
index=models.Index(fields=['created_at'], name='feed_post_created_ddd8c4_idx'),
),
migrations.AddIndex(
model_name='post',
index=models.Index(fields=['department', '-created_at'], name='feed_post_departm_b35669_idx'),
),
migrations.AddIndex(
model_name='post',
index=models.Index(fields=['author', '-created_at'], name='feed_post_author__c854fa_idx'),
),
migrations.AddConstraint(
model_name='post',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('department__isnull', False), ('type', 'department')), models.Q(('type', 'department'), _negated=True), _connector='OR'), name='post_department_required_when_type_is_department'),
),
migrations.AddConstraint(
model_name='post',
constraint=models.CheckConstraint(condition=models.Q(('type', 'department'), ('department__isnull', True), _connector='OR'), name='post_department_null_when_type_not_department'),
),
migrations.AddConstraint(
model_name='post',
constraint=models.CheckConstraint(condition=models.Q(('likes_count__gte', 0)), name='post_likes_count_non_negative'),
),
migrations.AddIndex(
model_name='comment',
index=models.Index(fields=['created_at'], name='feed_commen_created_a85775_idx'),
),
migrations.AddIndex(
model_name='comment',
index=models.Index(fields=['post', 'created_at'], name='feed_commen_post_id_3fe3f3_idx'),
),
migrations.AddIndex(
model_name='comment',
index=models.Index(fields=['author', 'created_at'], name='feed_commen_author__b1fbf5_idx'),
),
migrations.AddConstraint(
model_name='comment',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('text__isnull', False), models.Q(('text', ''), _negated=True)), ('image__isnull', False), ('attachment__isnull', False), _connector='OR'), name='comment_has_any_content_v3', violation_error_message='Нужно указать текст, изображение или файл.'),
),
migrations.AddIndex(
model_name='postlike',
index=models.Index(fields=['user', 'created_at'], name='feed_postli_user_id_15e79d_idx'),
),
migrations.AddIndex(
model_name='postlike',
index=models.Index(fields=['post', 'created_at'], name='feed_postli_post_id_2897d2_idx'),
),
migrations.AddConstraint(
model_name='postlike',
constraint=models.UniqueConstraint(fields=('post', 'user'), name='uq_postlike_post_user'),
),
]