Skip to content

Commit 56e25cb

Browse files
committed
Задокументирован и покрыт тестами модуль Feed
1 parent b32c80d commit 56e25cb

7 files changed

Lines changed: 152 additions & 59 deletions

File tree

docs/modules/feed.md

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ Feed отвечает за общую ленту `/feed/`.
1111
## Статус модуля
1212

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

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

@@ -31,8 +32,9 @@ Feed отвечает за общую ленту `/feed/`.
3132
- `feed/services.py` - helpers для лайков и служебных feed-записей.
3233
- `feed/mapping.py` - соответствие content object типам и serializers.
3334
- `feed/constants.py` - типы моделей, для которых signals создают feed-записи.
34-
- `feed/signals.py` - подключение signal handlers.
35-
- `feed/tests/` - regression-тесты API и service helpers.
35+
- `feed/signals.py` - создание и удаление служебных feed-записей для проектов
36+
и вакансий.
37+
- `feed/tests/` - regression-тесты API, service helpers и signal handlers.
3638

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

@@ -56,6 +58,10 @@ View выбирает подходящие `news.News`, сериализует
5658
Они используют `news.News` с пустым `text` и связью на объект, например проект
5759
или вакансию.
5860

61+
Для служебной записи проекта `type_model = "project"`, а `content` содержит
62+
проект. Для служебной записи вакансии `type_model = "vacancy"`, а `content`
63+
содержит вакансию.
64+
5965
### 4. Проект становится недоступным для публичной ленты
6066

6167
Если проект черновой или непубличный, связанные с ним записи не возвращаются в
@@ -65,26 +71,35 @@ View выбирает подходящие `news.News`, сериализует
6571

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

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

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

8087
## Тесты
8188

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

8491
- `/feed/?type=news` возвращает пользовательские новости;
8592
- `/feed/?type=project` возвращает проектные новости в frontend-формате;
93+
- `/feed/?type=project` возвращает служебную feed-запись проекта как
94+
`type_model = "project"`;
95+
- `/feed/?type=vacancy` возвращает служебную feed-запись вакансии как
96+
`type_model = "vacancy"`;
8697
- новости непубличных проектов не попадают в feed;
98+
- новости черновых проектов не попадают в feed;
99+
- liked flag выставляется для новостей, лайкнутых текущим пользователем;
87100
- `get_liked_news()` возвращает лайкнутые текущим пользователем записи;
88101
- `create_news_for_model()` создает одну служебную feed-запись без дублей;
89102
- `delete_news_for_model()` удаляет только служебную feed-запись и не трогает
90-
обычную новость с текстом.
103+
обычную новость с текстом;
104+
- signal handlers создают и удаляют служебные feed-записи при изменении проекта
105+
или вакансии.

feed/serializers.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ def get_type_model(self, obj) -> str:
2828

2929
def get_content_object(self, obj) -> dict:
3030
type_model = obj.content_type.model
31-
if is_content_news(obj) and self.get_type_model(obj) == "project":
32-
type_model = "news"
3331
serializer = CONTENT_OBJECT_SERIALIZER_MAPPING[type_model](obj.content_object)
3432
return serializer.data
3533

feed/tests/helpers.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from news.tests.helpers import create_project
2+
from projects.models import Project
3+
from vacancy.models import Vacancy
4+
5+
6+
def create_vacancy(
7+
*,
8+
project: Project | None = None,
9+
role: str = "Feed vacancy",
10+
is_active: bool = True,
11+
) -> Vacancy:
12+
return Vacancy.objects.create(
13+
project=project or create_project(name="Feed vacancy project"),
14+
role=role,
15+
description="Vacancy description",
16+
is_active=is_active,
17+
)

feed/tests/test_feed_api.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from django.test import TestCase
22
from rest_framework.test import APIClient
33

4+
from core.services import set_like
5+
from feed.services import create_news_for_model
6+
from feed.tests.helpers import create_vacancy
47
from news.tests.helpers import create_news_for, create_project, create_user
58

69

@@ -33,6 +36,39 @@ def test_feed_returns_project_news_as_news_content(self):
3336
self.assertEqual(item["content"]["id"], news.id)
3437
self.assertEqual(item["content"]["text"], "Project feed news")
3538

39+
def test_feed_returns_project_feed_record_as_project_content(self):
40+
project = create_project(name="Feed record project")
41+
create_news_for_model(project)
42+
43+
response = self.client.get("/feed/?type=project")
44+
45+
self.assertEqual(response.status_code, 200)
46+
item = response.data["results"][0]
47+
self.assertEqual(item["type_model"], "project")
48+
self.assertEqual(item["content"]["id"], project.id)
49+
50+
def test_feed_returns_vacancy_feed_record_as_vacancy_content(self):
51+
vacancy = create_vacancy(role="Backend developer")
52+
53+
response = self.client.get("/feed/?type=vacancy")
54+
55+
self.assertEqual(response.status_code, 200)
56+
item = response.data["results"][0]
57+
self.assertEqual(item["type_model"], "vacancy")
58+
self.assertEqual(item["content"]["id"], vacancy.id)
59+
self.assertEqual(item["content"]["role"], "Backend developer")
60+
61+
def test_feed_marks_news_liked_by_current_user(self):
62+
news = create_news_for(self.user, text="Liked user feed news")
63+
set_like(news, self.user, True)
64+
65+
response = self.client.get("/feed/?type=news")
66+
67+
self.assertEqual(response.status_code, 200)
68+
item = response.data["results"][0]
69+
self.assertEqual(item["type_model"], "news")
70+
self.assertTrue(item["content"]["is_user_liked"])
71+
3672
def test_feed_excludes_news_for_private_project(self):
3773
private_project = create_project(name="Private project", is_public=False)
3874
create_news_for(private_project, text="Private project news")
@@ -41,3 +77,12 @@ def test_feed_excludes_news_for_private_project(self):
4177

