Skip to content

Commit 2017501

Browse files
committed
Уточнён контекстный API модуля News
1 parent cad8bf7 commit 2017501

6 files changed

Lines changed: 153 additions & 57 deletions

File tree

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

news/serializers.py

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from rest_framework import serializers
33

44
from core.services import is_fan, get_likes_count, get_views_count
5+
from files.models import UserFile
56
from files.serializers import UserFileSerializer
67
from news.mapping import NewsMapping
78
from news.models import News
@@ -10,7 +11,20 @@
1011
User = get_user_model()
1112

1213

13-
class NewsCreateSerializer(serializers.ModelSerializer[News]):
14+
class NewsInputSerializer(serializers.ModelSerializer[News]):
15+
files = serializers.PrimaryKeyRelatedField(
16+
queryset=UserFile.objects.none(),
17+
many=True,
18+
required=False,
19+
)
20+
21+
def __init__(self, *args, **kwargs):
22+
super().__init__(*args, **kwargs)
23+
request = self.context.get("request")
24+
user = getattr(request, "user", None)
25+
if user and user.is_authenticated:
26+
self.fields["files"].queryset = UserFile.objects.filter(user=user)
27+
1428
class Meta:
1529
model = News
1630
fields = [
@@ -19,13 +33,20 @@ class Meta:
1933
]
2034

2135

22-
class NewsListResponseSerializer(serializers.ModelSerializer[News]):
36+
class NewsCreateSerializer(NewsInputSerializer):
37+
pass
38+
39+
40+
class NewsUpdateSerializer(NewsInputSerializer):
41+
pass
42+
43+
44+
class BaseNewsResponseSerializer(serializers.ModelSerializer[News]):
2345
views_count = serializers.SerializerMethodField()
2446
likes_count = serializers.SerializerMethodField()
2547
name = serializers.SerializerMethodField()
2648
image_address = serializers.SerializerMethodField()
2749
is_user_liked = serializers.SerializerMethodField()
28-
files = UserFileSerializer(many=True)
2950

3051
def get_name(self, obj):
3152
return NewsMapping.get_name(obj.content_object)
@@ -45,6 +66,10 @@ def get_is_user_liked(self, obj):
4566
return is_fan(obj, user)
4667
return False
4768

69+
70+
class BaseNewsListResponseSerializer(BaseNewsResponseSerializer):
71+
files = UserFileSerializer(many=True)
72+
4873
class Meta:
4974
model = News
5075
fields = [
@@ -62,30 +87,19 @@ class Meta:
6287
read_only_fields = ["pin"]
6388

6489

65-
class NewsDetailResponseSerializer(serializers.ModelSerializer):
66-
views_count = serializers.SerializerMethodField()
67-
likes_count = serializers.SerializerMethodField()
68-
name = serializers.SerializerMethodField()
69-
image_address = serializers.SerializerMethodField()
70-
is_user_liked = serializers.SerializerMethodField()
90+
class ProjectNewsListResponseSerializer(BaseNewsListResponseSerializer):
91+
pass
7192

72-
def get_name(self, obj):
73-
return NewsMapping.get_name(obj.content_object)
7493

75-
def get_image_address(self, obj):
76-
return NewsMapping.get_image_address(obj.content_object)
94+
class UserNewsListResponseSerializer(BaseNewsListResponseSerializer):
95+
pass
7796

78-
def get_views_count(self, obj):
79-
return get_views_count(obj)
8097

81-
def get_likes_count(self, obj):
82-
return get_likes_count(obj)
98+
class ProgramNewsListResponseSerializer(BaseNewsListResponseSerializer):
99+
pass
83100

84-
def get_is_user_liked(self, obj):
85-
user = self.context.get("user")
86-
if user:
87-
return is_fan(obj, user)
88-
return False
101+
102+
class BaseNewsDetailResponseSerializer(BaseNewsResponseSerializer):
89103

90104
class Meta:
91105
model = News
@@ -103,3 +117,15 @@ class Meta:
103117
"files",
104118
]
105119
read_only_fields = ["pin"]
120+
121+
122+
class ProjectNewsDetailResponseSerializer(BaseNewsDetailResponseSerializer):
123+
pass
124+
125+
126+
class UserNewsDetailResponseSerializer(BaseNewsDetailResponseSerializer):
127+
pass
128+
129+
130+
class ProgramNewsDetailResponseSerializer(BaseNewsDetailResponseSerializer):
131+
pass

