Skip to content

Commit 69e4e80

Browse files
committed
Уточнён контракт Feed и обработка новостей программ
1 parent 2017501 commit 69e4e80

5 files changed

Lines changed: 132 additions & 34 deletions

File tree

docs/modules/feed.md

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,9 @@ handlers для служебных записей.
2626

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

29-
- `feed/views.py` - endpoint `/feed/`, фильтрация по типам и финальная сборка
30-
response payload.
31-
- `feed/serializers.py` - `FeedNewsResponseSerializer`, который превращает
32-
`news.News` в элемент ленты.
29+
- `feed/views.py` - endpoint `/feed/` и фильтрация по типам.
30+
- `feed/serializers.py` - `FeedItemResponseSerializer`, который превращает
31+
`news.News` в элемент ленты формата `{type_model, content}`.
3332
- `feed/services.py` - helpers для лайков и служебных feed-записей.
3433
- `feed/mapping.py` - соответствие content object типам и serializers.
3534
- `feed/constants.py` - типы моделей, для которых signals создают feed-записи.
@@ -45,14 +44,28 @@ Frontend вызывает `/feed/?type=...`.
4544
View выбирает подходящие `news.News`, сериализует их и возвращает элементы
4645
ленты с полями `type_model` и `content`.
4746

47+
Ответ использует стандартную пагинацию DRF: `count`, `next`, `previous`,
48+
`results`. Каждый элемент `results` содержит только:
49+
50+
- `type_model` - тип элемента ленты;
51+
- `content` - сериализованный объект, соответствующий этому типу.
52+
4853
### 2. В ленту попадает обычная новость
4954

50-
Если `news.News` содержит текст и относится к пользователю или проекту, лента
51-
возвращает ее как новость.
55+
Если `news.News` содержит текст и относится к пользователю, проекту или
56+
партнерской программе, лента возвращает ее как новость.
5257

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

61+
Новость партнерской программы с текстом тоже возвращается как
62+
`type_model = "news"`. Отдельный `type_model = "partner_program"` пока не
63+
вводится.
64+
65+
Служебные feed-записи партнерских программ сейчас не создаются. Если такой
66+
сценарий понадобится, для него нужно отдельно согласовать `type_model` и
67+
frontend-контракт.
68+
5669
### 3. В ленту попадает служебная запись
5770

5871
Служебные feed-записи создаются через `feed.services.create_news_for_model()`.
@@ -78,14 +91,17 @@ View выбирает подходящие `news.News`, сериализует
7891
- `GET /feed/?type=news` - новости пользователей.
7992
- `GET /feed/?type=project` - проектные новости и проектные feed-записи.
8093
- `GET /feed/?type=vacancy` - служебные feed-записи вакансий.
81-
- `GET /feed/?type=project|news|vacancy` - комбинированная выдача по нескольким
82-
типам.
94+
- `GET /feed/?type=partnerprogram` - новости партнерских программ.
95+
- `GET /feed/?type=project|vacancy|news|partnerprogram` - комбинированная
96+
выдача по нескольким типам.
8397

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

86100
- Feed читает данные из `news.News`, но не отвечает за создание обычных
87101
project/user/program news.
88102
- Служебная feed-запись определяется через пустой `text`.
103+
- Новости партнерских программ с текстом отображаются как обычные новости;
104+
отдельные служебные карточки программ в ленте пока не поддерживаются.
89105
- Signals `feed` создают или удаляют служебные feed-записи для проектов и
90106
вакансий. Более широкие сценарии публикации проекта остаются в модуле
91107
`projects`.
@@ -96,6 +112,8 @@ View выбирает подходящие `news.News`, сериализует
96112

97113
- `/feed/?type=news` возвращает пользовательские новости;
98114
- `/feed/?type=project` возвращает проектные новости в frontend-формате;
115+
- `/feed/?type=partnerprogram` возвращает новости программ как
116+
`type_model = "news"`;
99117
- `/feed/?type=project` возвращает служебную feed-запись проекта как
100118
`type_model = "project"`;
101119
- `/feed/?type=vacancy` возвращает служебную feed-запись вакансии как

feed/mapping.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
66
from vacancy.models import Vacancy
77
from vacancy.serializers import VacancyDetailSerializer
88