4278
self.assertEqual(response.status_code, 200)
4379
self.assertEqual(response.data["results"], [])
80+
81+
def test_feed_excludes_news_for_draft_project(self):
82+
draft_project = create_project(name="Draft project", draft=True)
83+
create_news_for(draft_project, text="Draft project news")
84+
85+
response = self.client.get("/feed/?type=project")
86+
87+
self.assertEqual(response.status_code, 200)
88+
self.assertEqual(response.data["results"], [])

feed/tests/test_feed_signals.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from django.test import TestCase
2+
3+
from feed.tests.helpers import create_vacancy
4+
from news.models import News
5+
from news.services import FEED_RECORD_TEXT
6+
from news.tests.helpers import create_project
7+
8+
9+
class FeedSignalTests(TestCase):
10+
def test_project_signal_removes_feed_record_when_project_becomes_draft(self):
11+
project = create_project(name="Draft signal project")
12+
self.assertTrue(
13+
News.objects.get_news(project).filter(text=FEED_RECORD_TEXT).exists()
14+
)
15+
16+
project.draft = True
17+
project.save(update_fields=["draft"])
18+
19+
self.assertFalse(
20+
News.objects.get_news(project).filter(text=FEED_RECORD_TEXT).exists()
21+
)
22+
23+
def test_project_delete_signal_removes_feed_record(self):
24+
project = create_project(name="Deleted signal project")
25+
project_id = project.id
26+
27+
project.delete()
28+
29+
self.assertFalse(
30+
News.objects.filter(
31+
object_id=project_id,
32+
content_type__model="project",
33+
text=FEED_RECORD_TEXT,
34+
).exists()
35+
)
36+
37+
def test_vacancy_signal_creates_and_removes_feed_record_by_active_state(self):
38+
vacancy = create_vacancy(role="Signal vacancy")
39+
self.assertTrue(
40+
News.objects.get_news(vacancy).filter(text=FEED_RECORD_TEXT).exists()
41+
)
42+
43+
vacancy.is_active = False
44+
vacancy.save(update_fields=["is_active"])
45+
46+
self.assertFalse(
47+
News.objects.get_news(vacancy).filter(text=FEED_RECORD_TEXT).exists()
48+
)
49+
50+
def test_vacancy_delete_signal_removes_feed_record(self):
51+
vacancy = create_vacancy(role="Deleted signal vacancy")
52+
vacancy_id = vacancy.id
53+
54+
vacancy.delete()
55+
56+
self.assertFalse(
57+
News.objects.filter(
58+
object_id=vacancy_id,
59+
content_type__model="vacancy",
60+
text=FEED_RECORD_TEXT,
61+
).exists()
62+
)

feed/urls.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
from django.urls import path
22

3-
from feed.views import NewSimpleFeed, DevScript
3+
from feed.views import NewSimpleFeed
44

55
app_name = "feed"
66

77
urlpatterns = [
88
path("", NewSimpleFeed.as_view()),
9-
path("dev-needs-script", DevScript.as_view()),
109
]

feed/views.py

Lines changed: 3 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
1-
from django.contrib.contenttypes.models import ContentType
21
from django.db.models import Q, QuerySet
3-
from rest_framework.generics import CreateAPIView
4-
from rest_framework.response import Response
52
from rest_framework.views import APIView
63

7-
from core.serializers import EmptySerializer
84
from feed.pagination import FeedPagination
95
from feed.services import get_liked_news
106
from news.models import News
@@ -67,52 +63,13 @@ def get(self, *args, **kwargs):
6763
# временная подстройка данных под фронт
6864
for data in serializer.data:
6965
if data["type_model"] in ["project", "vacancy", None]:
70-
fomated_data = {
66+
formatted_data = {
7167
"type_model": data["type_model"],
7268
"content": data["content_object"],
7369
}
7470
elif data["type_model"] == "news":
7571
del data["type_model"]
76-
fomated_data = {"type_model": "news", "content": data}
77-
new_data.append(fomated_data)
72+
formatted_data = {"type_model": "news", "content": data}
73+
new_data.append(formatted_data)
7874

7975
return paginator.get_paginated_response(new_data)
80-
81-
82-
class DevScript(CreateAPIView):
83-
serializer_class = EmptySerializer
84-
85-
def create(self, request):
86-
content_type_project = ContentType.objects.filter(model="project").first()
87-
for project in Project.objects.filter(draft=False):
88-
if not News.objects.filter(
89-
content_type=content_type_project, object_id=project.id
90-
).exists():
91-
News.objects.create(
92-
content_type=content_type_project,
93-
object_id=project.id,
94-
datetime_created=project.datetime_created,
95-
)
96-
97-
content_type_vacancy = ContentType.objects.filter(model="vacancy").first()
98-
for vacancy in Vacancy.objects.filter(is_active=True):
99-
if not News.objects.filter(
100-
content_type=content_type_vacancy, object_id=vacancy.id
101-
).exists():
102-
News.objects.create(
103-
content_type=content_type_vacancy,
104-
object_id=vacancy.id,
105-
datetime_created=vacancy.datetime_created,
106-
)
107-
108-
news_to_delete = list(
109-
News.objects.filter(
110-
content_type__in=[content_type_vacancy, content_type_project]
111-
)
112-
)
113-
114-
for news in news_to_delete:
115-
if not news.content_object:
116-
news.delete()
117-
118-
return Response({"status": "success"}, status=201)

0 commit comments

Comments
 (0)