Skip to content

Commit 8495994

Browse files
committed
Задокументирован модуль News и добавлены тесты
1 parent 143a204 commit 8495994

8 files changed

Lines changed: 563 additions & 6 deletions

File tree

docs/modules/news.md

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,182 @@
11
# News
22

3-
TODO
3+
## Назначение
4+
5+
News отвечает за публикации и новости в разных частях продукта.
6+
7+
Модуль хранит новости в единой модели `News` и связывает их с объектами через
8+
generic relation:
9+
10+
- проектами;
11+
- пользователями;
12+
- партнерскими программами;
13+
- объектами ленты, например вакансиями.
14+
15+
Одна и та же модель используется для двух близких, но разных сценариев:
16+
17+
- обычная новость с текстом и файлами;
18+
- служебная запись ленты для существующего объекта, где `text = ""`.
19+
20+
## Статус модуля
21+
22+
Модуль рабочий, но связан с несколькими доменными областями и требует
23+
аккуратного рефакторинга. Сейчас он обслуживает проектные новости, новости
24+
пользователей, новости программ и часть общей ленты.
25+
26+
Первый слой regression-тестов добавлен для живых сценариев API и feed.
27+
28+
## Основные возможности
29+
30+
- создание новости в контексте проекта;
31+
- создание новости в профиле пользователя;
32+
- создание новости в партнерской программе;
33+
- просмотр списка новостей в конкретном контексте;
34+
- просмотр, редактирование и удаление отдельной новости;
35+
- отметка просмотра новости;
36+
- лайк и снятие лайка с новости;
37+
- участие новостей в общей ленте `/feed/`;
38+
- закрепление новости программы через `pin`.
39+
40+
## Архитектура
41+
42+
- `news/models.py` - модель `News` с `content_type/object_id`, файлами, лайками,
43+
просмотрами и флагом `pin`.
44+
- `news/managers.py` - `get_news(obj)` и `add_news(obj, **kwargs)` для работы с
45+
generic relation.
46+
- `news/mixins.py` - выбор queryset по контексту URL: project, user или partner
47+
program.
48+
- `news/views.py` - общий API для list/create/detail/update/delete, set_viewed и
49+
set_liked.
50+
- `news/serializers.py` - request/response serializers для списка, detail и feed
51+
представления.
52+
- `news/permissions.py` - права на создание и изменение новости в зависимости от
53+
связанного объекта.
54+
- `news/admin.py` - админка `News`.
55+
- `news/tests/` - regression-тесты живых сценариев модуля.
56+
57+
## Основные сущности
58+
59+
- `News` - публикация или запись ленты.
60+
- `content_object` - объект, к которому относится новость.
61+
- `files` - вложения новости.
62+
- `likes` - generic likes через `core.Like`.
63+
- `views` - generic views через `core.View`.
64+
- `pin` - закрепление новости, сейчас используется для новостей программ.
65+
66+
## API
67+
68+
Контекстные endpoints:
69+
70+
- `GET /projects/<project_id>/news/` - список новостей проекта.
71+
- `POST /projects/<project_id>/news/` - создание новости проекта.
72+
- `GET /projects/<project_id>/news/<news_id>/` - детальная новость проекта.
73+
- `PATCH /projects/<project_id>/news/<news_id>/` - редактирование новости
74+
проекта.
75+
- `DELETE /projects/<project_id>/news/<news_id>/` - удаление новости проекта.
76+
- `POST /projects/<project_id>/news/<news_id>/set_viewed/` - просмотр новости
77+
проекта.
78+
- `POST /projects/<project_id>/news/<news_id>/set_liked/` - лайк новости
79+
проекта.
80+
81+
- `GET /auth/users/<user_id>/news/` - список новостей пользователя.
82+
- `POST /auth/users/<user_id>/news/` - создание новости пользователя.
83+
- `GET /auth/users/<user_id>/news/<news_id>/` - детальная новость пользователя.
84+
- `PATCH /auth/users/<user_id>/news/<news_id>/` - редактирование новости
85+
пользователя.
86+
- `DELETE /auth/users/<user_id>/news/<news_id>/` - удаление новости
87+
пользователя.
88+
- `POST /auth/users/<user_id>/news/<news_id>/set_viewed/` - просмотр новости
89+
пользователя.
90+
- `POST /auth/users/<user_id>/news/<news_id>/set_liked/` - лайк новости
91+
пользователя.
92+
93+
- `GET /programs/<program_id>/news/` - список новостей программы.
94+
- `POST /programs/<program_id>/news/` - создание новости программы.
95+
- `GET /programs/<program_id>/news/<news_id>/` - детальная новость программы.
96+
- `PATCH /programs/<program_id>/news/<news_id>/` - редактирование новости
97+
программы.
98+
- `DELETE /programs/<program_id>/news/<news_id>/` - удаление новости программы.
99+
- `POST /programs/<program_id>/news/<news_id>/set_viewed/` - просмотр новости
100+
программы.
101+
- `POST /programs/<program_id>/news/<news_id>/set_liked/` - лайк новости
102+
программы.
103+
104+
Общие endpoints:
105+
106+
- `GET /news/` - подключен напрямую, но без контекста возвращает пустой список.
107+
- `GET /news/<news_id>/` - подключен напрямую, но без контекста не является
108+
основным пользовательским сценарием.
109+
- `GET /feed/?type=...` - общая лента, которая читает данные из `News`.
110+
111+
## Основные сценарии
112+
113+
### 1. Новость проекта
114+
115+
Лидер проекта создает новость через `/projects/<id>/news/`.
116+
117+
Новость сохраняется в `news.News`, а связь с проектом задается через
118+
`content_type = Project` и `object_id = project.id`.
119+
120+
Новости проекта с непустым `text` отображаются как новости внутри проекта.
121+
Служебные feed-записи проекта с `text = ""` из списка проектных новостей
122+
исключаются.
123+
124+
### 2. Новость пользователя
125+
126+
Пользователь создает новость в своем профиле через `/auth/users/<id>/news/`.
127+
Создавать новость за другого пользователя нельзя.
128+
129+
### 3. Новость программы
130+
131+
Менеджер партнерской программы создает новость через `/programs/<id>/news/`.
132+
Пользователь без роли менеджера программы не может создавать и изменять такие
133+
новости.
134+
135+
Новости программ сортируются с учетом `pin`: закрепленные новости идут выше
136+
обычных.
137+
138+
### 4. Просмотры и лайки
139+
140+
Просмотры и лайки работают через generic-модели `core.View` и `core.Like`.
141+
Они привязаны к самой новости, а не к объекту, которому эта новость посвящена.
142+
143+
### 5. Лента
144+
145+
`/feed/` читает `News` и возвращает элементы в формате, зависящем от типа
146+
связанного объекта.
147+
148+
Для проектных записей важно различать:
149+
150+
- `text = ""` - служебная запись ленты о проекте;
151+
- `text != ""` - полноценная новость проекта, которая в ленте возвращается как
152+
`type_model = "news"`.
153+
154+
Лента исключает новости, связанные с непубличными или черновыми проектами.
155+
156+
## Ограничения и правила
157+
158+
- Новость проекта может создавать и изменять только лидер проекта.
159+
- Новость пользователя может создавать и изменять только сам пользователь.
160+
- Новость программы может создавать и изменять только менеджер программы.
161+
- Прямой `/news/` без project/user/program context не является основным
162+
пользовательским API.
163+
- Старые `ProjectNews*` в `projects` не являются текущей реализацией проектных
164+
новостей; живые routes используют `news.News`.
165+
166+
## Тесты
167+
168+
Текущие regression-тесты проверяют:
169+
170+
- `NewsManager.add_news()` привязывает новость к content object и файлам;
171+
- `NewsManager.get_news()` возвращает новости нужного объекта;
172+
- лидер проекта может создавать, редактировать и удалять новости проекта;
173+
- пользователь без роли лидера не может создавать новость проекта;
174+
- список новостей проекта исключает служебные feed-записи с `text = ""`;
175+
- новости проекта можно отметить просмотренными и лайкнуть;
176+
- пользователь может создавать новости только в своем профиле;
177+
- менеджер программы может создавать новости программы;
178+
- пользователь без роли менеджера не может создавать новости программы;
179+
- закрепленные новости программы идут выше обычных;
180+
- `/feed/?type=news` возвращает новости пользователя;
181+
- `/feed/?type=project` возвращает проектные новости как `type_model = "news"`;
182+
- feed исключает новости непубличных проектов.