9-
CONTENT_OBJECT_MAPPING: dict[str, str | None] = {
9+
CONTENT_OBJECT_MAPPING: dict[str, str] = {
1010
Project.__name__.lower(): "project",
1111
CustomUser.__name__.lower(): "news",
12-
"partnerprogram": None,
1312
Vacancy.__name__.lower(): "vacancy",
1413
}
1514

feed/serializers.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
from news.mapping import NewsMapping
77
from news.models import News
88
from news.services import is_content_news
9+
from partner_programs.models import PartnerProgram
910
from projects.models import Project
1011
from users.models import CustomUser
1112

1213

13-
class FeedNewsResponseSerializer(serializers.ModelSerializer):
14+
class FeedNewsContentSerializer(serializers.ModelSerializer):
1415
name = serializers.SerializerMethodField()
1516
image_address = serializers.SerializerMethodField()
1617
is_user_liked = serializers.SerializerMethodField()
@@ -20,11 +21,18 @@ class FeedNewsResponseSerializer(serializers.ModelSerializer):
2021
content_object = serializers.SerializerMethodField()
2122
type_model = serializers.SerializerMethodField()
2223

23-
def get_type_model(self, obj) -> str:
24-
model_type = CONTENT_OBJECT_MAPPING[obj.content_type.model]
25-
if is_content_news(obj) and model_type == "project":
24+
def get_type_model(self, obj) -> str | None:
25+
content_model = obj.content_type.model
26+
27+
if content_model == PartnerProgram.__name__.lower():
28+
# Новости программ сейчас отображаются как обычные новости.
29+
# Отдельная служебная карточка программы в ленте пока не согласована.
30+
return "news" if is_content_news(obj) else None
31+
32+
if is_content_news(obj) and content_model == Project.__name__.lower():
2633
return "news"
27-
return model_type
34+
35+
return CONTENT_OBJECT_MAPPING[content_model]
2836

2937
def get_content_object(self, obj) -> dict:
3038
type_model = obj.content_type.model
@@ -65,3 +73,20 @@ class Meta:
6573
"type_model",
6674
]
6775
read_only_fields = ["views_count", "likes_count", "type_model"]
76+
77+
78+
class FeedItemResponseSerializer(serializers.Serializer):
79+
def to_representation(self, instance):
80+
data = FeedNewsContentSerializer(instance, context=self.context).data
81+
type_model = data["type_model"]
82+
83+
if type_model == "news":
84+
content = dict(data)
85+
del content["type_model"]
86+
else:
87+
content = data["content_object"]
88+
89+
return {
90+
"type_model": type_model,
91+
"content": content,
92+
}

feed/tests/test_feed_api.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44
from core.services import set_like
55
from feed.services import create_news_for_model
66
from feed.tests.helpers import create_vacancy
7-
from news.tests.helpers import create_news_for, create_project, create_user
7+
from news.tests.helpers import (
8+
create_news_for,
9+
create_partner_program,
10+
create_project,
11+
create_user,
12+
)
813

914

1015
class FeedAPITests(TestCase):
@@ -20,6 +25,7 @@ def test_feed_returns_user_news_when_news_filter_requested(self):
2025

2126
self.assertEqual(response.status_code, 200)
2227
item = response.data["results"][0]
28+
self.assertEqual(set(item.keys()), {"type_model", "content"})
2329
self.assertEqual(item["type_model"], "news")
2430
self.assertEqual(item["content"]["id"], news.id)
2531
self.assertEqual(item["content"]["text"], "User feed news")
@@ -32,10 +38,24 @@ def test_feed_returns_project_news_as_news_content(self):
3238

3339
self.assertEqual(response.status_code, 200)
3440
item = response.data["results"][0]
41+
self.assertEqual(set(item.keys()), {"type_model", "content"})
3542
self.assertEqual(item["type_model"], "news")
3643
self.assertEqual(item["content"]["id"], news.id)
3744
self.assertEqual(item["content"]["text"], "Project feed news")
3845

46+
def test_feed_returns_program_news_as_news_content(self):
47+
program = create_partner_program(name="Feed program")
48+
news = create_news_for(program, text="Program feed news")
49+
50+
response = self.client.get("/feed/?type=partnerprogram")
51+
52+
self.assertEqual(response.status_code, 200)
53+
item = response.data["results"][0]
54+
self.assertEqual(set(item.keys()), {"type_model", "content"})
55+
self.assertEqual(item["type_model"], "news")
56+
self.assertEqual(item["content"]["id"], news.id)
57+
self.assertEqual(item["content"]["text"], "Program feed news")
58+
3959
def test_feed_returns_project_feed_record_as_project_content(self):
4060
project = create_project(name="Feed record project")
4161
create_news_for_model(project)
@@ -44,6 +64,7 @@ def test_feed_returns_project_feed_record_as_project_content(self):
4464

