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
42 changes: 33 additions & 9 deletions docs/modules/feed.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ Feed отвечает за общую ленту `/feed/`.
## Статус модуля

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

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

Expand All @@ -20,6 +21,7 @@ Feed отвечает за общую ленту `/feed/`.
- отображение проектных новостей;
- отображение служебных записей для проектов и вакансий;
- исключение записей непубличных или черновых проектов;
- исключение записей закрытых вакансий и вакансий из недоступных проектов;
- передача признака лайка текущим пользователем.

## Архитектура
Expand All @@ -31,8 +33,9 @@ Feed отвечает за общую ленту `/feed/`.
- `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.
- `feed/signals.py` - создание и удаление служебных feed-записей для проектов
и вакансий.
- `feed/tests/` - regression-тесты API, service helpers и signal handlers.

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

Expand All @@ -56,35 +59,56 @@ View выбирает подходящие `news.News`, сериализует
Они используют `news.News` с пустым `text` и связью на объект, например проект
или вакансию.

Для служебной записи проекта `type_model = "project"`, а `content` содержит
проект. Для служебной записи вакансии `type_model = "vacancy"`, а `content`
содержит вакансию.

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

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

### 5. Вакансия становится недоступной для публичной ленты

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

## API

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

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

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

## Тесты

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

- `/feed/?type=news` возвращает пользовательские новости;
- `/feed/?type=project` возвращает проектные новости в frontend-формате;
- `/feed/?type=project` возвращает служебную feed-запись проекта как
`type_model = "project"`;
- `/feed/?type=vacancy` возвращает служебную feed-запись вакансии как
`type_model = "vacancy"`;
- новости непубличных проектов не попадают в feed;
- новости черновых проектов не попадают в feed;
- служебные записи закрытых вакансий не попадают в feed;
- служебные записи вакансий из черновых и непубличных проектов не попадают в
feed;
- liked flag выставляется для новостей, лайкнутых текущим пользователем;
- `get_liked_news()` возвращает лайкнутые текущим пользователем записи;
- `create_news_for_model()` создает одну служебную feed-запись без дублей;
- `delete_news_for_model()` удаляет только служебную feed-запись и не трогает
обычную новость с текстом.
обычную новость с текстом;
- signal handlers создают и удаляют служебные feed-записи при изменении проекта
или вакансии.
14 changes: 5 additions & 9 deletions docs/modules/news.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ generic relation:
различения обычной новости и 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/views.py` - контекстный API для list/create/detail/update/delete,
set_viewed и set_liked.
- `news/serializers.py` - request serializers и response serializers,
разделенные по контекстам project/user/program.
- `news/permissions.py` - права на создание и изменение новости в зависимости от
связанного объекта.
- `news/admin.py` - админка `News`.
Expand Down Expand Up @@ -89,9 +89,6 @@ Feed-запись определяется через helper `is_feed_record(new

Связанные endpoints:

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

## Основные сценарии
Expand Down Expand Up @@ -143,8 +140,7 @@ Feed-запись определяется через helper `is_feed_record(new
- Новость проекта может создавать и изменять только лидер проекта.
- Новость пользователя может создавать и изменять только сам пользователь.
- Новость программы может создавать и изменять только менеджер программы.
- Прямой `/news/` без project/user/program context не является основным
пользовательским API.
- Вложения новости должны ссылаться только на `UserFile` текущего пользователя.
- Несуществующий project/user/program context возвращает `404`.
- Проектные новости реализованы через `news.News`.

Expand Down
2 changes: 0 additions & 2 deletions feed/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ def get_type_model(self, obj) -> str:

def get_content_object(self, obj) -> dict:
type_model = obj.content_type.model
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 Down
17 changes: 17 additions & 0 deletions feed/tests/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from news.tests.helpers import create_project
from projects.models import Project
from vacancy.models import Vacancy


def create_vacancy(
*,
project: Project | None = None,
role: str = "Feed vacancy",
is_active: bool = True,
) -> Vacancy:
return Vacancy.objects.create(
project=project or create_project(name="Feed vacancy project"),
role=role,
description="Vacancy description",
is_active=is_active,
)
75 changes: 75 additions & 0 deletions feed/tests/test_feed_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from django.test import TestCase
from rest_framework.test import APIClient

from core.services import set_like
from feed.services import create_news_for_model
from feed.tests.helpers import create_vacancy
from news.tests.helpers import create_news_for, create_project, create_user


Expand Down Expand Up @@ -33,6 +36,69 @@ def test_feed_returns_project_news_as_news_content(self):
self.assertEqual(item["content"]["id"], news.id)
self.assertEqual(item["content"]["text"], "Project feed news")

def test_feed_returns_project_feed_record_as_project_content(self):
project = create_project(name="Feed record project")
create_news_for_model(project)

response = self.client.get("/feed/?type=project")

self.assertEqual(response.status_code, 200)
item = response.data["results"][0]
self.assertEqual(item["type_model"], "project")
self.assertEqual(item["content"]["id"], project.id)

def test_feed_returns_vacancy_feed_record_as_vacancy_content(self):
vacancy = create_vacancy(role="Backend developer")

response = self.client.get("/feed/?type=vacancy")

self.assertEqual(response.status_code, 200)
item = response.data["results"][0]
self.assertEqual(item["type_model"], "vacancy")
self.assertEqual(item["content"]["id"], vacancy.id)
self.assertEqual(item["content"]["role"], "Backend developer")

def test_feed_excludes_feed_record_for_inactive_vacancy(self):
vacancy = create_vacancy(role="Inactive vacancy", is_active=False)
create_news_for_model(vacancy)

response = self.client.get("/feed/?type=vacancy")

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["results"], [])

