Browse Source

api for adding polls to threads

Rafał Pitoń 8 years ago
parent
commit
94e678fe7e

+ 14 - 0
misago/acl/migrations/0003_default_roles.py

@@ -45,6 +45,12 @@ def create_default_roles(apps, schema_editor):
                 'can_download_other_users_attachments': True,
             },
 
+            # polls
+            'misago.threads.permissions.polls': {
+                'can_start_polls': 1,
+                'can_edit_polls': 1
+            },
+
             # delete users
             'misago.users.permissions.delete': {
                 'can_delete_users_newer_than': 0,
@@ -129,6 +135,14 @@ def create_default_roles(apps, schema_editor):
                 'can_delete_other_users_attachments': True,
             },
 
+            # polls
+            'misago.threads.permissions.polls': {
+                'can_start_polls': 2,
+                'can_edit_polls': 2,
+                'can_delete_polls': 2,
+                'can_always_see_poll_voters': 1
+            },
+
             # moderation
             'misago.threads.permissions.threads': {
                 'can_see_unapproved_content_lists': True,

+ 23 - 2
misago/categories/tests/test_categories_admin_views.py

@@ -1,6 +1,8 @@
 from django.core.urlresolvers import reverse
 
 from misago.admin.testutils import AdminTestCase
+from misago.threads import testutils
+from misago.threads.models import Thread
 
 from ..models import Category
 
@@ -268,6 +270,10 @@ class CategoryAdminDeleteViewTests(AdminTestCase):
 
     def test_delete_category_move_contents(self):
         """category was deleted and its contents were moved"""
+        for i in range(10):
+            testutils.post_thread(self.category_b)
+        self.assertEqual(Thread.objects.count(), 10)
+
         response = self.client.get(
             reverse('misago:admin:categories:nodes:delete', kwargs={
                 'pk': self.category_b.pk
@@ -284,9 +290,13 @@ class CategoryAdminDeleteViewTests(AdminTestCase):
             })
         self.assertEqual(response.status_code, 302)
         self.assertEqual(Category.objects.all_categories().count(), 6)
+        self.assertEqual(Thread.objects.count(), 10)
 
     def test_delete_category_and_contents(self):
         """category and its contents were deleted"""
+        for i in range(10):
+            testutils.post_thread(self.category_b)
+
         response = self.client.get(
             reverse('misago:admin:categories:nodes:delete', kwargs={
                 'pk': self.category_b.pk
@@ -297,16 +307,25 @@ class CategoryAdminDeleteViewTests(AdminTestCase):
             reverse('misago:admin:categories:nodes:delete', kwargs={
                 'pk': self.category_b.pk
             }),
-            data={'move_children_to': '', 'move_threads_to': ''})
+            data={
+                'move_children_to': '',
+                'move_threads_to': ''
+            })
         self.assertEqual(response.status_code, 302)
 
         self.assertEqual(Category.objects.all_categories().count(), 4)
+        self.assertEqual(Thread.objects.count(), 0)
+
 
     def test_delete_leaf_category(self):
         """category was deleted and its contents were moved"""
+        for i in range(10):
+            testutils.post_thread(self.category_d)
+        self.assertEqual(Thread.objects.count(), 10)
+
         response = self.client.get(
             reverse('misago:admin:categories:nodes:delete', kwargs={
-                'pk': self.category_b.pk
+                'pk': self.category_d.pk
             }))
         self.assertEqual(response.status_code, 200)
 
@@ -319,4 +338,6 @@ class CategoryAdminDeleteViewTests(AdminTestCase):
                 'move_threads_to': '',
             })
         self.assertEqual(response.status_code, 302)
+
         self.assertEqual(Category.objects.all_categories().count(), 6)
+        self.assertEqual(Thread.objects.count(), 0)

+ 1 - 0
misago/conf/defaults.py

@@ -119,6 +119,7 @@ MISAGO_ACL_EXTENSIONS = (
     'misago.users.permissions.delete',
     'misago.categories.permissions',
     'misago.threads.permissions.attachments',
+    'misago.threads.permissions.polls',
     'misago.threads.permissions.threads',
     'misago.threads.permissions.privatethreads',
 )

+ 2 - 2
misago/threads/api/attachments.py

@@ -86,14 +86,14 @@ def validate_filetype(upload, user_roles):
 
 def validate_filesize(upload, filetype, hard_limit):
     if upload.size > hard_limit * 1024:
-        message = _("You can't upload files larger than %(limit)s. (Your file has %(upload)s)")
+        message = _("You can't upload files larger than %(limit)s (your file has %(upload)s).")
         raise ValidationError(message % {
             'upload': filesizeformat(upload.size).rstrip('.0'),
             'limit': filesizeformat(hard_limit * 1024).rstrip('.0')
         })
 
     if filetype.size_limit and upload.size > filetype.size_limit * 1024:
-        message = _("You can't upload files of this type larger than %(limit)s. (Your file has %(upload)s)")
+        message = _("You can't upload files of this type larger than %(limit)s (your file has %(upload)s).")
         raise ValidationError(message % {
             'upload': filesizeformat(upload.size).rstrip('.0'),
             'limit': filesizeformat(filetype.size_limit * 1024).rstrip('.0')

+ 2 - 2
misago/threads/api/postingendpoint/attachments.py

@@ -117,8 +117,8 @@ def validate_attachments_count(data):
     total_attachments = len(data)
     if total_attachments > settings.MISAGO_POST_ATTACHMENTS_LIMIT:
         message = ungettext(
-            "You can't attach more than %(limit_value)s file to single post. (added %(show_value)s)",
-            "You can't attach more than %(limit_value)s flies to single post. (added %(show_value)s)",
+            "You can't attach more than %(limit_value)s file to single post (added %(show_value)s).",
+            "You can't attach more than %(limit_value)s flies to single post (added %(show_value)s).",
             settings.MISAGO_POST_ATTACHMENTS_LIMIT)
         raise serializers.ValidationError(message % {
             'limit_value': settings.MISAGO_POST_ATTACHMENTS_LIMIT,

+ 67 - 0
misago/threads/api/threadpoll.py

@@ -0,0 +1,67 @@
+from django.db import transaction
+from django.http import Http404
+
+from rest_framework import viewsets
+from rest_framework.decorators import detail_route, list_route
+from rest_framework.response import Response
+
+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 ..serializers import PollSerializer, NewPollSerializer, EditPollSerializer
+from ..viewmodels.thread import ForumThread
+
+
+class ViewSet(viewsets.ViewSet):
+    thread = None
+
+    def get_thread(self, request, thread_pk, select_for_update=False):
+        return self.thread(
+            request,
+            get_int_or_404(thread_pk),
+            select_for_update=select_for_update,
+        )
+
+    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):
+        try:
+            return thread.poll
+        except Poll.DoesNotExist:
+            raise Http404()
+
+    @transaction.atomic
+    def create(self, request, thread_pk):
+        thread = self.get_thread_for_update(request, thread_pk).model
+        allow_start_poll(request.user, thread)
+
+        instance = Poll(
+            thread=thread,
+            category=thread.category,
+            poster=request.user,
+            poster_name=request.user.username,
+            poster_slug=request.user.slug,
+            poster_ip=request.user_ip,
+        )
+
+        serializer = NewPollSerializer(instance, data=request.data)
+        if serializer.is_valid():
+            serializer.save()
+
+            add_acl(request.user, instance)
+            return Response(PollSerializer(instance).data)
+        else:
+            return Response(serializer.errors, status=400)
+
+    # create poll
+    # edit poll
+    # delete poll
+    # vote in poll
+    # see voters
+
+
+class ThreadPollViewSet(ViewSet):
+    thread = ForumThread

+ 1 - 1
misago/threads/api/threads.py

@@ -21,7 +21,6 @@ from .threadendpoints.patch import thread_patch_endpoint
 
 class ViewSet(viewsets.ViewSet):
     thread = None
-    TREE_ID = None
 
     def get_thread(self, request, pk, read_aware=True, subscription_aware=True, select_for_update=False):
         return self.thread(
@@ -67,6 +66,7 @@ class ViewSet(viewsets.ViewSet):
 class ThreadViewSet(ViewSet):
     thread = ForumThread
 
+    @transaction.atomic
     def create(self, request):
         # Initialize empty instances for new thread
         thread = Thread()

+ 35 - 0
misago/threads/migrations/0001_initial.py

@@ -260,4 +260,39 @@ class Migration(migrations.Migration):
             name='uploader',
             field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
         ),
+        migrations.CreateModel(
+            name='Poll',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('poster_name', models.CharField(max_length=255)),
+                ('poster_slug', models.CharField(max_length=255)),
+                ('poster_ip', models.GenericIPAddressField()),
+                ('posted_on', models.DateTimeField(default=django.utils.timezone.now)),
+                ('length', models.PositiveIntegerField(default=0)),
+                ('question', models.CharField(max_length=255)),
+                ('choices', django.contrib.postgres.fields.jsonb.JSONField()),
+                ('allowed_choices', models.PositiveIntegerField(default=1)),
+                ('allow_revotes', models.BooleanField(default=False)),
+                ('votes', models.PositiveIntegerField(default=0)),
+                ('is_public', models.BooleanField(default=False)),
+                ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_categories.Category')),
+                ('poster', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
+                ('thread', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Thread')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='PollVote',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('voter_name', models.CharField(max_length=255)),
+                ('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)),
+                ('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')),
+                ('voter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
     ]

+ 2 - 0
misago/threads/models/__init__.py

@@ -5,3 +5,5 @@ from .threadparticipant import ThreadParticipant
 from .subscription import Subscription
 from .attachmenttype import AttachmentType
 from .attachment import Attachment
+from .poll import Poll
+from .pollvote import PollVote

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

@@ -0,0 +1,29 @@
+from django.conf import settings
+from django.contrib.postgres.fields import JSONField
+from django.db import models
+from django.utils import timezone
+
+
+class Poll(models.Model):
+    category = models.ForeignKey('misago_categories.Category')
+    thread = models.OneToOneField('misago_threads.Thread')
+    poster = models.ForeignKey(
+        settings.AUTH_USER_MODEL,
+        blank=True,
+        null=True,
+        on_delete=models.SET_NULL,
+    )
+    poster_name = models.CharField(max_length=255)
+    poster_slug = models.CharField(max_length=255)
+    poster_ip = models.GenericIPAddressField()
+
+    posted_on = models.DateTimeField(default=timezone.now)
+    length = models.PositiveIntegerField(default=0)
+
+    question = models.CharField(max_length=255)
+    choices = JSONField()
+    allowed_choices = models.PositiveIntegerField(default=1)
+    allow_revotes = models.BooleanField(default=False)
+
+    votes = models.PositiveIntegerField(default=0)
+    is_public = models.BooleanField(default=False)

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

@@ -0,0 +1,20 @@
+from django.conf import settings
+from django.db import models
+from django.utils import timezone
+
+
+class PollVote(models.Model):
+    category = models.ForeignKey('misago_categories.Category')
+    thread = models.ForeignKey('misago_threads.Thread')
+    poll = models.ForeignKey('misago_threads.Poll')
+    voter = models.ForeignKey(
+        settings.AUTH_USER_MODEL,
+        blank=True,
+        null=True,
+        on_delete=models.SET_NULL,
+    )
+    voter_name = models.CharField(max_length=255)
+    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)

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

@@ -0,0 +1,135 @@
+from django.core.exceptions import PermissionDenied
+from django.utils.translation import ugettext_lazy as _, ungettext
+
+from misago.acl import algebra
+from misago.acl.decorators import return_boolean
+from misago.acl.models import Role
+from misago.core import forms
+
+from ..models import Poll, Thread
+
+
+"""
+Admin Permissions Forms
+"""
+class RolePermissionsForm(forms.Form):
+    legend = _("Polls")
+
+    can_start_polls = forms.TypedChoiceField(
+        label=_("Can start polls"),
+        coerce=int,
+        initial=0,
+        choices=(
+            (0, _("No")),
+            (1, _("Own threads")),
+            (2, _("All threads"))
+        )
+    )
+    can_edit_polls = forms.TypedChoiceField(
+        label=_("Can edit polls"),
+        coerce=int,
+        initial=0,
+        choices=(
+            (0, _("No")),
+            (1, _("Own polls")),
+            (2, _("All polls"))
+        )
+    )
+    can_delete_polls = forms.TypedChoiceField(
+        label=_("Can edit polls"),
+        coerce=int,
+        initial=0,
+        choices=(
+            (0, _("No")),
+            (1, _("Own polls")),
+            (2, _("All polls"))
+        )
+    )
+    poll_edit_time = forms.IntegerField(
+        label=_("Time limit for own polls edits, in minutes"),
+        help_text=_("Enter 0 to don't limit time for editing own polls."),
+        initial=0,
+        min_value=0
+    )
+    can_always_see_poll_voters = forms.YesNoSwitch(
+        label=_("Can always see polls voters"),
+        help_text=_("Allows users to see who voted in poll even if poll votes are secret.")
+    )
+
+
+def change_permissions_form(role):
+    if isinstance(role, Role) and role.special_role != 'anonymous':
+        return RolePermissionsForm
+    else:
+        return None
+
+
+"""
+ACL Builder
+"""
+def build_acl(acl, roles, key_name):
+    acl.update({
+        'can_start_polls': 0,
+        'can_edit_polls': 0,
+        'can_delete_polls': 0,
+        'poll_edit_time': 0,
+        'can_always_see_poll_voters': 0
+    })
+
+    return algebra.sum_acls(acl, roles=roles, key=key_name,
+        can_start_polls=algebra.greater,
+        can_edit_polls=algebra.greater,
+        can_delete_polls=algebra.greater,
+        poll_edit_time=algebra.greater_or_zero,
+        can_always_see_poll_voters=algebra.greater
+    )
+
+
+"""
+ACL's for targets
+"""
+def add_acl_to_poll(user, poll):
+    poll.acl.update({
+        'can_edit': False,
+        'can_delete': False,
+    })
+
+
+def add_acl_to_thread(user, thread):
+    thread.acl.update({
+        'can_start_poll': can_start_poll(user, thread)
+    })
+
+
+def register_with(registry):
+    registry.acl_annotator(Poll, add_acl_to_poll)
+    registry.acl_annotator(Thread, add_acl_to_thread)
+
+
+"""
+ACL tests
+"""
+def allow_start_poll(user, target):
+    if user.is_anonymous():
+        raise PermissionDenied(_("You have to sign in to start polls."))
+
+    category_acl = user.acl['categories'].get(target.category_id, {
+        'can_close_threads': False,
+    })
+
+    if not user.acl.get('can_start_polls'):
+        raise PermissionDenied(_("You can't start polls."))
+    if user.acl.get('can_start_polls') < 2 and user.pk != target.starter_id:
+        raise PermissionDenied(_("You can't start polls in other users threads."))
+
+    if not category_acl.get('can_close_threads'):
+        if target.category.is_closed:
+            raise PermissionDenied(_("This category is closed. You can't start polls in it."))
+        if target.is_closed:
+            raise PermissionDenied(_("This thread is closed. You can't start polls in it."))
+    try:
+        if target.poll:
+            raise PermissionDenied(_("There's already a poll in this thread."))
+    except Poll.DoesNotExist:
+        pass
+can_start_poll = return_boolean(allow_start_poll)

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

@@ -2,3 +2,5 @@ from .moderation import *
 from .thread import *
 from .post import *
 from .attachment import *
+from .poll import *
+from .pollvote import *

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

@@ -0,0 +1,157 @@
+from django.core.urlresolvers import reverse
+from django.utils.crypto import get_random_string
+from django.utils.translation import ugettext as _, ungettext
+
+from rest_framework import serializers
+
+from ..models import Poll
+
+
+MAX_POLL_OPTIONS = 16
+
+
+class PollSerializer(serializers.ModelSerializer):
+    acl = serializers.SerializerMethodField()
+    choices = serializers.SerializerMethodField()
+
+    api = serializers.SerializerMethodField()
+    url = serializers.SerializerMethodField()
+
+    class Meta:
+        model = Poll
+        fields = (
+            'poster_name',
+            'poster_slug',
+            'posted_on',
+            'length',
+            'question',
+            'allowed_choices',
+            'allow_revotes',
+            'votes',
+            'is_public',
+
+            'acl',
+            'choices',
+
+            'api',
+            'url',
+        )
+
+    def get_api(self, obj):
+        return {
+            'index': reverse('misago:api:thread-poll-list', kwargs={
+                'thread_pk': obj.thread_id
+            }),
+        }
+
+    def get_url(self, obj):
+        return {
+            'poster': self.get_last_poster_url(obj),
+        }
+
+    def get_last_poster_url(self, obj):
+        if obj.poster_id:
+            return reverse('misago:user', kwargs={
+                'slug': obj.poster_slug,
+                'pk': obj.poster_id,
+            })
+        else:
+            return None
+
+    def get_acl(self, obj):
+        try:
+            return obj.acl
+        except AttributeError:
+            return None
+
+    def get_choices(self, obj):
+        return obj.choices
+
+
+class EditPollSerializer(serializers.ModelSerializer):
+    length = serializers.IntegerField(required=True, min_value=0, max_value=180)
+    question = serializers.CharField(required=True, max_length=255)
+    allowed_choices = serializers.IntegerField(required=True, min_value=1)
+    choices = serializers.ListField(
+       allow_empty=False,
+       child=serializers.DictField(),
+    )
+
+    class Meta:
+        model = Poll
+        fields = (
+            'length',
+            'question',
+            'allowed_choices',
+            'allow_revotes',
+            'choices',
+        )
+
+    def validate_choices(self, choices):
+        clean_choices = map(self.clean_choice, choices)
+
+    def clean_choice(self, choice):
+        clean_choice = {
+            'hash': choice.get('hash', get_random_string(12)),
+            'label': choice.get('label', ''),
+        }
+
+        serializer = PollChoiceSerializer(data=clean_choice)
+        if not serializer.is_valid():
+            raise serializers.ValidationError(_("One or more poll choices are invalid."))
+
+        return serializer.data
+
+    def validate_choices_num(self, choices):
+        total_choices = len(choices)
+
+        if total_choices < 2:
+            raise serializers.ValidationError(_("You need to add at least two choices to a poll."))
+
+        if total_choices > MAX_POLL_OPTIONS:
+            message = ungettext(
+                "You can't add more than %(limit_value)s option to a single poll (added %(show_value)s).",
+                "You can't add more than %(limit_value)s options to a single poll (added %(show_value)s).",
+                MAX_POLL_OPTIONS)
+            raise serializers.ValidationError(message % {
+                'limit_value': MAX_POLL_OPTIONS,
+                'show_value': total_choices
+            })
+
+    def validate(self, data):
+        if data['allowed_choices'] > len(data['choices']):
+            raise serializers.ValidationError(
+                _("Number of allowed choices can't be greater than number of all choices."))
+        return data
+
+
+class NewPollSerializer(EditPollSerializer):
+    class Meta:
+        model = Poll
+        fields = (
+            'length',
+            'question',
+            'allowed_choices',
+            'allow_revotes',
+            'is_public',
+            'choices',
+        )
+
+    def validate_choices(self, choices):
+        clean_choices = map(self.clean_choice, choices)
+
+        self.validate_choices_num(clean_choices)
+
+        for choice in clean_choices:
+            choice.update({
+                'hash': get_random_string(12),
+                'votes': 0
+            })
+
+        return clean_choices
+
+
+class PollChoiceSerializer(serializers.Serializer):
+    hash = serializers.CharField(required=True, min_length=12, max_length=12)
+    label = serializers.CharField(required=True, max_length=255)
+

+ 0 - 0
misago/threads/serializers/pollvote.py


+ 3 - 0
misago/threads/serializers/thread.py

@@ -5,6 +5,7 @@ from rest_framework import serializers
 from misago.categories.serializers import BasicCategorySerializer
 
 from ..models import Thread
+from .poll import PollSerializer
 
 
 __all__ = [
@@ -20,6 +21,7 @@ class ThreadSerializer(serializers.ModelSerializer):
     is_new = serializers.SerializerMethodField()
     is_read = serializers.SerializerMethodField()
     path = BasicCategorySerializer(many=True, read_only=True)
+    poll = PollSerializer(many=False, read_only=True)
     subscription = serializers.SerializerMethodField()
 
     api = serializers.SerializerMethodField()
@@ -46,6 +48,7 @@ class ThreadSerializer(serializers.ModelSerializer):
             'is_new',
             'is_read',
             'path',
+            'poll',
             'subscription',
 
             'api',

+ 13 - 1
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.users.signals import delete_user_content, username_changed
 
-from .models import Attachment, Post, Thread
+from .models import Attachment, Post, Thread, Poll, PollVote
 
 
 delete_post = Signal()
@@ -53,6 +53,8 @@ def move_category_threads(sender, **kwargs):
 
     Thread.objects.filter(category=sender).update(category=new_category)
     Post.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)
 
 
 @receiver(delete_user_content)
@@ -107,6 +109,16 @@ def update_usernames(sender, **kwargs):
         uploader_slug=sender.slug
     )
 
+    Poll.objects.filter(poster=sender).update(
+        poster_name=sender.username,
+        poster_slug=sender.slug
+    )
+
+    PollVote.objects.filter(voter=sender).update(
+        voter_name=sender.username,
+        voter_slug=sender.slug
+    )
+
 
 @receiver(pre_delete, sender=get_user_model())
 def remove_unparticipated_private_threads(sender, **kwargs):

+ 369 - 0
misago/threads/tests/test_poll_api.py

@@ -0,0 +1,369 @@
+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)
+
+
+class ThreadPollCreateTests(ThreadPollApiTestCase):
+    def test_anonymous(self):
+        """api requires you to sign in to create poll"""
+        self.logout_user()
+
+        response = self.post(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-list', kwargs={
+            'thread_pk': 'kjha6dsa687sa'
+        })
+
+        response = self.post(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-list', kwargs={
+            'thread_pk': self.thread.pk + 1
+        })
+
+        response = self.post(api_link)
+        self.assertEqual(response.status_code, 404)
+
+    def test_no_permission(self):
+        """api validates that user has permission to start poll in thread"""
+        self.override_acl({
+            'can_start_polls': 0
+        })
+
+        response = self.post(self.api_link)
+        self.assertContains(response, "can't start polls", status_code=403)
+
+    def test_no_permission_closed_thread(self):
+        """api validates that user has permission to start poll in closed thread"""
+        self.override_acl(category={
+            'can_close_threads': 0
+        })
+
+        self.thread.is_closed = True
+        self.thread.save()
+
+        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.assertEqual(response.status_code, 400)
+
+    def test_no_permission_closed_thread(self):
+        """api validates that user has permission to start poll in closed category"""
+        self.override_acl(category={
+            'can_close_threads': 0
+        })
+
+        self.category.is_closed = True
+        self.category.save()
+
+        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.assertEqual(response.status_code, 400)
+
+    def test_no_permission_other_user_thread(self):
+        """api validates that user has permission to start poll in other user's thread"""
+        self.override_acl({
+            'can_start_polls': 1
+        })
+
+        self.thread.starter = None
+        self.thread.save()
+
+        response = self.post(self.api_link)
+        self.assertContains(response, "can't start polls in other users threads", status_code=403)
+
+        self.override_acl({
+            'can_start_polls': 2
+        })
+
+        response = self.post(self.api_link)
+        self.assertEqual(response.status_code, 400)
+
+    def test_no_permission_poll_exists(self):
+        """api validates that user can't start second poll in thread"""
+        self.thread.poll = Poll.objects.create(
+            thread=self.thread,
+            category=self.category,
+            poster_name='Test',
+            poster_slug='test',
+            poster_ip='127.0.0.1',
+            length=30,
+            question='Test',
+            choices=[{'hash': 't3st'}],
+            allowed_choices=1
+        )
+
+        response = self.post(self.api_link)
+        self.assertContains(response, "There's already a poll in this thread.", status_code=403)
+
+    def test_empty_data(self):
+        """api handles empty request data"""
+        response = self.post(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.post(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.post(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.post(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.post(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.post(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.post(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.post(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.post(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.post(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_created(self):
+        """api creates public poll if provided with valid data"""
+        response = self.post(self.api_link, data={
+            'length': 40,
+            'question': "Select two best colors",
+            'allowed_choices': 2,
+            'allow_revotes': True,
+            'is_public': True,
+            'choices': [
+                {
+                    'label': 'Red'
+                },
+                {
+                    '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'])
+        self.assertEqual(response_json['votes'], 0)
+        self.assertTrue(response_json['is_public'])
+
+        self.assertEqual(len(response_json['choices']), 3)
+        self.assertEqual(len(set([c['hash'] for c in response_json['choices']])), 3)
+
+        poll = self.thread.poll
+
+        self.assertEqual(poll.category_id, self.category.id)
+        self.assertEqual(poll.thread_id, self.thread.id)
+        self.assertEqual(poll.poster_id, self.user.id)
+        self.assertEqual(poll.poster_name, self.user.username)
+        self.assertEqual(poll.poster_slug, self.user.slug)
+        self.assertEqual(poll.length, 40)
+        self.assertEqual(poll.question, "Select two best colors")
+        self.assertEqual(poll.allowed_choices, 2)
+        self.assertTrue(poll.allow_revotes)
+        self.assertEqual(poll.votes, 0)
+        self.assertTrue(poll.is_public)
+
+        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

+ 2 - 0
misago/threads/urls/api.py

@@ -3,10 +3,12 @@ from misago.core.apirouter import MisagoApiRouter
 from ..api.attachments import AttachmentViewSet
 from ..api.threadposts import ThreadPostsViewSet
 from ..api.threads import ThreadViewSet
+from ..api.threadpoll import ThreadPollViewSet
 
 
 router = MisagoApiRouter()
 router.register(r'attachments', AttachmentViewSet, base_name='attachment')
 router.register(r'threads', ThreadViewSet, base_name='thread')
 router.register(r'threads/(?P<thread_pk>[^/.]+)/posts', ThreadPostsViewSet, base_name='thread-post')
+router.register(r'threads/(?P<thread_pk>[^/.]+)/poll', ThreadPollViewSet, base_name='thread-poll')
 urlpatterns = router.urls

+ 8 - 1
misago/threads/viewmodels/thread.py

@@ -13,7 +13,14 @@ from ..subscriptions import make_subscription_aware
 from ..threadtypes import trees_map
 
 
-BASE_RELATIONS = ('category', 'starter', 'starter__rank', 'starter__ban_cache', 'starter__online_tracker')
+BASE_RELATIONS = (
+    'category',
+    'poll',
+    'starter',
+    'starter__rank',
+    'starter__ban_cache',
+    'starter__online_tracker'
+)
 
 
 class ViewModel(object):