Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/modules/feed.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,19 @@ View выбирает подходящие `news.News`, сериализует
- `GET /feed/?type=project|vacancy|news` - комбинированная выдача по нескольким
типам.

Legacy `/feed/` сохраняет этот контракт без изменений. Для React добавлен
отдельный `/feed/news/`, который возвращает только полноценные публикации и
никогда не смешивает их со служебными записями проектов и вакансий. Подробный
контракт находится в `docs/react-news-feed-api.md`.

## Ограничения и правила

- Feed читает данные из `news.News`, но не отвечает за создание обычных
project/user news.
- Служебная feed-запись определяется через пустой `text`.
- Новости партнерских программ не отображаются в `/feed/`.
- Новая React-лента программ не меняет это правило legacy endpoint: она читает
`News` через отдельные selectors и serializers.
- Signals `feed` создают или удаляют служебные feed-записи для проектов и
вакансий. Более широкие сценарии публикации проекта остаются в модуле
`projects`.
Expand Down
18 changes: 18 additions & 0 deletions docs/modules/news.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ generic relation:
- лайк и снятие лайка с новости;
- участие новостей в общей ленте `/feed/`;
- закрепление новости программы через `pin`.
- явная аудитория новости программы: вся платформа или участники программы;
- плоские комментарии к публикациям нового React-контура.

## Архитектура

Expand Down Expand Up @@ -64,6 +66,8 @@ generic relation:
- `likes` - generic likes через `core.Like`.
- `views` - generic views через `core.View`.
- `pin` - закрепление новости, сейчас используется для новостей программ.
- `audience` - `platform` или `program_participants`.
- `NewsComment` - комментарий с автором, текстом и датами создания/изменения.