news/tests/test_news_project_api.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from core.models import Like, View
66
from news.models import News
77

8-
from .helpers import create_news_for, create_project, create_user
8+
from .helpers import create_news_for, create_project, create_user, create_user_file
99

1010

1111
class ProjectScopedNewsAPITests(TestCase):
@@ -28,6 +28,27 @@ def test_project_leader_can_create_project_news(self):
2828
self.assertEqual(news.content_object, self.project)
2929
self.assertEqual(news.text, "Project news")
3030

31+
def test_project_news_create_rejects_file_of_another_user(self):
32+
self.client.force_authenticate(self.leader)
33+
another_user = create_user(prefix="project-news-file-owner")
34+
another_user_file = create_user_file(another_user)
35+
36+
response = self.client.post(
37+
f"/projects/{self.project.id}/news/",
38+
{
39+
"text": "Project news with forbidden file",
40+
"files": [another_user_file.link],
41+
},
42+
format="json",
43+
)
44+
45+
self.assertEqual(response.status_code, 400)
46+
self.assertFalse(
47+
News.objects.get_news(self.project)
48+
.filter(text="Project news with forbidden file")
49+
.exists()
50+
)
51+
3152
def test_non_leader_cannot_create_project_news(self):
3253
self.client.force_authenticate(create_user(prefix="project-news-outsider"))
3354

@@ -78,6 +99,26 @@ def test_project_news_detail_can_be_updated_and_deleted_by_leader(self):
7899
self.assertEqual(delete_response.status_code, 204)
79100
self.assertFalse(News.objects.filter(pk=news.id).exists())
80101

102+
def test_project_news_update_rejects_file_of_another_user(self):
103+
self.client.force_authenticate(self.leader)
104+
leader_file = create_user_file(self.leader, name="leader-file")
105+
another_user = create_user(prefix="project-news-update-file-owner")
106+
another_user_file = create_user_file(another_user, name="foreign-file")
107+
news = create_news_for(
108+
self.project,
109+
text="Initial text",
110+
files=[leader_file],
111+
)
112+
113+
response = self.client.patch(
114+
f"/projects/{self.project.id}/news/{news.id}/",
115+
{"files": [another_user_file.link]},
116+
format="json",
117+
)
118+
119+
self.assertEqual(response.status_code, 400)
120+
self.assertEqual(list(news.files.all()), [leader_file])
121+
81122
def test_project_news_can_be_marked_viewed_and_liked(self):
82123
user = create_user(prefix="project-news-reader")
83124
self.client.force_authenticate(user)

news/urls.py

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

