diff --git a/docs/modules/feed.md b/docs/modules/feed.md index 06e2ffbe..f738e363 100644 --- a/docs/modules/feed.md +++ b/docs/modules/feed.md @@ -86,12 +86,19 @@ View выбирает подходящие `news.News`, сериализует - `GET /feed/?type=project|vacancy|news` - комбинированная выдача по нескольким типам. +Legacy `/feed/` сохраняет этот контракт без изменений. Для React добавлен +отдельный `/feed/news/`, который возвращает только полноценные публикации и +никогда не смешивает их со служебными записями проектов и вакансий. Подробный +контракт находится в `docs/react-news-feed-api.md`. + ## Ограничения и правила - Feed читает данные из `news.News`, но не отвечает за создание обычных project/user news. - Служебная feed-запись определяется через пустой `text`. - Новости партнерских программ не отображаются в `/feed/`. +- Новая React-лента программ не меняет это правило legacy endpoint: она читает + `News` через отдельные selectors и serializers. - Signals `feed` создают или удаляют служебные feed-записи для проектов и вакансий. Более широкие сценарии публикации проекта остаются в модуле `projects`. diff --git a/docs/modules/news.md b/docs/modules/news.md index 3ca99eab..a7e6c5b4 100644 --- a/docs/modules/news.md +++ b/docs/modules/news.md @@ -36,6 +36,8 @@ generic relation: - лайк и снятие лайка с новости; - участие новостей в общей ленте `/feed/`; - закрепление новости программы через `pin`. +- явная аудитория новости программы: вся платформа или участники программы; +- плоские комментарии к публикациям нового React-контура. ## Архитектура @@ -64,6 +66,8 @@ generic relation: - `likes` - generic likes через `core.Like`. - `views` - generic views через `core.View`. - `pin` - закрепление новости, сейчас используется для новостей программ. +- `audience` - `platform` или `program_participants`. +- `NewsComment` - комментарий с автором, текстом и датами создания/изменения. Feed-запись определяется через helper `is_feed_record(news)`, а обычная новость через `is_content_news(news)`. Сейчас оба helper'а используют текущий признак @@ -117,6 +121,11 @@ Feed-запись определяется через helper `is_feed_record(new Новости программ сортируются с учетом `pin`: закрепленные новости идут выше обычных. +Поле `audience` можно передать при создании и изменении. Для совместимости со +старым Angular отсутствие поля при создании означает `program_participants`. +Внутренние публикации видят участники программы, менеджеры и staff/superuser; +остальным list их не возвращает, а detail отвечает `404`. + ### 4. Просмотры и лайки Просмотры и лайки работают через generic-модели `core.View` и `core.Like`. @@ -143,6 +152,15 @@ Feed-запись определяется через helper `is_feed_record(new - Вложения новости должны ссылаться только на `UserFile` текущего пользователя. - Несуществующий project/user/program context возвращает `404`. - Проектные новости реализованы через `news.News`. +- Новости пользователей и проектов всегда имеют аудиторию `platform`. +- Старые новости программ мигрируются в `program_participants`, чтобы не + публиковать внутренние записи автоматически. + +## React API + +Новый стабильный контракт общей ленты и комментариев описан в +`docs/react-news-feed-api.md`. Он использует существующую модель `News`, +`core.Like` и `core.View`; отдельный модуль публикаций не создается. ## Тесты diff --git a/docs/react-news-feed-api.md b/docs/react-news-feed-api.md new file mode 100644 index 00000000..e17ffd06 --- /dev/null +++ b/docs/react-news-feed-api.md @@ -0,0 +1,197 @@ +# React News Feed API + +## Назначение + +DEV-083.1 добавляет стабильный backend-контракт для будущего раздела +`/office/news`. Он использует общую модель `news.News`, существующие +`core.Like`/`core.View` и новую модель `NewsComment`. Legacy `/feed/` и +контекстные endpoints Angular сохраняют прежние URL и формат ответа. + +В `News` по-прежнему хранятся как публикации, так и служебные feed-записи с +пустым текстом. Новый API возвращает только полноценные публикации. + +## Источники и список + +```text +GET /feed/news/?source=program&search=&limit=10&offset=0 +``` + +Endpoint требует авторизацию. `source` принимает: + +- `program` — значение по умолчанию, только новости программ с + `audience=platform`; +- `project` — новости опубликованных публичных проектов; +- `user` — новости пользователей. + +Неизвестный source возвращает `400`. Служебные записи с пустым текстом, +новости private/draft-проектов и participant-only новости программ в список не +попадают. Сортировка: `datetime_created DESC, id DESC`. + +`search` обрезается по краям, ограничен 200 символами и выполняет +регистронезависимый поиск внутри выбранной вкладки: по тексту публикации, имени +программы/проекта либо имени и фамилии пользователя. + +Ответ использует limit/offset pagination: + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 123, + "source_type": "program", + "source": { + "id": 45, + "name": "Название программы", + "image_address": "https://example.com/program.png" + }, + "text": "Текст публикации", + "files": [], + "audience": "platform", + "datetime_created": "2026-08-06T12:00:00Z", + "datetime_updated": "2026-08-06T12:00:00Z", + "likes_count": 5, + "comments_count": 3, + "views_count": 18, + "is_user_liked": true + } + ] +} +``` + +Counts и состояние лайка аннотируются в queryset; источники и файлы +prefetch-ятся, поэтому размер страницы не создает N+1. + +## Audience программ + +`News.audience` принимает: + +- `platform` — публикация доступна всем авторизованным пользователям и может + быть показана во вкладке программ; +- `program_participants` — публикация доступна участникам программы, её + менеджерам и staff/superuser. + +`program_participants` разрешен только для `PartnerProgram`. Новости +пользователей и проектов всегда имеют `platform`. + +База ограничивает поле двумя допустимыми значениями. Проверка того, что +`program_participants` относится именно к `PartnerProgram`, выполняется в +model validation и на API-boundary: generic foreign key нельзя надежно +сопоставить с content type в статическом check constraint. + +Контекстное создание программы совместимо с Angular: + +```text +POST /programs//news/ +``` + +```json +{ + "text": "Текст новости", + "files": [], + "audience": "platform" +} +``` + +Если `audience` отсутствует, создается `program_participants`. Менеджер может +изменить поле через существующий PATCH. Недоступная +внутренняя публикация исключается из контекстного списка и возвращает `404` в +detail. + +Data migration переводит все существующие program news в +`program_participants`, а остальные новости — в `platform`. + +## Detail, лайки и просмотры + +```text +GET /feed/news// +POST /feed/news//set-liked/ +POST /feed/news//set-viewed/ +``` + +Detail возвращает тот же объект, что элемент списка. Он предназначен для +будущего маршрута `/office/news/`. Публичная новость программы +доступна авторизованным пользователям, внутренняя — только своей аудитории, +новость проекта — только если проект опубликован и публичен. Недоступный, +несуществующий или служебный объект возвращает `404`. + +Лайк: + +```json +{ "is_liked": true } +``` + +```json +{ "is_user_liked": true, "likes_count": 6 } +``` + +Просмотр не требует payload и возвращает: + +```json +{ "views_count": 18 } +``` + +Обе операции идемпотентны благодаря существующим уникальным ограничениям +`core.Like` и `core.View`. Перед изменением проверяется доступ к публикации. + +## Комментарии + +```text +GET /feed/news//comments/ +POST /feed/news//comments/ +PATCH /feed/news//comments// +DELETE /feed/news//comments// +``` + +Список использует limit/offset pagination и сортировку от старых комментариев к +новым. Создание и изменение принимают: + +```json +{ "text": "Комментарий" } +``` + +Пробелы по краям удаляются; пустой текст и текст длиннее 2000 символов +возвращают `400`. + +Ответ: + +```json +{ + "id": 17, + "author": { + "id": 8, + "name": "Имя Фамилия", + "image_address": "https://example.com/avatar.png" + }, + "text": "Комментарий", + "datetime_created": "2026-08-06T12:00:00Z", + "datetime_updated": "2026-08-06T12:00:00Z", + "is_edited": false, + "can_edit": true, + "can_delete": true +} +``` + +Читать и создавать комментарии может любой авторизованный пользователь с +доступом к новости. Редактирует только автор; удаляет автор либо +staff/superuser. `news_id` входит в lookup комментария, поэтому подмена пары +`news_id/comment_id` дает `404`. Удаление новости каскадно удаляет комментарии. + +## Обратная совместимость и ограничения + +- `/feed/` сохраняет служебные project/vacancy records, старый serializer и + намеренное исключение новостей программ; +- context API пользователей, проектов и программ сохраняет URL и основные + поля; +- репостов нет: в продукте это копирование detail-ссылки; +- комментарии плоские, без ответов, лайков, упоминаний и файлов; +- создание публикаций из общей ленты не добавлено: программы продолжают + публиковать через context endpoint; +- UI ленты, popup, deep-link recovery и копирование ссылки входят в DEV-083.2; +- DEMO-новости, лайки и комментарии входят в DEV-083.3. + +Angular-аудит подтвердил: карточка копирует отдельную ссылку, project/profile +detail открывает новость в модальном маршруте, а блок комментариев в карточке +закомментирован и отдельного Angular-flow комментариев нет. diff --git a/feed/news_pagination.py b/feed/news_pagination.py new file mode 100644 index 00000000..e6c4a19a --- /dev/null +++ b/feed/news_pagination.py @@ -0,0 +1,11 @@ +from rest_framework.pagination import LimitOffsetPagination + + +class ReactNewsFeedPagination(LimitOffsetPagination): + default_limit = 10 + max_limit = 100 + + +class NewsCommentPagination(LimitOffsetPagination): + default_limit = 20 + max_limit = 100 diff --git a/feed/news_selectors.py b/feed/news_selectors.py new file mode 100644 index 00000000..b287c620 --- /dev/null +++ b/feed/news_selectors.py @@ -0,0 +1,101 @@ +from django.contrib.contenttypes.models import ContentType +from django.db.models import Count, Exists, OuterRef, Q, QuerySet +from django.http import Http404 +from django.shortcuts import get_object_or_404 + +from core.models import Like +from news.access import can_view_news_in_react_feed +from news.models import News +from partner_programs.models import PartnerProgram +from projects.models import Project +from users.models import CustomUser + + +NEWS_SOURCE_PROGRAM = "program" +NEWS_SOURCE_PROJECT = "project" +NEWS_SOURCE_USER = "user" +NEWS_SOURCES = ( + NEWS_SOURCE_PROGRAM, + NEWS_SOURCE_PROJECT, + NEWS_SOURCE_USER, +) + + +def _with_feed_annotations(queryset: QuerySet[News], user) -> QuerySet[News]: + news_content_type = ContentType.objects.get_for_model(News) + user_like = Like.objects.filter( + content_type=news_content_type, + object_id=OuterRef("pk"), + user=user, + ) + return queryset.annotate( + likes_count=Count("likes", distinct=True), + comments_count=Count("comments", distinct=True), + views_count=Count("views", distinct=True), + is_user_liked=Exists(user_like), + ) + + +def _content_source(source: str): + mapping = { + NEWS_SOURCE_PROGRAM: PartnerProgram, + NEWS_SOURCE_PROJECT: Project, + NEWS_SOURCE_USER: CustomUser, + } + return mapping[source] + + +def get_react_news_feed_queryset( + *, + source: str, + search: str, + user, +) -> QuerySet[News]: + """Строит вкладку только из публикаций выбранного доменного источника.""" + source_model = _content_source(source) + source_content_type = ContentType.objects.get_for_model(source_model) + source_objects = source_model.objects.all() + + if source == NEWS_SOURCE_PROJECT: + source_objects = source_objects.filter(draft=False, is_public=True) + + if search: + if source in (NEWS_SOURCE_PROGRAM, NEWS_SOURCE_PROJECT): + matching_source_ids = source_objects.filter( + name__icontains=search + ).values_list("id", flat=True) + else: + matching_source_ids = source_objects.filter( + Q(first_name__icontains=search) | Q(last_name__icontains=search) + ).values_list("id", flat=True) + search_filter = Q(text__icontains=search) | Q(object_id__in=matching_source_ids) + else: + search_filter = Q() + + queryset = ( + News.objects.filter( + content_type=source_content_type, + object_id__in=source_objects.values_list("id", flat=True), + audience=News.Audience.PLATFORM, + ) + .exclude(text__regex=r"^\s*$") + .filter(search_filter) + .select_related("content_type") + .prefetch_related("content_object", "files") + .order_by("-datetime_created", "-id") + ) + return _with_feed_annotations(queryset, user) + + +def get_react_feed_news_or_404(*, news_id: int, user) -> News: + queryset = _with_feed_annotations( + News.objects.select_related("content_type").prefetch_related( + "content_object", "files" + ), + user, + ) + news = get_object_or_404(queryset, pk=news_id) + if not can_view_news_in_react_feed(user, news): + # Единый 404 не раскрывает существование внутренней публикации. + raise Http404 + return news diff --git a/feed/news_serializers.py b/feed/news_serializers.py new file mode 100644 index 00000000..9003be59 --- /dev/null +++ b/feed/news_serializers.py @@ -0,0 +1,142 @@ +from rest_framework import serializers + +from files.serializers import UserFileSerializer +from news.models import News, NewsComment +from partner_programs.models import PartnerProgram +from projects.models import Project +from users.models import CustomUser + +from feed.news_selectors import NEWS_SOURCES + + +class ReactNewsFeedQuerySerializer(serializers.Serializer): + source = serializers.ChoiceField(choices=NEWS_SOURCES, default="program") + search = serializers.CharField( + required=False, + allow_blank=True, + default="", + max_length=200, + trim_whitespace=True, + ) + + +class ReactNewsSourceSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField() + image_address = serializers.URLField(allow_null=True) + + +class ReactNewsFeedItemSerializer(serializers.ModelSerializer[News]): + source_type = serializers.SerializerMethodField() + source = serializers.SerializerMethodField() + files = UserFileSerializer(many=True) + likes_count = serializers.IntegerField(read_only=True) + comments_count = serializers.IntegerField(read_only=True) + views_count = serializers.IntegerField(read_only=True) + is_user_liked = serializers.BooleanField(read_only=True) + + def get_source_type(self, obj: News) -> str: + if isinstance(obj.content_object, PartnerProgram): + return "program" + if isinstance(obj.content_object, Project): + return "project" + return "user" + + def get_source(self, obj: News) -> dict: + source = obj.content_object + if isinstance(source, CustomUser): + name = f"{source.first_name} {source.last_name}".strip() + image_address = source.avatar + else: + name = source.name + image_address = source.image_address + return ReactNewsSourceSerializer( + { + "id": source.pk, + "name": name, + "image_address": image_address, + } + ).data + + class Meta: + model = News + fields = ( + "id", + "source_type", + "source", + "text", + "files", + "audience", + "datetime_created", + "datetime_updated", + "likes_count", + "comments_count", + "views_count", + "is_user_liked", + ) + + +class SetReactNewsLikedSerializer(serializers.Serializer): + is_liked = serializers.BooleanField() + + +class NewsCommentInputSerializer(serializers.Serializer): + text = serializers.CharField( + max_length=2000, + allow_blank=False, + trim_whitespace=True, + ) + + +class NewsCommentAuthorSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField() + image_address = serializers.URLField(allow_null=True) + + +class NewsCommentResponseSerializer(serializers.ModelSerializer[NewsComment]): + author = serializers.SerializerMethodField() + datetime_updated = serializers.SerializerMethodField() + is_edited = serializers.SerializerMethodField() + can_edit = serializers.SerializerMethodField() + can_delete = serializers.SerializerMethodField() + + def get_author(self, obj: NewsComment) -> dict: + return NewsCommentAuthorSerializer( + { + "id": obj.author_id, + "name": f"{obj.author.first_name} {obj.author.last_name}".strip(), + "image_address": obj.author.avatar, + } + ).data + + def get_datetime_updated(self, obj: NewsComment): + return obj.datetime_updated or obj.datetime_created + + def get_is_edited(self, obj: NewsComment) -> bool: + return obj.datetime_updated is not None + + def get_can_edit(self, obj: NewsComment) -> bool: + request = self.context["request"] + return obj.author_id == request.user.pk + + def get_can_delete(self, obj: NewsComment) -> bool: + request = self.context["request"] + return bool( + obj.author_id == request.user.pk + or request.user.is_staff + or request.user.is_superuser + ) + + class Meta: + model = NewsComment + fields = ( + "id", + "author", + "text", + "datetime_created", + "datetime_updated", + "is_edited", + "can_edit", + "can_delete", + ) diff --git a/feed/news_views.py b/feed/news_views.py new file mode 100644 index 00000000..9c2497f4 --- /dev/null +++ b/feed/news_views.py @@ -0,0 +1,179 @@ +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import generics, status +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from core.models import Like, View +from core.services import add_view, set_like +from feed.news_pagination import NewsCommentPagination, ReactNewsFeedPagination +from feed.news_selectors import ( + get_react_feed_news_or_404, + get_react_news_feed_queryset, +) +from feed.news_serializers import ( + NewsCommentInputSerializer, + NewsCommentResponseSerializer, + ReactNewsFeedItemSerializer, + ReactNewsFeedQuerySerializer, + SetReactNewsLikedSerializer, +) +from news.models import News, NewsComment + + +class ReactNewsFeedListView(generics.ListAPIView): + permission_classes = [IsAuthenticated] + pagination_class = ReactNewsFeedPagination + serializer_class = ReactNewsFeedItemSerializer + + def get_queryset(self): + query_serializer = ReactNewsFeedQuerySerializer(data=self.request.query_params) + query_serializer.is_valid(raise_exception=True) + return get_react_news_feed_queryset( + source=query_serializer.validated_data["source"], + search=query_serializer.validated_data["search"], + user=self.request.user, + ) + + +class ReactNewsFeedDetailView(generics.RetrieveAPIView): + permission_classes = [IsAuthenticated] + serializer_class = ReactNewsFeedItemSerializer + + def get_object(self): + return get_react_feed_news_or_404( + news_id=self.kwargs["news_id"], + user=self.request.user, + ) + + +class ReactNewsSetLikedView(APIView): + permission_classes = [IsAuthenticated] + + @transaction.atomic + def post(self, request: Request, news_id: int) -> Response: + news = get_react_feed_news_or_404(news_id=news_id, user=request.user) + serializer = SetReactNewsLikedSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + is_liked = serializer.validated_data["is_liked"] + set_like(news, request.user, is_liked) + + content_type = ContentType.objects.get_for_model(News) + likes_count = Like.objects.filter( + content_type=content_type, + object_id=news.pk, + ).count() + return Response( + { + "is_user_liked": is_liked, + "likes_count": likes_count, + } + ) + + +class ReactNewsSetViewedView(APIView): + permission_classes = [IsAuthenticated] + + @transaction.atomic + def post(self, request: Request, news_id: int) -> Response: + news = get_react_feed_news_or_404(news_id=news_id, user=request.user) + add_view(news, request.user) + + content_type = ContentType.objects.get_for_model(News) + views_count = View.objects.filter( + content_type=content_type, + object_id=news.pk, + ).count() + return Response({"views_count": views_count}) + + +class ReactNewsCommentListCreateView(generics.ListCreateAPIView): + permission_classes = [IsAuthenticated] + pagination_class = NewsCommentPagination + serializer_class = NewsCommentResponseSerializer + + def get_news(self) -> News: + return get_react_feed_news_or_404( + news_id=self.kwargs["news_id"], + user=self.request.user, + ) + + def get_queryset(self): + return NewsComment.objects.filter(news=self.get_news()).select_related("author") + + @transaction.atomic + def post(self, request: Request, *args, **kwargs) -> Response: + news = self.get_news() + serializer = NewsCommentInputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + comment = NewsComment.objects.create( + news=news, + author=request.user, + text=serializer.validated_data["text"], + ) + return Response( + NewsCommentResponseSerializer( + comment, + context={"request": request}, + ).data, + status=status.HTTP_201_CREATED, + ) + + +class ReactNewsCommentDetailView(APIView): + permission_classes = [IsAuthenticated] + + def get_comment(self, request: Request, news_id: int, comment_id: int): + news = get_react_feed_news_or_404(news_id=news_id, user=request.user) + return get_object_or_404( + NewsComment.objects.select_related("author"), + pk=comment_id, + news=news, + ) + + @transaction.atomic + def patch( + self, + request: Request, + news_id: int, + comment_id: int, + ) -> Response: + comment = self.get_comment(request, news_id, comment_id) + if comment.author_id != request.user.pk: + raise PermissionDenied("Редактировать комментарий может только автор.") + + serializer = NewsCommentInputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + if "text" in serializer.validated_data: + comment.text = serializer.validated_data["text"] + comment.datetime_updated = timezone.now() + comment.save(update_fields=["text", "datetime_updated"]) + return Response( + NewsCommentResponseSerializer( + comment, + context={"request": request}, + ).data + ) + + @transaction.atomic + def delete( + self, + request: Request, + news_id: int, + comment_id: int, + ) -> Response: + comment = self.get_comment(request, news_id, comment_id) + can_delete = ( + comment.author_id == request.user.pk + or request.user.is_staff + or request.user.is_superuser + ) + if not can_delete: + raise PermissionDenied("Удалить комментарий может автор или администратор.") + comment.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/feed/tests/test_react_news_comments_api.py b/feed/tests/test_react_news_comments_api.py new file mode 100644 index 00000000..fe0fdbc8 --- /dev/null +++ b/feed/tests/test_react_news_comments_api.py @@ -0,0 +1,217 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from news.models import News, NewsComment +from news.tests.helpers import ( + create_news_for, + create_partner_program, + create_user, +) +from partner_programs.models import PartnerProgramUserProfile + + +class ReactNewsCommentAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.author = create_user(prefix="comment-author") + self.other_user = create_user(prefix="comment-other") + self.admin = create_user(prefix="comment-admin") + self.admin.is_staff = True + self.admin.save(update_fields=["is_staff"]) + self.program = create_partner_program(name="Comment program") + self.news = create_news_for(self.program, text="Commented post") + self.list_url = f"/feed/news/{self.news.pk}/comments/" + self.client.force_authenticate(self.author) + + def detail_url(self, comment: NewsComment, news: News | None = None) -> str: + return f"/feed/news/{(news or self.news).pk}/comments/{comment.pk}/" + + def test_user_can_create_and_read_comments_oldest_first(self): + first_response = self.client.post( + self.list_url, + {"text": " First comment "}, + format="json", + ) + second_response = self.client.post( + self.list_url, + {"text": "Second comment"}, + format="json", + ) + list_response = self.client.get(self.list_url) + + self.assertEqual(first_response.status_code, 201) + self.assertEqual(first_response.data["text"], "First comment") + self.assertFalse(first_response.data["is_edited"]) + self.assertTrue(first_response.data["can_edit"]) + self.assertTrue(first_response.data["can_delete"]) + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.data["count"], 2) + self.assertEqual( + [item["id"] for item in list_response.data["results"]], + [first_response.data["id"], second_response.data["id"]], + ) + + def test_author_can_edit_comment_and_user_input_is_preserved(self): + comment = NewsComment.objects.create( + news=self.news, + author=self.author, + text="Before edit", + ) + + response = self.client.patch( + self.detail_url(comment), + {"text": " After edit "}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["text"], "After edit") + self.assertTrue(response.data["is_edited"]) + self.assertNotEqual( + response.data["datetime_created"], + response.data["datetime_updated"], + ) + + def test_user_cannot_edit_another_authors_comment(self): + comment = NewsComment.objects.create( + news=self.news, + author=self.other_user, + text="Other comment", + ) + + response = self.client.patch( + self.detail_url(comment), + {"text": "Forbidden edit"}, + format="json", + ) + + self.assertEqual(response.status_code, 403) + comment.refresh_from_db() + self.assertEqual(comment.text, "Other comment") + + def test_author_and_admin_can_delete_comment(self): + author_comment = NewsComment.objects.create( + news=self.news, + author=self.author, + text="Author delete", + ) + admin_comment = NewsComment.objects.create( + news=self.news, + author=self.other_user, + text="Admin delete", + ) + + author_response = self.client.delete(self.detail_url(author_comment)) + self.client.force_authenticate(self.admin) + admin_response = self.client.delete(self.detail_url(admin_comment)) + + self.assertEqual(author_response.status_code, 204) + self.assertEqual(admin_response.status_code, 204) + self.assertFalse( + NewsComment.objects.filter( + pk__in=(author_comment.pk, admin_comment.pk) + ).exists() + ) + + def test_user_cannot_delete_another_authors_comment(self): + comment = NewsComment.objects.create( + news=self.news, + author=self.other_user, + text="Protected comment", + ) + + response = self.client.delete(self.detail_url(comment)) + + self.assertEqual(response.status_code, 403) + self.assertTrue(NewsComment.objects.filter(pk=comment.pk).exists()) + + def test_blank_and_too_long_comments_are_rejected(self): + blank = self.client.post( + self.list_url, + {"text": " "}, + format="json", + ) + too_long = self.client.post( + self.list_url, + {"text": "x" * 2001}, + format="json", + ) + + self.assertEqual(blank.status_code, 400) + self.assertIn("text", blank.data) + self.assertEqual(too_long.status_code, 400) + self.assertIn("text", too_long.data) + self.assertFalse(NewsComment.objects.filter(news=self.news).exists()) + + def test_comment_id_cannot_be_used_through_another_news(self): + other_news = create_news_for(self.program, text="Other post") + comment = NewsComment.objects.create( + news=self.news, + author=self.author, + text="Scoped comment", + ) + + patch_response = self.client.patch( + self.detail_url(comment, news=other_news), + {"text": "Wrong scope"}, + format="json", + ) + delete_response = self.client.delete(self.detail_url(comment, news=other_news)) + + self.assertEqual(patch_response.status_code, 404) + self.assertEqual(delete_response.status_code, 404) + self.assertTrue(NewsComment.objects.filter(pk=comment.pk).exists()) + + def test_inaccessible_participant_news_restricts_comments(self): + internal_news = create_news_for( + self.program, + text="Internal comments", + audience=News.Audience.PROGRAM_PARTICIPANTS, + ) + internal_url = f"/feed/news/{internal_news.pk}/comments/" + + forbidden_list = self.client.get(internal_url) + forbidden_create = self.client.post( + internal_url, + {"text": "Forbidden comment"}, + format="json", + ) + PartnerProgramUserProfile.objects.create( + partner_program=self.program, + user=self.author, + partner_program_data={}, + ) + allowed_create = self.client.post( + internal_url, + {"text": "Participant comment"}, + format="json", + ) + + self.assertEqual(forbidden_list.status_code, 404) + self.assertEqual(forbidden_create.status_code, 404) + self.assertEqual(allowed_create.status_code, 201) + + def test_comments_count_tracks_create_and_delete(self): + created = self.client.post( + self.list_url, + {"text": "Counted comment"}, + format="json", + ) + + after_create = self.client.get(f"/feed/news/{self.news.pk}/") + self.client.delete(f"/feed/news/{self.news.pk}/comments/{created.data['id']}/") + after_delete = self.client.get(f"/feed/news/{self.news.pk}/") + + self.assertEqual(after_create.data["comments_count"], 1) + self.assertEqual(after_delete.data["comments_count"], 0) + + def test_deleting_news_cascades_comments(self): + comment = NewsComment.objects.create( + news=self.news, + author=self.author, + text="Cascade comment", + ) + + self.news.delete() + + self.assertFalse(NewsComment.objects.filter(pk=comment.pk).exists()) diff --git a/feed/tests/test_react_news_feed_api.py b/feed/tests/test_react_news_feed_api.py new file mode 100644 index 00000000..6ea4af12 --- /dev/null +++ b/feed/tests/test_react_news_feed_api.py @@ -0,0 +1,309 @@ +from datetime import timedelta + +from django.contrib.contenttypes.models import ContentType +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from rest_framework.test import APIClient + +from core.models import Like, View +from feed.services import create_news_for_model +from news.models import News, NewsComment +from news.tests.helpers import ( + create_news_for, + create_partner_program, + create_project, + create_user, + create_user_file, +) +from partner_programs.models import PartnerProgramUserProfile + + +class ReactNewsFeedAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="react-feed-user") + self.client.force_authenticate(self.user) + + def test_authentication_is_required(self): + self.client.force_authenticate(user=None) + + response = self.client.get("/feed/news/") + + self.assertEqual(response.status_code, 401) + + def test_program_is_default_source_and_participant_news_is_excluded(self): + program = create_partner_program(name="Default feed program") + platform_news = create_news_for( + program, + text="Platform program post", + audience=News.Audience.PLATFORM, + ) + create_news_for( + program, + text="Internal program post", + audience=News.Audience.PROGRAM_PARTICIPANTS, + ) + create_news_for(self.user, text="User post") + + response = self.client.get("/feed/news/") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [platform_news.pk], + ) + self.assertEqual(response.data["results"][0]["source_type"], "program") + + def test_each_source_returns_only_its_publications(self): + program_news = create_news_for( + create_partner_program(name="Source program"), + text="Program source post", + ) + project_news = create_news_for( + create_project(name="Source project"), + text="Project source post", + ) + user_news = create_news_for(self.user, text="User source post") + + expected = { + "program": program_news.pk, + "project": project_news.pk, + "user": user_news.pk, + } + for source, news_id in expected.items(): + with self.subTest(source=source): + response = self.client.get(f"/feed/news/?source={source}") + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [news_id], + ) + + def test_service_records_and_unavailable_projects_are_excluded(self): + public_project = create_project(name="Public publication") + public_news = create_news_for(public_project, text="Visible project post") + create_news_for_model(public_project) + create_news_for( + create_project(name="Private publication", is_public=False), + text="Private project post", + ) + create_news_for( + create_project(name="Draft publication", draft=True), + text="Draft project post", + ) + + response = self.client.get("/feed/news/?source=project") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [public_news.pk], + ) + + def test_results_are_newest_first_and_paginated(self): + program = create_partner_program(name="Ordered program") + oldest = create_news_for(program, text="Oldest") + middle = create_news_for(program, text="Middle") + newest = create_news_for(program, text="Newest") + now = timezone.now() + News.objects.filter(pk=oldest.pk).update(datetime_created=now - timedelta(days=2)) + News.objects.filter(pk=middle.pk).update(datetime_created=now - timedelta(days=1)) + News.objects.filter(pk=newest.pk).update(datetime_created=now) + + first_page = self.client.get("/feed/news/?limit=2&offset=0") + second_page = self.client.get("/feed/news/?limit=2&offset=2") + + self.assertEqual(first_page.status_code, 200) + self.assertEqual(first_page.data["count"], 3) + self.assertEqual( + [item["id"] for item in first_page.data["results"]], + [newest.pk, middle.pk], + ) + self.assertEqual( + [item["id"] for item in second_page.data["results"]], + [oldest.pk], + ) + + def test_search_matches_text_and_source_name_inside_selected_tab(self): + matching_program = create_partner_program(name="Quantum accelerator") + name_match = create_news_for(matching_program, text="General update") + text_match = create_news_for( + create_partner_program(name="Other program"), + text="Quantum milestone", + ) + create_news_for( + create_partner_program(name="Unrelated program"), + text="Nothing relevant", + ) + create_news_for(self.user, text="Quantum user post") + + response = self.client.get("/feed/news/?source=program&search= Quantum ") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + {item["id"] for item in response.data["results"]}, + {name_match.pk, text_match.pk}, + ) + + def test_unknown_source_and_excessive_search_are_rejected(self): + unknown_source = self.client.get("/feed/news/?source=vacancy") + long_search = self.client.get(f"/feed/news/?search={'x' * 201}") + + self.assertEqual(unknown_source.status_code, 400) + self.assertIn("source", unknown_source.data) + self.assertEqual(long_search.status_code, 400) + self.assertIn("search", long_search.data) + + def test_unified_response_contains_files_counts_and_like_state(self): + program = create_partner_program(name="Contract program") + file = create_user_file(self.user) + news = create_news_for( + program, + text="Contract post", + files=[file], + ) + content_type = ContentType.objects.get_for_model(News) + Like.objects.create( + user=self.user, + content_type=content_type, + object_id=news.pk, + ) + View.objects.create( + user=self.user, + content_type=content_type, + object_id=news.pk, + ) + NewsComment.objects.create(news=news, author=self.user, text="Comment") + + response = self.client.get("/feed/news/") + + self.assertEqual(response.status_code, 200) + item = response.data["results"][0] + self.assertEqual( + set(item), + { + "id", + "source_type", + "source", + "text", + "files", + "audience", + "datetime_created", + "datetime_updated", + "likes_count", + "comments_count", + "views_count", + "is_user_liked", + }, + ) + self.assertEqual(item["source"]["id"], program.pk) + self.assertEqual(item["source"]["name"], program.name) + self.assertEqual(item["files"][0]["link"], file.link) + self.assertEqual(item["likes_count"], 1) + self.assertEqual(item["comments_count"], 1) + self.assertEqual(item["views_count"], 1) + self.assertTrue(item["is_user_liked"]) + + def test_list_query_count_does_not_grow_per_publication(self): + program = create_partner_program(name="Query budget program") + for index in range(5): + create_news_for(program, text=f"Query post {index}") + ContentType.objects.get_for_model(News) + ContentType.objects.get_for_model(type(program)) + + with CaptureQueriesContext(connection) as captured: + response = self.client.get("/feed/news/?limit=10") + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 5) + self.assertLessEqual(len(captured), 8) + + +class ReactNewsInteractionAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="react-interaction-user") + self.other_user = create_user(prefix="react-interaction-other") + self.program = create_partner_program(name="Interaction program") + self.news = create_news_for(self.program, text="Interactive post") + self.client.force_authenticate(self.user) + + def test_like_and_unlike_are_idempotent_and_user_specific(self): + like_url = f"/feed/news/{self.news.pk}/set-liked/" + + first = self.client.post(like_url, {"is_liked": True}, format="json") + repeated = self.client.post(like_url, {"is_liked": True}, format="json") + self.client.force_authenticate(self.other_user) + other = self.client.post(like_url, {"is_liked": True}, format="json") + self.client.force_authenticate(self.user) + unlike = self.client.post(like_url, {"is_liked": False}, format="json") + + self.assertEqual(first.data, {"is_user_liked": True, "likes_count": 1}) + self.assertEqual( + repeated.data, + {"is_user_liked": True, "likes_count": 1}, + ) + self.assertEqual(other.data["likes_count"], 2) + self.assertEqual( + unlike.data, + {"is_user_liked": False, "likes_count": 1}, + ) + + def test_view_is_idempotent(self): + url = f"/feed/news/{self.news.pk}/set-viewed/" + + first = self.client.post(url, {}, format="json") + repeated = self.client.post(url, {}, format="json") + + self.assertEqual(first.data, {"views_count": 1}) + self.assertEqual(repeated.data, {"views_count": 1}) + + def test_inaccessible_news_cannot_be_opened_liked_or_viewed(self): + internal = create_news_for( + self.program, + text="Internal interaction", + audience=News.Audience.PROGRAM_PARTICIPANTS, + ) + + detail = self.client.get(f"/feed/news/{internal.pk}/") + liked = self.client.post( + f"/feed/news/{internal.pk}/set-liked/", + {"is_liked": True}, + format="json", + ) + viewed = self.client.post( + f"/feed/news/{internal.pk}/set-viewed/", + {}, + format="json", + ) + + self.assertEqual(detail.status_code, 404) + self.assertEqual(liked.status_code, 404) + self.assertEqual(viewed.status_code, 404) + + def test_participant_can_open_internal_program_news_by_detail_link(self): + internal = create_news_for( + self.program, + audience=News.Audience.PROGRAM_PARTICIPANTS, + ) + PartnerProgramUserProfile.objects.create( + partner_program=self.program, + user=self.user, + partner_program_data={}, + ) + + response = self.client.get(f"/feed/news/{internal.pk}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], internal.pk) + + def test_empty_service_record_cannot_be_opened(self): + project = create_project(name="Hidden service record") + create_news_for_model(project) + service_record = News.objects.get_news(project).get(text="") + + response = self.client.get(f"/feed/news/{service_record.pk}/") + + self.assertEqual(response.status_code, 404) diff --git a/feed/urls.py b/feed/urls.py index 0a63a076..a2cc93c2 100644 --- a/feed/urls.py +++ b/feed/urls.py @@ -1,9 +1,43 @@ from django.urls import path from feed.views import NewSimpleFeed +from feed.news_views import ( + ReactNewsCommentDetailView, + ReactNewsCommentListCreateView, + ReactNewsFeedDetailView, + ReactNewsFeedListView, + ReactNewsSetLikedView, + ReactNewsSetViewedView, +) app_name = "feed" urlpatterns = [ path("", NewSimpleFeed.as_view()), + path("news/", ReactNewsFeedListView.as_view(), name="react-news-list"), + path( + "news//", + ReactNewsFeedDetailView.as_view(), + name="react-news-detail", + ), + path( + "news//set-liked/", + ReactNewsSetLikedView.as_view(), + name="react-news-set-liked", + ), + path( + "news//set-viewed/", + ReactNewsSetViewedView.as_view(), + name="react-news-set-viewed", + ), + path( + "news//comments/", + ReactNewsCommentListCreateView.as_view(), + name="react-news-comment-list", + ), + path( + "news//comments//", + ReactNewsCommentDetailView.as_view(), + name="react-news-comment-detail", + ), ] diff --git a/news/access.py b/news/access.py new file mode 100644 index 00000000..1374a486 --- /dev/null +++ b/news/access.py @@ -0,0 +1,38 @@ +from django.contrib.auth.models import AnonymousUser + +from news.models import News +from news.services import is_content_news +from partner_programs.models import PartnerProgram +from projects.models import Project +from users.models import CustomUser + + +def is_administrative_user(user: CustomUser | AnonymousUser | None) -> bool: + """Проверяет административный доступ без привязки к конкретной программе.""" + return bool(user and user.is_authenticated and (user.is_staff or user.is_superuser)) + + +def can_view_program_participant_news(user, program: PartnerProgram) -> bool: + """Разрешает внутренние новости участникам, менеджерам и администраторам.""" + if not user or not user.is_authenticated: + return False + if is_administrative_user(user) or program.is_manager(user): + return True + return program.users.filter(pk=user.pk).exists() + + +def can_view_news_in_react_feed(user, news: News) -> bool: + """Проверяет доступ к полноценной публикации нового React-контура.""" + if not user or not user.is_authenticated or not is_content_news(news): + return False + + source = news.content_object + if isinstance(source, PartnerProgram): + if news.audience == News.Audience.PLATFORM: + return True + return can_view_program_participant_news(user, source) + + if isinstance(source, Project): + return not source.draft and source.is_public + + return isinstance(source, CustomUser) diff --git a/news/admin.py b/news/admin.py index dcefeb48..83892f1f 100644 --- a/news/admin.py +++ b/news/admin.py @@ -2,7 +2,7 @@ from django.db.models import QuerySet from django.http import HttpRequest -from news.models import News +from news.models import News, NewsComment from partner_programs.models import PartnerProgram @@ -15,6 +15,7 @@ class NewsAdmin(admin.ModelAdmin): "object_id", "text", "pin", + "audience", "datetime_created", "datetime_updated", ) @@ -24,10 +25,12 @@ class NewsAdmin(admin.ModelAdmin): "object_id", "text", "pin", + "audience", "datetime_created", "datetime_updated", ) list_filter = ( + "audience", "datetime_created", "datetime_updated", ) @@ -51,6 +54,20 @@ def get_queryset(self, request: HttpRequest) -> QuerySet[News]: ) return qs + +@admin.register(NewsComment) +class NewsCommentAdmin(admin.ModelAdmin): + list_display = ( + "id", + "news", + "author", + "datetime_created", + "datetime_updated", + ) + list_filter = ("datetime_created", "datetime_updated") + search_fields = ("text", "author__email") + readonly_fields = ("datetime_created", "datetime_updated") + # fieldsets = ( # ( # None, diff --git a/news/migrations/0010_news_audience_newscomment.py b/news/migrations/0010_news_audience_newscomment.py new file mode 100644 index 00000000..0431fd93 --- /dev/null +++ b/news/migrations/0010_news_audience_newscomment.py @@ -0,0 +1,121 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +def set_existing_news_audiences(apps, schema_editor): + News = apps.get_model("news", "News") + ContentType = apps.get_model("contenttypes", "ContentType") + + News.objects.all().update(audience="platform") + program_content_type_id = ( + ContentType.objects.filter( + app_label="partner_programs", + model="partnerprogram", + ) + .values_list("id", flat=True) + .first() + ) + if program_content_type_id is not None: + # Старые новости программ считаются внутренними: миграция не должна + # случайно опубликовать их во всей платформе. + News.objects.filter(content_type_id=program_content_type_id).update( + audience="program_participants" + ) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("news", "0009_news_pin"), + ] + + operations = [ + migrations.AddField( + model_name="news", + name="audience", + field=models.CharField( + choices=[ + ("platform", "Вся платформа"), + ("program_participants", "Участники программы"), + ], + db_index=True, + default="platform", + max_length=24, + verbose_name="Аудитория", + ), + ), + migrations.RunPython( + set_existing_news_audiences, + migrations.RunPython.noop, + ), + migrations.AddConstraint( + model_name="news", + constraint=models.CheckConstraint( + check=models.Q( + audience__in=("platform", "program_participants") + ), + name="news_valid_audience", + ), + ), + migrations.CreateModel( + name="NewsComment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField(max_length=2000, verbose_name="Текст")), + ( + "datetime_created", + models.DateTimeField( + default=django.utils.timezone.now, + verbose_name="Дата создания", + ), + ), + ( + "datetime_updated", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Дата изменения", + ), + ), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="news_comments", + to=settings.AUTH_USER_MODEL, + verbose_name="Автор", + ), + ), + ( + "news", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="news.news", + verbose_name="Новость", + ), + ), + ], + options={ + "verbose_name": "Комментарий к новости", + "verbose_name_plural": "Комментарии к новостям", + "ordering": ["datetime_created", "id"], + "indexes": [ + models.Index( + fields=["news", "datetime_created"], + name="news_comment_order_idx", + ) + ], + }, + ), + ] diff --git a/news/models.py b/news/models.py index a1a5a189..d6eebe32 100644 --- a/news/models.py +++ b/news/models.py @@ -1,5 +1,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType +from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone @@ -10,6 +12,10 @@ class News(models.Model): + class Audience(models.TextChoices): + PLATFORM = "platform", "Вся платформа" + PROGRAM_PARTICIPANTS = "program_participants", "Участники программы" + content_type = models.ForeignKey( ContentType, on_delete=models.CASCADE, @@ -38,6 +44,13 @@ class News(models.Model): verbose_name="Закрепить новость", help_text="Закрепить новость (пока только для профиля программ)", ) + audience = models.CharField( + max_length=24, + choices=Audience.choices, + default=Audience.PLATFORM, + db_index=True, + verbose_name="Аудитория", + ) datetime_created = models.DateTimeField( verbose_name="Дата создания", null=False, default=timezone.now ) @@ -49,7 +62,70 @@ class News(models.Model): objects = NewsManager() + def clean(self): + """Запрещает внутреннюю аудиторию вне контекста партнерской программы.""" + super().clean() + if ( + self.audience == self.Audience.PROGRAM_PARTICIPANTS + and self.content_type_id + and ( + self.content_type.app_label != "partner_programs" + or self.content_type.model != "partnerprogram" + ) + ): + raise ValidationError( + { + "audience": ( + "Закрытая аудитория доступна только для новостей программы." + ) + } + ) + class Meta(TypedModelMeta): verbose_name = "Новость" verbose_name_plural = "Новости" ordering = ["-datetime_created"] + constraints = [ + models.CheckConstraint( + check=models.Q(audience__in=("platform", "program_participants")), + name="news_valid_audience", + ) + ] + + +class NewsComment(models.Model): + news = models.ForeignKey( + News, + on_delete=models.CASCADE, + related_name="comments", + verbose_name="Новость", + ) + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="news_comments", + verbose_name="Автор", + ) + text = models.TextField(max_length=2000, verbose_name="Текст") + datetime_created = models.DateTimeField( + default=timezone.now, + verbose_name="Дата создания", + ) + # Null означает, что комментарий ещё не редактировали. В API при этом + # возвращается дата создания, поэтому контракт datetime_updated стабилен. + datetime_updated = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата изменения", + ) + + class Meta(TypedModelMeta): + verbose_name = "Комментарий к новости" + verbose_name_plural = "Комментарии к новостям" + ordering = ["datetime_created", "id"] + indexes = [ + models.Index( + fields=["news", "datetime_created"], + name="news_comment_order_idx", + ) + ] diff --git a/news/querysets.py b/news/querysets.py index dc40b78e..0dd59083 100644 --- a/news/querysets.py +++ b/news/querysets.py @@ -3,6 +3,7 @@ from django.shortcuts import get_object_or_404 from news.models import News +from news.access import can_view_program_participant_news from news.services import FEED_RECORD_TEXT from partner_programs.models import PartnerProgram from projects.models import Project @@ -23,16 +24,26 @@ def get_user_news_queryset(user_id: int) -> QuerySet[News]: return News.objects.get_news(obj=user) -def get_program_news_queryset(program_id: int) -> QuerySet[News]: +def get_program_news_queryset(program_id: int, user=None) -> QuerySet[News]: program = get_object_or_404(PartnerProgram, pk=program_id) - return News.objects.get_news(obj=program).order_by("-pin", "-datetime_created") - - -def get_news_queryset_for_context(kwargs: dict) -> QuerySet[News]: + queryset = News.objects.get_news(obj=program) + if can_view_program_participant_news(user, program): + queryset = queryset.filter( + audience__in=( + News.Audience.PLATFORM, + News.Audience.PROGRAM_PARTICIPANTS, + ) + ) + else: + queryset = queryset.filter(audience=News.Audience.PLATFORM) + return queryset.order_by("-pin", "-datetime_created") + + +def get_news_queryset_for_context(kwargs: dict, user=None) -> QuerySet[News]: if kwargs.get("project_pk") is not None: return get_project_news_queryset(kwargs["project_pk"]) if kwargs.get("partnerprogram_pk") is not None: - return get_program_news_queryset(kwargs["partnerprogram_pk"]) + return get_program_news_queryset(kwargs["partnerprogram_pk"], user=user) if kwargs.get("user_pk") is not None: return get_user_news_queryset(kwargs["user_pk"]) return News.objects.none() diff --git a/news/serializers.py b/news/serializers.py index e0aa69a2..1a02f058 100644 --- a/news/serializers.py +++ b/news/serializers.py @@ -25,11 +25,34 @@ def __init__(self, *args, **kwargs): if user and user.is_authenticated: self.fields["files"].queryset = UserFile.objects.filter(user=user) + def validate(self, attrs): + attrs = super().validate(attrs) + context_type = self.context.get("news_context") + audience = attrs.get("audience") + + if context_type == "program": + if self.instance is None and audience is None: + attrs["audience"] = News.Audience.PROGRAM_PARTICIPANTS + return attrs + + if audience not in (None, News.Audience.PLATFORM): + raise serializers.ValidationError( + { + "audience": ( + "Закрытая аудитория доступна только для новостей программы." + ) + } + ) + if self.instance is None: + attrs["audience"] = News.Audience.PLATFORM + return attrs + class Meta: model = News fields = [ "text", "files", + "audience", ] @@ -78,6 +101,7 @@ class Meta: "pin", "image_address", "text", + "audience", "datetime_created", "views_count", "likes_count", @@ -100,7 +124,6 @@ class ProgramNewsListResponseSerializer(BaseNewsListResponseSerializer): class BaseNewsDetailResponseSerializer(BaseNewsResponseSerializer): - class Meta: model = News fields = [ @@ -109,6 +132,7 @@ class Meta: "pin", "image_address", "text", + "audience", "datetime_created", "datetime_updated", "views_count", diff --git a/news/tests/helpers.py b/news/tests/helpers.py index d8062b36..39182948 100644 --- a/news/tests/helpers.py +++ b/news/tests/helpers.py @@ -78,7 +78,7 @@ def create_user_file( ) -> UserFile: suffix = unique_suffix() return UserFile.objects.create( - link=f"https://cdn.example.com/news/{suffix}/{name}.{extension}", + link=f"https://cdn.example.com/news/{suffix}/{name}.{extension}", # noqa: E231 user=user, name=name, extension=extension, @@ -87,8 +87,20 @@ def create_user_file( ) -def create_news_for(obj, *, text: str = "News text", files=None, pin: bool = False) -> News: - news = News.objects.add_news(obj, text=text, files=files or []) +def create_news_for( + obj, + *, + text: str = "News text", + files=None, + pin: bool = False, + audience: str = News.Audience.PLATFORM, +) -> News: + news = News.objects.add_news( + obj, + text=text, + files=files or [], + audience=audience, + ) if pin: news.pin = True news.save(update_fields=["pin"]) diff --git a/news/tests/test_program_news_audience.py b/news/tests/test_program_news_audience.py new file mode 100644 index 00000000..3b888927 --- /dev/null +++ b/news/tests/test_program_news_audience.py @@ -0,0 +1,187 @@ +from importlib import import_module + +from django.apps import apps +from django.test import TestCase +from rest_framework.test import APIClient + +from news.models import News +from partner_programs.models import PartnerProgramUserProfile + +from .helpers import create_news_for, create_partner_program, create_user + + +class ProgramNewsAudienceAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.manager = create_user(prefix="audience-manager") + self.participant = create_user(prefix="audience-participant") + self.outsider = create_user(prefix="audience-outsider") + self.admin = create_user(prefix="audience-admin") + self.admin.is_staff = True + self.admin.save(update_fields=["is_staff"]) + self.program = create_partner_program(manager=self.manager) + PartnerProgramUserProfile.objects.create( + partner_program=self.program, + user=self.participant, + partner_program_data={}, + ) + + def test_manager_can_create_platform_news(self): + self.client.force_authenticate(self.manager) + + response = self.client.post( + f"/programs/{self.program.pk}/news/", + {"text": "Public program news", "audience": "platform"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["audience"], News.Audience.PLATFORM) + self.assertEqual( + News.objects.get(pk=response.data["id"]).audience, + News.Audience.PLATFORM, + ) + + def test_manager_can_create_participant_news(self): + self.client.force_authenticate(self.manager) + + response = self.client.post( + f"/programs/{self.program.pk}/news/", + { + "text": "Internal program news", + "audience": "program_participants", + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["audience"], News.Audience.PROGRAM_PARTICIPANTS) + + def test_omitted_audience_is_participant_only_for_angular_compatibility(self): + self.client.force_authenticate(self.manager) + + response = self.client.post( + f"/programs/{self.program.pk}/news/", + {"text": "Legacy Angular program news"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual( + News.objects.get(pk=response.data["id"]).audience, + News.Audience.PROGRAM_PARTICIPANTS, + ) + + def test_manager_can_change_program_news_audience(self): + news = create_news_for( + self.program, + audience=News.Audience.PROGRAM_PARTICIPANTS, + ) + self.client.force_authenticate(self.manager) + + response = self.client.patch( + f"/programs/{self.program.pk}/news/{news.pk}/", + {"audience": "platform"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + news.refresh_from_db() + self.assertEqual(news.audience, News.Audience.PLATFORM) + + def test_outsider_cannot_create_or_change_program_news(self): + news = create_news_for(self.program) + self.client.force_authenticate(self.outsider) + + create_response = self.client.post( + f"/programs/{self.program.pk}/news/", + {"text": "Forbidden"}, + format="json", + ) + update_response = self.client.patch( + f"/programs/{self.program.pk}/news/{news.pk}/", + {"audience": "program_participants"}, + format="json", + ) + + self.assertEqual(create_response.status_code, 403) + self.assertEqual(update_response.status_code, 403) + + def test_participant_manager_and_admin_see_internal_news(self): + news = create_news_for( + self.program, + text="Internal audience", + audience=News.Audience.PROGRAM_PARTICIPANTS, + ) + + for user in (self.participant, self.manager, self.admin): + with self.subTest(user=user.email): + self.client.force_authenticate(user) + list_response = self.client.get(f"/programs/{self.program.pk}/news/") + detail_response = self.client.get( + f"/programs/{self.program.pk}/news/{news.pk}/" + ) + self.assertEqual(list_response.status_code, 200) + self.assertIn( + news.pk, + [item["id"] for item in list_response.data["results"]], + ) + self.assertEqual(detail_response.status_code, 200) + + def test_outsider_cannot_discover_internal_news(self): + internal_news = create_news_for( + self.program, + audience=News.Audience.PROGRAM_PARTICIPANTS, + ) + platform_news = create_news_for( + self.program, + text="Public audience", + audience=News.Audience.PLATFORM, + ) + self.client.force_authenticate(self.outsider) + + list_response = self.client.get(f"/programs/{self.program.pk}/news/") + detail_response = self.client.get( + f"/programs/{self.program.pk}/news/{internal_news.pk}/" + ) + + self.assertEqual(list_response.status_code, 200) + self.assertEqual( + [item["id"] for item in list_response.data["results"]], + [platform_news.pk], + ) + self.assertEqual(detail_response.status_code, 404) + + def test_closed_audience_is_rejected_for_user_news(self): + self.client.force_authenticate(self.outsider) + + response = self.client.post( + f"/auth/users/{self.outsider.pk}/news/", + {"text": "Invalid audience", "audience": "program_participants"}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("audience", response.data) + + +class ProgramNewsAudienceDataMigrationTests(TestCase): + def test_data_migration_keeps_existing_program_news_internal(self): + program = create_partner_program(name="Migrated program") + user = create_user(prefix="migrated-user-news") + program_news = create_news_for(program, audience=News.Audience.PLATFORM) + user_news = create_news_for(user) + News.objects.filter(pk=user_news.pk).update( + audience=News.Audience.PROGRAM_PARTICIPANTS + ) + migration = import_module("news.migrations.0010_news_audience_newscomment") + + migration.set_existing_news_audiences(apps, None) + + program_news.refresh_from_db() + user_news.refresh_from_db() + self.assertEqual( + program_news.audience, + News.Audience.PROGRAM_PARTICIPANTS, + ) + self.assertEqual(user_news.audience, News.Audience.PLATFORM) diff --git a/news/views.py b/news/views.py index e7c5c4be..e6d70565 100644 --- a/news/views.py +++ b/news/views.py @@ -47,7 +47,7 @@ class ContextNewsAPIView: def get_queryset(self): - return get_news_queryset_for_context(self.kwargs) + return get_news_queryset_for_context(self.kwargs, user=self.request.user) def get_news_object(self): return get_object_or_404(self.get_queryset(), pk=self.kwargs["pk"]) @@ -76,7 +76,10 @@ class NewsList(ContextNewsAPIView, generics.ListCreateAPIView): def post(self, request: Request, *args, **kwargs) -> Response: serializer = NewsCreateSerializer( data=request.data, - context={"request": request}, + context={ + "request": request, + "news_context": self.get_news_context(), + }, ) serializer.is_valid(raise_exception=True) data = serializer.validated_data @@ -85,14 +88,18 @@ def post(self, request: Request, *args, **kwargs) -> Response: project = get_object_or_404(Project, pk=kwargs["project_pk"]) news = create_project_news(project, request.user, data) return Response( - self.get_detail_response_serializer_class()(news).data, + self.get_detail_response_serializer_class()( + news, context={"user": request.user} + ).data, status=status.HTTP_201_CREATED, ) if kwargs.get("user_pk"): user = get_object_or_404(User, pk=kwargs["user_pk"]) news = create_user_news(user, request.user, data) return Response( - self.get_detail_response_serializer_class()(news).data, + self.get_detail_response_serializer_class()( + news, context={"user": request.user} + ).data, status=status.HTTP_201_CREATED, ) @@ -100,7 +107,9 @@ def post(self, request: Request, *args, **kwargs) -> Response: program = get_object_or_404(PartnerProgram, pk=kwargs["partnerprogram_pk"]) news = create_program_news(program, request.user, data) return Response( - self.get_detail_response_serializer_class()(news).data, + self.get_detail_response_serializer_class()( + news, context={"user": request.user} + ).data, status=status.HTTP_201_CREATED, ) return Response(status=status.HTTP_400_BAD_REQUEST) @@ -133,7 +142,10 @@ def update(self, request: Request, *args, **kwargs) -> Response: serializer = NewsUpdateSerializer( news, data=request.data, - context={"request": request}, + context={ + "request": request, + "news_context": self.get_news_context(), + }, partial=kwargs.get("partial", False), ) serializer.is_valid(raise_exception=True)