Browse Source

thread poll api

Rafał Pitoń 8 years ago
parent
commit
cafcdddcce

+ 98 - 0
misago/threads/api/pollvotecreateendpoint.py

@@ -0,0 +1,98 @@
+from copy import deepcopy
+
+from django.core.exceptions import ValidationError
+from django.utils import six
+from django.utils.translation import gettext as _, ungettext
+
+from rest_framework.response import Response
+
+from misago.acl import add_acl
+
+from ..permissions.polls import allow_vote_poll
+from ..serializers import PollSerializer
+
+
+def poll_vote_create(request, thread, poll):
+    poll.make_choices_votes_aware(request.user, poll.choices)
+
+    allow_vote_poll(request.user, poll)
+
+    try:
+        clean_votes = validate_votes(poll, request.data)
+    except ValidationError as e:
+        return Response({'detail': six.text_type(e)}, status=400)
+
+    remove_user_votes(request.user, poll, clean_votes)
+    set_new_votes(request, poll, clean_votes)
+
+    add_acl(request.user, poll)
+    serialized_poll = PollSerializer(poll).data
+
+    poll.choices = list(map(presave_clean_choice, deepcopy(poll.choices)))
+    poll.save()
+
+    return Response(serialized_poll)
+
+
+def presave_clean_choice(choice):
+    del choice['selected']
+    return choice
+
+
+def validate_votes(poll, votes):
+    try:
+        votes_len = len(votes)
+        if votes_len > poll.allowed_choices:
+            message = ungettext(
+                "This poll disallows voting for more than %(choices)s choice.",
+                "This poll disallows voting for more than %(choices)s choices.",
+                poll.allowed_choices)
+            raise ValidationError(message % {'choices': poll.allowed_choices})
+    except TypeError:
+        raise ValidationError(_("One or more of poll choices were invalid."))
+
+    valid_choices = [c['hash'] for c in poll.choices]
+    clean_votes = []
+
+    for vote in votes:
+        if vote in valid_choices:
+            clean_votes.append(vote)
+
+    if len(clean_votes) != len(votes):
+        raise ValidationError(_("One or more of poll choices were invalid."))
+    if not len(votes):
+        raise ValidationError(_("You have to make a choice."))
+
+    return clean_votes
+
+
+def remove_user_votes(user, poll, final_votes):
+    removed_votes = []
+    for choice in poll.choices:
+        if choice['selected'] and choice['hash'] not in final_votes:
+            poll.votes -= 1
+            choice['votes'] -= 1
+
+            choice['selected'] = False
+            removed_votes.append(choice['hash'])
+
+    if removed_votes:
+        poll.pollvote_set.filter(voter=user, choice_hash__in=removed_votes).delete()
+
+
+def set_new_votes(request, poll, final_votes):
+    for choice in poll.choices:
+        if not choice['selected'] and choice['hash'] in final_votes:
+            poll.votes += 1
+            choice['votes'] += 1
+
+            choice['selected'] = True
+            poll.pollvote_set.create(
+                category=poll.category,
+                thread=poll.thread,
+                voter=request.user,
+                voter_name=request.user.username,
+                voter_slug=request.user.slug,
+                choice_hash=choice['hash'],
+                voter_ip=request.user_ip
+            )

+ 8 - 4
misago/threads/api/threadpoll.py

@@ -8,11 +8,12 @@ from rest_framework.response import Response
 from misago.acl import add_acl
 from misago.acl import add_acl
 from misago.core.shortcuts import get_int_or_404
 from misago.core.shortcuts import get_int_or_404
 
 
-from ..models import Poll, PollVote
+from ..models import Poll
 from ..permissions.polls import (
 from ..permissions.polls import (
     allow_see_poll_votes, allow_start_poll, allow_edit_poll, allow_delete_poll, can_start_poll)
     allow_see_poll_votes, allow_start_poll, allow_edit_poll, allow_delete_poll, can_start_poll)
 from ..serializers import PollSerializer, PollVoteSerializer, NewPollSerializer, EditPollSerializer
 from ..serializers import PollSerializer, PollVoteSerializer, NewPollSerializer, EditPollSerializer
 from ..viewmodels.thread import ForumThread
 from ..viewmodels.thread import ForumThread
+from .pollvotecreateendpoint import poll_vote_create
 
 
 
 
 class ViewSet(viewsets.ViewSet):
 class ViewSet(viewsets.ViewSet):
