diff --git a/news/tests/test_news_project_api.py b/news/tests/test_news_project_api.py index 5f15f70f..313d5f3a 100644 --- a/news/tests/test_news_project_api.py +++ b/news/tests/test_news_project_api.py @@ -4,8 +4,15 @@ from core.models import Like, View from news.models import News +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile -from .helpers import create_news_for, create_project, create_user, create_user_file +from .helpers import ( + create_news_for, + create_partner_program, + create_project, + create_user, + create_user_file, +) class ProjectScopedNewsAPITests(TestCase): @@ -99,6 +106,50 @@ def test_project_news_detail_can_be_updated_and_deleted_by_leader(self): self.assertEqual(delete_response.status_code, 204) self.assertFalse(News.objects.filter(pk=news.id).exists()) + def test_submitted_project_copy_preserves_news_and_original_can_publish(self): + self.client.force_authenticate(self.leader) + program = create_partner_program() + program.is_competitive = True + program.save(update_fields=["is_competitive"]) + project_copy = create_project(leader=self.leader) + PartnerProgramUserProfile.objects.create( + partner_program=program, + user=self.leader, + project=project_copy, + partner_program_data={}, + ) + news = create_news_for(project_copy, text="News saved before submission") + PartnerProgramProject.objects.create( + partner_program=program, + project=project_copy, + source_project=self.project, + submitted=True, + ) + feed_url = f"/projects/{project_copy.id}/news/" + entry_url = f"{feed_url}{news.id}/" + + self.assertEqual(self.client.get(feed_url).status_code, 200) + self.assertEqual(self.client.get(entry_url).status_code, 200) + self.assertEqual( + self.client.post(feed_url, {"text": "New post"}, format="json").status_code, + 403, + ) + self.assertEqual( + self.client.patch(entry_url, {"text": "Changed"}, format="json").status_code, + 403, + ) + self.assertEqual(self.client.delete(entry_url).status_code, 403) + news.refresh_from_db() + self.assertEqual(news.text, "News saved before submission") + self.assertEqual( + self.client.post( + f"/projects/{self.project.id}/news/", + {"text": "New post on the original"}, + format="json", + ).status_code, + 201, + ) + def test_project_news_update_rejects_file_of_another_user(self): self.client.force_authenticate(self.leader) leader_file = create_user_file(self.leader, name="leader-file") diff --git a/news/views.py b/news/views.py index e7c5c4be..3dad4994 100644 --- a/news/views.py +++ b/news/views.py @@ -1,4 +1,7 @@ +from contextlib import contextmanager + from django.contrib.auth import get_user_model +from django.db import transaction from django.shortcuts import get_object_or_404 from rest_framework import generics, status from rest_framework.permissions import IsAuthenticated @@ -27,7 +30,7 @@ ) from partner_programs.models import PartnerProgram from projects.models import Project -from projects.permissions import ProjectVisibilityPermission +from projects.permissions import ProjectVisibilityPermission, require_editable_submission User = get_user_model() @@ -46,6 +49,21 @@ class ContextNewsAPIView: + @contextmanager + def editable_project_news(self): + project_id = self.kwargs.get("project_pk") + if project_id is None: + yield None + return + + # Submission takes the same Project lock before setting submitted. + with transaction.atomic(): + project = get_object_or_404( + Project.objects.select_for_update(), pk=project_id + ) + require_editable_submission(project) + yield project + def get_queryset(self): return get_news_queryset_for_context(self.kwargs) @@ -82,12 +100,12 @@ def post(self, request: Request, *args, **kwargs) -> Response: data = serializer.validated_data if kwargs.get("project_pk"): - 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, - status=status.HTTP_201_CREATED, - ) + with self.editable_project_news() as project: + news = create_project_news(project, request.user, data) + return Response( + self.get_detail_response_serializer_class()(news).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) @@ -128,19 +146,24 @@ def get(self, request: Request, *args, **kwargs) -> Response: ) def update(self, request: Request, *args, **kwargs) -> Response: - news = self.get_news_object() - context = {"user": request.user} - serializer = NewsUpdateSerializer( - news, - data=request.data, - context={"request": request}, - partial=kwargs.get("partial", False), - ) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response( - self.get_detail_response_serializer_class()(news, context=context).data - ) + with self.editable_project_news(): + news = self.get_news_object() + context = {"user": request.user} + serializer = NewsUpdateSerializer( + news, + data=request.data, + context={"request": request}, + partial=kwargs.get("partial", False), + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response( + self.get_detail_response_serializer_class()(news, context=context).data + ) + + def destroy(self, request: Request, *args, **kwargs) -> Response: + with self.editable_project_news(): + return super().destroy(request, *args, **kwargs) class NewsDetailSetViewed(ContextNewsAPIView, generics.CreateAPIView):