Просмотр исходного кода

poll api tests split into separate modules, poll edit endpoint

Rafał Pitoń 8 лет назад
Родитель
Сommit
c65f717443

+ 29 - 6
misago/threads/api/threadpoll.py

@@ -9,7 +9,7 @@ from misago.acl import add_acl
 from misago.core.shortcuts import get_int_or_404
 
 from ..models import Poll, PollVote
-from ..permissions.polls import allow_start_poll
+from ..permissions.polls import allow_start_poll, allow_edit_poll
 from ..serializers import PollSerializer, NewPollSerializer, EditPollSerializer
 from ..viewmodels.thread import ForumThread
 
@@ -22,20 +22,25 @@ class ViewSet(viewsets.ViewSet):
             request,
             get_int_or_404(thread_pk),
             select_for_update=select_for_update,
-        )
+        ).model
 
     def get_thread_for_update(self, request, thread_pk):
         return self.get_thread(request, thread_pk, select_for_update=True)
 
-    def get_poll(self, thread):
+    def get_poll(self, thread, pk):
         try:
-            return thread.poll
+            poll_id = get_int_or_404(pk)
+            if thread.poll.pk != poll_id:
+                raise Http404()
+            poll = Poll.objects.select_for_update().get(pk=thread.poll.pk)
+            poll.thread = thread
+            return poll
         except Poll.DoesNotExist:
             raise Http404()
 
     @transaction.atomic
     def create(self, request, thread_pk):
