From 4c785ceb83ace7a3507c05206cfa488efed7b5ca Mon Sep 17 00:00:00 2001 From: Todd Dembrey <todd.dembrey@torchbox.com> Date: Tue, 7 May 2019 13:43:58 +0100 Subject: [PATCH] Make the comment editable through the API --- .../0022_add_versioning_to_comments.py | 29 ++++++++ opentech/apply/activity/models.py | 10 ++- opentech/apply/activity/tests/test_models.py | 11 +++ opentech/apply/funds/api_views.py | 42 +++++++++++- opentech/apply/funds/permissions.py | 5 ++ opentech/apply/funds/serializers.py | 11 ++- opentech/apply/funds/tests/test_api_views.py | 67 +++++++++++++++++++ opentech/apply/funds/urls.py | 2 + 8 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 opentech/apply/activity/migrations/0022_add_versioning_to_comments.py create mode 100644 opentech/apply/activity/tests/test_models.py create mode 100644 opentech/apply/funds/tests/test_api_views.py diff --git a/opentech/apply/activity/migrations/0022_add_versioning_to_comments.py b/opentech/apply/activity/migrations/0022_add_versioning_to_comments.py new file mode 100644 index 000000000..e883730f1 --- /dev/null +++ b/opentech/apply/activity/migrations/0022_add_versioning_to_comments.py @@ -0,0 +1,29 @@ +# Generated by Django 2.0.9 on 2019-02-24 00:03 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0021_add_review_delete_event'), + ] + + operations = [ + migrations.AddField( + model_name='activity', + name='current', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='activity', + name='edited', + field=models.DateTimeField(default=None, null=True), + ), + migrations.AddField( + model_name='activity', + name='previous', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='activity.Activity'), + ), + ] diff --git a/opentech/apply/activity/models.py b/opentech/apply/activity/models.py index 159bd219c..fcd0c488f 100644 --- a/opentech/apply/activity/models.py +++ b/opentech/apply/activity/models.py @@ -59,7 +59,10 @@ class ActivityBaseManager(models.Manager): return super().create(**kwargs) def get_queryset(self): - return super().get_queryset().filter(type=self.type) + return super().get_queryset().filter( + type=self.type, + current=True, + ) class CommentQueryset(BaseActivityQuerySet): @@ -86,6 +89,11 @@ class Activity(models.Model): message = models.TextField() visibility = models.CharField(choices=list(VISIBILITY.items()), default=PUBLIC, max_length=10) + # Fields for handling versioning of the comment activity models + edited = models.DateTimeField(default=None, null=True) + current = models.BooleanField(default=True) + previous = models.ForeignKey("self", on_delete=models.CASCADE, null=True) + # Fields for generic relations to other objects. related_object should implement `get_absolute_url` content_type = models.ForeignKey(ContentType, blank=True, null=True, on_delete=models.CASCADE) object_id = models.PositiveIntegerField(blank=True, null=True) diff --git a/opentech/apply/activity/tests/test_models.py b/opentech/apply/activity/tests/test_models.py new file mode 100644 index 000000000..2230036f3 --- /dev/null +++ b/opentech/apply/activity/tests/test_models.py @@ -0,0 +1,11 @@ +from django.test import TestCase + +from .factories import CommentFactory +from ..models import Activity + + +class TestActivityOnlyIncludesCurrent(TestCase): + def test_doesnt_include_non_current(self): + CommentFactory() + CommentFactory(current=False) + self.assertEqual(Activity.comments.count(), 1) diff --git a/opentech/apply/funds/api_views.py b/opentech/apply/funds/api_views.py index f4017549d..bc3862af4 100644 --- a/opentech/apply/funds/api_views.py +++ b/opentech/apply/funds/api_views.py @@ -1,6 +1,8 @@ from django.core.exceptions import PermissionDenied as DjangoPermissionDenied +from django.db import transaction from django.db.models import Q -from rest_framework import generics, permissions +from django.utils import timezone +from rest_framework import generics, mixins, permissions from rest_framework.response import Response from rest_framework.exceptions import (NotFound, PermissionDenied, ValidationError) @@ -15,13 +17,14 @@ from .models import ApplicationSubmission, RoundsAndLabs from .serializers import ( CommentSerializer, CommentCreateSerializer, + CommentEditSerializer, RoundLabDetailSerializer, RoundLabSerializer, SubmissionActionSerializer, SubmissionListSerializer, SubmissionDetailSerializer, ) -from .permissions import IsApplyStaffUser +from .permissions import IsApplyStaffUser, IsAuthor from .workflow import PHASES @@ -188,3 +191,38 @@ class CommentListCreate(generics.ListCreateAPIView): submission=obj.submission, related=obj, ) + + +class CommentEdit( + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + generics.GenericAPIView, +): + queryset = Activity.comments.all() + serializer_class = CommentEditSerializer + permission_classes = ( + permissions.IsAuthenticated, IsAuthor + ) + + def post(self, request, *args, **kwargs): + return self.edit(request, *args, **kwargs) + + @transaction.atomic + def edit(self, request, *args, **kwargs): + comment_to_edit = self.get_object() + comment_to_update = self.get_object() + + comment_to_edit.previous = comment_to_update + comment_to_edit.pk = None + comment_to_edit.edited = timezone.now() + + serializer = self.get_serializer(comment_to_edit, data=request.data) + serializer.is_valid(raise_exception=True) + + if serializer.validated_data['message'] != comment_to_update.message: + self.perform_create(serializer) + comment_to_update.current = False + comment_to_update.save() + return Response(serializer.data) + + return Response(self.get_serializer(comment_to_update).data) diff --git a/opentech/apply/funds/permissions.py b/opentech/apply/funds/permissions.py index ec6f22f83..8d7438c54 100644 --- a/opentech/apply/funds/permissions.py +++ b/opentech/apply/funds/permissions.py @@ -1,6 +1,11 @@ from rest_framework import permissions +class IsAuthor(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + return obj.user == request.user + + class IsApplyStaffUser(permissions.BasePermission): """ Custom permission to only allow OTF Staff or higher diff --git a/opentech/apply/funds/serializers.py b/opentech/apply/funds/serializers.py index 4f3640364..4d1ca335e 100644 --- a/opentech/apply/funds/serializers.py +++ b/opentech/apply/funds/serializers.py @@ -214,7 +214,7 @@ class CommentSerializer(serializers.ModelSerializer): class Meta: model = Activity - fields = ('id', 'timestamp', 'user', 'submission', 'message', 'visibility') + fields = ('id', 'timestamp', 'user', 'submission', 'message', 'visibility', 'edited') def get_message(self, obj): return bleach_value(markdown(obj.message)) @@ -225,4 +225,11 @@ class CommentCreateSerializer(serializers.ModelSerializer): class Meta: model = Activity - fields = ('id', 'timestamp', 'user', 'message', 'visibility') + fields = ('id', 'timestamp', 'user', 'message', 'visibility', 'edited') + + +class CommentEditSerializer(CommentCreateSerializer): + user = serializers.StringRelatedField() + + class Meta(CommentCreateSerializer.Meta): + read_only_fields = ('visibility', 'edited',) diff --git a/opentech/apply/funds/tests/test_api_views.py b/opentech/apply/funds/tests/test_api_views.py new file mode 100644 index 000000000..68251a09e --- /dev/null +++ b/opentech/apply/funds/tests/test_api_views.py @@ -0,0 +1,67 @@ +from django.test import TestCase, override_settings +from django.urls import reverse_lazy + +from opentech.apply.activity.models import Activity, PUBLIC, PRIVATE +from opentech.apply.activity.tests.factories import CommentFactory + +from opentech.apply.users.tests.factories import UserFactory + + +@override_settings(ROOT_URLCONF='opentech.apply.urls') +class TestCommentEdit(TestCase): + def post_to_edit(self, comment_pk, message='my message'): + return self.client.post( + reverse_lazy('funds:api:comments:edit', kwargs={'pk': comment_pk}), + secure=True, + data={'message': message}, + ) + + def test_cant_edit_if_not_author(self): + comment = CommentFactory() + response = self.post_to_edit(comment.pk) + self.assertEqual(response.status_code, 403) + + def test_edit_updates_correctly(self): + user = UserFactory() + comment = CommentFactory(user=user) + self.client.force_login(user) + + new_message = 'hi there' + + response = self.post_to_edit(comment.pk, new_message) + + self.assertEqual(response.status_code, 200) + self.assertEqual(Activity.objects.count(), 2) + + comment.refresh_from_db() + self.assertFalse(comment.current) + self.assertEqual(response.json()['message'], new_message) + + def test_incorrect_id_denied(self): + response = self.post_to_edit(10000) + self.assertEqual(response.status_code, 403) + + def test_does_nothing_if_same_message(self): + user = UserFactory() + comment = CommentFactory(user=user) + self.client.force_login(user) + + self.post_to_edit(comment.pk, comment.message) + self.assertEqual(Activity.objects.count(), 1) + + def test_cant_change_visibility(self): + user = UserFactory() + comment = CommentFactory(user=user, visibility=PRIVATE) + self.client.force_login(user) + + response = self.client.post( + reverse_lazy('funds:api:comments:edit', kwargs={'pk': comment.pk}), + secure=True, + data={ + 'message': 'the new message', + 'visibility': PUBLIC, + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['visibility'], PRIVATE) diff --git a/opentech/apply/funds/urls.py b/opentech/apply/funds/urls.py index 72b29d3b7..88990380d 100644 --- a/opentech/apply/funds/urls.py +++ b/opentech/apply/funds/urls.py @@ -14,6 +14,7 @@ from .views import ( SubmissionDeleteView, ) from .api_views import ( + CommentEdit, CommentList, CommentListCreate, RoundLabDetail, @@ -62,6 +63,7 @@ api_urls = ([ ], 'rounds'))), path('comments/', include(([ path('', CommentList.as_view(), name='list'), + path('<int:pk>/edit/', CommentEdit.as_view(), name='edit'), ], 'comments'))) ], 'api') -- GitLab