news/views.py

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@
1212
from news.querysets import get_news_queryset_for_context
1313
from news.serializers import (
1414
NewsCreateSerializer,
15-
NewsDetailResponseSerializer,
16-
NewsListResponseSerializer,
15+
NewsUpdateSerializer,
16+
ProgramNewsDetailResponseSerializer,
17+
ProgramNewsListResponseSerializer,
18+
ProjectNewsDetailResponseSerializer,
19+
ProjectNewsListResponseSerializer,
20+
UserNewsDetailResponseSerializer,
21+
UserNewsListResponseSerializer,
1722
)
1823
from news.services import (
1924
create_program_news,
@@ -27,75 +32,115 @@
2732
User = get_user_model()
2833

2934

35+
LIST_RESPONSE_SERIALIZERS = {
36+
"project": ProjectNewsListResponseSerializer,
37+
"user": UserNewsListResponseSerializer,
38+
"program": ProgramNewsListResponseSerializer,
39+
}
40+
41+
DETAIL_RESPONSE_SERIALIZERS = {
42+
"project": ProjectNewsDetailResponseSerializer,
43+
"user": UserNewsDetailResponseSerializer,
44+
"program": ProgramNewsDetailResponseSerializer,
45+
}
46+
47+
3048
class ContextNewsAPIView:
3149
def get_queryset(self):
3250
return get_news_queryset_for_context(self.kwargs)
3351

3452
def get_news_object(self):
3553
return get_object_or_404(self.get_queryset(), pk=self.kwargs["pk"])
3654

55+
def get_news_context(self):
56+
if self.kwargs.get("project_pk") is not None:
57+
return "project"
58+
if self.kwargs.get("user_pk") is not None:
59+
return "user"
60+
if self.kwargs.get("partnerprogram_pk") is not None:
61+
return "program"
62+
return None
63+
64+
def get_list_response_serializer_class(self):
65+
return LIST_RESPONSE_SERIALIZERS[self.get_news_context()]
66+
67+
def get_detail_response_serializer_class(self):
68+
return DETAIL_RESPONSE_SERIALIZERS[self.get_news_context()]
69+
3770

3871
class NewsList(ContextNewsAPIView, generics.ListCreateAPIView):
39-
serializer_class = NewsListResponseSerializer
72+
serializer_class = ProjectNewsListResponseSerializer
4073
permission_classes = [ProjectVisibilityPermission, IsNewsCreatorOrReadOnly]
4174
pagination_class = NewsPagination
4275

4376
def post(self, request: Request, *args, **kwargs) -> Response:
44-
serializer = NewsCreateSerializer(data=request.data)
77+
serializer = NewsCreateSerializer(
78+
data=request.data,
79+
context={"request": request},
80+
)
4581
serializer.is_valid(raise_exception=True)
4682
data = serializer.validated_data
4783

4884
if kwargs.get("project_pk"):
4985
project = get_object_or_404(Project, pk=kwargs["project_pk"])
5086
news = create_project_news(project, request.user, data)
5187
return Response(
52-
NewsDetailResponseSerializer(news).data,
88+
self.get_detail_response_serializer_class()(news).data,
5389
status=status.HTTP_201_CREATED,
5490
)
5591
if kwargs.get("user_pk"):
5692
user = get_object_or_404(User, pk=kwargs["user_pk"])
5793
news = create_user_news(user, request.user, data)
5894
return Response(
59-
NewsDetailResponseSerializer(news).data,
95+
self.get_detail_response_serializer_class()(news).data,
6096
status=status.HTTP_201_CREATED,
6197
)
6298

6399
if kwargs.get("partnerprogram_pk"):
64100
program = get_object_or_404(PartnerProgram, pk=kwargs["partnerprogram_pk"])
65101
news = create_program_news(program, request.user, data)
66102
return Response(
67-
NewsDetailResponseSerializer(news).data,
103+
self.get_detail_response_serializer_class()(news).data,
68104
status=status.HTTP_201_CREATED,
69105
)
70106
return Response(status=status.HTTP_400_BAD_REQUEST)
71107

72108
def get(self, request: Request, *args, **kwargs) -> Response:
73109
news = self.paginate_queryset(self.get_queryset())
74110
context = {"user": request.user}
75-
serializer = NewsListResponseSerializer(news, context=context, many=True)
111+
serializer = self.get_list_response_serializer_class()(
112+
news,
113+
context=context,
114+
many=True,
115+
)
76116
return self.get_paginated_response(serializer.data)
77117

78118

79119
class NewsDetail(ContextNewsAPIView, generics.RetrieveUpdateDestroyAPIView):
80-
serializer_class = NewsDetailResponseSerializer
120+
serializer_class = ProjectNewsDetailResponseSerializer
81121
permission_classes = [ProjectVisibilityPermission, IsNewsCreatorOrReadOnly]
82122

83123
def get(self, request: Request, *args, **kwargs) -> Response:
84124
news = self.get_news_object()
85125
context = {"user": request.user}
86-
return Response(NewsDetailResponseSerializer(news, context=context).data)
126+
return Response(
127+
self.get_detail_response_serializer_class()(news, context=context).data
128+
)
87129

88130
def update(self, request: Request, *args, **kwargs) -> Response:
89131
news = self.get_news_object()
90132
context = {"user": request.user}
91-
serializer = NewsDetailResponseSerializer(
133+
serializer = NewsUpdateSerializer(
92134
news,
93135
data=request.data,
94-
context=context,
136+
context={"request": request},
137+
partial=kwargs.get("partial", False),
95138
)
96139
serializer.is_valid(raise_exception=True)
97140
serializer.save()
98-
return Response(serializer.data)
141+
return Response(
142+
self.get_detail_response_serializer_class()(news, context=context).data
143+
)
99144

100145

101146
class NewsDetailSetViewed(ContextNewsAPIView, generics.CreateAPIView):

procollab/urls.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@
4545
),
4646
path("files/", include("files.urls", namespace="files")),
4747
path("industries/", include("industries.urls", namespace="industries")),
48-
path("news/", include("news.urls", namespace="news")),
4948
path("projects/", include("projects.urls", namespace="projects")),
5049
path("vacancies/", include("vacancy.urls", namespace="vacancies")),
5150
path("core/", include("core.urls", namespace="core")),

0 commit comments

Comments
 (0)