news/tests.py

Lines changed: 0 additions & 5 deletions
This file was deleted.

news/tests/__init__.py

Whitespace-only changes.

news/tests/helpers.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from datetime import timedelta
2+
from uuid import uuid4
3+
4+
from django.utils import timezone
5+
6+
from files.models import UserFile
7+
from industries.models import Industry
8+
from news.models import News
9+
from partner_programs.models import PartnerProgram
10+
from projects.models import Project
11+
from users.models import CustomUser
12+
13+
14+
def unique_suffix() -> str:
15+
return uuid4().hex[:8]
16+
17+
18+
def create_user(*, prefix: str = "news-test") -> CustomUser:
19+
suffix = unique_suffix()
20+
return CustomUser.objects.create_user(
21+
email=f"{prefix}-{suffix}@example.com",
22+
password="testpass123",
23+
first_name="Test",
24+
last_name="User",
25+
birthday="2000-01-01",
26+
is_active=True,
27+
)
28+
29+
30+
def create_industry(*, name: str = "Industry") -> Industry:
31+
return Industry.objects.create(name=f"{name} {unique_suffix()}")
32+
33+
34+
def create_project(
35+
*,
36+
leader: CustomUser | None = None,
37+
name: str = "Project",
38+
draft: bool = False,
39+
is_public: bool = True,
40+
) -> Project:
41+
return Project.objects.create(
42+
leader=leader or create_user(prefix="news-project-leader"),
43+
name=f"{name} {unique_suffix()}",
44+
description="Project description",
45+
draft=draft,
46+
is_public=is_public,
47+
industry=create_industry(),
48+
)
49+
50+
51+
def create_partner_program(
52+
*,
53+
manager: CustomUser | None = None,
54+
name: str = "Program",
55+
) -> PartnerProgram:
56+
suffix = unique_suffix()
57+
now = timezone.now()
58+
program = PartnerProgram.objects.create(
59+
name=f"{name} {suffix}",
60+
tag=f"program-{suffix}",
61+
city="Moscow",
62+
datetime_registration_ends=now + timedelta(days=10),
63+
datetime_started=now - timedelta(days=1),
64+
datetime_finished=now + timedelta(days=30),
65+
draft=False,
66+
)
67+
if manager is not None:
68+
program.managers.add(manager)
69+
return program
70+
71+
72+
def create_user_file(
73+
user: CustomUser,
74+
*,
75+
name: str = "attachment",
76+
extension: str = "pdf",
77+
mime_type: str = "application/pdf",
78+
) -> UserFile:
79+
suffix = unique_suffix()
80+
return UserFile.objects.create(
81+
link=f"https://cdn.example.com/news/{suffix}/{name}.{extension}",
82+
user=user,
83+
name=name,
84+
extension=extension,
85+
mime_type=mime_type,
86+
size=1024,
87+
)
88+
89+
90+
def create_news_for(obj, *, text: str = "News text", files=None, pin: bool = False) -> News:
91+
news = News.objects.add_news(obj, text=text, files=files or [])
92+
if pin:
93+
news.pin = True
94+
news.save(update_fields=["pin"])
95+
return news

