Skip to content
Merged
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
89 changes: 88 additions & 1 deletion docs/modules/feed.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,90 @@
# Feed

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

Feed отвечает за общую ленту `/feed/`.

Модуль не хранит отдельную доменную модель ленты. Он читает записи из
`news.News` и возвращает их во frontend-формате, зависящем от связанного
объекта.

## Статус модуля

Модуль рабочий, но небольшой и связан с `news`, `projects` и `vacancy`.
Основная логика сосредоточена во view, serializer и service helpers.

## Основные возможности

- сбор общей ленты по query-параметру `type`;
- отображение пользовательских новостей;
- отображение проектных новостей;
- отображение служебных записей для проектов и вакансий;
- исключение записей непубличных или черновых проектов;
- передача признака лайка текущим пользователем.

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

- `feed/views.py` - endpoint `/feed/`, фильтрация по типам и финальная сборка
response payload.
- `feed/serializers.py` - `FeedNewsResponseSerializer`, который превращает
`news.News` в элемент ленты.
- `feed/services.py` - helpers для лайков и служебных feed-записей.
- `feed/mapping.py` - соответствие content object типам и serializers.
- `feed/constants.py` - типы моделей, для которых signals создают feed-записи.
- `feed/signals.py` - подключение signal handlers.
- `feed/tests/` - regression-тесты API и service helpers.

## Основные сценарии

### 1. Пользователь открывает ленту

Frontend вызывает `/feed/?type=...`.
View выбирает подходящие `news.News`, сериализует их и возвращает элементы
ленты с полями `type_model` и `content`.

### 2. В ленту попадает обычная новость

Если `news.News` содержит текст и относится к пользователю или проекту, лента
возвращает ее как новость.

Проектная новость с текстом возвращается как `type_model = "news"`, даже если
ее `content_object` - проект.

### 3. В ленту попадает служебная запись

Служебные feed-записи создаются через `feed.services.create_news_for_model()`.
Они используют `news.News` с пустым `text` и связью на объект, например проект
или вакансию.

### 4. Проект становится недоступным для публичной ленты

Если проект черновой или непубличный, связанные с ним записи не возвращаются в
`/feed/`.

## API

- `GET /feed/?type=news` - новости пользователей.
- `GET /feed/?type=project` - проектные новости и проектные feed-записи.
- `GET /feed/?type=project|news` - комбинированная выдача по нескольким типам.

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

- Feed читает данные из `news.News`, но не отвечает за создание обычных
project/user/program news.
- Служебная feed-запись определяется через пустой `text`.
- Signals проектов могут создавать или удалять feed-записи, но тесты этих
side effects остаются в модуле `projects`.
- `DevScript` в `feed/views.py` остается служебным legacy-инструментом и не
является основным пользовательским API.

## Тесты

Текущие regression-тесты проверяют:

- `/feed/?type=news` возвращает пользовательские новости;
- `/feed/?type=project` возвращает проектные новости в frontend-формате;
- новости непубличных проектов не попадают в feed;
- `get_liked_news()` возвращает лайкнутые текущим пользователем записи;
- `create_news_for_model()` создает одну служебную feed-запись без дублей;
- `delete_news_for_model()` удаляет только служебную feed-запись и не трогает
обычную новость с текстом.
159 changes: 158 additions & 1 deletion docs/modules/news.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,160 @@
# News

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

News отвечает за публикации и новости в разных частях продукта.

Модуль хранит новости в единой модели `News` и связывает их с объектами через
generic relation:

- проектами;
- пользователями;
- партнерскими программами;
- объектами ленты, например вакансиями.

Одна и та же модель используется для двух сценариев:

- обычная новость с текстом и файлами;
- служебная запись ленты для существующего объекта, где `text = ""`.

## Статус модуля

Модуль рабочий, но связан с несколькими доменными областями и требует
аккуратного рефакторинга. Сейчас он обслуживает проектные новости, новости
пользователей, новости программ и часть общей ленты.

Первый слой regression-тестов добавлен для живых сценариев API.

## Основные возможности

- создание новости в контексте проекта;
- создание новости в профиле пользователя;
- создание новости в партнерской программе;
- просмотр списка новостей в конкретном контексте;
- просмотр, редактирование и удаление отдельной новости;
- отметка просмотра новости;
- лайк и снятие лайка с новости;
- участие новостей в общей ленте `/feed/`;
- закрепление новости программы через `pin`.

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

