Browse Source

misc cleanups, API for post changelog

Rafał Pitoń 8 years ago
parent
commit
44e2ab1e6e

+ 91 - 0
misago/threads/api/postendpoints/editsrecord.py

@@ -0,0 +1,91 @@
+from django.core.exceptions import PermissionDenied
+from django.db.models import F
+from django.http import Http404
+from django.utils import timezone
+from django.utils.translation import ugettext as _
+
+from rest_framework.response import Response
+
+from misago.acl import add_acl
+from misago.core.shortcuts import get_int_or_404, get_object_or_404
+from misago.markup import common_flavour
+
+from ...checksums import update_post_checksum
+from ...serializers import PostSerializer, PostEditSerializer
+
+
+def get_edit_endpoint(request, post):
+    edit = get_edit(post, request.GET.get('edit'))
+
+    data = PostEditSerializer(edit).data
+
+    try:
+        queryset = post.edits_record.filter(id__gt=edit.id).order_by('id')
+        data['next'] = queryset[:1][0].id
+    except IndexError:
+        data['next'] = None
+
+    try:
+        queryset = post.edits_record.filter(id__lt=edit.id).order_by('-id')
+        data['previous'] = queryset[:1][0].id
+    except IndexError:
+        data['previous'] = None
+
+    return Response(data)
+
+
+def revert_post_endpoint(request, post):
+    edit = get_edit_by_pk(post, request.GET.get('edit'))
+
+    datetime = timezone.now()
+    post_edits = post.edits
+
+    post.edits_record.create(
+        category=post.category,
+        thread=post.thread,
+        edited_on=datetime,
+        editor=request.user,
+        editor_name=request.user.username,
+        editor_slug=request.user.slug,
+        editor_ip=request.user_ip,
+        edited_from=post.original,
+        edited_to=edit.edited_from
+    )
+
+    parsing_result = common_flavour(request, post.poster, edit.edited_from)
+
+    post.original = parsing_result['original_text']
+    post.parsed = parsing_result['parsed_text']
+
+    update_post_checksum(post)
+
+    post.updated_on = datetime
+    post.edits = F('edits') + 1
+
+    post.last_editor = request.user
+    post.last_editor_name = request.user.username
+    post.last_editor_slug = request.user.slug
+
+    post.save()
+
+    post.is_read = True
+    post.is_new = False
+    post.edits = post_edits + 1
+
+    add_acl(request.user, post)
+
+    return Response(PostSerializer(post, context={'user': request.user}).data)
+
+
+def get_edit(post, pk=None):
+    if pk is not None:
+        return get_edit_by_pk(post, pk)
+
+    edit = post.edits_record.first()
+    if not edit:
+        raise PermissionDenied(_("Edits record is unavailable for this post."))
+    return edit
+
+
+def get_edit_by_pk(post, pk):
+    return get_object_or_404(post.edits_record, pk=get_int_or_404(pk))

+ 1 - 0
misago/threads/api/postingendpoint/recordedit.py

