**Что сделано**
* Добавлены модели ленты:
* `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` — это все посты,
когда-либо опубликованные этим пользователем (компания/отдел).
132 lines
7.8 KiB
Python
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'),
|
|
),
|
|
]
|