@@ -68,7 +69,7 @@ class ViewSet(viewsets.ViewSet):
 
 
     @transaction.atomic
     @transaction.atomic
     def update(self, request, thread_pk, pk):
     def update(self, request, thread_pk, pk):
-        thread = self.get_thread(request, thread_pk)
+        thread = self.get_thread_for_update(request, thread_pk)
         instance = self.get_poll(thread, pk)
         instance = self.get_poll(thread, pk)
 
 
         allow_edit_poll(request.user, instance)
         allow_edit_poll(request.user, instance)
@@ -86,7 +87,7 @@ class ViewSet(viewsets.ViewSet):
 
 
     @transaction.atomic
     @transaction.atomic
     def delete(self, request, thread_pk, pk):
     def delete(self, request, thread_pk, pk):
-        thread = self.get_thread(request, thread_pk)
+        thread = self.get_thread_for_update(request, thread_pk)
         instance = self.get_poll(thread, pk)
         instance = self.get_poll(thread, pk)
 
 
         allow_delete_poll(request.user, instance)
         allow_delete_poll(request.user, instance)
@@ -106,7 +107,10 @@ class ViewSet(viewsets.ViewSet):
 
 
     @transaction.atomic
     @transaction.atomic
     def post_votes(self, request, thread_pk, pk):
     def post_votes(self, request, thread_pk, pk):
-        pass
+        thread = self.get_thread_for_update(request, thread_pk)
+        instance = self.get_poll(thread, pk)
+
+        return poll_vote_create(request, thread, instance)
 
 
     def get_votes(self, request, thread_pk, pk):
     def get_votes(self, request, thread_pk, pk):
         poll_pk = get_int_or_404(pk)
         poll_pk = get_int_or_404(pk)

+ 2 - 1
misago/threads/models/poll.py

@@ -33,7 +33,8 @@ class Poll(models.Model):
     @property
     @property
     def is_over(self):
     def is_over(self):
         if self.length:
         if self.length:
-            return self.posted_on - timedelta(days=self.length) < timezone.now()
+            poll_cutoff = self.posted_on + timedelta(days=self.length)
+            return timezone.now() > poll_cutoff
         return False
         return False
 
 
     @property
     @property

+ 28 - 0
misago/threads/permissions/polls.py