def test_feed_excludes_vacancy_feed_record_for_draft_project(self):
draft_project = create_project(name="Draft vacancy project", draft=True)
create_vacancy(project=draft_project, role="Draft project vacancy")

response = self.client.get("/feed/?type=vacancy")

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["results"], [])

def test_feed_excludes_vacancy_feed_record_for_private_project(self):
private_project = create_project(
name="Private vacancy project",
is_public=False,
)
create_vacancy(project=private_project, role="Private project vacancy")

response = self.client.get("/feed/?type=vacancy")

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["results"], [])

def test_feed_marks_news_liked_by_current_user(self):
news = create_news_for(self.user, text="Liked user feed news")
set_like(news, self.user, True)

response = self.client.get("/feed/?type=news")

self.assertEqual(response.status_code, 200)
item = response.data["results"][0]
self.assertEqual(item["type_model"], "news")
self.assertTrue(item["content"]["is_user_liked"])

def test_feed_excludes_news_for_private_project(self):
private_project = create_project(name="Private project", is_public=False)
create_news_for(private_project, text="Private project news")
Expand All @@ -41,3 +107,12 @@ def test_feed_excludes_news_for_private_project(self):

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["results"], [])

def test_feed_excludes_news_for_draft_project(self):
draft_project = create_project(name="Draft project", draft=True)
create_news_for(draft_project, text="Draft project news")

response = self.client.get("/feed/?type=project")

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["results"], [])
62 changes: 62 additions & 0 deletions feed/tests/test_feed_signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from django.test import TestCase

from feed.tests.helpers import create_vacancy
from news.models import News
from news.services import FEED_RECORD_TEXT
from news.tests.helpers import create_project


class FeedSignalTests(TestCase):
def test_project_signal_removes_feed_record_when_project_becomes_draft(self):
project = create_project(name="Draft signal project")
self.assertTrue(
News.objects.get_news(project).filter(text=FEED_RECORD_TEXT).exists()
)

project.draft = True
project.save(update_fields=["draft"])

self.assertFalse(
News.objects.get_news(project).filter(text=FEED_RECORD_TEXT).exists()
)

def test_project_delete_signal_removes_feed_record(self):
project = create_project(name="Deleted signal project")
project_id = project.id

project.delete()

self.assertFalse(
News.objects.filter(
object_id=project_id,
content_type__model="project",
text=FEED_RECORD_TEXT,
).exists()
)

def test_vacancy_signal_creates_and_removes_feed_record_by_active_state(self):
vacancy = create_vacancy(role="Signal vacancy")
self.assertTrue(
News.objects.get_news(vacancy).filter(text=FEED_RECORD_TEXT).exists()
)

vacancy.is_active = False
vacancy.save(update_fields=["is_active"])

self.assertFalse(
News.objects.get_news(vacancy).filter(text=FEED_RECORD_TEXT).exists()
)

def test_vacancy_delete_signal_removes_feed_record(self):
vacancy = create_vacancy(role="Deleted signal vacancy")
vacancy_id = vacancy.id

vacancy.delete()

self.assertFalse(
News.objects.filter(
object_id=vacancy_id,
content_type__model="vacancy",
text=FEED_RECORD_TEXT,
).exists()
)
3 changes: 1 addition & 2 deletions feed/urls.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
from django.urls import path

from feed.views import NewSimpleFeed, DevScript
from feed.views import NewSimpleFeed

app_name = "feed"

urlpatterns = [
path("", NewSimpleFeed.as_view()),
path("dev-needs-script", DevScript.as_view()),
]
Loading
Loading