@@ -32,6 +32,7 @@ class RecordEditMiddleware(PostingMiddleware):
         self.post.edits_record.create(
         self.post.edits_record.create(
             category=self.post.category,
             category=self.post.category,
             thread=self.thread,
             thread=self.thread,
+            edited_on=self.datetime,
             editor=self.user,
             editor=self.user,
             editor_name=self.user.username,
             editor_name=self.user.username,
             editor_slug=self.user.slug,
             editor_slug=self.user.slug,

+ 23 - 6
misago/threads/api/threadposts.py

@@ -19,6 +19,7 @@ from ..viewmodels.post import ThreadPost
 from ..viewmodels.posts import ThreadPosts
 from ..viewmodels.posts import ThreadPosts
 from ..viewmodels.thread import ForumThread
 from ..viewmodels.thread import ForumThread
 from .postingendpoint import PostingEndpoint
 from .postingendpoint import PostingEndpoint
+from .postendpoints.editsrecord import get_edit_endpoint, revert_post_endpoint
 from .postendpoints.merge import posts_merge_endpoint
 from .postendpoints.merge import posts_merge_endpoint
 from .postendpoints.move import posts_move_endpoint
 from .postendpoints.move import posts_move_endpoint
 from .postendpoints.patch_event import event_patch_endpoint
 from .postendpoints.patch_event import event_patch_endpoint
@@ -186,8 +187,8 @@ class ViewSet(viewsets.ViewSet):
     @detail_route(methods=['post'])
     @detail_route(methods=['post'])
     @transaction.atomic
     @transaction.atomic
     def read(self, request, thread_pk, pk):
     def read(self, request, thread_pk, pk):
-        thread = self.get_thread(request, get_int_or_404(thread_pk))
-        post = self.get_post(request, thread, get_int_or_404(pk)).model
+        thread = self.get_thread(request, thread_pk)
+        post = self.get_post(request, thread, pk).model
 
 
         request.user.lock()
         request.user.lock()
 
 
@@ -197,11 +198,11 @@ class ViewSet(viewsets.ViewSet):
     def post_editor(self, request, thread_pk, pk):
     def post_editor(self, request, thread_pk, pk):
         thread = self.get_thread(
         thread = self.get_thread(
             request,
             request,
-            get_int_or_404(thread_pk),
+            thread_pk,
             read_aware=False,
             read_aware=False,
             subscription_aware=False
             subscription_aware=False
         )
         )
-        post = self.get_post(request, thread, get_int_or_404(pk)).model
+        post = self.get_post(request, thread, pk).model
 
 
         allow_edit_post(request.user, post)
         allow_edit_post(request.user, post)
 
 
@@ -226,14 +227,14 @@ class ViewSet(viewsets.ViewSet):
     def reply_editor(self, request, thread_pk):
     def reply_editor(self, request, thread_pk):
         thread = self.get_thread(
         thread = self.get_thread(
             request,
             request,
-            get_int_or_404(thread_pk),
+            thread_pk,
             read_aware=False,
             read_aware=False,
             subscription_aware=False
             subscription_aware=False
         )
         )
         allow_reply_thread(request.user, thread.model)
         allow_reply_thread(request.user, thread.model)
 
 
         if 'reply' in request.query_params:
         if 'reply' in request.query_params:
-            reply_to = self.get_post(request, thread, get_int_or_404(request.query_params['reply'])).model
+            reply_to = self.get_post(request, thread, request.query_params['reply']).model
 
 
             if reply_to.is_event:
             if reply_to.is_event:
                 raise PermissionDenied(_("You can't reply to events."))
                 raise PermissionDenied(_("You can't reply to events."))
@@ -248,6 +249,22 @@ class ViewSet(viewsets.ViewSet):
         else:
         else:
             return Response({})
             return Response({})
 
 
+    @detail_route(methods=['get', 'post'])
+    def edits(self, request, thread_pk, pk):
+        if request.method == 'GET':
+            thread = self.get_thread(request, thread_pk)
+            post = self.get_post(request, thread, pk).model
+
+            return get_edit_endpoint(request, post)
+
+        if request.method == 'POST':
+            thread = self.get_thread(request, thread_pk)
+            post = self.get_post_for_update(request, thread, pk).model
+
+            allow_edit_post(request.user, post)
+
+            return revert_post_endpoint(request, post)
+
 
 
 class ThreadPostsViewSet(ViewSet):
 class ThreadPostsViewSet(ViewSet):
     thread = ForumThread
     thread = ForumThread

+ 3 - 0
misago/threads/models/post.py

@@ -135,6 +135,9 @@ class Post(models.Model):
     def get_editor_api_url(self):
     def get_editor_api_url(self):
         return self.thread_type.get_post_editor_api_url(self)
         return self.thread_type.get_post_editor_api_url(self)
 
 
+    def get_edits_api_url(self):
+        return self.thread_type.get_post_edits_api_url(self)
+
     def get_read_api_url(self):
     def get_read_api_url(self):
         return self.thread_type.get_post_read_api_url(self)
         return self.thread_type.get_post_read_api_url(self)
 
 

+ 5 - 0
misago/threads/models/postedit.py

@@ -1,3 +1,5 @@
+import difflib
+
 from django.conf import settings
 from django.conf import settings
 from django.db import models
 from django.db import models
 from django.utils import timezone
 from django.utils import timezone
@@ -25,3 +27,6 @@ class PostEdit(models.Model):
 
 
     class Meta:
     class Meta:
         ordering = ['-id']
         ordering = ['-id']
+
+    def get_diff(self):
+        return difflib.ndiff(self.edited_from.splitlines(), self.edited_to.splitlines()),

+ 1 - 0
misago/threads/serializers/__init__.py

@@ -1,6 +1,7 @@
 from .moderation import *
 from .moderation import *
 from .thread import *
 from .thread import *
 from .post import *
 from .post import *
+from .postedit import *
 from .attachment import *
 from .attachment import *
 from .poll import *
 from .poll import *
 from .pollvote import *
 from .pollvote import *

+ 1 - 0
misago/threads/serializers/poll.py

@@ -20,6 +20,7 @@ class PollSerializer(serializers.ModelSerializer):
     class Meta:
     class Meta:
         model = Poll
         model = Poll
         fields = (
         fields = (
+            'id',
             'poster_name',
             'poster_name',
             'posted_on',
             'posted_on',
             'length',
             'length',

+ 1 - 0
misago/threads/serializers/post.py

@@ -98,6 +98,7 @@ class PostSerializer(serializers.ModelSerializer):
         return {
         return {
             'index': obj.get_api_url(),
             'index': obj.get_api_url(),
             'editor': obj.get_editor_api_url(),
             'editor': obj.get_editor_api_url(),
+            'edits': obj.get_edits_api_url(),
             'read': obj.get_read_api_url(),
             'read': obj.get_read_api_url(),
         }
         }
 
 

+ 46 - 0
misago/threads/serializers/postedit.py

@@ -0,0 +1,46 @@
+from django.core.urlresolvers import reverse
+
+from rest_framework import serializers
+
+from ..models import PostEdit
+
+
+__all__ = [
+    'PostEditSerializer',
+]
+
+
+class PostEditSerializer(serializers.ModelSerializer):
+    diff = serializers.SerializerMethodField()
+
+    url = serializers.SerializerMethodField()
+
+    class Meta:
+        model = PostEdit
+        fields = (
+            'id',
+            'edited_on',
+            'editor_name',
+            'editor_slug',
+
+            'diff',
+
+            'url',
+        )
+
+    def get_diff(self, obj):
+        return obj.get_diff()
+
+    def get_url(self, obj):
+        return {
+            'editor': self.get_editor_url(obj),
+        }
+
+    def get_editor_url(self, obj):
+        if obj.editor_id:
+            return reverse('misago:user', kwargs={
+                'slug': obj.editor_slug,
+                'pk': obj.editor_id,
+            })
+        else:
+            return None

+ 11 - 2
misago/threads/signals.py

@@ -8,7 +8,7 @@ from misago.categories.signals import delete_category_content, move_category_con
 from misago.core.pgutils import batch_delete, batch_update
 from misago.core.pgutils import batch_delete, batch_update
 from misago.users.signals import delete_user_content, username_changed
 from misago.users.signals import delete_user_content, username_changed
 
 
-from .models import Attachment, Post, Thread, Poll, PollVote
+from .models import Attachment, Post, PostEdit, Thread, Poll, PollVote
 
 
 
 
 delete_post = Signal()
 delete_post = Signal()
@@ -38,7 +38,10 @@ def merge_posts(sender, **kwargs):
 
 
 @receiver(move_thread)
 @receiver(move_thread)
 def move_thread_content(sender, **kwargs):
 def move_thread_content(sender, **kwargs):
-    sender.post_set.update(category=sender.category)
+    Post.objects.filter(thread=sender).update(category=sender.category)
+    PostEdit.objects.filter(thread=sender).update(category=sender.category)
+    Poll.objects.filter(thread=sender).update(category=sender.category)
+    PollVote.objects.filter(thread=sender).update(category=sender.category)
 
 
 
 
 @receiver(delete_category_content)
 @receiver(delete_category_content)
@@ -53,6 +56,7 @@ def move_category_threads(sender, **kwargs):
 
 
     Thread.objects.filter(category=sender).update(category=new_category)
     Thread.objects.filter(category=sender).update(category=new_category)
     Post.objects.filter(category=sender).update(category=new_category)
     Post.objects.filter(category=sender).update(category=new_category)
+    PostEdit.objects.filter(category=sender).update(category=new_category)
     Poll.objects.filter(category=sender).update(category=new_category)
     Poll.objects.filter(category=sender).update(category=new_category)
     PollVote.objects.filter(category=sender).update(category=new_category)
     PollVote.objects.filter(category=sender).update(category=new_category)
 
 
@@ -104,6 +108,11 @@ def update_usernames(sender, **kwargs):
         last_editor_slug=sender.slug
         last_editor_slug=sender.slug
     )
     )
 
 
+    PostEdit.objects.filter(editor=sender).update(
+        editor_name=sender.username,
+        editor_slug=sender.slug
+    )
+
     Attachment.objects.filter(uploader=sender).update(
     Attachment.objects.filter(uploader=sender).update(
         uploader_name=sender.username,
         uploader_name=sender.username,
         uploader_slug=sender.slug
         uploader_slug=sender.slug

+ 191 - 0
misago/threads/tests/test_thread_postedits_api.py

@@ -0,0 +1,191 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import json
+from datetime import timedelta
+
+from django.core.urlresolvers import reverse
+
+from misago.acl.testutils import override_acl
+from misago.categories.models import Category
+
+from .. import testutils
+from ..models import Post
+from .test_threads_api import ThreadsApiTestCase
+
+
+class ThreadPostEditsApiTestCase(ThreadsApiTestCase):
+    def setUp(self):
+        super(ThreadPostEditsApiTestCase, self).setUp()
+
+        self.category = Category.objects.get(slug='first-category')
+        self.thread = testutils.post_thread(category=self.category)
+        self.post = testutils.reply_thread(self.thread, poster=self.user)
+
+        self.api_link = reverse('misago:api:thread-post-edits', kwargs={
+            'thread_pk': self.thread.pk,
+            'pk': self.post.pk
+        })
+
+        self.override_acl()
+
+    def mock_edit_record(self):
+        edits_record = [
+            self.post.edits_record.create(
+                category=self.category,
+                thread=self.thread,
+                editor=self.user,
+                editor_name=self.user.username,
+                editor_slug=self.user.slug,
+                editor_ip='127.0.0.1',
+                edited_from="Original body",
+                edited_to="First Edit"
+            ),
+            self.post.edits_record.create(
+                category=self.category,
+                thread=self.thread,
+                editor_name='Deleted',
+                editor_slug='deleted',
+                editor_ip='127.0.0.1',
+                edited_from="First Edit",
+                edited_to="Second Edit"
+            ),
+            self.post.edits_record.create(
+                category=self.category,
+                thread=self.thread,
+                editor=self.user,
+                editor_name=self.user.username,
+                editor_slug=self.user.slug,
+                editor_ip='127.0.0.1',
+                edited_from="Second Edit",
+                edited_to="Last Edit"
+            )
+        ]
+
+        self.post.original = 'Last Edit'
+        self.post.parsed = '<p>Last Edit</p>'
+        self.post.save()
+
+        return edits_record
+
+
+class ThreadPostGetEditTests(ThreadPostEditsApiTestCase):
+    def test_no_edits(self):
+        """api returns 403 if post has no edits record"""
+        response = self.client.get(self.api_link)
+        self.assertContains(response, "Edits record is unavailable", status_code=403)
+
+        self.logout_user()
+
+        response = self.client.get(self.api_link)
+        self.assertContains(response, "Edits record is unavailable", status_code=403)
+
+    def test_empty_edit_id(self):
+        """api handles empty edit in querystring"""
+        response = self.client.get('{}?edit='.format(self.api_link))
+        self.assertEqual(response.status_code, 404)
+
+    def test_invalid_edit_id(self):
+        """api handles invalid edit in querystring"""
+        response = self.client.get('{}?edit=dsa67d8sa68'.format(self.api_link))
+        self.assertEqual(response.status_code, 404)
+
+    def test_nonexistant_edit_id(self):
+        """api handles nonexistant edit in querystring"""
+        response = self.client.get('{}?edit=1321'.format(self.api_link))
+        self.assertEqual(response.status_code, 404)
+
+    def test_get_last_edit(self):
+        """api returns last edit record"""
+        edits = self.mock_edit_record()
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+        response_json = response.json()
+
+        self.assertEqual(response_json['id'], edits[-1].id)
+        self.assertIsNone(response_json['next'])
+        self.assertEqual(response_json['previous'], edits[1].id)
+
+    def test_get_middle_edit(self):
+        """api returns middle edit record"""
+        edits = self.mock_edit_record()
+
+        response = self.client.get('{}?edit={}'.format(self.api_link, edits[1].id))
+        self.assertEqual(response.status_code, 200)
+
+        response_json = response.json()
+
+        self.assertEqual(response_json['id'], edits[1].id)
+        self.assertEqual(response_json['next'], edits[-1].id)
+        self.assertEqual(response_json['previous'], edits[0].id)
+
+    def test_get_first_edit(self):
+        """api returns middle edit record"""
+        edits = self.mock_edit_record()
+
+        response = self.client.get('{}?edit={}'.format(self.api_link, edits[0].id))
+        self.assertEqual(response.status_code, 200)
+
+        response_json = response.json()
+
+        self.assertEqual(response_json['id'], edits[0].id)
+        self.assertEqual(response_json['next'], edits[1].id)
+        self.assertIsNone(response_json['previous'])
+
+
+class ThreadPostPostEditTests(ThreadPostEditsApiTestCase):
+    def setUp(self):
+        super(ThreadPostPostEditTests, self).setUp()
+        self.edits = self.mock_edit_record()
+
+        self.override_acl({
+            'can_edit_posts': 2
+        })
+
+    def test_empty_edit_id(self):
+        """api handles empty edit in querystring"""
+        response = self.client.post('{}?edit='.format(self.api_link))
+        self.assertEqual(response.status_code, 404)
+
+    def test_invalid_edit_id(self):
+        """api handles invalid edit in querystring"""
+        response = self.client.post('{}?edit=dsa67d8sa68'.format(self.api_link))
+        self.assertEqual(response.status_code, 404)
+
+    def test_nonexistant_edit_id(self):
+        """api handles nonexistant edit in querystring"""
+        response = self.client.post('{}?edit=1321'.format(self.api_link))
+        self.assertEqual(response.status_code, 404)
+
+    def test_anonymous(self):
+        """only signed in users can rever ports"""
+        self.logout_user()
+
+        response = self.client.post('{}?edit={}'.format(self.api_link, self.edits[0].id))
+        self.assertEqual(response.status_code, 403)
+
+    def test_no_permission(self):
+        """api validates permission to revert post"""
+        self.override_acl({
+            'can_edit_posts': 0
+        })
+
+        response = self.client.post('{}?edit=1321'.format(self.api_link))
+        self.assertEqual(response.status_code, 403)
+
+    def test_revert_post(self):
+        """api reverts post to version from before specified edit"""
+        response = self.client.post('{}?edit={}'.format(self.api_link, self.edits[0].id))
+        self.assertEqual(response.status_code, 200)
+
+        response_json = response.json()
+        self.assertEqual(response_json['edits'], 1)
+        self.assertEqual(response_json['content'], "<p>Original body</p>")
+
+        self.assertEqual(self.post.edits_record.count(), 4)
+
+        edit = self.post.edits_record.first()
+        self.assertEqual(edit.edited_from, self.post.original)
+        self.assertEqual(edit.edited_to, "Original body")

+ 6 - 0
misago/threads/threadtypes/thread.py

@@ -141,6 +141,12 @@ class Thread(ThreadType):
             'pk': post.pk
             'pk': post.pk
         })
         })
 
 
+    def get_post_edits_api_url(self, post):
+        return reverse('misago:api:thread-post-edits', kwargs={
+            'thread_pk': post.thread_id,
+            'pk': post.pk
+        })
+
     def get_post_read_api_url(self, post):
     def get_post_read_api_url(self, post):
         return reverse('misago:api:thread-post-read', kwargs={
         return reverse('misago:api:thread-post-read', kwargs={
             'thread_pk': post.thread_id,
             'thread_pk': post.thread_id,