@@ -91,6 +91,7 @@ ACL's for targets
 """
 """
 def add_acl_to_poll(user, poll):
 def add_acl_to_poll(user, poll):
     poll.acl.update({
     poll.acl.update({
+        'can_vote': can_vote_poll(user, poll),
         'can_edit': can_edit_poll(user, poll),
         'can_edit': can_edit_poll(user, poll),
         'can_delete': can_delete_poll(user, poll),
         'can_delete': can_delete_poll(user, poll),
         'can_see_votes': can_see_poll_votes(user, poll),
         'can_see_votes': can_see_poll_votes(user, poll),
@@ -200,6 +201,33 @@ def allow_delete_poll(user, target):
 can_delete_poll = return_boolean(allow_delete_poll)
 can_delete_poll = return_boolean(allow_delete_poll)
 
 
 
 
+def allow_vote_poll(user, target):
+    if user.is_anonymous():
+        raise PermissionDenied(_("You have to sign in to vote in polls."))
+
+    user_has_voted = False
+    for choice in target.choices:
+        if choice['selected']:
+            user_has_voted = True
+            break
+
+    if user_has_voted and not target.allow_revotes:
+        raise PermissionDenied(_("You have already voted in this poll."))
+    if target.is_over:
+        raise PermissionDenied(_("This poll is over. You can't vote in it."))
+
+    category_acl = user.acl['categories'].get(target.category_id, {
+        'can_close_threads': False,
+    })
+
+    if not category_acl.get('can_close_threads'):
+        if target.category.is_closed:
+            raise PermissionDenied(_("This category is closed. You can't vote in it."))
+        if target.thread.is_closed:
+            raise PermissionDenied(_("This thread is closed. You can't vote in it."))
+can_vote_poll = return_boolean(allow_vote_poll)
+
+
 def allow_see_poll_votes(user, target):
 def allow_see_poll_votes(user, target):
     if not target.is_public and not user.acl['can_always_see_poll_voters']:
     if not target.is_public and not user.acl['can_always_see_poll_voters']:
         raise PermissionDenied(_("You dont have permission to this poll's voters."))
         raise PermissionDenied(_("You dont have permission to this poll's voters."))

+ 1 - 79
misago/threads/tests/test_thread_poll_api.py

@@ -1,6 +1,5 @@
 import json
 import json
 
 
-from django.contrib.auth import get_user_model
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 
 
 from misago.acl.testutils import override_acl
 from misago.acl.testutils import override_acl
@@ -8,7 +7,6 @@ from misago.categories.models import Category
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 from .. import testutils
 from .. import testutils
-from ..models import Poll
 
 
 
 
 class ThreadPollApiTestCase(AuthenticatedUserTestCase):
 class ThreadPollApiTestCase(AuthenticatedUserTestCase):
@@ -53,83 +51,7 @@ class ThreadPollApiTestCase(AuthenticatedUserTestCase):
         override_acl(self.user, new_acl)
         override_acl(self.user, new_acl)
 
 
     def mock_poll(self):
     def mock_poll(self):
-        self.poll = self.thread.poll = Poll.objects.create(
-            category=self.category,
-            thread=self.thread,
-            poster=self.user,
-            poster_name=self.user.username,
-            poster_slug=self.user.slug,
-            poster_ip='127.0.0.1',
-            question="Lorem ipsum dolor met?",
-            choices=[
-                {
-                    'hash': 'aaaaaaaaaaaa',
-                    'label': 'Alpha',
-                    'votes': 1
-                },
-                {
-                    'hash': 'bbbbbbbbbbbb',
-                    'label': 'Beta',
-                    'votes': 0
-                },
-                {
-                    'hash': 'gggggggggggg',
-                    'label': 'Gamma',
-                    'votes': 2
-                },
-                {
-                    'hash': 'dddddddddddd',
-                    'label': 'Delta',
-                    'votes': 1
-                }
-            ],
-            allowed_choices=2,
-            votes=4
-        )
-
-        # one user voted for Alpha choice
-        User = get_user_model()
-        user = User.objects.create_user('bob', 'bob@test.com', 'Pass.123')
-
-        self.poll.pollvote_set.create(
-            category=self.category,
-            thread=self.thread,
-            voter=user,
-            voter_name=user.username,
-            voter_slug=user.slug,
-            voter_ip='127.0.0.1',
-            choice_hash='aaaaaaaaaaaa'
-        )
-
-        # test user voted on third and last choices
-        self.poll.pollvote_set.create(
-            category=self.category,
-            thread=self.thread,
-            voter=self.user,
-            voter_name=self.user.username,
-            voter_slug=self.user.slug,
-            voter_ip='127.0.0.1',
-            choice_hash='gggggggggggg'
-        )
-        self.poll.pollvote_set.create(
-            category=self.category,
-            thread=self.thread,
-            voter=self.user,
-            voter_name=self.user.username,
-            voter_slug=self.user.slug,
-            voter_ip='127.0.0.1',
-            choice_hash='dddddddddddd'
-        )
-
-        # somebody else voted on third option before being deleted
-        self.poll.pollvote_set.create(
-            category=self.category,
-            thread=self.thread,
-            voter_name='deleted',
-            voter_slug='deleted',
-            voter_ip='127.0.0.1',
-            choice_hash='gggggggggggg'
-        )
+        self.poll = self.thread.poll = testutils.post_poll(self.thread, self.user)
 
 
         self.api_link = reverse('misago:api:thread-poll-detail', kwargs={
         self.api_link = reverse('misago:api:thread-poll-detail', kwargs={
             'thread_pk': self.thread.pk,
             'thread_pk': self.thread.pk,

+ 162 - 1
misago/threads/tests/test_thread_pollvotes_api.py

@@ -1,7 +1,10 @@
+from datetime import timedelta
+
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
+from django.utils import timezone
 
 
-from ..models import Poll, PollVote
+from ..models import Poll
 from .test_thread_poll_api import ThreadPollApiTestCase
 from .test_thread_poll_api import ThreadPollApiTestCase
 
 
 
 
@@ -147,9 +150,167 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
             'pk': self.poll.pk
             'pk': self.poll.pk
         })
         })
 
 
+    def delete_user_votes(self):
+        self.poll.choices[2]['votes'] = 1
+        self.poll.choices[3]['votes'] = 0
+        self.poll.votes = 2
+        self.poll.save()
+
+        self.poll.pollvote_set.filter(voter=self.user).delete()
+
     def test_anonymous(self):
     def test_anonymous(self):
         """api requires you to sign in to vote in poll"""
         """api requires you to sign in to vote in poll"""
         self.logout_user()
         self.logout_user()
 
 
         response = self.post(self.api_link)
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
+
+    def test_empty_vote(self):
+        """api validates if vote that user has made was empty"""
+        self.delete_user_votes()
+
+        response = self.post(self.api_link)
+        self.assertContains(response, "You have to make a choice.", status_code=400)
+
+    def test_noninterable_vote(self):
+        """api validates if vote that user has made was iterable"""
+        self.delete_user_votes()
+
+        response = self.post(self.api_link, data=123)
+        self.assertContains(response, "One or more of poll choices were invalid.", status_code=400)
+
+    def test_invalid_choices(self):
+        """api validates if vote that user has made overlaps with allowed votes"""
+        self.delete_user_votes()
+
+        response = self.post(self.api_link, data=['lorem', 'ipsum'])
+        self.assertContains(response, "One or more of poll choices were invalid.", status_code=400)
+
+    def test_too_many_choices(self):
+        """api validates if vote that user has made overlaps with allowed votes"""
+        self.poll.allowed_choices = 1
+        self.poll.allow_revotes = True
+        self.poll.save()
+
+        response = self.post(self.api_link, data=['aaaaaaaaaaaa', 'bbbbbbbbbbbb'])
+        self.assertContains(response, "This poll disallows voting for more than 1 choice.", status_code=400)
+
+    def test_revote(self):
+        """api validates if user is trying to change vote in poll that disallows revoting"""
+        response = self.post(self.api_link, data=['lorem', 'ipsum'])
+        self.assertContains(response, "You have already voted in this poll.", status_code=403)
+
+        self.delete_user_votes()
+
+        response = self.post(self.api_link)
+        self.assertContains(response, "You have to make a choice.", status_code=400)
+
+    def test_vote_in_closed_thread(self):
+        """api validates is user has permission to vote poll in closed thread"""
+        self.override_acl(category={
+            'can_close_threads': 0
+        })
+
+        self.thread.is_closed = True
+        self.thread.save()
+
+        self.delete_user_votes()
+
+        response = self.post(self.api_link)
+        self.assertContains(response, "thread is closed", status_code=403)
+
+        self.override_acl(category={
+            'can_close_threads': 1
+        })
+
+        response = self.post(self.api_link)
+        self.assertContains(response, "You have to make a choice.", status_code=400)
+
+    def test_vote_in_closed_category(self):
+        """api validates is user has permission to vote poll in closed category"""
+        self.override_acl(category={
+            'can_close_threads': 0
+        })
+
+        self.category.is_closed = True
+        self.category.save()
+
+        self.delete_user_votes()
+
+        response = self.post(self.api_link)
+        self.assertContains(response, "category is closed", status_code=403)
+
+        self.override_acl(category={
+            'can_close_threads': 1
+        })
+
+        response = self.post(self.api_link)
+        self.assertContains(response, "You have to make a choice.", status_code=400)
+
+    def test_vote_in_finished_poll(self):
+        """api valdiates if poll has finished before letting user to vote in it"""
+        self.poll.posted_on = timezone.now() - timedelta(days=15)
+        self.poll.length = 5
+        self.poll.save()
+
+        self.delete_user_votes()
+
+        response = self.post(self.api_link)
+        self.assertContains(response, "This poll is over. You can't vote in it.", status_code=403)
+
+        self.poll.length = 50
+        self.poll.save()
+
+        response = self.post(self.api_link)
+        self.assertContains(response, "You have to make a choice.", status_code=400)
+
+    def test_fresh_vote(self):
+        """api handles first vote in poll"""
+        self.delete_user_votes()
+
+        response = self.post(self.api_link, data=['aaaaaaaaaaaa', 'bbbbbbbbbbbb'])
+        self.assertEqual(response.status_code, 200)
+
+        # validate state change
+        poll = Poll.objects.get(pk=self.poll.pk)
+        self.assertEqual(poll.votes, 4)
+        self.assertEqual([c['votes'] for c in poll.choices], [2, 1, 1, 0])
+
+        for choice in poll.choices:
+            self.assertNotIn('selected', choice)
+
+        self.assertEqual(poll.pollvote_set.count(), 4)
+
+        # validate response json
+        response_json = response.json()
+        self.assertEqual(response_json['votes'], 4)
+        self.assertEqual([c['votes'] for c in response_json['choices']], [2, 1, 1, 0])
+        self.assertEqual([c['selected'] for c in response_json['choices']], [True, True, False, False])
+
+        self.assertFalse(response_json['acl']['can_vote'])
+
+    def test_vote_change(self):
+        """api handles vote change"""
+        self.poll.allow_revotes = True
+        self.poll.save()
+
+        response = self.post(self.api_link, data=['aaaaaaaaaaaa', 'bbbbbbbbbbbb'])
+        self.assertEqual(response.status_code, 200)
+
+        # validate state change
+        poll = Poll.objects.get(pk=self.poll.pk)
+        self.assertEqual(poll.votes, 4)
+        self.assertEqual([c['votes'] for c in poll.choices], [2, 1, 1, 0])
+
+        for choice in poll.choices:
+            self.assertNotIn('selected', choice)
+
+        self.assertEqual(poll.pollvote_set.count(), 4)
+
+        # validate response json
+        response_json = response.json()
+        self.assertEqual(response_json['votes'], 4)
+        self.assertEqual([c['votes'] for c in response_json['choices']], [2, 1, 1, 0])
+        self.assertEqual([c['selected'] for c in response_json['choices']], [True, True, False, False])
+
+        self.assertTrue(response_json['acl']['can_vote'])

+ 84 - 1
misago/threads/testutils.py

@@ -1,11 +1,12 @@
 from datetime import timedelta
 from datetime import timedelta
 
 
+from django.contrib.auth import get_user_model
 from django.utils import timezone
 from django.utils import timezone
 
 
 from misago.core.utils import slugify
 from misago.core.utils import slugify
 
 
 from .checksums import update_post_checksum
 from .checksums import update_post_checksum
-from .models import Post, Thread
+from .models import Poll, Post, Thread
 
 
 
 
 def post_thread(category, title='Test thread', poster='Tester',
 def post_thread(category, title='Test thread', poster='Tester',
@@ -95,3 +96,85 @@ def reply_thread(thread, poster="Tester", message="I am test message",
     thread.category.save()
     thread.category.save()
 
 
     return post
     return post
+
+
+def post_poll(thread, poster):
+    poll = Poll.objects.create(
+        category=thread.category,
+        thread=thread,
+        poster=poster,
+        poster_name=poster.username,
+        poster_slug=poster.slug,
+        poster_ip='127.0.0.1',
+        question="Lorem ipsum dolor met?",
+        choices=[
+            {
+                'hash': 'aaaaaaaaaaaa',
+                'label': 'Alpha',
+                'votes': 1
+            },
+            {
+                'hash': 'bbbbbbbbbbbb',
+                'label': 'Beta',
+                'votes': 0
+            },
+            {
+                'hash': 'gggggggggggg',
+                'label': 'Gamma',
+                'votes': 2
+            },
+            {
+                'hash': 'dddddddddddd',
+                'label': 'Delta',
+                'votes': 1
+            }
+        ],
+        allowed_choices=2,
+        votes=4
+    )
+
+    # one user voted for Alpha choice
+    User = get_user_model()
+    user = User.objects.create_user('bob', 'bob@test.com', 'Pass.123')
+
+    poll.pollvote_set.create(
+        category=thread.category,
+        thread=thread,
+        voter=user,
+        voter_name=user.username,
+        voter_slug=user.slug,
+        voter_ip='127.0.0.1',
+        choice_hash='aaaaaaaaaaaa'
+    )
+
+    # test user voted on third and last choices
+    poll.pollvote_set.create(
+        category=thread.category,
+        thread=thread,
+        voter=poster,
+        voter_name=poster.username,
+        voter_slug=poster.slug,
+        voter_ip='127.0.0.1',
+        choice_hash='gggggggggggg'
+    )
+    poll.pollvote_set.create(
+        category=thread.category,
+        thread=thread,
+        voter=poster,
+        voter_name=poster.username,
+        voter_slug=poster.slug,
+        voter_ip='127.0.0.1',
+        choice_hash='dddddddddddd'
+    )
+
+    # somebody else voted on third option before being deleted
+    poll.pollvote_set.create(
+        category=thread.category,
+        thread=thread,
+        voter_name='deleted',
+        voter_slug='deleted',
+        voter_ip='127.0.0.1',
+        choice_hash='gggggggggggg'
+    )
+
+    return poll