4565
self.assertEqual(response.status_code, 200)
4666
item = response.data["results"][0]
67+
self.assertEqual(set(item.keys()), {"type_model", "content"})
4768
self.assertEqual(item["type_model"], "project")
4869
self.assertEqual(item["content"]["id"], project.id)
4970

@@ -54,10 +75,59 @@ def test_feed_returns_vacancy_feed_record_as_vacancy_content(self):
5475

5576
self.assertEqual(response.status_code, 200)
5677
item = response.data["results"][0]
78+
self.assertEqual(set(item.keys()), {"type_model", "content"})
5779
self.assertEqual(item["type_model"], "vacancy")
5880
self.assertEqual(item["content"]["id"], vacancy.id)
5981
self.assertEqual(item["content"]["role"], "Backend developer")
6082

83+
def test_feed_combines_all_supported_filters(self):
84+
project_news = create_news_for(
85+
create_project(name="Combined project news"),
86+
text="Combined project news",
87+
)
88+
program_news = create_news_for(
89+
create_partner_program(name="Combined program"),
90+
text="Combined program news",
91+
)
92+
user_news = create_news_for(self.user, text="Combined user news")
93+
project = create_project(name="Combined project record")
94+
vacancy = create_vacancy(role="Combined vacancy")
95+
create_news_for_model(project)
96+
97+
response = self.client.get(
98+
"/feed/?type=project|vacancy|news|partnerprogram"
99+
)
100+
101+
self.assertEqual(response.status_code, 200)
102+
items_by_text = {
103+
item["content"].get("text"): item
104+
for item in response.data["results"]
105+
if item["type_model"] == "news"
106+
}
107+
content_ids_by_type = {
108+
type_model: {
109+
item["content"]["id"]
110+
for item in response.data["results"]
111+
if item["type_model"] == type_model
112+
}
113+
for type_model in ["project", "vacancy"]
114+
}
115+
116+
self.assertEqual(
117+
items_by_text[project_news.text]["content"]["id"],
118+
project_news.id,
119+
)
120+
self.assertEqual(
121+
items_by_text[program_news.text]["content"]["id"],
122+
program_news.id,
123+
)
124+
self.assertEqual(
125+
items_by_text[user_news.text]["content"]["id"],
126+
user_news.id,
127+
)
128+
self.assertIn(project.id, content_ids_by_type["project"])
129+
self.assertIn(vacancy.id, content_ids_by_type["vacancy"])
130+
61131
def test_feed_excludes_feed_record_for_inactive_vacancy(self):
62132
vacancy = create_vacancy(role="Inactive vacancy", is_active=False)
63133
create_news_for_model(vacancy)

feed/views.py

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
from projects.models import Project
88
from vacancy.models import Vacancy
99

10-
from .serializers import FeedNewsResponseSerializer
10+
from .serializers import FeedItemResponseSerializer
1111

1212

1313
class NewSimpleFeed(APIView):
14-
serializator_class = FeedNewsResponseSerializer
14+
serializer_class = FeedItemResponseSerializer
1515
pagination_class = FeedPagination
1616

1717
def _get_filter_data(self) -> list[str]:
@@ -54,26 +54,12 @@ def get_queryset(self) -> QuerySet[News]:
5454
def get(self, *args, **kwargs):
5555
paginator = self.pagination_class()
5656
paginated_data = paginator.paginate_queryset(self.get_queryset(), self.request)
57-
serializer = FeedNewsResponseSerializer(
57+
serializer = FeedItemResponseSerializer(
5858
paginated_data,
5959
context={
6060
"user": self.request.user,
6161
"liked_news": get_liked_news(self.request.user, paginated_data),
6262
},
6363
many=True,
6464
)
65-
66-
new_data = []
67-
# временная подстройка данных под фронт
68-
for data in serializer.data:
69-
if data["type_model"] in ["project", "vacancy", None]:
70-
formatted_data = {
71-
"type_model": data["type_model"],
72-
"content": data["content_object"],
73-
}
74-
elif data["type_model"] == "news":
75-
del data["type_model"]
76-
formatted_data = {"type_model": "news", "content": data}
77-
new_data.append(formatted_data)
78-
79-
return paginator.get_paginated_response(new_data)
65+
return paginator.get_paginated_response(serializer.data)

0 commit comments

Comments
 (0)