news/tests/test_news_feed.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from django.test import TestCase
2+
from rest_framework.test import APIClient
3+
4+
from .helpers import create_news_for, create_project, create_user
5+
6+
7+
class NewsFeedTests(TestCase):
8+
def setUp(self):
9+
self.client = APIClient()
10+
self.user = create_user(prefix="news-feed-user")
11+
self.client.force_authenticate(self.user)
12+
13+
def test_feed_returns_user_news_when_news_filter_requested(self):
14+
news = create_news_for(self.user, text="User feed news")
15+
16+
response = self.client.get("/feed/?type=news")
17+
18+
self.assertEqual(response.status_code, 200)
19+
item = response.data["results"][0]
20+
self.assertEqual(item["type_model"], "news")
21+
self.assertEqual(item["content"]["id"], news.id)
22+
self.assertEqual(item["content"]["text"], "User feed news")
23+
24+
def test_feed_returns_project_news_as_news_content(self):
25+
project = create_project(name="Feed project")
26+
news = create_news_for(project, text="Project feed news")
27+
28+
response = self.client.get("/feed/?type=project")
29+
30+
self.assertEqual(response.status_code, 200)
31+
item = response.data["results"][0]
32+
self.assertEqual(item["type_model"], "news")
33+
self.assertEqual(item["content"]["id"], news.id)
34+
self.assertEqual(item["content"]["text"], "Project feed news")
35+
36+
def test_feed_excludes_news_for_private_project(self):
37+
private_project = create_project(name="Private project", is_public=False)
38+
create_news_for(private_project, text="Private project news")
39+
40+
response = self.client.get("/feed/?type=project")
41+
42+
self.assertEqual(response.status_code, 200)
43+
self.assertEqual(response.data["results"], [])

news/tests/test_news_manager.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from django.test import TestCase
2+
3+
from news.models import News
4+
5+
from .helpers import create_news_for, create_project, create_user_file
6+
7+
8+
class NewsManagerTests(TestCase):
9+
def test_add_news_binds_news_to_content_object_and_files(self):
10+
project = create_project()
11+
file = create_user_file(project.leader)
12+
13+
news = News.objects.add_news(
14+
project,
15+
text="Project update",
16+
files=[file],
17+
)
18+
19+
self.assertEqual(news.content_object, project)
20+
self.assertEqual(news.text, "Project update")
21+
self.assertEqual(list(news.files.all()), [file])
22+
23+
def test_get_news_returns_only_news_for_requested_object(self):
24+
project = create_project(name="Target project")
25+
other_project = create_project(name="Other project")
26+
target_news = create_news_for(project, text="Target news")
27+
create_news_for(other_project, text="Other news")
28+
29+
queryset = News.objects.get_news(project).filter(text="Target news")
30+
31+
self.assertEqual(list(queryset), [target_news])

0 commit comments

Comments
 (0)