-        thread = self.get_thread_for_update(request, thread_pk).model
+        thread = self.get_thread_for_update(request, thread_pk)
         allow_start_poll(request.user, thread)
 
         instance = Poll(
@@ -56,7 +61,25 @@ class ViewSet(viewsets.ViewSet):
         else:
             return Response(serializer.errors, status=400)
 
-    # create poll
+    @transaction.atomic
+    def update(self, request, thread_pk, pk):
+        thread = self.get_thread(request, thread_pk)
+        instance = self.get_poll(thread, pk)
+
+        allow_edit_poll(request.user, instance)
+
+        serializer = EditPollSerializer(instance, data=request.data)
+        if serializer.is_valid():
+            serializer.save()
+
+            add_acl(request.user, instance)
+            serialized_poll = PollSerializer(instance).data
+            instance.make_choices_votes_aware(request.user, serialized_poll['choices'])
+            return Response(serialized_poll)
+        else:
+            return Response(serializer.errors, status=400)
+
+
     # edit poll
     # delete poll
     # vote in poll

+ 1 - 1
misago/threads/migrations/0001_initial.py

@@ -288,7 +288,7 @@ class Migration(migrations.Migration):
                 ('voter_slug', models.CharField(max_length=255)),
                 ('voter_ip', models.GenericIPAddressField()),
                 ('voted_on', models.DateTimeField(default=django.utils.timezone.now)),
-                ('option_hash', models.CharField(max_length=12)),
+                ('choice_hash', models.CharField(max_length=12, db_index=True)),
                 ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_categories.Category')),
                 ('poll', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Poll')),
                 ('thread', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Thread')),

+ 20 - 0
misago/threads/models/poll.py

@@ -1,3 +1,5 @@
+from datetime import timedelta
+
 from django.conf import settings
 from django.contrib.postgres.fields import JSONField
 from django.db import models
@@ -27,3 +29,21 @@ class Poll(models.Model):
 
     votes = models.PositiveIntegerField(default=0)
     is_public = models.BooleanField(default=False)
+
+    @property
+    def is_over(self):
+        if self.length:
+            return self.posted_on - timedelta(days=self.length) < timezone.now()
+        return False
+
+    def make_choices_votes_aware(self, user, choices):
+        if user.is_anonymous():
+            for choice in choices:
+                choice['selected'] = False
+            return
+
+        queryset = self.pollvote_set.filter(voter=user).values('choice_hash')
+        user_votes = [v['choice_hash'] for v in queryset]
+
+        for choice in choices:
+            choice['selected'] = choice['hash'] in user_votes

+ 1 - 1
misago/threads/models/pollvote.py

@@ -17,4 +17,4 @@ class PollVote(models.Model):
     voter_slug = models.CharField(max_length=255)
     voter_ip = models.GenericIPAddressField()
     voted_on = models.DateTimeField(default=timezone.now)
-    option_hash = models.CharField(max_length=12)
+    choice_hash = models.CharField(max_length=12, db_index=True)

+ 45 - 1
misago/threads/permissions/polls.py

@@ -1,4 +1,5 @@
 from django.core.exceptions import PermissionDenied
+from django.utils import timezone
 from django.utils.translation import ugettext_lazy as _, ungettext
 
 from misago.acl import algebra
@@ -90,7 +91,7 @@ ACL's for targets
 """
 def add_acl_to_poll(user, poll):
     poll.acl.update({
-        'can_edit': False,
+        'can_edit': can_edit_poll(user, poll),
         'can_delete': False,
     })
 
@@ -133,3 +134,46 @@ def allow_start_poll(user, target):
     except Poll.DoesNotExist:
         pass
 can_start_poll = return_boolean(allow_start_poll)
+
+
+def allow_edit_poll(user, target):
+    if user.is_anonymous():
+        raise PermissionDenied(_("You have to sign in to edit polls."))
+
+    category_acl = user.acl['categories'].get(target.category_id, {
+        'can_close_threads': False,
+    })
+
+    if not user.acl.get('can_edit_polls'):
+        raise PermissionDenied(_("You can't edit polls."))
+
+    if user.acl.get('can_edit_polls') < 2:
+        if user.pk != target.poster_id:
+            raise PermissionDenied(_("You can't edit other users polls in this category."))
+        if not has_time_to_edit_poll(user, target):
+            message = ungettext(
+                "You can't edit polls that are older than %(minutes)s minute.",
+                "You can't edit polls that are older than %(minutes)s minutes.",
+                user.acl['poll_edit_time'])
+            raise PermissionDenied(message % {'minutes': user.acl['poll_edit_time']})
+
+    if target.is_over:
+        raise PermissionDenied(_("This poll is over. You can't edit it."))
+
+    if not category_acl.get('can_close_threads'):
+        if target.category.is_closed:
+            raise PermissionDenied(_("This category is closed. You can't edito polls in it."))
+        if target.thread.is_closed:
+            raise PermissionDenied(_("This thread is closed. You can't edito polls in it."))
+can_edit_poll = return_boolean(allow_edit_poll)
+
+
+def has_time_to_edit_poll(user, target):
+    edit_time = user.acl['poll_edit_time']
+    if edit_time:
+        diff = timezone.now() - target.posted_on
+        diff_minutes = int(diff.total_seconds() / 60)
+
+        return diff_minutes < edit_time
+    else:
+        return True

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

@@ -90,6 +90,29 @@ class EditPollSerializer(serializers.ModelSerializer):
     def validate_choices(self, choices):
         clean_choices = list(map(self.clean_choice, choices))
 
+        # generate hashes for added choices
+        choices_map = {}
+        for choice in self.instance.choices:
+            choices_map[choice['hash']] = choice
+
+        final_choices = []
+        for choice in clean_choices:
+            if choice['hash'] in choices_map:
+                choices_map[choice['hash']].update({
+                    'label': choice['label']
+                })
+                final_choices.append(choices_map[choice['hash']])
+            else:
+                choice.update({
+                    'hash': get_random_string(12),
+                    'votes': 0
+                })
+                final_choices.append(choice)
+
+        self.validate_choices_num(final_choices)
+
+        return final_choices
+
     def clean_choice(self, choice):
         clean_choice = {
             'hash': choice.get('hash', get_random_string(12)),
@@ -124,6 +147,24 @@ class EditPollSerializer(serializers.ModelSerializer):
                 _("Number of allowed choices can't be greater than number of all choices."))
         return data
 
+    def update(self, instance, validated_data):
+        if instance.choices:
+            self.update_choices(instance, validated_data['choices'])
+
+        return super(EditPollSerializer, self).update(instance, validated_data)
+
+    def update_choices(self, instance, cleaned_choices):
+        removed_hashes = []
+
+        final_hashes = [c['hash'] for c in cleaned_choices]
+        for choice in instance.choices:
+            if choice['hash'] not in final_hashes:
+                instance.votes -= choice['votes']
+                removed_hashes.append(choice['hash'])
+
+        if removed_hashes:
+            instance.pollvote_set.filter(choice_hash__in=removed_hashes).delete()
+
 
 class NewPollSerializer(EditPollSerializer):
     class Meta:

+ 51 - 0
misago/threads/tests/test_thread_poll_api.py

@@ -0,0 +1,51 @@
+import json
+
+from django.core.urlresolvers import reverse
+
+from misago.acl.testutils import override_acl
+from misago.categories.models import Category
+from misago.users.testutils import AuthenticatedUserTestCase
+
+from .. import testutils
+
+
+class ThreadPollApiTestCase(AuthenticatedUserTestCase):
+    def setUp(self):
+        super(ThreadPollApiTestCase, self).setUp()
+
+        self.category = Category.objects.get(slug='first-category')
+        self.thread = testutils.post_thread(self.category, poster=self.user)
+        self.override_acl()
+
+        self.api_link = reverse('misago:api:thread-poll-list', kwargs={
+            'thread_pk': self.thread.pk
+        })
+
+    def post(self, url, data=None):
+        return self.client.post(url, json.dumps(data or {}), content_type='application/json')
+
+    def put(self, url, data=None):
+        return self.client.put(url, json.dumps(data or {}), content_type='application/json')
+
+    def override_acl(self, user=None, category=None):
+        new_acl = self.user.acl
+        new_acl['categories'][self.category.pk].update({
+            'can_see': 1,
+            'can_browse': 1,
+            'can_close_threads': 0,
+        })
+
+        new_acl.update({
+            'can_start_polls': 1,
+            'can_edit_polls': 1,
+            'can_delete_polls': 1,
+            'poll_edit_time': 0,
+            'can_always_see_poll_voters': 0
+        })
+
+        if user:
+            new_acl.update(user)
+        if category:
+            new_acl['categories'][self.category.pk].update(category)
+
+        override_acl(self.user, new_acl)

+ 4 - 64
misago/threads/tests/test_poll_api.py → misago/threads/tests/test_thread_pollcreate_api.py

@@ -1,53 +1,8 @@
-import json
-
 from django.core.urlresolvers import reverse
 
-from misago.acl.testutils import override_acl
-from misago.categories.models import Category
-from misago.users.testutils import AuthenticatedUserTestCase
-
-from .. import testutils
 from ..models import Poll
 from ..serializers.poll import MAX_POLL_OPTIONS
-
-
-class ThreadPollApiTestCase(AuthenticatedUserTestCase):
-    def setUp(self):
-        super(ThreadPollApiTestCase, self).setUp()
-
-        self.category = Category.objects.get(slug='first-category')
-        self.thread = testutils.post_thread(self.category, poster=self.user)
-        self.override_acl()
-
-        self.api_link = reverse('misago:api:thread-poll-list', kwargs={
-            'thread_pk': self.thread.pk
-        })
-
-    def post(self, url, data=None):
-        return self.client.post(url, json.dumps(data or {}), content_type='application/json')
-
-    def override_acl(self, user=None, category=None):
-        new_acl = self.user.acl
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_close_threads': 0,
-        })
-
-        new_acl.update({
-            'can_start_polls': 1,
-            'can_edit_polls': 1,
-            'can_delete_polls': 1,
-            'poll_edit_time': 0,
-            'can_always_see_poll_voters': 0
-        })
-
-        if user:
-            new_acl.update(user)
-        if category:
-            new_acl['categories'][self.category.pk].update(category)
-
-        override_acl(self.user, new_acl)
+from .test_thread_poll_api import ThreadPollApiTestCase
 
 
 class ThreadPollCreateTests(ThreadPollApiTestCase):
@@ -104,7 +59,7 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
 
-    def test_no_permission_closed_thread(self):
+    def test_no_permission_closed_category(self):
         """api validates that user has permission to start poll in closed category"""
         self.override_acl(category={
             'can_close_threads': 0
@@ -309,7 +264,7 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
             'is_public': True,
             'choices': [
                 {
-                    'label': 'Red'
+                    'label': '\nRed '
                 },
                 {
                     'label': 'Green'
@@ -334,6 +289,7 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
 
         self.assertEqual(len(response_json['choices']), 3)
         self.assertEqual(len(set([c['hash'] for c in response_json['choices']])), 3)
+        self.assertEqual([c['label'] for c in response_json['choices']], ['Red', 'Green', 'Blue'])
 
         poll = self.thread.poll
 
@@ -351,19 +307,3 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
 
         self.assertEqual(len(poll.choices), 3)
         self.assertEqual(len(set([c['hash'] for c in poll.choices])), 3)
-
-
-class ThreadPollEditTests(ThreadPollApiTestCase):
-    pass
-
-
-class ThreadPollDeleteTests(ThreadPollApiTestCase):
-    pass
-
-
-class ThreadPollVoteTests(ThreadPollApiTestCase):
-    pass
-
-
-class ThreadPollVotersTests(ThreadPollApiTestCase):
-    pass

+ 561 - 0
misago/threads/tests/test_thread_polledit_api.py

@@ -0,0 +1,561 @@
+from datetime import timedelta
+
+from django.contrib.auth import get_user_model
+from django.core.urlresolvers import reverse
+from django.utils import timezone
+
+from ..models import Poll
+from ..serializers.poll import MAX_POLL_OPTIONS
+from .test_thread_poll_api import ThreadPollApiTestCase
+
+
+class ThreadPollEditTests(ThreadPollApiTestCase):
+    def setUp(self):
+        super(ThreadPollEditTests, self).setUp()
+
+        # mock poll
+        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.api_link = reverse('misago:api:thread-poll-detail', kwargs={
+            'thread_pk': self.thread.pk,
+            'pk': self.poll.pk
+        })
+
+    def test_anonymous(self):
+        """api requires you to sign in to create poll"""
+        self.logout_user()
+
+        response = self.put(self.api_link)
+        self.assertEqual(response.status_code, 403)
+
+    def test_invalid_thread_id(self):
+        """api validates that thread id is integer"""
+        api_link = reverse('misago:api:thread-poll-detail', kwargs={
+            'thread_pk': 'kjha6dsa687sa',
+            'pk': self.poll.pk
+        })
+
+        response = self.put(api_link)
+        self.assertEqual(response.status_code, 404)
+
+    def test_nonexistant_thread_id(self):
+        """api validates that thread exists"""
+        api_link = reverse('misago:api:thread-poll-detail', kwargs={
+            'thread_pk': self.thread.pk + 1,
+            'pk': self.poll.pk
+        })
+
+        response = self.put(api_link)
+        self.assertEqual(response.status_code, 404)
+
+    def test_invalid_poll_id(self):
+        """api validates that poll id is integer"""
+        api_link = reverse('misago:api:thread-poll-detail', kwargs={
+            'thread_pk': self.thread.pk,
+            'pk': 'sad98as7d97sa98'
+        })
+
+        response = self.put(api_link)
+        self.assertEqual(response.status_code, 404)
+
+    def test_nonexistant_poll_id(self):
+        """api validates that poll exists"""
+        api_link = reverse('misago:api:thread-poll-detail', kwargs={
+            'thread_pk': self.thread.pk,
+            'pk': self.poll.pk + 123
+        })
+
+        response = self.put(api_link)
+        self.assertEqual(response.status_code, 404)
+
+    def test_no_permission(self):
+        """api validates that user has permission to edit poll in thread"""
+        self.override_acl({
+            'can_edit_polls': 0
+        })
+
+        response = self.put(self.api_link)
+        self.assertContains(response, "can't edit polls", status_code=403)
+
+    def test_no_permission_timeout(self):
+        """api validates that user's window to edit poll in thread has closed"""
+        self.override_acl({
+            'can_edit_polls': 1,
+            'poll_edit_time': 5
+        })
+
+        self.poll.posted_on = timezone.now() - timedelta(minutes=15)
+        self.poll.save()
+
+        response = self.put(self.api_link)
+        self.assertContains(response, "can't edit polls that are older than 5 minutes", status_code=403)
+
+    def test_no_permission_poll_closed(self):
+        """api validates that user's window to edit poll in thread has closed"""
+        self.override_acl({
+            'can_edit_polls': 1
+        })
+
+        self.poll.posted_on = timezone.now() - timedelta(days=15)
+        self.poll.length = 5
+        self.poll.save()
+
+        response = self.put(self.api_link)
+        self.assertContains(response, "This poll is over", status_code=403)
+
+    def test_no_permission_other_user_poll(self):
+        """api validates that user has permission to edit other user poll in thread"""
+        self.override_acl({
+            'can_edit_polls': 1
+        })
+
+        self.poll.poster = None
+        self.poll.save()
+
+        response = self.put(self.api_link)
+        self.assertContains(response, "can't edit other users polls", status_code=403)
+
+    def test_no_permission_closed_thread(self):
+        """api validates that user has permission to edit poll in closed thread"""
+        self.override_acl(category={
+            'can_close_threads': 0
+        })
+
+        self.thread.is_closed = True
+        self.thread.save()
+
+        response = self.put(self.api_link)
+        self.assertContains(response, "thread is closed", status_code=403)
+
+        self.override_acl(category={
+            'can_close_threads': 1
+        })
+
+        response = self.put(self.api_link)
+        self.assertEqual(response.status_code, 400)
+
+    def test_no_permission_closed_category(self):
+        """api validates that user has permission to edit poll in closed category"""
+        self.override_acl(category={
+            'can_close_threads': 0
+        })
+
+        self.category.is_closed = True
+        self.category.save()
+
+        response = self.put(self.api_link)
+        self.assertContains(response, "category is closed", status_code=403)
+
+        self.override_acl(category={
+            'can_close_threads': 1
+        })
+
+        response = self.put(self.api_link)
+        self.assertEqual(response.status_code, 400)
+
+    def test_empty_data(self):
+        """api handles empty request data"""
+        response = self.put(self.api_link)
+        self.assertEqual(response.status_code, 400)
+
+        response_json = response.json()
+        self.assertEqual(len(response_json), 4)
+
+    def test_length_validation(self):
+        """api validates poll's length"""
+        response = self.put(self.api_link, data={
+            'length': -1
+        })
+        self.assertEqual(response.status_code, 400)
+
+        response_json = response.json()
+        self.assertEqual(response_json['length'], [
+            "Ensure this value is greater than or equal to 0."
+        ])
+
+        response = self.put(self.api_link, data={
+            'length': 200
+        })
+        self.assertEqual(response.status_code, 400)
+
+        response_json = response.json()
+        self.assertEqual(response_json['length'], [
+            "Ensure this value is less than or equal to 180."
+        ])
+
+    def test_question_validation(self):
+        """api validates question length"""
+        response = self.put(self.api_link, data={
+            'question': 'abcd' * 255
+        })
+        self.assertEqual(response.status_code, 400)
+
+        response_json = response.json()
+        self.assertEqual(response_json['question'], [
+            "Ensure this field has no more than 255 characters."
+        ])
+
+    def test_validate_choice_length(self):
+        """api validates single choice length"""
+        response = self.put(self.api_link, data={
+            'choices': [
+                {
+                    'hash': 'qwertyuiopas',
+                    'label': ''
+                }
+            ]
+        })
+        self.assertEqual(response.status_code, 400)
+
+        response_json = response.json()
+        self.assertEqual(response_json['choices'], [
+            "One or more poll choices are invalid."
+        ])
+
+        response = self.put(self.api_link, data={
+            'choices': [
+                {
+                    'hash': 'qwertyuiopas',
+                    'label': 'abcd' * 255
+                }
+            ]
+        })
+        self.assertEqual(response.status_code, 400)
+
+        response_json = response.json()
+        self.assertEqual(response_json['choices'], [
+            "One or more poll choices are invalid."
+        ])
+
+    def test_validate_two_choices(self):
+        """api validates that there are at least two choices in poll"""
+        response = self.put(self.api_link, data={
+            'choices': [
+                {
+                    'label': 'Choice'
+                }
+            ]
+        })
+        self.assertEqual(response.status_code, 400)
+
+        response_json = response.json()
+        self.assertEqual(response_json['choices'], [
+            "You need to add at least two choices to a poll."
+        ])
+
+    def test_validate_max_choices(self):
+        """api validates that there are no more choices in poll than allowed number"""
+        response = self.put(self.api_link, data={
+            'choices': [
+                {
+                    'label': 'Choice'
+                }
+            ] * (MAX_POLL_OPTIONS + 1)
+        })
+        self.assertEqual(response.status_code, 400)
+
+        error_formats = (MAX_POLL_OPTIONS, MAX_POLL_OPTIONS + 1)
+
+        response_json = response.json()
+        self.assertEqual(response_json['choices'], [
+            "You can't add more than %s options to a single poll (added %s)." % error_formats
+        ])
+
+    def test_allowed_choices_validation(self):
+        """api validates allowed choices number"""
+        response = self.put(self.api_link, data={
+            'allowed_choices': 0
+        })
+        self.assertEqual(response.status_code, 400)
+
+        response_json = response.json()
+        self.assertEqual(response_json['allowed_choices'], [
+            "Ensure this value is greater than or equal to 1."
+        ])
+
+        response = self.put(self.api_link, data={
+            'length': 0,
+            'question': "Lorem ipsum",
+            'allowed_choices': 3,
+            'choices': [
+                {
+                    'label': 'Choice'
+                },
+                {
+                    'label': 'Choice'
+                }
+            ]
+        })
+        self.assertEqual(response.status_code, 400)
+
+        response_json = response.json()
+        self.assertEqual(response_json['non_field_errors'], [
+            "Number of allowed choices can't be greater than number of all choices."
+        ])
+
+    def test_poll_all_choices_replaced(self):
+        """api edits all poll choices out"""
+        response = self.put(self.api_link, data={
+            'length': 40,
+            'question': "Select two best colors",
+            'allowed_choices': 2,
+            'allow_revotes': True,
+            'is_public': True,
+            'choices': [
+                {
+                    'label': '\nRed  '
+                },
+                {
+                    'label': 'Green'
+                },
+                {
+                    'label': 'Blue'
+                }
+            ]
+        })
+        self.assertEqual(response.status_code, 200)
+
+        response_json = response.json()
+
+        self.assertEqual(response_json['poster_name'], self.user.username)
+        self.assertEqual(response_json['poster_slug'], self.user.slug)
+        self.assertEqual(response_json['length'], 40)
+        self.assertEqual(response_json['question'], "Select two best colors")
+        self.assertEqual(response_json['allowed_choices'], 2)
+        self.assertTrue(response_json['allow_revotes'])
+
+        # you can't change poll's type after its posted
+        self.assertFalse(response_json['is_public'])
+
+        # choices were updated
+        self.assertEqual(len(response_json['choices']), 3)
+        self.assertEqual(len(set([c['hash'] for c in response_json['choices']])), 3)
+        self.assertEqual([c['label'] for c in response_json['choices']], ['Red', 'Green', 'Blue'])
+        self.assertEqual([c['votes'] for c in response_json['choices']], [0, 0, 0])
+        self.assertEqual([c['selected'] for c in response_json['choices']], [False, False, False])
+
+        # votes were removed
+        self.assertEqual(response_json['votes'], 0)
+        self.assertEqual(self.poll.pollvote_set.count(), 0)
+
+    def test_poll_current_choices_edited(self):
+        """api edits current poll choices"""
+        response = self.put(self.api_link, data={
+            'length': 40,
+            'question': "Select two best colors",
+            'allowed_choices': 2,
+            'allow_revotes': True,
+            'is_public': True,
+            'choices': [
+                {
+                    'hash': 'aaaaaaaaaaaa',
+                    'label': '\nFirst  ',
+                    'votes': 5555
+                },
+                {
+                    'hash': 'bbbbbbbbbbbb',
+                    'label': 'Second',
+                    'votes': 5555
+                },
+                {
+                    'hash': 'gggggggggggg',
+                    'label': 'Third',
+                    'votes': 5555
+                },
+                {
+                    'hash': 'dddddddddddd',
+                    'label': 'Fourth',
+                    'votes': 5555
+                }
+            ]
+        })
+        self.assertEqual(response.status_code, 200)
+
+        response_json = response.json()
+
+        self.assertEqual(response_json['poster_name'], self.user.username)
+        self.assertEqual(response_json['poster_slug'], self.user.slug)
+        self.assertEqual(response_json['length'], 40)
+        self.assertEqual(response_json['question'], "Select two best colors")
+        self.assertEqual(response_json['allowed_choices'], 2)
+        self.assertTrue(response_json['allow_revotes'])
+
+        # you can't change poll's type after its posted
+        self.assertFalse(response_json['is_public'])
+
+        # choices were updated
+        self.assertEqual(len(response_json['choices']), 4)
+        self.assertEqual(response_json['choices'], [
+            {
+                'hash': 'aaaaaaaaaaaa',
+                'label': 'First',
+                'votes': 1,
+                'selected': False
+            },
+            {
+                'hash': 'bbbbbbbbbbbb',
+                'label': 'Second',
+                'votes': 0,
+                'selected': False
+            },
+            {
+                'hash': 'gggggggggggg',
+                'label': 'Third',
+                'votes': 2,
+                'selected': True
+            },
+            {
+                'hash': 'dddddddddddd',
+                'label': 'Fourth',
+                'votes': 1,
+                'selected': True
+            }
+        ])
+
+        # no votes were removed
+        self.assertEqual(response_json['votes'], 4)
+        self.assertEqual(self.poll.pollvote_set.count(), 4)
+
+    def test_poll_some_choices_edited(self):
+        """api edits some poll choices"""
+        response = self.put(self.api_link, data={
+            'length': 40,
+            'question': "Select two best colors",
+            'allowed_choices': 2,
+            'allow_revotes': True,
+            'is_public': True,
+            'choices': [
+                {
+                    'hash': 'aaaaaaaaaaaa',
+                    'label': '\nFirst ',
+                    'votes': 5555
+                },
+                {
+                    'hash': 'bbbbbbbbbbbb',
+                    'label': 'Second',
+                    'votes': 5555
+                },
+                {
+                    'hash': 'dsadsadsa788',
+                    'label': 'New Option',
+                    'votes': 5555
+                }
+            ]
+        })
+        self.assertEqual(response.status_code, 200)
+
+        response_json = response.json()
+
+        self.assertEqual(response_json['poster_name'], self.user.username)
+        self.assertEqual(response_json['poster_slug'], self.user.slug)
+        self.assertEqual(response_json['length'], 40)
+        self.assertEqual(response_json['question'], "Select two best colors")
+        self.assertEqual(response_json['allowed_choices'], 2)
+        self.assertTrue(response_json['allow_revotes'])
+
+        # you can't change poll's type after its posted
+        self.assertFalse(response_json['is_public'])
+
+        # choices were updated
+        self.assertEqual(len(response_json['choices']), 3)
+        self.assertEqual(response_json['choices'], [
+            {
+                'hash': 'aaaaaaaaaaaa',
+                'label': 'First',
+                'votes': 1,
+                'selected': False
+            },
+            {
+                'hash': 'bbbbbbbbbbbb',
+                'label': 'Second',
+                'votes': 0,
+                'selected': False
+            },
+            {
+                'hash': response_json['choices'][2]['hash'],
+                'label': 'New Option',
+                'votes': 0,
+                'selected': False
+            }
+        ])
+
+        # no votes were removed
+        self.assertEqual(response_json['votes'], 1)
+        self.assertEqual(self.poll.pollvote_set.count(), 1)
+