Skip to content

Commit b9dde5d

Browse files
authored
Merge pull request #640 from PROCOLLAB-github/refactor/modules
Refactor/modules
2 parents b32c80d + 2017501 commit b9dde5d

13 files changed

Lines changed: 349 additions & 117 deletions

File tree

docs/modules/feed.md

Lines changed: 33 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

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

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

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

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

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

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

71+
### 5. Вакансия становится недоступной для публичной ленты
72+
73+
Если вакансия закрыта, находится в черновом проекте или относится к непубличному
74+
проекту, связанная с ней служебная feed-запись не возвращается в `/feed/`.
75+
6476
## API
6577

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

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

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

8093
## Тесты
8194

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

8497
- `/feed/?type=news` возвращает пользовательские новости;
8598
- `/feed/?type=project` возвращает проектные новости в frontend-формате;
99+
- `/feed/?type=project` возвращает служебную feed-запись проекта как
100+
`type_model = "project"`;
101+
- `/feed/?type=vacancy` возвращает служебную feed-запись вакансии как
102+
`type_model = "vacancy"`;
86103
- новости непубличных проектов не попадают в feed;
104+
- новости черновых проектов не попадают в feed;
105+
- служебные записи закрытых вакансий не попадают в feed;
106+
- служебные записи вакансий из черновых и непубличных проектов не попадают в
107+
feed;
108+
- liked flag выставляется для новостей, лайкнутых текущим пользователем;
87109
- `get_liked_news()` возвращает лайкнутые текущим пользователем записи;
88110
- `create_news_for_model()` создает одну служебную feed-запись без дублей;
89111
- `delete_news_for_model()` удаляет только служебную feed-запись и не трогает
90-
обычную новость с текстом.
112+
обычную новость с текстом;
113+
- signal handlers создают и удаляют служебные feed-записи при изменении проекта
114+
или вакансии.

docs/modules/news.md

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ generic relation:
4747
различения обычной новости и feed-записи.
4848
- `news/querysets.py` - явные queryset helpers по контексту URL: project, user
4949
или partner program.
50-
- `news/views.py` - общий API для list/create/detail/update/delete, set_viewed и
51-
set_liked.
52-
- `news/serializers.py` - request/response serializers для создания, списка и
53-
detail.
50+
- `news/views.py` - контекстный API для list/create/detail/update/delete,
51+
set_viewed и set_liked.
52+
- `news/serializers.py` - request serializers и response serializers,
53+
разделенные по контекстам project/user/program.
5454
- `news/permissions.py` - права на создание и изменение новости в зависимости от
5555
связанного объекта.
5656
- `news/admin.py` - админка `News`.
@@ -89,9 +89,6 @@ Feed-запись определяется через helper `is_feed_record(new
8989

9090
Связанные endpoints:
9191

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

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

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: 75 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,69 @@ 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_excludes_feed_record_for_inactive_vacancy(self):
62+
vacancy = create_vacancy(role="Inactive vacancy", is_active=False)
63+
create_news_for_model(vacancy)
64+
65+
response = self.client.get("/feed/?type=vacancy")
66+
67+
self.assertEqual(response.status_code, 200)
68+
self.assertEqual(response.data["results"], [])
69+
70+
def test_feed_excludes_vacancy_feed_record_for_draft_project(self):
71+
draft_project = create_project(name="Draft vacancy project", draft=True)
72+
create_vacancy(project=draft_project, role="Draft project vacancy")
73+
74+
response = self.client.get("/feed/?type=vacancy")
75+
76+
self.assertEqual(response.status_code, 200)
77+
self.assertEqual(response.data["results"], [])
78+
79+
def test_feed_excludes_vacancy_feed_record_for_private_project(self):
80+
private_project = create_project(
81+
name="Private vacancy project",
82+
is_public=False,
83+
)
84+
create_vacancy(project=private_project, role="Private project vacancy")
85+
86+
response = self.client.get("/feed/?type=vacancy")
87+
88+
self.assertEqual(response.status_code, 200)
89+
self.assertEqual(response.data["results"], [])
90+
91+
def test_feed_marks_news_liked_by_current_user(self):
92+
news = create_news_for(self.user, text="Liked user feed news")
93+
set_like(news, self.user, True)
94+
95+
response = self.client.get("/feed/?type=news")
96+
97+
self.assertEqual(response.status_code, 200)
98+
item = response.data["results"][0]
99+
self.assertEqual(item["type_model"], "news")
100+
self.assertTrue(item["content"]["is_user_liked"])
101+
36102
def test_feed_excludes_news_for_private_project(self):
37103
private_project = create_project(name="Private project", is_public=False)
38104
create_news_for(private_project, text="Private project news")
@@ -41,3 +107,12 @@ def test_feed_excludes_news_for_private_project(self):
41107

42108
self.assertEqual(response.status_code, 200)
43109
self.assertEqual(response.data["results"], [])
110+
111+
def test_feed_excludes_news_for_draft_project(self):
112+
draft_project = create_project(name="Draft project", draft=True)
113+
create_news_for(draft_project, text="Draft project news")
114+
115+
response = self.client.get("/feed/?type=project")
116+
117+
self.assertEqual(response.status_code, 200)
118+
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
]

0 commit comments

Comments
 (0)