Feed-запись определяется через helper `is_feed_record(news)`, а обычная новость
через `is_content_news(news)`. Сейчас оба helper'а используют текущий признак
Expand Down Expand Up @@ -117,6 +121,11 @@ Feed-запись определяется через helper `is_feed_record(new
Новости программ сортируются с учетом `pin`: закрепленные новости идут выше
обычных.

Поле `audience` можно передать при создании и изменении. Для совместимости со
старым Angular отсутствие поля при создании означает `program_participants`.
Внутренние публикации видят участники программы, менеджеры и staff/superuser;
остальным list их не возвращает, а detail отвечает `404`.

### 4. Просмотры и лайки

Просмотры и лайки работают через generic-модели `core.View` и `core.Like`.
Expand All @@ -143,6 +152,15 @@ Feed-запись определяется через helper `is_feed_record(new
- Вложения новости должны ссылаться только на `UserFile` текущего пользователя.
- Несуществующий project/user/program context возвращает `404`.
- Проектные новости реализованы через `news.News`.
- Новости пользователей и проектов всегда имеют аудиторию `platform`.
- Старые новости программ мигрируются в `program_participants`, чтобы не
публиковать внутренние записи автоматически.

## React API

Новый стабильный контракт общей ленты и комментариев описан в
`docs/react-news-feed-api.md`. Он использует существующую модель `News`,
`core.Like` и `core.View`; отдельный модуль публикаций не создается.

## Тесты

Expand Down
197 changes: 197 additions & 0 deletions docs/react-news-feed-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# React News Feed API

## Назначение

DEV-083.1 добавляет стабильный backend-контракт для будущего раздела
`/office/news`. Он использует общую модель `news.News`, существующие
`core.Like`/`core.View` и новую модель `NewsComment`. Legacy `/feed/` и
контекстные endpoints Angular сохраняют прежние URL и формат ответа.

В `News` по-прежнему хранятся как публикации, так и служебные feed-записи с
пустым текстом. Новый API возвращает только полноценные публикации.

## Источники и список

```text
GET /feed/news/?source=program&search=&limit=10&offset=0
```

Endpoint требует авторизацию. `source` принимает:

- `program` — значение по умолчанию, только новости программ с
`audience=platform`;
- `project` — новости опубликованных публичных проектов;
- `user` — новости пользователей.

Неизвестный source возвращает `400`. Служебные записи с пустым текстом,
новости private/draft-проектов и participant-only новости программ в список не
попадают. Сортировка: `datetime_created DESC, id DESC`.

`search` обрезается по краям, ограничен 200 символами и выполняет
регистронезависимый поиск внутри выбранной вкладки: по тексту публикации, имени
программы/проекта либо имени и фамилии пользователя.

Ответ использует limit/offset pagination:

```json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 123,
"source_type": "program",
"source": {
"id": 45,
"name": "Название программы",
"image_address": "https://example.com/program.png"
},
"text": "Текст публикации",
"files": [],
"audience": "platform",
"datetime_created": "2026-08-06T12:00:00Z",
"datetime_updated": "2026-08-06T12:00:00Z",
"likes_count": 5,
"comments_count": 3,
"views_count": 18,
"is_user_liked": true
}
]
}
```

Counts и состояние лайка аннотируются в queryset; источники и файлы
prefetch-ятся, поэтому размер страницы не создает N+1.

## Audience программ

`News.audience` принимает:

- `platform` — публикация доступна всем авторизованным пользователям и может
быть показана во вкладке программ;
- `program_participants` — публикация доступна участникам программы, её
менеджерам и staff/superuser.

`program_participants` разрешен только для `PartnerProgram`. Новости
пользователей и проектов всегда имеют `platform`.

База ограничивает поле двумя допустимыми значениями. Проверка того, что
`program_participants` относится именно к `PartnerProgram`, выполняется в
model validation и на API-boundary: generic foreign key нельзя надежно
сопоставить с content type в статическом check constraint.

Контекстное создание программы совместимо с Angular:

```text
POST /programs/<program_id>/news/
```

```json
{
"text": "Текст новости",
"files": [],
"audience": "platform"
}
```

Если `audience` отсутствует, создается `program_participants`. Менеджер может
изменить поле через существующий PATCH. Недоступная
внутренняя публикация исключается из контекстного списка и возвращает `404` в
detail.

Data migration переводит все существующие program news в
`program_participants`, а остальные новости — в `platform`.

## Detail, лайки и просмотры

```text
GET /feed/news/<news_id>/
POST /feed/news/<news_id>/set-liked/
POST /feed/news/<news_id>/set-viewed/
```

Detail возвращает тот же объект, что элемент списка. Он предназначен для
будущего маршрута `/office/news/<news_id>`. Публичная новость программы
доступна авторизованным пользователям, внутренняя — только своей аудитории,
новость проекта — только если проект опубликован и публичен. Недоступный,
несуществующий или служебный объект возвращает `404`.

Лайк:

```json
{ "is_liked": true }
```

```json
{ "is_user_liked": true, "likes_count": 6 }
```

Просмотр не требует payload и возвращает:

```json
{ "views_count": 18 }
```

Обе операции идемпотентны благодаря существующим уникальным ограничениям
`core.Like` и `core.View`. Перед изменением проверяется доступ к публикации.

## Комментарии

```text
GET /feed/news/<news_id>/comments/
POST /feed/news/<news_id>/comments/
PATCH /feed/news/<news_id>/comments/<comment_id>/
DELETE /feed/news/<news_id>/comments/<comment_id>/
```

Список использует limit/offset pagination и сортировку от старых комментариев к
новым. Создание и изменение принимают:

```json
{ "text": "Комментарий" }
```

Пробелы по краям удаляются; пустой текст и текст длиннее 2000 символов
возвращают `400`.

Ответ:

```json
{
"id": 17,
"author": {
"id": 8,
"name": "Имя Фамилия",
"image_address": "https://example.com/avatar.png"
},
"text": "Комментарий",
"datetime_created": "2026-08-06T12:00:00Z",
"datetime_updated": "2026-08-06T12:00:00Z",
"is_edited": false,
"can_edit": true,
"can_delete": true
}
```

Читать и создавать комментарии может любой авторизованный пользователь с
доступом к новости. Редактирует только автор; удаляет автор либо
staff/superuser. `news_id` входит в lookup комментария, поэтому подмена пары
`news_id/comment_id` дает `404`. Удаление новости каскадно удаляет комментарии.

## Обратная совместимость и ограничения

- `/feed/` сохраняет служебные project/vacancy records, старый serializer и
намеренное исключение новостей программ;
- context API пользователей, проектов и программ сохраняет URL и основные
поля;
- репостов нет: в продукте это копирование detail-ссылки;
- комментарии плоские, без ответов, лайков, упоминаний и файлов;
- создание публикаций из общей ленты не добавлено: программы продолжают
публиковать через context endpoint;
- UI ленты, popup, deep-link recovery и копирование ссылки входят в DEV-083.2;
- DEMO-новости, лайки и комментарии входят в DEV-083.3.

Angular-аудит подтвердил: карточка копирует отдельную ссылку, project/profile
detail открывает новость в модальном маршруте, а блок комментариев в карточке
закомментирован и отдельного Angular-flow комментариев нет.
11 changes: 11 additions & 0 deletions feed/news_pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from rest_framework.pagination import LimitOffsetPagination


class ReactNewsFeedPagination(LimitOffsetPagination):
default_limit = 10
max_limit = 100


class NewsCommentPagination(LimitOffsetPagination):
default_limit = 20
max_limit = 100
101 changes: 101 additions & 0 deletions feed/news_selectors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Exists, OuterRef, Q, QuerySet
from django.http import Http404
from django.shortcuts import get_object_or_404

from core.models import Like
from news.access import can_view_news_in_react_feed
from news.models import News
from partner_programs.models import PartnerProgram
from projects.models import Project
from users.models import CustomUser


NEWS_SOURCE_PROGRAM = "program"
NEWS_SOURCE_PROJECT = "project"
NEWS_SOURCE_USER = "user"
NEWS_SOURCES = (
NEWS_SOURCE_PROGRAM,
NEWS_SOURCE_PROJECT,
NEWS_SOURCE_USER,
)


def _with_feed_annotations(queryset: QuerySet[News], user) -> QuerySet[News]:
news_content_type = ContentType.objects.get_for_model(News)
user_like = Like.objects.filter(
content_type=news_content_type,
object_id=OuterRef("pk"),
user=user,
)
return queryset.annotate(
likes_count=Count("likes", distinct=True),
comments_count=Count("comments", distinct=True),
views_count=Count("views", distinct=True),
is_user_liked=Exists(user_like),
)


def _content_source(source: str):
mapping = {
NEWS_SOURCE_PROGRAM: PartnerProgram,
NEWS_SOURCE_PROJECT: Project,
NEWS_SOURCE_USER: CustomUser,
}
return mapping[source]


def get_react_news_feed_queryset(
*,
source: str,
search: str,
user,
) -> QuerySet[News]:
"""Строит вкладку только из публикаций выбранного доменного источника."""
source_model = _content_source(source)
source_content_type = ContentType.objects.get_for_model(source_model)
source_objects = source_model.objects.all()

if source == NEWS_SOURCE_PROJECT:
source_objects = source_objects.filter(draft=False, is_public=True)

if search:
if source in (NEWS_SOURCE_PROGRAM, NEWS_SOURCE_PROJECT):
matching_source_ids = source_objects.filter(
name__icontains=search
).values_list("id", flat=True)
else:
matching_source_ids = source_objects.filter(
Q(first_name__icontains=search) | Q(last_name__icontains=search)
).values_list("id", flat=True)
search_filter = Q(text__icontains=search) | Q(object_id__in=matching_source_ids)
else:
search_filter = Q()

queryset = (
News.objects.filter(
content_type=source_content_type,
object_id__in=source_objects.values_list("id", flat=True),
audience=News.Audience.PLATFORM,
)
.exclude(text__regex=r"^\s*$")
.filter(search_filter)
.select_related("content_type")
.prefetch_related("content_object", "files")
.order_by("-datetime_created", "-id")
)
return _with_feed_annotations(queryset, user)


def get_react_feed_news_or_404(*, news_id: int, user) -> News:
queryset = _with_feed_annotations(
News.objects.select_related("content_type").prefetch_related(
"content_object", "files"
),
user,
)
news = get_object_or_404(queryset, pk=news_id)
if not can_view_news_in_react_feed(user, news):
# Единый 404 не раскрывает существование внутренней публикации.
raise Http404
return news
Loading
Loading