- `news/models.py` - модель `News` с `content_type/object_id`, файлами, лайками,
просмотрами и флагом `pin`.
- `news/managers.py` - низкоуровневые `get_news(obj)` и `add_news(obj,
**kwargs)` для работы с generic relation.
- `news/services.py` - явное создание project/user/program news и helpers для
различения обычной новости и feed-записи.
- `news/querysets.py` - явные queryset helpers по контексту URL: project, user
или partner program.
- `news/views.py` - общий API для list/create/detail/update/delete, set_viewed и
set_liked.
- `news/serializers.py` - request/response serializers для создания, списка и
detail.
- `news/permissions.py` - права на создание и изменение новости в зависимости от
связанного объекта.
- `news/admin.py` - админка `News`.
- `news/tests/` - regression-тесты живых сценариев модуля.

## Основные сущности

- `News` - публикация или запись ленты.
- `content_object` - объект, к которому относится новость.
- `files` - вложения новости.
- `likes` - generic likes через `core.Like`.
- `views` - generic views через `core.View`.
- `pin` - закрепление новости, сейчас используется для новостей программ.

Feed-запись определяется через helper `is_feed_record(news)`, а обычная новость
через `is_content_news(news)`. Сейчас оба helper'а используют текущий признак
`text`, но вызывающий код не должен напрямую проверять `text == ""`.

## API

Контекстные endpoints работают для трех базовых URL:

- `/projects/<project_id>/news/` - новости проекта;
- `/auth/users/<user_id>/news/` - новости пользователя;
- `/programs/<program_id>/news/` - новости партнерской программы.

Для каждого контекста доступны:

- `GET <base>` - список новостей;
- `POST <base>` - создание новости;
- `GET <base><news_id>/` - детальная новость;
- `PATCH <base><news_id>/` - редактирование новости;
- `DELETE <base><news_id>/` - удаление новости;
- `POST <base><news_id>/set_viewed/` - просмотр новости;
- `POST <base><news_id>/set_liked/` - лайк новости.

Связанные endpoints:

- `GET /news/` - подключен напрямую, но без контекста возвращает пустой список.
- `GET /news/<news_id>/` - подключен напрямую, но без контекста не является
основным пользовательским сценарием.
- `GET /feed/?type=...` - общая лента, которая читает данные из `News`.

## Основные сценарии

### 1. Новость проекта

Лидер проекта создает новость через `/projects/<id>/news/`.

Новость сохраняется в `news.News`, а связь с проектом задается через
`content_type = Project` и `object_id = project.id`.

Новости проекта с текстом отображаются внутри проекта. Служебные feed-записи
из списка проектных новостей исключаются.

### 2. Новость пользователя

Пользователь создает новость в своем профиле через `/auth/users/<id>/news/`.
Создавать новость за другого пользователя нельзя.

### 3. Новость программы

Менеджер партнерской программы создает новость через `/programs/<id>/news/`.
Пользователь без роли менеджера программы не может создавать и изменять такие
новости.

Новости программ сортируются с учетом `pin`: закрепленные новости идут выше
обычных.

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

Просмотры и лайки работают через generic-модели `core.View` и `core.Like`.
Они привязаны к самой новости, а не к объекту, которому эта новость посвящена.

### 5. Лента

`/feed/` читает `News` и возвращает элементы в формате, зависящем от типа
связанного объекта.

Для проектных записей важно различать:

- `text = ""` - служебная запись ленты о проекте;
- новость с текстом - полноценная новость проекта, которая в ленте возвращается
как `type_model = "news"`.

Лента исключает новости, связанные с непубличными или черновыми проектами.

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

- Новость проекта может создавать и изменять только лидер проекта.
- Новость пользователя может создавать и изменять только сам пользователь.
- Новость программы может создавать и изменять только менеджер программы.
- Прямой `/news/` без project/user/program context не является основным
пользовательским API.
- Несуществующий project/user/program context возвращает `404`.
- Проектные новости реализованы через `news.News`.

## Тесты

Текущие regression-тесты проверяют:

- manager, service и query helpers;
- project/user/program API;
- права на создание и изменение новостей;
- лайки и просмотры;
- исключение служебных feed-записей из списка новостей проекта;
- сортировку закрепленных новостей программы.
9 changes: 2 additions & 7 deletions docs/modules/projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Projects отвечают за проектную часть Procollab: созд
## Архитектура

- `projects/models.py` - модели проекта, участников, целей, компаний,
ресурсов, ссылок, достижений и остаточной старой модели `ProjectNews`.
ресурсов, ссылок и достижений.
- `projects/views.py` - HTTP endpoints и значительная часть orchestration
logic.
- `projects/serializers.py` - request/response contracts, часть validation и
Expand All @@ -69,8 +69,6 @@ Projects отвечают за проектную часть Procollab: созд
- `Resource` - ресурс проекта.
- проектные новости - новости внутри проекта; актуальный API реализован через
`news.News` с привязкой к `Project` через `content_type/object_id`.
- `ProjectNews` - старая модель проектных новостей, оставшаяся после переноса
данных в `news.News`.
- `DefaultProjectCover` - дефолтная обложка проекта.
- `DefaultProjectAvatar` - дефолтный аватар проекта.

Expand Down Expand Up @@ -208,9 +206,6 @@ Projects отвечают за проектную часть Procollab: созд
`news`: запись хранится в `news.News`, а связь с проектом задается через
`content_type = Project` и `object_id = project.id`.

Старые `ProjectNews`, `ProjectNews*Serializer` и `ProjectNews*View` остаются в
коде, но текущие routes проекта подключены к `news.views`.

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

- Публичный каталог показывает только `draft = False` и `is_public = True`.
Expand All @@ -222,7 +217,7 @@ Projects отвечают за проектную часть Procollab: созд
- Компания может быть связана с проектом только один раз.
- Ресурс может ссылаться только на компанию, уже привязанную к проекту.
- Проектные новости являются живым frontend-сценарием, но текущие routes
используют общий модуль `news`, а не старую модель `ProjectNews`.
используют общий модуль `news`.

## Тесты

Expand Down
9 changes: 5 additions & 4 deletions feed/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
from files.serializers import UserFileSerializer
from news.mapping import NewsMapping
from news.models import News
from news.services import is_content_news
from projects.models import Project
from users.models import CustomUser


class NewsFeedListSerializer(serializers.ModelSerializer):
class FeedNewsResponseSerializer(serializers.ModelSerializer):
name = serializers.SerializerMethodField()
image_address = serializers.SerializerMethodField()
is_user_liked = serializers.SerializerMethodField()
Expand All @@ -21,13 +22,13 @@ class NewsFeedListSerializer(serializers.ModelSerializer):

def get_type_model(self, obj) -> str:
model_type = CONTENT_OBJECT_MAPPING[obj.content_type.model]
if obj.text != "" and model_type == "project":
if is_content_news(obj) and model_type == "project":
return "news"
return model_type

def get_content_object(self, obj) -> dict:
type_model = obj.content_type.model
if obj.text != "" and self.get_type_model(obj) == "project":
if is_content_news(obj) and self.get_type_model(obj) == "project":
type_model = "news"
serializer = CONTENT_OBJECT_SERIALIZER_MAPPING[type_model](obj.content_object)
return serializer.data
Expand All @@ -41,7 +42,7 @@ def get_likes_count(self, obj):
def get_name(self, obj):
if obj.content_type.model == CustomUser.__name__.lower():
return f"{obj.content_object.first_name} {obj.content_object.last_name}"
elif obj.text != "" and obj.content_type.model == Project.__name__.lower():
elif is_content_news(obj) and obj.content_type.model == Project.__name__.lower():
return f"{obj.content_object.name}"

def get_image_address(self, obj):
Expand Down
11 changes: 9 additions & 2 deletions feed/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from core.models import Like
from feed.constants import SIGNALS_MODELS
from news.models import News
from news.services import FEED_RECORD_TEXT
from users.models import CustomUser


Expand All @@ -18,12 +19,18 @@ def get_liked_news(user: CustomUser, queryset: list[News]) -> list[int]:
def delete_news_for_model(instance: SIGNALS_MODELS):
content_type = ContentType.objects.get_for_model(instance)
obj = News.objects.filter(
text="", content_type=content_type, object_id=instance.id
text=FEED_RECORD_TEXT,
content_type=content_type,
object_id=instance.id,
).first()
if obj:
obj.delete()


def create_news_for_model(instance: SIGNALS_MODELS):
content_type = ContentType.objects.get_for_model(instance)
News.objects.get_or_create(text="", content_type=content_type, object_id=instance.id)
News.objects.get_or_create(
text=FEED_RECORD_TEXT,
content_type=content_type,
object_id=instance.id,
)
Empty file added feed/tests/__init__.py
Empty file.
Loading
Loading