From 9ff0321a4c88c3c39d2e28ec9b76949659cb7444 Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Tue, 17 Feb 2015 16:07:37 +0300 Subject: [PATCH 01/33] updated to work with django 1.6 --- polls/templates/polls/poll_detail.html | 2 +- polls/templates/polls/poll_list.html | 2 +- polls/urls.py | 2 +- requirements.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/polls/templates/polls/poll_detail.html b/polls/templates/polls/poll_detail.html index de49e57..a5234d8 100644 --- a/polls/templates/polls/poll_detail.html +++ b/polls/templates/polls/poll_detail.html @@ -6,7 +6,7 @@ {% block content %}

{{poll.question}}

{% if poll.votable %} -
+ {% csrf_token %} {% for choice in poll.choice_set.all %} diff --git a/polls/templates/polls/poll_list.html b/polls/templates/polls/poll_list.html index fccc597..e9bf3cd 100644 --- a/polls/templates/polls/poll_list.html +++ b/polls/templates/polls/poll_list.html @@ -7,7 +7,7 @@

{% trans "Polls" %}

{% if poll_list %} {% else %} diff --git a/polls/urls.py b/polls/urls.py index 16c920e..9422e54 100644 --- a/polls/urls.py +++ b/polls/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls.defaults import patterns, url +from django.conf.urls import patterns, url from django.contrib.auth.decorators import login_required from views import PollDetailView, PollListView, PollVoteView diff --git a/requirements.txt b/requirements.txt index b165490..d1eaf32 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -django>=1.4.1 +django>=1.6.0 From b47358eb90eefca960e97bc7b33642c140952404 Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Wed, 18 Feb 2015 15:49:36 +0300 Subject: [PATCH 02/33] fixes: anonymous user can't view poll details, IntegrityError --- polls/templates/polls/poll_detail.html | 1 - polls/views.py | 10 ++++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/polls/templates/polls/poll_detail.html b/polls/templates/polls/poll_detail.html index a5234d8..18ae114 100644 --- a/polls/templates/polls/poll_detail.html +++ b/polls/templates/polls/poll_detail.html @@ -1,7 +1,6 @@ {% extends "base.html" %} {% load i18n %} -{% load polls %} {% block content %}

{{poll.question}}

diff --git a/polls/views.py b/polls/views.py index 5597f31..a0b9bfa 100644 --- a/polls/views.py +++ b/polls/views.py @@ -2,7 +2,7 @@ from django.core.urlresolvers import reverse_lazy from django.contrib import messages from django.utils.translation import ugettext_lazy as _ - +from django.core.exceptions import PermissionDenied from models import Choice, Poll, Vote @@ -15,7 +15,10 @@ class PollDetailView(DetailView): def get_context_data(self, **kwargs): context = super(PollDetailView, self).get_context_data(**kwargs) - context['poll'].votable = self.object.can_vote(self.request.user) + if self.request.user.is_anonymous(): + context['poll'].votable = False + else: + context['poll'].votable = self.object.can_vote(self.request.user) return context @@ -24,6 +27,9 @@ def post(self, request, *args, **kwargs): poll = Poll.objects.get(id=kwargs['pk']) user = request.user choice = Choice.objects.get(id=request.POST['choice_pk']) + # if already voted, prevent IntegrityError + if Vote.objects.filter(poll=poll, user=user).exists(): + raise PermissionDenied Vote.objects.create(poll=poll, user=user, choice=choice) messages.success(request, _("Thanks for your vote.")) return super(PollVoteView, self).post(request, *args, **kwargs) From 03eb8285ffc89170765e41ad6ddf2c62fb2f88e4 Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Wed, 18 Feb 2015 15:50:48 +0300 Subject: [PATCH 03/33] new fields, migration --- ...dd_field_vote_datetime__add_field_vote_.py | 153 ++++++++++++++++++ polls/models.py | 21 ++- setup.py | 1 + 3 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 polls/migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py diff --git a/polls/migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py b/polls/migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py new file mode 100644 index 0000000..2b5be6c --- /dev/null +++ b/polls/migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'Vote.comment' + db.add_column(u'polls_vote', 'comment', + self.gf('django.db.models.fields.TextField')(max_length=144, null=True, blank=True), + keep_default=False) + + # Adding field 'Vote.datetime' + db.add_column(u'polls_vote', 'datetime', + self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, default=datetime.datetime(2015, 2, 19, 0, 0), blank=True), + keep_default=False) + + # Adding field 'Vote.data' + db.add_column(u'polls_vote', 'data', + self.gf('django.db.models.fields.TextField')(default='{}', null=True, blank=True), + keep_default=False) + + # Adding field 'Poll.is_closed' + db.add_column(u'polls_poll', 'is_closed', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'Poll.reference' + db.add_column(u'polls_poll', 'reference', + self.gf('django.db.models.fields.CharField')(default='', max_length=20, blank=True), + keep_default=False) + + # Adding field 'Poll.is_multiple' + db.add_column(u'polls_poll', 'is_multiple', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'Poll.is_anonymous' + db.add_column(u'polls_poll', 'is_anonymous', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'Poll.start_votes' + db.add_column(u'polls_poll', 'start_votes', + self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2015, 2, 19, 0, 0)), + keep_default=False) + + # Adding field 'Poll.end_votes' + db.add_column(u'polls_poll', 'end_votes', + self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2015, 2, 24, 0, 0)), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'Vote.comment' + db.delete_column(u'polls_vote', 'comment') + + # Deleting field 'Vote.datetime' + db.delete_column(u'polls_vote', 'datetime') + + # Deleting field 'Vote.data' + db.delete_column(u'polls_vote', 'data') + + # Deleting field 'Poll.is_closed' + db.delete_column(u'polls_poll', 'is_closed') + + # Deleting field 'Poll.reference' + db.delete_column(u'polls_poll', 'reference') + + # Deleting field 'Poll.is_multiple' + db.delete_column(u'polls_poll', 'is_multiple') + + # Deleting field 'Poll.is_anonymous' + db.delete_column(u'polls_poll', 'is_anonymous') + + # Deleting field 'Poll.start_votes' + db.delete_column(u'polls_poll', 'start_votes') + + # Deleting field 'Poll.end_votes' + db.delete_column(u'polls_poll', 'end_votes') + + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'polls.choice': { + 'Meta': {'ordering': "['choice']", 'object_name': 'Choice'}, + 'choice': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poll': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Poll']"}) + }, + u'polls.poll': { + 'Meta': {'ordering': "['-start_votes']", 'object_name': 'Poll'}, + 'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'end_votes': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 2, 24, 0, 0)'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_anonymous': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_multiple': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'question': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'reference': ('django.db.models.fields.CharField', [], {'max_length': '20', 'blank': 'True'}), + 'start_votes': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 2, 19, 0, 0)'}) + }, + u'polls.vote': { + 'Meta': {'unique_together': "(('user', 'poll'),)", 'object_name': 'Vote'}, + 'choice': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Choice']"}), + 'comment': ('django.db.models.fields.TextField', [], {'max_length': '144', 'null': 'True', 'blank': 'True'}), + 'data': ('django.db.models.fields.TextField', [], {'default': "'{}'", 'null': 'True', 'blank': 'True'}), + 'datetime': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poll': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Poll']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']"}) + } + } + + complete_apps = ['polls'] \ No newline at end of file diff --git a/polls/models.py b/polls/models.py index 3f6d9b9..1e7c9b8 100644 --- a/polls/models.py +++ b/polls/models.py @@ -1,10 +1,21 @@ +from datetime import datetime, timedelta from django.db import models +from django_extensions.db.fields import UUIDField +from django_extensions.db.fields.json import JSONField from django.contrib.auth.models import User +from django.utils.translation import ugettext_lazy as _ class Poll(models.Model): question = models.CharField(max_length=255) description = models.TextField(blank=True) + reference = UUIDField(max_length=20, version=4) + is_anonymous = models.BooleanField(default=False, help_text=_('Allow to vote for anonymous user')) + is_multiple = models.BooleanField(default=False, help_text=_('Allow to make multiple choices')) + is_closed = models.BooleanField(default=False, help_text=_('Do not accept votes')) + start_votes = models.DateTimeField(default=datetime.today, help_text=_('The earliest time votes get accepted')) + end_votes = models.DateTimeField(default=(lambda: datetime.today()+timedelta(days=5)), + help_text=_('The latest time votes get accepted')) def count_choices(self): return self.choice_set.count() @@ -21,6 +32,9 @@ def can_vote(self, user): def __unicode__(self): return self.question + class Meta: + ordering = ['-start_votes'] + class Choice(models.Model): poll = models.ForeignKey(Poll) @@ -40,9 +54,12 @@ class Vote(models.Model): user = models.ForeignKey(User) poll = models.ForeignKey(Poll) choice = models.ForeignKey(Choice) + comment = models.TextField(max_length=144, blank=True, null=True) + datetime = models.DateTimeField(auto_now_add=True) + data = JSONField(blank=True, null=True) def __unicode__(self): - return u'Vote for %s' % (self.choice) + return u'Vote for %s' % self.choice class Meta: - unique_together = (('user', 'poll')) + unique_together = ('user', 'poll') diff --git a/setup.py b/setup.py index 2550757..20a99c5 100644 --- a/setup.py +++ b/setup.py @@ -20,5 +20,6 @@ def read(fname): include_package_data=True, install_requires=[ 'Django', + 'django-extensions==1.3.11', ], ) From d7ac0ba3851c3f081178ec19ce13c28652f3fb8e Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Fri, 20 Feb 2015 11:10:11 +0300 Subject: [PATCH 04/33] added exceptions --- polls/exceptions.py | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 polls/exceptions.py diff --git a/polls/exceptions.py b/polls/exceptions.py new file mode 100644 index 0000000..efc9912 --- /dev/null +++ b/polls/exceptions.py @@ -0,0 +1,4 @@ +class PollNotOpen(Exception): pass +class PollClosed(Exception): pass +class PollNotAnonymous(Exception): pass +class PollNotMultiple(Exception): pass From 32be9678bfaafb7f37553eb40f3ed706aac50874 Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Fri, 20 Feb 2015 14:57:04 +0300 Subject: [PATCH 05/33] model migration: allow anonymous users to vote, allow multiple choices per poll --- ...ld_vote_user__del_unique_vote_user_poll.py | 97 +++++++++++++++++++ polls/models.py | 5 +- 2 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 polls/migrations/0005_auto__chg_field_vote_user__del_unique_vote_user_poll.py diff --git a/polls/migrations/0005_auto__chg_field_vote_user__del_unique_vote_user_poll.py b/polls/migrations/0005_auto__chg_field_vote_user__del_unique_vote_user_poll.py new file mode 100644 index 0000000..4c8c9cc --- /dev/null +++ b/polls/migrations/0005_auto__chg_field_vote_user__del_unique_vote_user_poll.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Removing unique constraint on 'Vote', fields ['user', 'poll'] + db.delete_unique(u'polls_vote', ['user_id', 'poll_id']) + + + # Changing field 'Vote.user' + db.alter_column(u'polls_vote', 'user_id', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True)) + + def backwards(self, orm): + + # User chose to not deal with backwards NULL issues for 'Vote.user' + raise RuntimeError("Cannot reverse this migration. 'Vote.user' and its values cannot be restored.") + + # The following code is provided here to aid in writing a correct migration + # Changing field 'Vote.user' + db.alter_column(u'polls_vote', 'user_id', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])) + # Adding unique constraint on 'Vote', fields ['user', 'poll'] + db.create_unique(u'polls_vote', ['user_id', 'poll_id']) + + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'polls.choice': { + 'Meta': {'ordering': "['choice']", 'object_name': 'Choice'}, + 'choice': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poll': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Poll']"}) + }, + u'polls.poll': { + 'Meta': {'ordering': "['-start_votes']", 'object_name': 'Poll'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'end_votes': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 2, 25, 0, 0)'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_anonymous': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_multiple': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'question': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'reference': ('django.db.models.fields.CharField', [], {'max_length': '20', 'blank': 'True'}), + 'start_votes': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 2, 20, 0, 0)'}) + }, + u'polls.vote': { + 'Meta': {'object_name': 'Vote'}, + 'choice': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Choice']"}), + 'comment': ('django.db.models.fields.TextField', [], {'max_length': '144', 'null': 'True', 'blank': 'True'}), + 'data': ('django.db.models.fields.TextField', [], {'default': "'{}'", 'null': 'True', 'blank': 'True'}), + 'datetime': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poll': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Poll']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']", 'null': 'True', 'blank': 'True'}) + } + } + + complete_apps = ['polls'] \ No newline at end of file diff --git a/polls/models.py b/polls/models.py index 1e7c9b8..58faa3a 100644 --- a/polls/models.py +++ b/polls/models.py @@ -51,7 +51,7 @@ class Meta: class Vote(models.Model): - user = models.ForeignKey(User) + user = models.ForeignKey(User, blank=True, null=True) poll = models.ForeignKey(Poll) choice = models.ForeignKey(Choice) comment = models.TextField(max_length=144, blank=True, null=True) @@ -60,6 +60,3 @@ class Vote(models.Model): def __unicode__(self): return u'Vote for %s' % self.choice - - class Meta: - unique_together = ('user', 'poll') From 27d341c7e8b7d3aaddfb8866854026adf242ba7d Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Fri, 20 Feb 2015 18:30:34 +0300 Subject: [PATCH 06/33] added vote and count_percentage functions --- polls/models.py | 26 ++++++- polls/tests.py | 198 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 214 insertions(+), 10 deletions(-) diff --git a/polls/models.py b/polls/models.py index 58faa3a..9c531c7 100644 --- a/polls/models.py +++ b/polls/models.py @@ -4,6 +4,7 @@ from django_extensions.db.fields.json import JSONField from django.contrib.auth.models import User from django.utils.translation import ugettext_lazy as _ +from exceptions import PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple class Poll(models.Model): @@ -17,9 +18,31 @@ class Poll(models.Model): end_votes = models.DateTimeField(default=(lambda: datetime.today()+timedelta(days=5)), help_text=_('The latest time votes get accepted')) + def vote(self, choices, user=None): + if self.is_closed: + raise PollClosed + if self.end_votes < datetime.now(): + raise PollNotOpen + if user is None and not self.is_anonymous: + raise PollNotAnonymous + if len(choices) > 1 and not self.is_multiple: + raise PollNotMultiple + # if self.is_anonymous: user = None # pass None, even though user is authenticated + for choice_id in choices: + choice = Choice.objects.get(pk=choice_id) + Vote.objects.create(poll=self, user=user, choice=choice) + def count_choices(self): return self.choice_set.count() + def count_percentage(self): + votes = [choice.count_votes() for choice in self.choice_set.all()] + total_votes = sum(votes) + if total_votes is 0: + return [0.0 for vote in votes] + else: + return [float(vote)/total_votes for vote in votes] + def count_total_votes(self): result = 0 for choice in self.choice_set.all(): @@ -46,9 +69,6 @@ def count_votes(self): def __unicode__(self): return self.choice - class Meta: - ordering = ['choice'] - class Vote(models.Model): user = models.ForeignKey(User, blank=True, null=True) diff --git a/polls/tests.py b/polls/tests.py index 501deb7..bd97e8b 100644 --- a/polls/tests.py +++ b/polls/tests.py @@ -4,13 +4,197 @@ Replace this with more appropriate tests for your application. """ - +import random from django.test import TestCase +from django.contrib.auth import get_user +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from polls.models import Poll, Choice, Vote +from polls.exceptions import PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple + + +# view tests are outdated +class PollsViewTest(TestCase): + def setUp(self): + self.username = "user%d" % (random.random() * 100) + self.user = User.objects.create_user(self.username, 'test@test.com', 'testtest') + create_poll_single() + + def tearDown(self): + self.client.logout() + + def test_polls_list_view(self): + self.client.login(username=self.username, password='testtest') + resp = self.client.get(reverse('polls:list')) + self.assertEqual(resp.status_code, 200) + Poll.objects.all().delete() + resp = self.client.get(reverse('polls:list')) + self.assertEqual(resp.status_code, 200) + + def test_polls_list_view_anonymous(self): + user = get_user(self.client) + self.assertTrue(user.is_anonymous()) + resp = self.client.get(reverse('polls:list')) + self.assertEqual(resp.status_code, 200) + + def test_detail_view(self): + self.client.login(username=self.username, password='testtest') + resp = self.client.get(reverse('polls:detail', args=[1])) + self.assertEqual(resp.status_code, 200) + # get nonexistent poll + resp = self.client.get(reverse('polls:detail', args=[2])) + self.assertEqual(resp.status_code, 404) + + def test_detail_view_anonymous(self): + user = get_user(self.client) + self.assertTrue(user.is_anonymous()) + resp = self.client.get(reverse('polls:detail', args=[1])) + self.assertEqual(resp.status_code, 200) + + def test_vote_view(self): + self.client.login(username=self.username, password='testtest') + resp = self.client.post(reverse('polls:vote', args=[1]), {'choice_pk': 2}) + self.assertEqual(resp.status_code, 301) + self.assertEqual(Vote.objects.all().count(), 1) + # vote again + resp = self.client.post(reverse('polls:vote', args=[1]), {'choice_pk': 2}) + self.assertEqual(resp.status_code, 403) + self.assertEqual(Vote.objects.all().count(), 1) + + def test_vote_anonymous(self): + user = get_user(self.client) + self.assertTrue(user.is_anonymous()) + resp = self.client.post(reverse('polls:vote', args=[1]), {'choice_pk': 2}) + + +class PollsModelTest(TestCase): + def setUp(self): + self.user1 = User.objects.create_user('user1', 'test1@test.com', 'testtest1') + self.user2 = User.objects.create_user('user2', 'test2@test.com', 'testtest2') + self.user3 = User.objects.create_user('user3', 'test3@test.com', 'testtest3') + self.user4 = User.objects.create_user('user4', 'test4@test.com', 'testtest4') + + def tearDown(self): + pass + + def test_poll_methods(self): + poll = create_poll_single() + self.assertEqual(Poll.objects.count(), 1) + self.assertEqual(poll.count_choices(), 3) + + def test_single_vote(self): + poll = create_poll_single() + poll.vote([1], self.user1) + self.assertRaises(PollNotMultiple, poll.vote, *([1,2], self.user2)) + self.assertRaises(PollNotAnonymous, poll.vote, [1]) + + def test_single_vote_stat(self): + poll = create_poll_single() + poll.vote([1], self.user1) + poll.vote([2], self.user2) + self.assertEqual(poll.count_percentage(), [0.5, 0.5, 0]) + poll.delete() + + poll = create_poll_single() + poll.vote([1], self.user1) + self.assertEqual(poll.count_percentage(), [1.0, 0, 0]) + poll.delete() + + poll = create_poll_single() + poll.vote([1], self.user1) + poll.vote([2], self.user2) + poll.vote([3], self.user3) + self.assertEqual(poll.count_percentage(), [1.0/3, 1.0/3, 1.0/3]) + poll.delete() + + # user changed his choice + poll = create_poll_single() + poll.vote([1], self.user1) + poll.vote([2], self.user1) + self.assertEqual(poll.count_percentage(), [0, 1.0, 0]) + + def test_multiple_vote(self): + poll = create_poll_multiple() + poll.vote([1,2], self.user1) + self.assertRaises(PollNotAnonymous, poll.vote, [1,2]) + + def test_multiple_vote_stat(self): + poll = create_poll_multiple() + poll.vote([1,2], self.user1) + self.assertEqual(poll.count_percentage(), [0.5, 0.5, 0.0, 0.0, 0.0]) + poll.vote([3,4,5], self.user2) + self.assertEqual(poll.count_percentage(), [0.2, 0.2, 0.2, 0.2, 0.2]) + + def test_anonymous_single_vote(self): + poll = create_poll_anonymous_single() + poll.vote([2], self.user1) + poll.vote([2]) + self.assertRaises(PollNotMultiple, poll.vote, *([1,2], self.user2)) + self.assertRaises(PollNotMultiple, poll.vote, [1,2]) + + def test_anonymous_single_vote_stat(self): + poll = create_poll_anonymous_single() + poll.vote([1]) + self.assertEqual(poll.count_percentage(), [1.0, 0, 0]) + poll.vote([2], self.user1) + poll.vote([3], self.user2) + self.assertEqual(poll.count_percentage(), [1.0/3, 1.0/3, 1.0/3]) + + def test_anonymous_multiple_vote(self): + poll = create_poll_anonymous_multiple() + poll.vote([2], self.user1) + poll.vote([2]) + poll.vote([1,2], self.user2) + poll.vote([1,2]) + + def test_anonymous_multiple_vote_stat(self): + poll = create_poll_anonymous_multiple() + poll.vote([2]) + self.assertEqual(poll.count_percentage(), [0.0, 1.0, 0.0, 0.0, 0.0]) + + +class PollsApiTest(TestCase): + pass + + +# for authenticated users, only one vote allowed +def create_poll_single(): + poll = Poll(question='How are you?', description='description') + poll.save() + Choice(poll=poll, choice='I am fine').save() + Choice(poll=poll, choice='So so').save() + Choice(poll=poll, choice='Bad').save() + return poll + +# for authenticated users, multiple votes are allowed +def create_poll_multiple(): + poll = Poll(question='Which languages do you know?', description='description', is_multiple=True) + poll.save() + Choice(poll=poll, choice='French').save() + Choice(poll=poll, choice='English').save() + Choice(poll=poll, choice='German').save() + Choice(poll=poll, choice='Japanese').save() + Choice(poll=poll, choice='Chinese').save() + return poll + +# for anonymous and authenticated users, only one vote allowed +def create_poll_anonymous_single(): + poll = Poll(question='Are you an anonymous?', description='description', is_anonymous=True) + poll.save() + Choice(poll=poll, choice='Yes').save() + Choice(poll=poll, choice='No').save() + Choice(poll=poll, choice='Nobody knows').save() + return poll + +# for anonymouse authenticated users, multiple votes are allowed +def create_poll_anonymous_multiple(): + poll = Poll(question='Choose what do you like', description='description', is_anonymous=True, is_multiple=True) + poll.save() + Choice(poll=poll, choice='Chocolate').save() + Choice(poll=poll, choice='Milk').save() + Choice(poll=poll, choice='Fruits').save() + Choice(poll=poll, choice='Meat').save() + Choice(poll=poll, choice='Vegetables').save() + return poll -class SimpleTest(TestCase): - def test_basic_addition(self): - """ - Tests that 1 + 1 always equals 2. - """ - self.assertEqual(1 + 1, 2) From a04c21ee83e12b462816a4d13743b3ad688b30a9 Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Fri, 20 Feb 2015 18:31:15 +0300 Subject: [PATCH 07/33] dummy api (commit will be rewrited/deleted) --- polls/api.py | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++ polls/urls.py | 11 +++++++- setup.py | 1 + 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 polls/api.py diff --git a/polls/api.py b/polls/api.py new file mode 100644 index 0000000..9954e07 --- /dev/null +++ b/polls/api.py @@ -0,0 +1,73 @@ +from django.contrib.auth import get_user_model +from tastypie import fields +from tastypie.authorization import DjangoAuthorization +from tastypie.authentication import BasicAuthentication +from tastypie.resources import ModelResource, ALL, ALL_WITH_RELATIONS +from polls.models import Poll, Choice, Vote + + +''' + Api("v1/poll") + POST /poll/ -- create a new poll, shall allow to post choices in the same API call + POST /choice/ -- add a choice to an existing poll + POST /vote// -- vote on poll with pk + PUT /choice// -- update choice data + PUT /poll// -- update poll data + GET /poll// -- retrieve the poll information, including choice details + GET /result// -- retrieve the statistics on the poll. This shall return a JSON formatted like so. Note the actual statistics calculation shall be implemented in poll.service.stats (later on, this will be externalized into a batch job). +''' + + +class UserResource(ModelResource): + class Meta: + queryset = get_user_model().objects.all() + allowed_methods = ['get'] + resource_name = 'user' + authentication = BasicAuthentication() + authorization = DjangoAuthorization() + excludes = ['date_joined', 'password', 'is_superuser', 'is_staff', 'is_active', 'last_login', 'first_name', 'last_name'] + filtering = { + 'username': ALL, + } + + +class PollResource(ModelResource): + # POST, GET, PUT + #user = fields.ForeignKey(UserResource, 'user') + + class Meta: + queryset = Poll.objects.all() + allowed_methods = ['get','post','put'] + resource_name = 'poll' + authentication = BasicAuthentication() + authorization = DjangoAuthorization() + filtering = { + 'user': ALL_WITH_RELATIONS, + 'pub_date': ['exact', 'lt', 'lte', 'gte', 'gt'], + } + + +class ChoiceResource(ModelResource): + class Meta: + queryset = Choice.objects.all() + allowed_methods = ['post','put'] + authentication = BasicAuthentication() + authorization = DjangoAuthorization() + resource_name = 'choice' + + +class VoteResource(ModelResource): + class Meta: + queryset = Vote.objects.all() + allowed_methods = ['post'] + authentication = BasicAuthentication() + authorization = DjangoAuthorization() + resource_name = 'vote' + + +class ResultResource(ModelResource): + class Meta: + allowed_methods = ['get'] + authentication = BasicAuthentication() + authorization = DjangoAuthorization() + resource_name = 'result' \ No newline at end of file diff --git a/polls/urls.py b/polls/urls.py index 9422e54..2a30bfb 100644 --- a/polls/urls.py +++ b/polls/urls.py @@ -1,11 +1,20 @@ -from django.conf.urls import patterns, url +from django.conf.urls import patterns, url, include from django.contrib.auth.decorators import login_required from views import PollDetailView, PollListView, PollVoteView +from tastypie.api import Api +from polls.api import UserResource, PollResource, ChoiceResource, VoteResource, ResultResource +v1_api = Api(api_name='v1') +v1_api.register(UserResource()) +v1_api.register(PollResource()) +v1_api.register(ChoiceResource()) +v1_api.register(VoteResource()) +v1_api.register(ResultResource()) urlpatterns = patterns('', url(r'^$', PollListView.as_view(), name='list'), + url(r'^api/', include(v1_api.urls)), url(r'^(?P\d+)/$', PollDetailView.as_view(), name='detail'), url(r'^(?P\d+)/vote/$', login_required(PollVoteView.as_view()), name='vote'), ) diff --git a/setup.py b/setup.py index 20a99c5..ef4e997 100644 --- a/setup.py +++ b/setup.py @@ -21,5 +21,6 @@ def read(fname): install_requires=[ 'Django', 'django-extensions==1.3.11', + 'django-tastypie==0.12.1', ], ) From d34bd60c0026e145a6df8b738054b18ea14354e9 Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Tue, 24 Feb 2015 14:13:39 +0300 Subject: [PATCH 08/33] renamed vote.datetime field to vote.created --- ...d_vote_datetime__add_field_vote_created.py | 83 +++++++++++++++++++ polls/models.py | 2 +- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 polls/migrations/0006_auto__del_field_vote_datetime__add_field_vote_created.py diff --git a/polls/migrations/0006_auto__del_field_vote_datetime__add_field_vote_created.py b/polls/migrations/0006_auto__del_field_vote_datetime__add_field_vote_created.py new file mode 100644 index 0000000..1e11ab3 --- /dev/null +++ b/polls/migrations/0006_auto__del_field_vote_datetime__add_field_vote_created.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + db.rename_column(u'polls_vote', 'datetime', 'created') + + def backwards(self, orm): + db.rename_column(u'polls_vote', 'created', 'datetime') + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'polls.choice': { + 'Meta': {'object_name': 'Choice'}, + 'choice': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poll': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Poll']"}) + }, + u'polls.poll': { + 'Meta': {'ordering': "['-start_votes']", 'object_name': 'Poll'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'end_votes': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 3, 1, 0, 0)'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_anonymous': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_multiple': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'question': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'reference': ('django.db.models.fields.CharField', [], {'max_length': '20', 'blank': 'True'}), + 'start_votes': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 2, 24, 0, 0)'}) + }, + u'polls.vote': { + 'Meta': {'object_name': 'Vote'}, + 'choice': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Choice']"}), + 'comment': ('django.db.models.fields.TextField', [], {'max_length': '144', 'null': 'True', 'blank': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'data': ('django.db.models.fields.TextField', [], {'default': "'{}'", 'null': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poll': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Poll']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']", 'null': 'True', 'blank': 'True'}) + } + } + + complete_apps = ['polls'] \ No newline at end of file diff --git a/polls/models.py b/polls/models.py index 9c531c7..554ef40 100644 --- a/polls/models.py +++ b/polls/models.py @@ -75,7 +75,7 @@ class Vote(models.Model): poll = models.ForeignKey(Poll) choice = models.ForeignKey(Choice) comment = models.TextField(max_length=144, blank=True, null=True) - datetime = models.DateTimeField(auto_now_add=True) + created = models.DateTimeField(auto_now_add=True) data = JSONField(blank=True, null=True) def __unicode__(self): From c581ea5cbff75fc97cf09bc9d1f20a0c0a4f0717 Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Tue, 24 Feb 2015 15:07:32 +0300 Subject: [PATCH 09/33] UUIDField was replaced with CharField --- polls/models.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/polls/models.py b/polls/models.py index 554ef40..2601387 100644 --- a/polls/models.py +++ b/polls/models.py @@ -1,6 +1,6 @@ +from uuid import uuid4 from datetime import datetime, timedelta from django.db import models -from django_extensions.db.fields import UUIDField from django_extensions.db.fields.json import JSONField from django.contrib.auth.models import User from django.utils.translation import ugettext_lazy as _ @@ -10,7 +10,7 @@ class Poll(models.Model): question = models.CharField(max_length=255) description = models.TextField(blank=True) - reference = UUIDField(max_length=20, version=4) + reference = models.CharField(max_length=20, default=uuid4) is_anonymous = models.BooleanField(default=False, help_text=_('Allow to vote for anonymous user')) is_multiple = models.BooleanField(default=False, help_text=_('Allow to make multiple choices')) is_closed = models.BooleanField(default=False, help_text=_('Do not accept votes')) @@ -19,9 +19,10 @@ class Poll(models.Model): help_text=_('The latest time votes get accepted')) def vote(self, choices, user=None): + current_time = datetime.now() if self.is_closed: raise PollClosed - if self.end_votes < datetime.now(): + if current_time < self.start_votes and current_time > self.end_votes: raise PollNotOpen if user is None and not self.is_anonymous: raise PollNotAnonymous From cc08c7a42dda6e94d5bbefe626255c1931a4fafb Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Fri, 27 Feb 2015 11:49:41 +0300 Subject: [PATCH 10/33] uuid4 has 36 chars and reference should be unique --- ...ote_comment__add_field_vote_datetime__add_field_vote_.py | 6 ++++-- polls/models.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/polls/migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py b/polls/migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py index 2b5be6c..e035fbe 100644 --- a/polls/migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py +++ b/polls/migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py @@ -30,8 +30,9 @@ def forwards(self, orm): # Adding field 'Poll.reference' db.add_column(u'polls_poll', 'reference', - self.gf('django.db.models.fields.CharField')(default='', max_length=20, blank=True), + self.gf('django.db.models.fields.CharField')(default='', max_length=36, blank=True, unique=True), keep_default=False) + db.create_unique(u'polls_poll', ['reference']) # Adding field 'Poll.is_multiple' db.add_column(u'polls_poll', 'is_multiple', @@ -69,6 +70,7 @@ def backwards(self, orm): # Deleting field 'Poll.reference' db.delete_column(u'polls_poll', 'reference') + db.delete_unique(u'polls_poll', ['reference']) # Deleting field 'Poll.is_multiple' db.delete_column(u'polls_poll', 'is_multiple') @@ -135,7 +137,7 @@ def backwards(self, orm): 'is_anonymous': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'is_multiple': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'question': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'reference': ('django.db.models.fields.CharField', [], {'max_length': '20', 'blank': 'True'}), + 'reference': ('django.db.models.fields.CharField', [], {'max_length': '36', 'blank': 'True', 'unique': 'True'}), 'start_votes': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 2, 19, 0, 0)'}) }, u'polls.vote': { diff --git a/polls/models.py b/polls/models.py index 2601387..576cd39 100644 --- a/polls/models.py +++ b/polls/models.py @@ -10,7 +10,7 @@ class Poll(models.Model): question = models.CharField(max_length=255) description = models.TextField(blank=True) - reference = models.CharField(max_length=20, default=uuid4) + reference = models.CharField(max_length=36, default=uuid4, unique=True) is_anonymous = models.BooleanField(default=False, help_text=_('Allow to vote for anonymous user')) is_multiple = models.BooleanField(default=False, help_text=_('Allow to make multiple choices')) is_closed = models.BooleanField(default=False, help_text=_('Do not accept votes')) From 825330f1941b73d93afa749c08f91b415676ca8c Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Mon, 2 Mar 2015 12:13:47 +0300 Subject: [PATCH 11/33] modified PollResource and ResultResource --- polls/api.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/polls/api.py b/polls/api.py index 9954e07..dee95d7 100644 --- a/polls/api.py +++ b/polls/api.py @@ -1,8 +1,9 @@ from django.contrib.auth import get_user_model +from django.forms.models import model_to_dict from tastypie import fields from tastypie.authorization import DjangoAuthorization from tastypie.authentication import BasicAuthentication -from tastypie.resources import ModelResource, ALL, ALL_WITH_RELATIONS +from tastypie.resources import ModelResource, Resource, ALL, ALL_WITH_RELATIONS from polls.models import Poll, Choice, Vote @@ -23,6 +24,7 @@ class Meta: queryset = get_user_model().objects.all() allowed_methods = ['get'] resource_name = 'user' + always_return_data = True authentication = BasicAuthentication() authorization = DjangoAuthorization() excludes = ['date_joined', 'password', 'is_superuser', 'is_staff', 'is_active', 'last_login', 'first_name', 'last_name'] @@ -34,11 +36,16 @@ class Meta: class PollResource(ModelResource): # POST, GET, PUT #user = fields.ForeignKey(UserResource, 'user') + def dehydrate(self, bundle): + choices = Choice.objects.filter(poll=bundle.data['id']) + bundle.data['choices'] = [model_to_dict(choice) for choice in choices] + return bundle class Meta: queryset = Poll.objects.all() allowed_methods = ['get','post','put'] resource_name = 'poll' + always_return_data = True authentication = BasicAuthentication() authorization = DjangoAuthorization() filtering = { @@ -54,6 +61,7 @@ class Meta: authentication = BasicAuthentication() authorization = DjangoAuthorization() resource_name = 'choice' + always_return_data = True class VoteResource(ModelResource): @@ -63,11 +71,22 @@ class Meta: authentication = BasicAuthentication() authorization = DjangoAuthorization() resource_name = 'vote' + always_return_data = True class ResultResource(ModelResource): + def dehydrate(self, bundle): + percentage = Poll.objects.get(pk=bundle.data['id']).count_percentage() + labels = [choice.choice for choice in Choice.objects.filter(poll=bundle.data['id'])] + bundle.data['stats'] = dict(values=percentage, labels=labels, votes=len(labels)) + return bundle + class Meta: + queryset = Poll.objects.all() allowed_methods = ['get'] authentication = BasicAuthentication() authorization = DjangoAuthorization() - resource_name = 'result' \ No newline at end of file + resource_name = 'result' + always_return_data = True + excludes = ['description', 'start_votes', 'end_votes', 'is_anonymous', 'is_multiple', 'is_closed', 'reference'] + From 6c4d32311e28e2c7bd71d3b917d9172b83e8a169 Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Tue, 3 Mar 2015 14:54:24 +0300 Subject: [PATCH 12/33] user shouldn't be able to vote multiple times for same choice --- ..._choice__chg_field_poll_reference__add_.py | 99 +++++++++++++++++++ polls/models.py | 3 + 2 files changed, 102 insertions(+) create mode 100644 polls/migrations/0007_auto__add_unique_vote_user_poll_choice__chg_field_poll_reference__add_.py diff --git a/polls/migrations/0007_auto__add_unique_vote_user_poll_choice__chg_field_poll_reference__add_.py b/polls/migrations/0007_auto__add_unique_vote_user_poll_choice__chg_field_poll_reference__add_.py new file mode 100644 index 0000000..ab5fea2 --- /dev/null +++ b/polls/migrations/0007_auto__add_unique_vote_user_poll_choice__chg_field_poll_reference__add_.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding unique constraint on 'Vote', fields ['user', 'poll', 'choice'] + db.create_unique(u'polls_vote', ['user_id', 'poll_id', 'choice_id']) + + + # Changing field 'Poll.reference' + db.alter_column(u'polls_poll', 'reference', self.gf('django.db.models.fields.CharField')(unique=True, max_length=36)) + # Adding unique constraint on 'Poll', fields ['reference'] + db.create_unique(u'polls_poll', ['reference']) + + + def backwards(self, orm): + # Removing unique constraint on 'Poll', fields ['reference'] + db.delete_unique(u'polls_poll', ['reference']) + + # Removing unique constraint on 'Vote', fields ['user', 'poll', 'choice'] + db.delete_unique(u'polls_vote', ['user_id', 'poll_id', 'choice_id']) + + + # Changing field 'Poll.reference' + db.alter_column(u'polls_poll', 'reference', self.gf('django.db.models.fields.CharField')(max_length=20)) + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'polls.choice': { + 'Meta': {'object_name': 'Choice'}, + 'choice': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poll': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Poll']"}) + }, + u'polls.poll': { + 'Meta': {'ordering': "['-start_votes']", 'object_name': 'Poll'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'end_votes': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 3, 8, 0, 0)'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_anonymous': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_multiple': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'question': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'reference': ('django.db.models.fields.CharField', [], {'default': "'fcfa941c-9127-4a96-aff8-437d3cd35fea'", 'unique': 'True', 'max_length': '36'}), + 'start_votes': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 3, 3, 0, 0)'}) + }, + u'polls.vote': { + 'Meta': {'unique_together': "(('user', 'poll', 'choice'),)", 'object_name': 'Vote'}, + 'choice': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Choice']"}), + 'comment': ('django.db.models.fields.TextField', [], {'max_length': '144', 'null': 'True', 'blank': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'data': ('django.db.models.fields.TextField', [], {'default': "'{}'", 'null': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poll': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Poll']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']", 'null': 'True', 'blank': 'True'}) + } + } + + complete_apps = ['polls'] \ No newline at end of file diff --git a/polls/models.py b/polls/models.py index 576cd39..96e810c 100644 --- a/polls/models.py +++ b/polls/models.py @@ -81,3 +81,6 @@ class Vote(models.Model): def __unicode__(self): return u'Vote for %s' % self.choice + + class Meta: + unique_together = ('user', 'poll', 'choice') From e351da603d24e9e0374194e7bc7060f746682b0c Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Tue, 3 Mar 2015 14:56:23 +0300 Subject: [PATCH 13/33] changed authentication, user can vote only for himself --- polls/api.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/polls/api.py b/polls/api.py index dee95d7..02ced30 100644 --- a/polls/api.py +++ b/polls/api.py @@ -2,7 +2,7 @@ from django.forms.models import model_to_dict from tastypie import fields from tastypie.authorization import DjangoAuthorization -from tastypie.authentication import BasicAuthentication +from tastypie.authentication import SessionAuthentication from tastypie.resources import ModelResource, Resource, ALL, ALL_WITH_RELATIONS from polls.models import Poll, Choice, Vote @@ -25,7 +25,7 @@ class Meta: allowed_methods = ['get'] resource_name = 'user' always_return_data = True - authentication = BasicAuthentication() + authentication = SessionAuthentication() authorization = DjangoAuthorization() excludes = ['date_joined', 'password', 'is_superuser', 'is_staff', 'is_active', 'last_login', 'first_name', 'last_name'] filtering = { @@ -46,29 +46,33 @@ class Meta: allowed_methods = ['get','post','put'] resource_name = 'poll' always_return_data = True - authentication = BasicAuthentication() + authentication = SessionAuthentication() authorization = DjangoAuthorization() - filtering = { - 'user': ALL_WITH_RELATIONS, - 'pub_date': ['exact', 'lt', 'lte', 'gte', 'gt'], - } class ChoiceResource(ModelResource): + poll = fields.ToOneField(PollResource, 'poll') class Meta: queryset = Choice.objects.all() allowed_methods = ['post','put'] - authentication = BasicAuthentication() + authentication = SessionAuthentication() authorization = DjangoAuthorization() resource_name = 'choice' always_return_data = True class VoteResource(ModelResource): + user = fields.ToOneField(UserResource, 'user') + choice = fields.ToOneField(ChoiceResource, 'choice') + poll = fields.ToOneField(PollResource, 'poll') + + def obj_create(self, bundle, **kwargs): + return super(VoteResource, self).obj_create(bundle, user=bundle.request.user) + class Meta: queryset = Vote.objects.all() allowed_methods = ['post'] - authentication = BasicAuthentication() + authentication = SessionAuthentication() authorization = DjangoAuthorization() resource_name = 'vote' always_return_data = True @@ -84,7 +88,7 @@ def dehydrate(self, bundle): class Meta: queryset = Poll.objects.all() allowed_methods = ['get'] - authentication = BasicAuthentication() + authentication = SessionAuthentication() authorization = DjangoAuthorization() resource_name = 'result' always_return_data = True From 517a889aefbfd561b863a5b6ab078872c0a8eae4 Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Thu, 5 Mar 2015 16:40:18 +0300 Subject: [PATCH 14/33] start_votes and env_votes should be aware --- polls/models.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/polls/models.py b/polls/models.py index 96e810c..4508b75 100644 --- a/polls/models.py +++ b/polls/models.py @@ -1,8 +1,9 @@ from uuid import uuid4 -from datetime import datetime, timedelta +from datetime import timedelta from django.db import models from django_extensions.db.fields.json import JSONField from django.contrib.auth.models import User +from django.utils import timezone from django.utils.translation import ugettext_lazy as _ from exceptions import PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple @@ -14,12 +15,12 @@ class Poll(models.Model): is_anonymous = models.BooleanField(default=False, help_text=_('Allow to vote for anonymous user')) is_multiple = models.BooleanField(default=False, help_text=_('Allow to make multiple choices')) is_closed = models.BooleanField(default=False, help_text=_('Do not accept votes')) - start_votes = models.DateTimeField(default=datetime.today, help_text=_('The earliest time votes get accepted')) - end_votes = models.DateTimeField(default=(lambda: datetime.today()+timedelta(days=5)), + start_votes = models.DateTimeField(default=timezone.now, help_text=_('The earliest time votes get accepted')) + end_votes = models.DateTimeField(default=(lambda: timezone.now()+timedelta(days=5)), help_text=_('The latest time votes get accepted')) def vote(self, choices, user=None): - current_time = datetime.now() + current_time = timezone.now() if self.is_closed: raise PollClosed if current_time < self.start_votes and current_time > self.end_votes: From 4d97af5e159a63400a829b2f25c5f7b6ab5482e0 Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Tue, 10 Mar 2015 13:51:18 +0300 Subject: [PATCH 15/33] limit user's list, create a Vote with Poll.vote() method, added 'already_voted' field --- polls/api.py | 82 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/polls/api.py b/polls/api.py index 02ced30..57c10d9 100644 --- a/polls/api.py +++ b/polls/api.py @@ -1,32 +1,57 @@ from django.contrib.auth import get_user_model +from django.core.urlresolvers import resolve from django.forms.models import model_to_dict from tastypie import fields -from tastypie.authorization import DjangoAuthorization -from tastypie.authentication import SessionAuthentication -from tastypie.resources import ModelResource, Resource, ALL, ALL_WITH_RELATIONS +from tastypie.authorization import Authorization, ReadOnlyAuthorization +from tastypie.authentication import MultiAuthentication, BasicAuthentication, SessionAuthentication +from tastypie.resources import ModelResource, ALL +from tastypie.exceptions import ImmediateHttpResponse +from tastypie import http from polls.models import Poll, Choice, Vote +from exceptions import PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple ''' Api("v1/poll") POST /poll/ -- create a new poll, shall allow to post choices in the same API call POST /choice/ -- add a choice to an existing poll - POST /vote// -- vote on poll with pk - PUT /choice// -- update choice data - PUT /poll// -- update poll data - GET /poll// -- retrieve the poll information, including choice details - GET /result// -- retrieve the statistics on the poll. This shall return a JSON formatted like so. Note the actual statistics calculation shall be implemented in poll.service.stats (later on, this will be externalized into a batch job). + POST /vote/ -- vote on poll with pk + PUT /choice/ -- update choice data + PUT /poll/ -- update poll data + GET /poll/ -- retrieve the poll information, including choice details + GET /result/ -- retrieve the statistics on the poll. + This shall return a JSON formatted like so. Note the actual statistics calculation shall be implemented + in poll.service.stats (later on, this will be externalized into a batch job). ''' class UserResource(ModelResource): + def limit_list_by_user(self, request, object_list): + """ + limit the request object list to its own profile, except + for superusers. Superusers get a list of all users + + note that for POST requests tastypie internally + queries get_object_list, and we should return a valid + list + """ + view, args, kwargs = resolve(request.path) + if request.method == 'GET' and not 'pk' in kwargs and not request.user.is_superuser: + return object_list.filter(pk=request.user.pk) + return object_list + + def get_object_list(self, request): + object_list = super(UserResource, self).get_object_list(request) + object_list = self.limit_list_by_user(request, object_list) + return object_list + class Meta: queryset = get_user_model().objects.all() allowed_methods = ['get'] resource_name = 'user' always_return_data = True - authentication = SessionAuthentication() - authorization = DjangoAuthorization() + authentication = MultiAuthentication(BasicAuthentication(), SessionAuthentication()) + authorization = ReadOnlyAuthorization() excludes = ['date_joined', 'password', 'is_superuser', 'is_staff', 'is_active', 'last_login', 'first_name', 'last_name'] filtering = { 'username': ALL, @@ -36,27 +61,35 @@ class Meta: class PollResource(ModelResource): # POST, GET, PUT #user = fields.ForeignKey(UserResource, 'user') + def obj_create(self, bundle, **kwargs): + return super(PollResource, self).obj_create(bundle, user=bundle.request.user) + def dehydrate(self, bundle): choices = Choice.objects.filter(poll=bundle.data['id']) - bundle.data['choices'] = [model_to_dict(choice) for choice in choices] + bundle.data['choices'] = [model_to_dict(choice) for choice in choices] return bundle + def alter_detail_data_to_serialize(self, request, data): + data.data['already_voted'] = Poll.objects.get(pk=data.data.get('id')).already_voted(user=request.user) + return data + class Meta: queryset = Poll.objects.all() allowed_methods = ['get','post','put'] resource_name = 'poll' always_return_data = True - authentication = SessionAuthentication() - authorization = DjangoAuthorization() + authentication = MultiAuthentication(BasicAuthentication(), SessionAuthentication()) + authorization = Authorization() class ChoiceResource(ModelResource): poll = fields.ToOneField(PollResource, 'poll') + class Meta: queryset = Choice.objects.all() allowed_methods = ['post','put'] - authentication = SessionAuthentication() - authorization = DjangoAuthorization() + authentication = MultiAuthentication(BasicAuthentication(), SessionAuthentication()) + authorization = Authorization() resource_name = 'choice' always_return_data = True @@ -67,13 +100,22 @@ class VoteResource(ModelResource): poll = fields.ToOneField(PollResource, 'poll') def obj_create(self, bundle, **kwargs): - return super(VoteResource, self).obj_create(bundle, user=bundle.request.user) + poll = PollResource().get_via_uri(bundle.data.get('poll')) + if not poll.already_voted(bundle.request.user): + try: + poll.vote(choices=bundle.data.get('choice'), user=bundle.request.user) + raise ImmediateHttpResponse(response=http.HttpCreated()) + except (PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple): + raise ImmediateHttpResponse(response=http.HttpForbidden('not allowed')) + else: + raise ImmediateHttpResponse(response=http.HttpForbidden('already voted')) + class Meta: queryset = Vote.objects.all() allowed_methods = ['post'] - authentication = SessionAuthentication() - authorization = DjangoAuthorization() + authentication = MultiAuthentication(BasicAuthentication(), SessionAuthentication()) + #authorization = Authorization() resource_name = 'vote' always_return_data = True @@ -88,8 +130,8 @@ def dehydrate(self, bundle): class Meta: queryset = Poll.objects.all() allowed_methods = ['get'] - authentication = SessionAuthentication() - authorization = DjangoAuthorization() + authentication = MultiAuthentication(BasicAuthentication(), SessionAuthentication()) + authorization = Authorization() resource_name = 'result' always_return_data = True excludes = ['description', 'start_votes', 'end_votes', 'is_anonymous', 'is_multiple', 'is_closed', 'reference'] From da0c2300903299501c709b069374227899bbbae8 Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Tue, 10 Mar 2015 13:54:53 +0300 Subject: [PATCH 16/33] can_vote renamed to alredy_voted, a bug fixed in the Poll.vote method --- polls/models.py | 9 ++++++--- polls/views.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/polls/models.py b/polls/models.py index 4508b75..5a60a17 100644 --- a/polls/models.py +++ b/polls/models.py @@ -23,7 +23,7 @@ def vote(self, choices, user=None): current_time = timezone.now() if self.is_closed: raise PollClosed - if current_time < self.start_votes and current_time > self.end_votes: + if current_time < self.start_votes or current_time > self.end_votes: raise PollNotOpen if user is None and not self.is_anonymous: raise PollNotAnonymous @@ -51,8 +51,11 @@ def count_total_votes(self): result += choice.count_votes() return result - def can_vote(self, user): - return not self.vote_set.filter(user=user).exists() + def already_voted(self, user): + if not user.is_anonymous(): + return self.vote_set.filter(user=user).exists() + else: + return False def __unicode__(self): return self.question diff --git a/polls/views.py b/polls/views.py index a0b9bfa..ae3b9f2 100644 --- a/polls/views.py +++ b/polls/views.py @@ -18,7 +18,7 @@ def get_context_data(self, **kwargs): if self.request.user.is_anonymous(): context['poll'].votable = False else: - context['poll'].votable = self.object.can_vote(self.request.user) + context['poll'].votable = self.object.already_voted(self.request.user) return context From a16832d394b13b942a80fcfca408ad4d5b0ac2e4 Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Tue, 10 Mar 2015 13:44:51 +0300 Subject: [PATCH 17/33] splitting tests, api tests added --- polls/test/__init__.py | 0 polls/test/test_api.py | 150 ++++++++++++++++++++++++ polls/{tests.py => test/test_models.py} | 0 3 files changed, 150 insertions(+) create mode 100644 polls/test/__init__.py create mode 100644 polls/test/test_api.py rename polls/{tests.py => test/test_models.py} (100%) diff --git a/polls/test/__init__.py b/polls/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/polls/test/test_api.py b/polls/test/test_api.py new file mode 100644 index 0000000..8945eff --- /dev/null +++ b/polls/test/test_api.py @@ -0,0 +1,150 @@ +import logging +import uuid +from datetime import timedelta +from django.contrib.auth.models import Permission, User +from tastypie.test import ResourceTestCase +from django.utils import timezone +from tastypie.utils import make_naive + +logger = logging.getLogger(__name__) +URL = '/api/v1' # or '/polls/api/v1' in the case when we don't set 'urls' attribute + + +class PollsApiTest(ResourceTestCase): + urls = 'polls.urls' + + def setUp(self): + super(PollsApiTest, self).setUp() + self.username = 'test' + self.password = 'password' + self.user = User.objects.create_user(self.username, 't@gmail.com', self.password) + # user can't add/change/delete poll model without permissions + permissions = (Permission.objects.get(codename=p) + for p in ['add_poll', 'change_poll', 'delete_poll']) + self.user.user_permissions.add(*permissions) + + def tearDown(self): + self.api_client.client.logout() + + def getURL(self, resource, id=None): + if id: + return "%s/%s/%s/" % (URL, resource, id) + return "%s/%s/" % (URL, resource) + + def get_credentials(self): + return self.create_basic(username=self.username, password=self.password) + + def test_create_poll(self): + self.assertTrue(self.user.has_perm('polls.add_poll')) + poll_data = self.poll_data() + resp = self.create_poll(poll_data) + self.assertHttpCreated(resp) + resp = self.api_client.get(self.getURL('poll'), authentication=self.get_credentials()) + #logger.debug(resp) + self.assertValidJSONResponse(resp) + deserialized = self.deserialize(resp)['objects'] + self.assertEqual(deserialized[0], { + u'id': 1, + u'choices': [], + u'description': poll_data['description'], + u'question': poll_data['question'], + u'reference': deserialized[0]['reference'], + u'is_anonymous': poll_data['is_anonymous'], + u'is_closed': poll_data['is_closed'], + u'is_multiple': poll_data['is_multiple'], + u'resource_uri': deserialized[0]['resource_uri'], + u'start_votes': poll_data['start_votes'], + u'end_votes': poll_data['end_votes'], + }) + # check that 'reference' is correct uuid string + try: + uuid.UUID('{' + deserialized[0]['reference'] + '}') + except ValueError: + self.fail('badly formed UUID string') + + def test_create_poll_unauthenticated(self): + resp = self.api_client.post(self.getURL('poll'), format='json') + self.assertHttpUnauthorized(resp) + + def test_put_poll(self): + poll_data = self.poll_data() + poll_data['is_anonymous'] = True + self.create_poll(poll_data) + resp = self.api_client.put(self.getURL('poll', 1), data=poll_data, authentication=self.get_credentials()) + self.assertHttpOK(resp) + resp = self.api_client.get(self.getURL('poll', 1), authentication=self.get_credentials()) + self.assertEqual(self.deserialize(resp)['is_anonymous'], True) + + def test_voting(self): + # create a poll + poll_data = self.poll_data() + resp = self.create_poll(poll_data) + choice_data = self.choice_data(poll_id=1) + # create 3 choices + self.create_choices(choice_data, quantity=3) + resp = self.api_client.get(self.getURL('poll', 1), authentication=self.get_credentials()) + deserialized = self.deserialize(resp) + self.assertEqual(len(deserialized['choices']), 3) + # vote + vote_data = self.vote_data(poll_id=1, choices=[2]) + resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json', + authentication=self.get_credentials()) + self.assertHttpCreated(resp) + resp = self.api_client.get(self.getURL('result', id=1), format='json', + authentication=self.get_credentials()) + deserialized = self.deserialize(resp) + self.assertEqual(deserialized['stats']['values'], [0.0, 1.0, 0.0]) + + def test_anonymous_voting(self): + poll_data = self.poll_data(anonymous=True) + resp = self.create_poll(poll_data) + self.assertHttpCreated(resp) + choice_data = self.choice_data(poll_id=1) + self.create_choices(choice_data, quantity=3) + vote_data = self.vote_data(poll_id=1, choices=[1]) + resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') + self.assertHttpCreated(resp) + + def create_poll(self, poll_data): + return self.api_client.post(self.getURL('poll'), format='json', + data=poll_data, authentication=self.get_credentials()) + + def create_choice(self, choice_data): + return self.api_client.post(self.getURL('choice'), format='json', + data=choice_data, authentication=self.get_credentials()) + + def create_choices(self, choice_data, quantity): + for x in xrange(quantity): + choice_data['choice'] = 'choice' + str(x) + resp = self.api_client.post(self.getURL('choice'), format='json', + data=choice_data, authentication=self.get_credentials()) + self.assertHttpCreated(resp) + + def poll_data(self, anonymous=False, multiple=False, closed=False): + now = timezone.now() + # delete microseconds + start_votes = make_naive(now) - timedelta(microseconds=now.microsecond) + end_votes = start_votes + timedelta(days=10) + return { + 'question': u'question', + 'description': u'desc', + 'is_anonymous': anonymous, + 'is_multiple': multiple, + 'is_closed': closed, + 'start_votes': start_votes.isoformat(), + 'end_votes': end_votes.isoformat(), + } + + def choice_data(self, poll_id): + return { + 'poll': self.getURL('poll', id=poll_id), + 'choice': 'choice' + } + + def vote_data(self, poll_id, choices): + return { + 'choice': choices, + 'poll': self.getURL('poll', id=poll_id) + + } + diff --git a/polls/tests.py b/polls/test/test_models.py similarity index 100% rename from polls/tests.py rename to polls/test/test_models.py From adab97f32421ec5eded42e73d4c8cd4f8fb9818e Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Wed, 11 Mar 2015 13:33:22 +0300 Subject: [PATCH 18/33] fixed 'Duplicate index 'polls_poll_reference_4cacbf22888a7509_uniq' defined on the table 'polls.polls_poll' on mysql backend --- ...ote_comment__add_field_vote_datetime__add_field_vote_.py | 6 ++++-- ...vote_user_poll_choice__chg_field_poll_reference__add_.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/polls/migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py b/polls/migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py index e035fbe..8cd1b1d 100644 --- a/polls/migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py +++ b/polls/migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py @@ -32,7 +32,9 @@ def forwards(self, orm): db.add_column(u'polls_poll', 'reference', self.gf('django.db.models.fields.CharField')(default='', max_length=36, blank=True, unique=True), keep_default=False) - db.create_unique(u'polls_poll', ['reference']) + # mysql error: + # Duplicate index 'polls_poll_reference_4cacbf22888a7509_uniq' defined on the table 'polls.polls_poll + #db.create_unique(u'polls_poll', ['reference']) # Adding field 'Poll.is_multiple' db.add_column(u'polls_poll', 'is_multiple', @@ -70,7 +72,7 @@ def backwards(self, orm): # Deleting field 'Poll.reference' db.delete_column(u'polls_poll', 'reference') - db.delete_unique(u'polls_poll', ['reference']) + #db.delete_unique(u'polls_poll', ['reference']) # Deleting field 'Poll.is_multiple' db.delete_column(u'polls_poll', 'is_multiple') diff --git a/polls/migrations/0007_auto__add_unique_vote_user_poll_choice__chg_field_poll_reference__add_.py b/polls/migrations/0007_auto__add_unique_vote_user_poll_choice__chg_field_poll_reference__add_.py index ab5fea2..4ac9ee3 100644 --- a/polls/migrations/0007_auto__add_unique_vote_user_poll_choice__chg_field_poll_reference__add_.py +++ b/polls/migrations/0007_auto__add_unique_vote_user_poll_choice__chg_field_poll_reference__add_.py @@ -15,12 +15,14 @@ def forwards(self, orm): # Changing field 'Poll.reference' db.alter_column(u'polls_poll', 'reference', self.gf('django.db.models.fields.CharField')(unique=True, max_length=36)) # Adding unique constraint on 'Poll', fields ['reference'] - db.create_unique(u'polls_poll', ['reference']) + # mysql error: + # Duplicate index 'polls_poll_reference_4cacbf22888a7509_uniq' defined on the table 'polls.polls_poll + # db.create_unique(u'polls_poll', ['reference']) def backwards(self, orm): # Removing unique constraint on 'Poll', fields ['reference'] - db.delete_unique(u'polls_poll', ['reference']) + #db.delete_unique(u'polls_poll', ['reference']) # Removing unique constraint on 'Vote', fields ['user', 'poll', 'choice'] db.delete_unique(u'polls_vote', ['user_id', 'poll_id', 'choice_id']) From 8fb9217634380e15dc0b1855868a19880491cfbb Mon Sep 17 00:00:00 2001 From: Sergey Vishnikin Date: Wed, 11 Mar 2015 18:38:07 +0300 Subject: [PATCH 19/33] tests are working with mysql now --- polls/test/test_api.py | 32 +++++--- polls/test/test_models.py | 163 +++++++++++++++++++------------------- 2 files changed, 101 insertions(+), 94 deletions(-) diff --git a/polls/test/test_api.py b/polls/test/test_api.py index 8945eff..32b27cb 100644 --- a/polls/test/test_api.py +++ b/polls/test/test_api.py @@ -2,9 +2,10 @@ import uuid from datetime import timedelta from django.contrib.auth.models import Permission, User -from tastypie.test import ResourceTestCase from django.utils import timezone from tastypie.utils import make_naive +from tastypie.test import ResourceTestCase +from polls.models import Poll, Choice logger = logging.getLogger(__name__) URL = '/api/v1' # or '/polls/api/v1' in the case when we don't set 'urls' attribute @@ -39,12 +40,13 @@ def test_create_poll(self): poll_data = self.poll_data() resp = self.create_poll(poll_data) self.assertHttpCreated(resp) + pk = Poll.objects.order_by('-id')[0].pk resp = self.api_client.get(self.getURL('poll'), authentication=self.get_credentials()) #logger.debug(resp) self.assertValidJSONResponse(resp) deserialized = self.deserialize(resp)['objects'] self.assertEqual(deserialized[0], { - u'id': 1, + u'id': pk, u'choices': [], u'description': poll_data['description'], u'question': poll_data['question'], @@ -69,28 +71,32 @@ def test_create_poll_unauthenticated(self): def test_put_poll(self): poll_data = self.poll_data() poll_data['is_anonymous'] = True - self.create_poll(poll_data) - resp = self.api_client.put(self.getURL('poll', 1), data=poll_data, authentication=self.get_credentials()) + resp = self.create_poll(poll_data) + self.assertHttpCreated(resp) + pk = Poll.objects.order_by('-id')[0].pk + resp = self.api_client.put(self.getURL('poll', pk), data=poll_data, authentication=self.get_credentials()) self.assertHttpOK(resp) - resp = self.api_client.get(self.getURL('poll', 1), authentication=self.get_credentials()) + resp = self.api_client.get(self.getURL('poll', pk), authentication=self.get_credentials()) self.assertEqual(self.deserialize(resp)['is_anonymous'], True) def test_voting(self): # create a poll poll_data = self.poll_data() resp = self.create_poll(poll_data) - choice_data = self.choice_data(poll_id=1) + pk = Poll.objects.order_by('-id')[0].pk + choice_data = self.choice_data(poll_id=pk) # create 3 choices self.create_choices(choice_data, quantity=3) - resp = self.api_client.get(self.getURL('poll', 1), authentication=self.get_credentials()) + resp = self.api_client.get(self.getURL('poll', pk), authentication=self.get_credentials()) deserialized = self.deserialize(resp) self.assertEqual(len(deserialized['choices']), 3) # vote - vote_data = self.vote_data(poll_id=1, choices=[2]) + choice_pk = Choice.objects.order_by('-id')[1].pk + vote_data = self.vote_data(poll_id=pk, choices=[choice_pk]) resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json', authentication=self.get_credentials()) self.assertHttpCreated(resp) - resp = self.api_client.get(self.getURL('result', id=1), format='json', + resp = self.api_client.get(self.getURL('result', id=pk), format='json', authentication=self.get_credentials()) deserialized = self.deserialize(resp) self.assertEqual(deserialized['stats']['values'], [0.0, 1.0, 0.0]) @@ -99,7 +105,8 @@ def test_anonymous_voting(self): poll_data = self.poll_data(anonymous=True) resp = self.create_poll(poll_data) self.assertHttpCreated(resp) - choice_data = self.choice_data(poll_id=1) + pk = Poll.objects.order_by('-id')[0].pk + choice_data = self.choice_data(poll_id=pk) self.create_choices(choice_data, quantity=3) vote_data = self.vote_data(poll_id=1, choices=[1]) resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') @@ -131,8 +138,8 @@ def poll_data(self, anonymous=False, multiple=False, closed=False): 'is_anonymous': anonymous, 'is_multiple': multiple, 'is_closed': closed, - 'start_votes': start_votes.isoformat(), - 'end_votes': end_votes.isoformat(), + 'start_votes': unicode(start_votes.isoformat(), 'utf-8'), + 'end_votes': unicode(end_votes.isoformat(), 'utf-8'), } def choice_data(self, poll_id): @@ -145,6 +152,5 @@ def vote_data(self, poll_id, choices): return { 'choice': choices, 'poll': self.getURL('poll', id=poll_id) - } diff --git a/polls/test/test_models.py b/polls/test/test_models.py index bd97e8b..a28c2e7 100644 --- a/polls/test/test_models.py +++ b/polls/test/test_models.py @@ -5,6 +5,7 @@ Replace this with more appropriate tests for your application. """ import random +import logging from django.test import TestCase from django.contrib.auth import get_user from django.contrib.auth.models import User @@ -12,13 +13,16 @@ from polls.models import Poll, Choice, Vote from polls.exceptions import PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple +logger = logging.getLogger(__name__) + # view tests are outdated class PollsViewTest(TestCase): def setUp(self): self.username = "user%d" % (random.random() * 100) self.user = User.objects.create_user(self.username, 'test@test.com', 'testtest') - create_poll_single() + poll, self.cids = create_poll_single() + self.poll_pk = poll.pk def tearDown(self): self.client.logout() @@ -39,32 +43,32 @@ def test_polls_list_view_anonymous(self): def test_detail_view(self): self.client.login(username=self.username, password='testtest') - resp = self.client.get(reverse('polls:detail', args=[1])) + resp = self.client.get(reverse('polls:detail', args=[self.poll_pk])) self.assertEqual(resp.status_code, 200) # get nonexistent poll - resp = self.client.get(reverse('polls:detail', args=[2])) + resp = self.client.get(reverse('polls:detail', args=[self.poll_pk+1])) self.assertEqual(resp.status_code, 404) def test_detail_view_anonymous(self): user = get_user(self.client) self.assertTrue(user.is_anonymous()) - resp = self.client.get(reverse('polls:detail', args=[1])) + resp = self.client.get(reverse('polls:detail', args=[self.poll_pk])) self.assertEqual(resp.status_code, 200) def test_vote_view(self): self.client.login(username=self.username, password='testtest') - resp = self.client.post(reverse('polls:vote', args=[1]), {'choice_pk': 2}) + resp = self.client.post(reverse('polls:vote', args=[self.poll_pk]), {'choice_pk': self.cids[1]}) self.assertEqual(resp.status_code, 301) self.assertEqual(Vote.objects.all().count(), 1) # vote again - resp = self.client.post(reverse('polls:vote', args=[1]), {'choice_pk': 2}) + resp = self.client.post(reverse('polls:vote', args=[self.poll_pk]), {'choice_pk': self.cids[1]}) self.assertEqual(resp.status_code, 403) self.assertEqual(Vote.objects.all().count(), 1) def test_vote_anonymous(self): user = get_user(self.client) self.assertTrue(user.is_anonymous()) - resp = self.client.post(reverse('polls:vote', args=[1]), {'choice_pk': 2}) + resp = self.client.post(reverse('polls:vote', args=[1]), {'choice_pk': self.cids[1]}) class PollsModelTest(TestCase): @@ -73,128 +77,125 @@ def setUp(self): self.user2 = User.objects.create_user('user2', 'test2@test.com', 'testtest2') self.user3 = User.objects.create_user('user3', 'test3@test.com', 'testtest3') self.user4 = User.objects.create_user('user4', 'test4@test.com', 'testtest4') + Choice.objects.all().delete() def tearDown(self): pass def test_poll_methods(self): - poll = create_poll_single() + poll, cids = create_poll_single() self.assertEqual(Poll.objects.count(), 1) self.assertEqual(poll.count_choices(), 3) def test_single_vote(self): - poll = create_poll_single() - poll.vote([1], self.user1) - self.assertRaises(PollNotMultiple, poll.vote, *([1,2], self.user2)) - self.assertRaises(PollNotAnonymous, poll.vote, [1]) - - def test_single_vote_stat(self): - poll = create_poll_single() - poll.vote([1], self.user1) - poll.vote([2], self.user2) + poll, cids = create_poll_single() + poll.vote([cids[0]], self.user1) + self.assertRaises(PollNotMultiple, poll.vote, *([cids[0], cids[1]], self.user2)) + self.assertRaises(PollNotAnonymous, poll.vote, [cids[0]]) + + def test_single_vote_stat_1(self): + poll, cids = create_poll_single() + poll.vote([cids[0]], self.user1) + poll.vote([cids[1]], self.user2) self.assertEqual(poll.count_percentage(), [0.5, 0.5, 0]) - poll.delete() - poll = create_poll_single() - poll.vote([1], self.user1) + def test_single_vote_stat_2(self): + poll, cids = create_poll_single() + poll.vote([cids[0]], self.user1) self.assertEqual(poll.count_percentage(), [1.0, 0, 0]) - poll.delete() - poll = create_poll_single() - poll.vote([1], self.user1) - poll.vote([2], self.user2) - poll.vote([3], self.user3) + def test_single_vote_stat_3(self): + poll, cids = create_poll_single() + poll.vote([cids[0]], self.user1) + poll.vote([cids[1]], self.user2) + poll.vote([cids[2]], self.user3) self.assertEqual(poll.count_percentage(), [1.0/3, 1.0/3, 1.0/3]) - poll.delete() - - # user changed his choice - poll = create_poll_single() - poll.vote([1], self.user1) - poll.vote([2], self.user1) - self.assertEqual(poll.count_percentage(), [0, 1.0, 0]) def test_multiple_vote(self): - poll = create_poll_multiple() - poll.vote([1,2], self.user1) - self.assertRaises(PollNotAnonymous, poll.vote, [1,2]) + poll, cids = create_poll_multiple() + poll.vote([cids[0],cids[1]], self.user1) + self.assertRaises(PollNotAnonymous, poll.vote, [cids[1], cids[2]]) def test_multiple_vote_stat(self): - poll = create_poll_multiple() - poll.vote([1,2], self.user1) + poll, cids = create_poll_multiple() + poll.vote([cids[0],cids[1]], self.user1) self.assertEqual(poll.count_percentage(), [0.5, 0.5, 0.0, 0.0, 0.0]) - poll.vote([3,4,5], self.user2) + poll.vote([cids[2],cids[3],cids[4]], self.user2) self.assertEqual(poll.count_percentage(), [0.2, 0.2, 0.2, 0.2, 0.2]) def test_anonymous_single_vote(self): - poll = create_poll_anonymous_single() - poll.vote([2], self.user1) - poll.vote([2]) - self.assertRaises(PollNotMultiple, poll.vote, *([1,2], self.user2)) - self.assertRaises(PollNotMultiple, poll.vote, [1,2]) + poll, cids = create_poll_anonymous_single() + poll.vote([cids[1]], self.user1) + poll.vote([cids[1]]) + self.assertRaises(PollNotMultiple, poll.vote, *([cids[0],cids[1]], self.user2)) + self.assertRaises(PollNotMultiple, poll.vote, [cids[0],cids[1]]) def test_anonymous_single_vote_stat(self): - poll = create_poll_anonymous_single() - poll.vote([1]) + poll, cids = create_poll_anonymous_single() + poll.vote([cids[0]]) self.assertEqual(poll.count_percentage(), [1.0, 0, 0]) - poll.vote([2], self.user1) - poll.vote([3], self.user2) + poll.vote([cids[1]], self.user1) + poll.vote([cids[2]], self.user2) self.assertEqual(poll.count_percentage(), [1.0/3, 1.0/3, 1.0/3]) def test_anonymous_multiple_vote(self): - poll = create_poll_anonymous_multiple() - poll.vote([2], self.user1) - poll.vote([2]) - poll.vote([1,2], self.user2) - poll.vote([1,2]) + poll, cids = create_poll_anonymous_multiple() + poll.vote([cids[1]], self.user1) + poll.vote([cids[1]]) + poll.vote([cids[0], cids[1]], self.user2) + poll.vote([cids[0],cids[1]]) def test_anonymous_multiple_vote_stat(self): - poll = create_poll_anonymous_multiple() - poll.vote([2]) + poll, cids = create_poll_anonymous_multiple() + poll.vote([cids[1]]) self.assertEqual(poll.count_percentage(), [0.0, 1.0, 0.0, 0.0, 0.0]) - -class PollsApiTest(TestCase): - pass - - # for authenticated users, only one vote allowed def create_poll_single(): poll = Poll(question='How are you?', description='description') poll.save() - Choice(poll=poll, choice='I am fine').save() - Choice(poll=poll, choice='So so').save() - Choice(poll=poll, choice='Bad').save() - return poll + choices = list() + choices.append(Choice(poll=poll, choice='I am fine')) + choices.append(Choice(poll=poll, choice='So so')) + choices.append(Choice(poll=poll, choice='Bad')) + [choice.save() for choice in choices] + return poll, [choice.pk for choice in choices] # for authenticated users, multiple votes are allowed def create_poll_multiple(): poll = Poll(question='Which languages do you know?', description='description', is_multiple=True) poll.save() - Choice(poll=poll, choice='French').save() - Choice(poll=poll, choice='English').save() - Choice(poll=poll, choice='German').save() - Choice(poll=poll, choice='Japanese').save() - Choice(poll=poll, choice='Chinese').save() - return poll + choices = list() + choices.append(Choice(poll=poll, choice='French')) + choices.append(Choice(poll=poll, choice='English')) + choices.append(Choice(poll=poll, choice='German')) + choices.append(Choice(poll=poll, choice='Japanese')) + choices.append(Choice(poll=poll, choice='Chinese')) + [choice.save() for choice in choices] + return poll, [choice.pk for choice in choices] # for anonymous and authenticated users, only one vote allowed def create_poll_anonymous_single(): poll = Poll(question='Are you an anonymous?', description='description', is_anonymous=True) poll.save() - Choice(poll=poll, choice='Yes').save() - Choice(poll=poll, choice='No').save() - Choice(poll=poll, choice='Nobody knows').save() - return poll - -# for anonymouse authenticated users, multiple votes are allowed + choices = list() + choices.append(Choice(poll=poll, choice='Yes')) + choices.append(Choice(poll=poll, choice='No')) + choices.append(Choice(poll=poll, choice='Nobody knows')) + [choice.save() for choice in choices] + return poll, [choice.pk for choice in choices] + +# for anonymous and authenticated users, multiple votes are allowed def create_poll_anonymous_multiple(): poll = Poll(question='Choose what do you like', description='description', is_anonymous=True, is_multiple=True) poll.save() - Choice(poll=poll, choice='Chocolate').save() - Choice(poll=poll, choice='Milk').save() - Choice(poll=poll, choice='Fruits').save() - Choice(poll=poll, choice='Meat').save() - Choice(poll=poll, choice='Vegetables').save() - return poll + choices = list() + choices.append(Choice(poll=poll, choice='Chocolate')) + choices.append(Choice(poll=poll, choice='Milk')) + choices.append(Choice(poll=poll, choice='Fruits')) + choices.append(Choice(poll=poll, choice='Meat')) + choices.append(Choice(poll=poll, choice='Vegetables')) + [choice.save() for choice in choices] + return poll, [choice.pk for choice in choices] From a06e5504cfcc3cd2da985a2dcb7493fad2f32a82 Mon Sep 17 00:00:00 2001 From: miraculixx Date: Sat, 14 Mar 2015 17:30:29 +0100 Subject: [PATCH 20/33] support name spaced url/uri, poll and result by reference * uri was empty => namespaced tastypie uri - http://stackoverflow.com/a/27162751 * support poll details and result by reference and pk --- polls/api.py | 60 ++++++++++++++++++++++++++++++++----------------- polls/models.py | 9 ++++---- polls/urls.py | 10 ++++----- 3 files changed, 50 insertions(+), 29 deletions(-) diff --git a/polls/api.py b/polls/api.py index 57c10d9..b45a540 100644 --- a/polls/api.py +++ b/polls/api.py @@ -1,16 +1,3 @@ -from django.contrib.auth import get_user_model -from django.core.urlresolvers import resolve -from django.forms.models import model_to_dict -from tastypie import fields -from tastypie.authorization import Authorization, ReadOnlyAuthorization -from tastypie.authentication import MultiAuthentication, BasicAuthentication, SessionAuthentication -from tastypie.resources import ModelResource, ALL -from tastypie.exceptions import ImmediateHttpResponse -from tastypie import http -from polls.models import Poll, Choice, Vote -from exceptions import PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple - - ''' Api("v1/poll") POST /poll/ -- create a new poll, shall allow to post choices in the same API call @@ -24,8 +11,23 @@ in poll.service.stats (later on, this will be externalized into a batch job). ''' +from exceptions import PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple -class UserResource(ModelResource): +from django.conf.urls import url +from django.contrib.auth import get_user_model +from django.core.urlresolvers import resolve +from django.forms.models import model_to_dict +from tastypie import fields +from tastypie import http +from tastypie.authentication import MultiAuthentication, BasicAuthentication, SessionAuthentication +from tastypie.authorization import Authorization, ReadOnlyAuthorization +from tastypie.exceptions import ImmediateHttpResponse +from tastypie.resources import ModelResource, ALL, NamespacedModelResource + +from polls.models import Poll, Choice, Vote + + +class UserResource(NamespacedModelResource): def limit_list_by_user(self, request, object_list): """ limit the request object list to its own profile, except @@ -44,7 +46,7 @@ def get_object_list(self, request): object_list = super(UserResource, self).get_object_list(request) object_list = self.limit_list_by_user(request, object_list) return object_list - + class Meta: queryset = get_user_model().objects.all() allowed_methods = ['get'] @@ -58,12 +60,12 @@ class Meta: } -class PollResource(ModelResource): +class PollResource(NamespacedModelResource): # POST, GET, PUT #user = fields.ForeignKey(UserResource, 'user') def obj_create(self, bundle, **kwargs): return super(PollResource, self).obj_create(bundle, user=bundle.request.user) - + def dehydrate(self, bundle): choices = Choice.objects.filter(poll=bundle.data['id']) bundle.data['choices'] = [model_to_dict(choice) for choice in choices] @@ -72,6 +74,15 @@ def dehydrate(self, bundle): def alter_detail_data_to_serialize(self, request, data): data.data['already_voted'] = Poll.objects.get(pk=data.data.get('id')).already_voted(user=request.user) return data + + def prepend_urls(self): + """ match by pk or reference """ + return [ + url(r"^(?P%s)/(?P[0-9]+)/$" % self._meta.resource_name, + self.wrap_view('dispatch_detail'), name="api_dispatch_detail"), + url(r"^(?P%s)/(?P[\w-]+)/$" % self._meta.resource_name, + self.wrap_view('dispatch_detail'), name="api_dispatch_detail"), + ] class Meta: queryset = Poll.objects.all() @@ -82,7 +93,7 @@ class Meta: authorization = Authorization() -class ChoiceResource(ModelResource): +class ChoiceResource(NamespacedModelResource): poll = fields.ToOneField(PollResource, 'poll') class Meta: @@ -120,9 +131,18 @@ class Meta: always_return_data = True -class ResultResource(ModelResource): +class ResultResource(NamespacedModelResource): + def prepend_urls(self): + """ match by pk or reference """ + return [ + url(r"^(?P%s)/(?P[0-9]+)/$" % self._meta.resource_name, + self.wrap_view('dispatch_detail'), name="api_dispatch_detail"), + url(r"^(?P%s)/(?P[\w-]+)/$" % self._meta.resource_name, + self.wrap_view('dispatch_detail'), name="api_dispatch_detail"), + ] + def dehydrate(self, bundle): - percentage = Poll.objects.get(pk=bundle.data['id']).count_percentage() + percentage = bundle.obj.count_percentage() labels = [choice.choice for choice in Choice.objects.filter(poll=bundle.data['id'])] bundle.data['stats'] = dict(values=percentage, labels=labels, votes=len(labels)) return bundle diff --git a/polls/models.py b/polls/models.py index 5a60a17..82170d6 100644 --- a/polls/models.py +++ b/polls/models.py @@ -1,11 +1,12 @@ -from uuid import uuid4 from datetime import timedelta -from django.db import models -from django_extensions.db.fields.json import JSONField +from exceptions import PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple +from uuid import uuid4 + from django.contrib.auth.models import User +from django.db import models from django.utils import timezone from django.utils.translation import ugettext_lazy as _ -from exceptions import PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple +from django_extensions.db.fields.json import JSONField class Poll(models.Model): diff --git a/polls/urls.py b/polls/urls.py index 2a30bfb..3fbee5f 100644 --- a/polls/urls.py +++ b/polls/urls.py @@ -2,10 +2,10 @@ from django.contrib.auth.decorators import login_required from views import PollDetailView, PollListView, PollVoteView -from tastypie.api import Api +from tastypie.api import Api, NamespacedApi from polls.api import UserResource, PollResource, ChoiceResource, VoteResource, ResultResource -v1_api = Api(api_name='v1') +v1_api = NamespacedApi(api_name='v1', urlconf_namespace='polls') v1_api.register(UserResource()) v1_api.register(PollResource()) v1_api.register(ChoiceResource()) @@ -13,8 +13,8 @@ v1_api.register(ResultResource()) urlpatterns = patterns('', - url(r'^$', PollListView.as_view(), name='list'), + #url(r'^$', PollListView.as_view(), name='list'), url(r'^api/', include(v1_api.urls)), - url(r'^(?P\d+)/$', PollDetailView.as_view(), name='detail'), - url(r'^(?P\d+)/vote/$', login_required(PollVoteView.as_view()), name='vote'), + #url(r'^(?P\d+)/$', PollDetailView.as_view(), name='detail'), + #url(r'^(?P\d+)/vote/$', login_required(PollVoteView.as_view()), name='vote'), ) From 264fd8a4294851de216f7139dcaa44687bfd9cbf Mon Sep 17 00:00:00 2001 From: miraculixx Date: Sat, 14 Mar 2015 17:30:40 +0100 Subject: [PATCH 21/33] added django-extensions --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index d1eaf32..ba4071b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ django>=1.6.0 +django-tastypie +django-extensions==1.5.1 From bfb5ee9c1229dc64dcd6204a2062a5e342d8893c Mon Sep 17 00:00:00 2001 From: miraculixx Date: Wed, 18 Mar 2015 17:32:27 +0100 Subject: [PATCH 22/33] various fixes, add code field * votes on anonymous polls don't store user * choices add a mnemonic code field for easier choice reference --- polls/api.py | 61 +++++++++---- .../0008_auto__add_field_choice_code.py | 90 +++++++++++++++++++ polls/models.py | 44 +++++++-- 3 files changed, 167 insertions(+), 28 deletions(-) create mode 100644 polls/migrations/0008_auto__add_field_choice_code.py diff --git a/polls/api.py b/polls/api.py index b45a540..2ca1419 100644 --- a/polls/api.py +++ b/polls/api.py @@ -62,7 +62,7 @@ class Meta: class PollResource(NamespacedModelResource): # POST, GET, PUT - #user = fields.ForeignKey(UserResource, 'user') + # user = fields.ForeignKey(UserResource, 'user') def obj_create(self, bundle, **kwargs): return super(PollResource, self).obj_create(bundle, user=bundle.request.user) @@ -78,15 +78,15 @@ def alter_detail_data_to_serialize(self, request, data): def prepend_urls(self): """ match by pk or reference """ return [ - url(r"^(?P%s)/(?P[0-9]+)/$" % self._meta.resource_name, + url(r"^(?P%s)/(?P[0-9]+)/$" % self._meta.resource_name, self.wrap_view('dispatch_detail'), name="api_dispatch_detail"), - url(r"^(?P%s)/(?P[\w-]+)/$" % self._meta.resource_name, + url(r"^(?P%s)/(?P[\w-]+)/$" % self._meta.resource_name, self.wrap_view('dispatch_detail'), name="api_dispatch_detail"), ] class Meta: queryset = Poll.objects.all() - allowed_methods = ['get','post','put'] + allowed_methods = ['get', 'post', 'put'] resource_name = 'poll' always_return_data = True authentication = MultiAuthentication(BasicAuthentication(), SessionAuthentication()) @@ -98,35 +98,53 @@ class ChoiceResource(NamespacedModelResource): class Meta: queryset = Choice.objects.all() - allowed_methods = ['post','put'] + allowed_methods = ['post', 'put'] authentication = MultiAuthentication(BasicAuthentication(), SessionAuthentication()) authorization = Authorization() resource_name = 'choice' always_return_data = True -class VoteResource(ModelResource): - user = fields.ToOneField(UserResource, 'user') - choice = fields.ToOneField(ChoiceResource, 'choice') - poll = fields.ToOneField(PollResource, 'poll') +class VoteResource(NamespacedModelResource): + user = fields.ToOneField(UserResource, 'user', blank=True, null=True, readonly=True) + choice = fields.ToOneField(ChoiceResource, 'choice', readonly=True) + poll = fields.ToOneField(PollResource, 'poll', readonly=True) + + def dispatch(self, *args, **kwargs): + request = args[1] + return super(VoteResource, self).dispatch(*args, **kwargs) def obj_create(self, bundle, **kwargs): poll = PollResource().get_via_uri(bundle.data.get('poll')) if not poll.already_voted(bundle.request.user): try: - poll.vote(choices=bundle.data.get('choice'), user=bundle.request.user) - raise ImmediateHttpResponse(response=http.HttpCreated()) + votes = poll.vote(choices=bundle.data.get('choice'), + data=bundle.data.get('data'), + user=bundle.request.user) except (PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple): raise ImmediateHttpResponse(response=http.HttpForbidden('not allowed')) + else: + bundle.obj = votes[0] else: raise ImmediateHttpResponse(response=http.HttpForbidden('already voted')) - - + return bundle + + def obj_update(self, bundle, **kwargs): + poll = PollResource().get_via_uri(bundle.data.get('poll')) + # non anonymous votes by the same user can be modified + if not poll.is_anonymous and bundle.obj.user == bundle.request.user: + bundle.obj.change_vote(choices=bundle.data.get('choice'), + data=bundle.data.get('data'), + user=bundle.request.user) + else: + raise ImmediateHttpResponse(response=http.HttpForbidden('already voted')) + + class Meta: queryset = Vote.objects.all() - allowed_methods = ['post'] + allowed_methods = ['post', 'put'] authentication = MultiAuthentication(BasicAuthentication(), SessionAuthentication()) - #authorization = Authorization() + # authorization = Authorization() resource_name = 'vote' always_return_data = True @@ -135,16 +153,21 @@ class ResultResource(NamespacedModelResource): def prepend_urls(self): """ match by pk or reference """ return [ - url(r"^(?P%s)/(?P[0-9]+)/$" % self._meta.resource_name, + url(r"^(?P%s)/(?P[0-9]+)/$" % self._meta.resource_name, self.wrap_view('dispatch_detail'), name="api_dispatch_detail"), - url(r"^(?P%s)/(?P[\w-]+)/$" % self._meta.resource_name, + url(r"^(?P%s)/(?P[\w-]+)/$" % self._meta.resource_name, self.wrap_view('dispatch_detail'), name="api_dispatch_detail"), ] def dehydrate(self, bundle): - percentage = bundle.obj.count_percentage() + poll = bundle.obj + percentage = poll.count_percentage() + count = poll.count_total_votes() + # FIXME order of labels must be guaranteed to be the same as order stats + # since we have different db queries here, in Polls, Choices this is + # not guaranteed. solution: implement consistent Polls.get_stats() labels = [choice.choice for choice in Choice.objects.filter(poll=bundle.data['id'])] - bundle.data['stats'] = dict(values=percentage, labels=labels, votes=len(labels)) + bundle.data['stats'] = dict(values=percentage, labels=labels, votes=count) return bundle class Meta: diff --git a/polls/migrations/0008_auto__add_field_choice_code.py b/polls/migrations/0008_auto__add_field_choice_code.py new file mode 100644 index 0000000..bbd834d --- /dev/null +++ b/polls/migrations/0008_auto__add_field_choice_code.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models +from uuid import UUID + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'Choice.code' + db.add_column(u'polls_choice', 'code', + self.gf('django.db.models.fields.CharField')(default='', max_length=36, blank=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'Choice.code' + db.delete_column(u'polls_choice', 'code') + + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'polls.choice': { + 'Meta': {'object_name': 'Choice'}, + 'choice': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'code': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '36', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poll': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Poll']"}) + }, + u'polls.poll': { + 'Meta': {'ordering': "['-start_votes']", 'object_name': 'Poll'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'end_votes': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 3, 21, 0, 0)'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_anonymous': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_multiple': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'question': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'reference': ('django.db.models.fields.CharField', [], {'default': "UUID('5e0cdc46-d6fe-43fd-9849-b66ec15ecdb6')", 'unique': 'True', 'max_length': '36'}), + 'start_votes': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}) + }, + u'polls.vote': { + 'Meta': {'unique_together': "(('user', 'poll', 'choice'),)", 'object_name': 'Vote'}, + 'choice': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Choice']"}), + 'comment': ('django.db.models.fields.TextField', [], {'max_length': '144', 'null': 'True', 'blank': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'data': ('django.db.models.fields.TextField', [], {'default': "'{}'", 'null': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poll': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Poll']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']", 'null': 'True', 'blank': 'True'}) + } + } + + complete_apps = ['polls'] \ No newline at end of file diff --git a/polls/models.py b/polls/models.py index 82170d6..ab5c3bf 100644 --- a/polls/models.py +++ b/polls/models.py @@ -5,6 +5,7 @@ from django.contrib.auth.models import User from django.db import models from django.utils import timezone +from django.utils.text import slugify from django.utils.translation import ugettext_lazy as _ from django_extensions.db.fields.json import JSONField @@ -20,7 +21,7 @@ class Poll(models.Model): end_votes = models.DateTimeField(default=(lambda: timezone.now()+timedelta(days=5)), help_text=_('The latest time votes get accepted')) - def vote(self, choices, user=None): + def vote(self, choices, user=None, data=None): current_time = timezone.now() if self.is_closed: raise PollClosed @@ -31,9 +32,27 @@ def vote(self, choices, user=None): if len(choices) > 1 and not self.is_multiple: raise PollNotMultiple # if self.is_anonymous: user = None # pass None, even though user is authenticated + votes = [] for choice_id in choices: - choice = Choice.objects.get(pk=choice_id) - Vote.objects.create(poll=self, user=user, choice=choice) + if isinstance(choice_id, int) or choice_id.isdigit(): + query = dict(pk=choice_id) + else: + query = dict(code=choice_id) + choice = Choice.objects.get(**query) + if self.is_anonymous: + user = None + vote = Vote.objects.create(poll=self, user=user, choice=choice, data=data) + votes.append(vote) + return votes + + def change_vote(self, choices, user=None, data=None): + """ + this deletes all previous votes of the user and revotes with + new choices. + """ + votes = self.vote_set.filter(user=user).delete() + self.vote(choices, user=user, data=data) + return votes def count_choices(self): return self.choice_set.count() @@ -48,12 +67,11 @@ def count_percentage(self): def count_total_votes(self): result = 0 - for choice in self.choice_set.all(): - result += choice.count_votes() - return result + votes = sum((choice.count_votes() for choice in self.choice_set.all())) + return votes def already_voted(self, user): - if not user.is_anonymous(): + if not self.is_anonymous: return self.vote_set.filter(user=user).exists() else: return False @@ -66,16 +84,24 @@ class Meta: class Choice(models.Model): + #: poll reference poll = models.ForeignKey(Poll) + #: label field choice = models.CharField(max_length=255) + #: code as an alternative to id + code = models.CharField(max_length=36, default='', blank=True) def count_votes(self): return self.vote_set.count() def __unicode__(self): return self.choice - - + + def save(self, *args, **kwargs): + if not self.code: + self.code = slugify(self.choice) + super(Choice, self).save(*args, **kwargs) + class Vote(models.Model): user = models.ForeignKey(User, blank=True, null=True) poll = models.ForeignKey(Poll) From 77985344f7e7e2067bde1e0dd759b480a64e9556 Mon Sep 17 00:00:00 2001 From: miraculixx Date: Wed, 18 Mar 2015 17:33:54 +0100 Subject: [PATCH 23/33] add more unit tests, fix some problems * new method get_stats to keep labels and values in sync on /result * tests for invalid votes * tests with data on votes * indexing on votes --- polls/api.py | 31 ++++--- polls/exceptions.py | 2 + .../0009_auto__add_unique_choice_poll_code.py | 88 +++++++++++++++++++ polls/models.py | 70 ++++++++++++--- polls/test/test_api.py | 65 ++++++++++++-- polls/test/test_models.py | 33 +++++-- polls/urls.py | 6 +- 7 files changed, 253 insertions(+), 42 deletions(-) create mode 100644 polls/migrations/0009_auto__add_unique_choice_poll_code.py diff --git a/polls/api.py b/polls/api.py index 2ca1419..e42d808 100644 --- a/polls/api.py +++ b/polls/api.py @@ -12,6 +12,7 @@ ''' from exceptions import PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple +import json from django.conf.urls import url from django.contrib.auth import get_user_model @@ -19,11 +20,13 @@ from django.forms.models import model_to_dict from tastypie import fields from tastypie import http -from tastypie.authentication import MultiAuthentication, BasicAuthentication, SessionAuthentication +from tastypie.authentication import MultiAuthentication, BasicAuthentication, SessionAuthentication, \ + Authentication from tastypie.authorization import Authorization, ReadOnlyAuthorization from tastypie.exceptions import ImmediateHttpResponse from tastypie.resources import ModelResource, ALL, NamespacedModelResource +from polls.exceptions import PollInvalidChoice from polls.models import Poll, Choice, Vote @@ -110,10 +113,6 @@ class VoteResource(NamespacedModelResource): choice = fields.ToOneField(ChoiceResource, 'choice', readonly=True) poll = fields.ToOneField(PollResource, 'poll', readonly=True) - def dispatch(self, *args, **kwargs): - request = args[1] - return super(VoteResource, self).dispatch(*args, **kwargs) - def obj_create(self, bundle, **kwargs): poll = PollResource().get_via_uri(bundle.data.get('poll')) if not poll.already_voted(bundle.request.user): @@ -123,6 +122,8 @@ def obj_create(self, bundle, **kwargs): user=bundle.request.user) except (PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple): raise ImmediateHttpResponse(response=http.HttpForbidden('not allowed')) + except PollInvalidChoice: + raise ImmediateHttpResponse(response=http.HttpBadRequest('invalid data')) else: bundle.obj = votes[0] else: @@ -138,12 +139,20 @@ def obj_update(self, bundle, **kwargs): user=bundle.request.user) else: raise ImmediateHttpResponse(response=http.HttpForbidden('already voted')) - + + def dehydrate(self, bundle): + # convert JSON Field + bundle = super(VoteResource, self).dehydrate(bundle) + bundle.data['data'] = json.dumps(bundle.obj.data) + return bundle class Meta: queryset = Vote.objects.all() allowed_methods = ['post', 'put'] - authentication = MultiAuthentication(BasicAuthentication(), SessionAuthentication()) + # by default require authentication but regress for anonymous votes + authentication = MultiAuthentication(BasicAuthentication(), + SessionAuthentication(), + Authentication()) # authorization = Authorization() resource_name = 'vote' always_return_data = True @@ -161,13 +170,7 @@ def prepend_urls(self): def dehydrate(self, bundle): poll = bundle.obj - percentage = poll.count_percentage() - count = poll.count_total_votes() - # FIXME order of labels must be guaranteed to be the same as order stats - # since we have different db queries here, in Polls, Choices this is - # not guaranteed. solution: implement consistent Polls.get_stats() - labels = [choice.choice for choice in Choice.objects.filter(poll=bundle.data['id'])] - bundle.data['stats'] = dict(values=percentage, labels=labels, votes=count) + bundle.data['stats'] = poll.get_stats() return bundle class Meta: diff --git a/polls/exceptions.py b/polls/exceptions.py index efc9912..74c2aa3 100644 --- a/polls/exceptions.py +++ b/polls/exceptions.py @@ -2,3 +2,5 @@ class PollNotOpen(Exception): pass class PollClosed(Exception): pass class PollNotAnonymous(Exception): pass class PollNotMultiple(Exception): pass +class PollChoiceRequired(Exception): pass +class PollInvalidChoice(Exception): pass diff --git a/polls/migrations/0009_auto__add_unique_choice_poll_code.py b/polls/migrations/0009_auto__add_unique_choice_poll_code.py new file mode 100644 index 0000000..bb484bd --- /dev/null +++ b/polls/migrations/0009_auto__add_unique_choice_poll_code.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models +from uuid import UUID + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding unique constraint on 'Choice', fields ['poll', 'code'] + db.create_unique(u'polls_choice', ['poll_id', 'code']) + + + def backwards(self, orm): + # Removing unique constraint on 'Choice', fields ['poll', 'code'] + db.delete_unique(u'polls_choice', ['poll_id', 'code']) + + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'polls.choice': { + 'Meta': {'unique_together': "(('poll', 'code'),)", 'object_name': 'Choice'}, + 'choice': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'code': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '36', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poll': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Poll']"}) + }, + u'polls.poll': { + 'Meta': {'ordering': "['-start_votes']", 'object_name': 'Poll'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'end_votes': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 3, 23, 0, 0)'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_anonymous': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_multiple': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'question': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'reference': ('django.db.models.fields.CharField', [], {'default': "UUID('bdc7cb0d-e3a6-4e0e-8211-bdda001c68ba')", 'unique': 'True', 'max_length': '36'}), + 'start_votes': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}) + }, + u'polls.vote': { + 'Meta': {'unique_together': "(('user', 'poll', 'choice'),)", 'object_name': 'Vote'}, + 'choice': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Choice']"}), + 'comment': ('django.db.models.fields.TextField', [], {'max_length': '144', 'null': 'True', 'blank': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'data': ('django.db.models.fields.TextField', [], {'default': "'{}'", 'null': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poll': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['polls.Poll']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']", 'null': 'True', 'blank': 'True'}) + } + } + + complete_apps = ['polls'] \ No newline at end of file diff --git a/polls/models.py b/polls/models.py index ab5c3bf..af41949 100644 --- a/polls/models.py +++ b/polls/models.py @@ -9,6 +9,8 @@ from django.utils.translation import ugettext_lazy as _ from django_extensions.db.fields.json import JSONField +from polls.exceptions import PollChoiceRequired, PollInvalidChoice + class Poll(models.Model): question = models.CharField(max_length=255) @@ -29,16 +31,23 @@ def vote(self, choices, user=None, data=None): raise PollNotOpen if user is None and not self.is_anonymous: raise PollNotAnonymous + if not choices: + raise PollInvalidChoice if len(choices) > 1 and not self.is_multiple: raise PollNotMultiple + if len(choices) == 0: + raise PollChoiceRequired # if self.is_anonymous: user = None # pass None, even though user is authenticated votes = [] for choice_id in choices: if isinstance(choice_id, int) or choice_id.isdigit(): query = dict(pk=choice_id) else: - query = dict(code=choice_id) - choice = Choice.objects.get(**query) + query = dict(poll=self, code=choice_id) + try: + choice = Choice.objects.get(**query) + except: + raise PollInvalidChoice if self.is_anonymous: user = None vote = Vote.objects.create(poll=self, user=user, choice=choice, data=data) @@ -57,21 +66,55 @@ def change_vote(self, choices, user=None, data=None): def count_choices(self): return self.choice_set.count() - def count_percentage(self): - votes = [choice.count_votes() for choice in self.choice_set.all()] - total_votes = sum(votes) - if total_votes is 0: - return [0.0 for vote in votes] - else: - return [float(vote)/total_votes for vote in votes] + def count_percentage(self, as_code=False): + """ + return a dict of choices and percentages + { + : percentage + (...) + } + """ + total_votes = self.count_total_votes() + stats = {} + for choice in self.choice_set.all(): + key = choice.code if as_code else choice + stats[key] = float(choice.count_votes()) / total_votes + return stats def count_total_votes(self): - result = 0 votes = sum((choice.count_votes() for choice in self.choice_set.all())) return votes + + def get_stats(self): + """ + return a statistics object + + returns a dict of + { + labels : [choice, ...], + codes : [code, ...], + percentage : [%, ...], + } + """ + # get statistics as dict of { choice : pct } + stats = self.count_percentage() + # convert to same indexed labels, codes, percentages + labels = [] + codes = [] + percentage = [] + for c,p in stats.iteritems(): + labels.append(c.choice) + codes.append(c.code) + percentage.append(p) + count = self.count_total_votes() + stats = dict(values=percentage, codes=codes, + labels=labels, votes=count) + return stats def already_voted(self, user): if not self.is_anonymous: + if user.is_anonymous(): + raise PollNotAnonymous return self.vote_set.filter(user=user).exists() else: return False @@ -99,8 +142,12 @@ def __unicode__(self): def save(self, *args, **kwargs): if not self.code: - self.code = slugify(self.choice) + self.code = slugify(unicode(self.choice)) super(Choice, self).save(*args, **kwargs) + + class Meta: + unique_together = (('poll', 'code'),) + ordering = ['poll', 'choice'] class Vote(models.Model): user = models.ForeignKey(User, blank=True, null=True) @@ -115,3 +162,4 @@ def __unicode__(self): class Meta: unique_together = ('user', 'poll', 'choice') + ordering = ['poll', 'choice'] diff --git a/polls/test/test_api.py b/polls/test/test_api.py index 32b27cb..e3dcb2e 100644 --- a/polls/test/test_api.py +++ b/polls/test/test_api.py @@ -1,12 +1,16 @@ +from datetime import timedelta +import json import logging import uuid -from datetime import timedelta + from django.contrib.auth.models import Permission, User from django.utils import timezone -from tastypie.utils import make_naive from tastypie.test import ResourceTestCase +from tastypie.utils import make_naive + from polls.models import Poll, Choice + logger = logging.getLogger(__name__) URL = '/api/v1' # or '/polls/api/v1' in the case when we don't set 'urls' attribute @@ -99,7 +103,11 @@ def test_voting(self): resp = self.api_client.get(self.getURL('result', id=pk), format='json', authentication=self.get_credentials()) deserialized = self.deserialize(resp) - self.assertEqual(deserialized['stats']['values'], [0.0, 1.0, 0.0]) + self.assertDictEqual(deserialized['stats'], + {u'labels': [u'choice0', u'choice1', u'choice2'], + u'votes': 1, + u'codes': [u'choice0', u'choice1', u'choice2'], + u'values': [0.0, 1.0, 0.0]}) def test_anonymous_voting(self): poll_data = self.poll_data(anonymous=True) @@ -111,7 +119,54 @@ def test_anonymous_voting(self): vote_data = self.vote_data(poll_id=1, choices=[1]) resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') self.assertHttpCreated(resp) - + + def test_voting_with_data(self): + poll_data = self.poll_data(anonymous=True) + resp = self.create_poll(poll_data) + self.assertHttpCreated(resp) + pk = Poll.objects.order_by('-id')[0].pk + choice_data = self.choice_data(poll_id=pk) + self.create_choices(choice_data, quantity=3) + vote_data = self.vote_data(poll_id=1, choices=[1]) + vote_data['data'] = { + 'foo' : 'bar' + } + resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') + self.assertHttpCreated(resp) + rdata = json.loads(self.deserialize(resp)['data']) + self.assertDictEqual(rdata, vote_data['data']) + + def test_voting_no_choice(self): + poll_data = self.poll_data(anonymous=True) + resp = self.create_poll(poll_data) + self.assertHttpCreated(resp) + pk = Poll.objects.order_by('-id')[0].pk + choice_data = self.choice_data(poll_id=pk) + self.create_choices(choice_data, quantity=3) + vote_data = self.vote_data(poll_id=1, choices=[1]) + del vote_data['choice'] + resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') + self.assertHttpBadRequest(resp) + vote_data['choice'] = None + resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') + self.assertHttpBadRequest(resp) + + def test_voting_by_code(self): + poll_data = self.poll_data(anonymous=True) + resp = self.create_poll(poll_data) + self.assertHttpCreated(resp) + pk = Poll.objects.order_by('-id')[0].pk + choice_data = self.choice_data(poll_id=pk) + self.create_choices(choice_data, quantity=3) + vote_data = self.vote_data(poll_id=1, choices=[1]) + # invalid choice + vote_data['choice'] = ['xchoice'] + resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') + self.assertHttpBadRequest(resp) + vote_data['choice'] = ['choice1'] + resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') + self.assertHttpCreated(resp) + def create_poll(self, poll_data): return self.api_client.post(self.getURL('poll'), format='json', data=poll_data, authentication=self.get_credentials()) @@ -126,7 +181,7 @@ def create_choices(self, choice_data, quantity): resp = self.api_client.post(self.getURL('choice'), format='json', data=choice_data, authentication=self.get_credentials()) self.assertHttpCreated(resp) - + def poll_data(self, anonymous=False, multiple=False, closed=False): now = timezone.now() # delete microseconds diff --git a/polls/test/test_models.py b/polls/test/test_models.py index a28c2e7..f2f5315 100644 --- a/polls/test/test_models.py +++ b/polls/test/test_models.py @@ -11,7 +11,7 @@ from django.contrib.auth.models import User from django.core.urlresolvers import reverse from polls.models import Poll, Choice, Vote -from polls.exceptions import PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple +from polls.exceptions import PollNotAnonymous, PollNotMultiple logger = logging.getLogger(__name__) @@ -97,19 +97,24 @@ def test_single_vote_stat_1(self): poll, cids = create_poll_single() poll.vote([cids[0]], self.user1) poll.vote([cids[1]], self.user2) - self.assertEqual(poll.count_percentage(), [0.5, 0.5, 0]) + self.assertDictEqual(poll.count_percentage(True), + {u'i-am-fine': 0.5, u'so-so': 0.5, u'bad': 0.0}) def test_single_vote_stat_2(self): poll, cids = create_poll_single() poll.vote([cids[0]], self.user1) - self.assertEqual(poll.count_percentage(), [1.0, 0, 0]) + self.assertDictEqual(poll.count_percentage(True), + {u'i-am-fine': 1.0, u'so-so': 0.0, u'bad': 0.0}) def test_single_vote_stat_3(self): poll, cids = create_poll_single() poll.vote([cids[0]], self.user1) poll.vote([cids[1]], self.user2) poll.vote([cids[2]], self.user3) - self.assertEqual(poll.count_percentage(), [1.0/3, 1.0/3, 1.0/3]) + self.assertEqual(poll.count_percentage(True), + {u'i-am-fine': 0.3333333333333333, + u'so-so': 0.3333333333333333, + u'bad': 0.3333333333333333}) def test_multiple_vote(self): poll, cids = create_poll_multiple() @@ -119,9 +124,13 @@ def test_multiple_vote(self): def test_multiple_vote_stat(self): poll, cids = create_poll_multiple() poll.vote([cids[0],cids[1]], self.user1) - self.assertEqual(poll.count_percentage(), [0.5, 0.5, 0.0, 0.0, 0.0]) + self.assertDictEqual(poll.count_percentage(True), + {u'german': 0.0, u'japanese': 0.0, u'french': 0.5, + u'chinese': 0.0, u'english': 0.5}) poll.vote([cids[2],cids[3],cids[4]], self.user2) - self.assertEqual(poll.count_percentage(), [0.2, 0.2, 0.2, 0.2, 0.2]) + self.assertDictEqual(poll.count_percentage(True), + {u'german': 0.2, u'japanese': 0.2, + u'french': 0.2, u'chinese': 0.2, u'english': 0.2}) def test_anonymous_single_vote(self): poll, cids = create_poll_anonymous_single() @@ -133,10 +142,14 @@ def test_anonymous_single_vote(self): def test_anonymous_single_vote_stat(self): poll, cids = create_poll_anonymous_single() poll.vote([cids[0]]) - self.assertEqual(poll.count_percentage(), [1.0, 0, 0]) + self.assertDictEqual(poll.count_percentage(True), + {u'yes': 1.0, u'nobody-knows': 0.0, u'no': 0.0}) poll.vote([cids[1]], self.user1) poll.vote([cids[2]], self.user2) - self.assertEqual(poll.count_percentage(), [1.0/3, 1.0/3, 1.0/3]) + self.assertDictEqual(poll.count_percentage(True), + {u'yes': 0.3333333333333333, + u'nobody-knows': 0.3333333333333333, + u'no': 0.3333333333333333}) def test_anonymous_multiple_vote(self): poll, cids = create_poll_anonymous_multiple() @@ -148,7 +161,9 @@ def test_anonymous_multiple_vote(self): def test_anonymous_multiple_vote_stat(self): poll, cids = create_poll_anonymous_multiple() poll.vote([cids[1]]) - self.assertEqual(poll.count_percentage(), [0.0, 1.0, 0.0, 0.0, 0.0]) + self.assertDictEqual(poll.count_percentage(True), + {u'vegetables': 0.0, u'fruits': 0.0, + u'milk': 1.0, u'meat': 0.0, u'chocolate': 0.0}) # for authenticated users, only one vote allowed def create_poll_single(): diff --git a/polls/urls.py b/polls/urls.py index 3fbee5f..d4739b2 100644 --- a/polls/urls.py +++ b/polls/urls.py @@ -13,8 +13,8 @@ v1_api.register(ResultResource()) urlpatterns = patterns('', - #url(r'^$', PollListView.as_view(), name='list'), + url(r'^$', PollListView.as_view(), name='list'), url(r'^api/', include(v1_api.urls)), - #url(r'^(?P\d+)/$', PollDetailView.as_view(), name='detail'), - #url(r'^(?P\d+)/vote/$', login_required(PollVoteView.as_view()), name='vote'), + url(r'^(?P\d+)/$', PollDetailView.as_view(), name='detail'), + url(r'^(?P\d+)/vote/$', login_required(PollVoteView.as_view()), name='vote'), ) From b2e0904b65af0f1a87f22b9cb037ca01597ba4b1 Mon Sep 17 00:00:00 2001 From: miraculixx Date: Wed, 18 Mar 2015 17:45:31 +0100 Subject: [PATCH 24/33] bump version --- polls/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/polls/__init__.py b/polls/__init__.py index b794fd4..020ed73 100644 --- a/polls/__init__.py +++ b/polls/__init__.py @@ -1 +1 @@ -__version__ = '0.1.0' +__version__ = '0.2.2' From 0ffd10ecc3072d67041f59debe098ccdb2e48bda Mon Sep 17 00:00:00 2001 From: miraculixx Date: Wed, 18 Mar 2015 18:24:41 +0100 Subject: [PATCH 25/33] set django dependency --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ef4e997..9720e28 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ def read(fname): packages=find_packages(), include_package_data=True, install_requires=[ - 'Django', + 'Django>=1.6', 'django-extensions==1.3.11', 'django-tastypie==0.12.1', ], From 6892874f22204c5797e5d9aad45882d04440bc47 Mon Sep 17 00:00:00 2001 From: miraculixx Date: Sun, 3 May 2015 23:09:55 +0200 Subject: [PATCH 26/33] add comment if it is submitted... --- polls/api.py | 3 ++- polls/models.py | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/polls/api.py b/polls/api.py index e42d808..6fab210 100644 --- a/polls/api.py +++ b/polls/api.py @@ -119,7 +119,8 @@ def obj_create(self, bundle, **kwargs): try: votes = poll.vote(choices=bundle.data.get('choice'), data=bundle.data.get('data'), - user=bundle.request.user) + user=bundle.request.user, + comment=bundle.data.get('comment')) except (PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple): raise ImmediateHttpResponse(response=http.HttpForbidden('not allowed')) except PollInvalidChoice: diff --git a/polls/models.py b/polls/models.py index af41949..d063939 100644 --- a/polls/models.py +++ b/polls/models.py @@ -23,7 +23,7 @@ class Poll(models.Model): end_votes = models.DateTimeField(default=(lambda: timezone.now()+timedelta(days=5)), help_text=_('The latest time votes get accepted')) - def vote(self, choices, user=None, data=None): + def vote(self, choices, user=None, data=None, comment=None): current_time = timezone.now() if self.is_closed: raise PollClosed @@ -50,7 +50,9 @@ def vote(self, choices, user=None, data=None): raise PollInvalidChoice if self.is_anonymous: user = None - vote = Vote.objects.create(poll=self, user=user, choice=choice, data=data) + vote = Vote.objects.create(poll=self, user=user, + choice=choice, data=data, + comment=comment) votes.append(vote) return votes From 8c91979ce408821484a389e109fc2411612834f3 Mon Sep 17 00:00:00 2001 From: miraculixx Date: Sun, 3 May 2015 23:18:46 +0200 Subject: [PATCH 27/33] add test for comment in api --- polls/test/test_api.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/polls/test/test_api.py b/polls/test/test_api.py index e3dcb2e..3a2f9f9 100644 --- a/polls/test/test_api.py +++ b/polls/test/test_api.py @@ -136,6 +136,26 @@ def test_voting_with_data(self): rdata = json.loads(self.deserialize(resp)['data']) self.assertDictEqual(rdata, vote_data['data']) + + def test_voting_with_comment(self): + poll_data = self.poll_data(anonymous=True) + resp = self.create_poll(poll_data) + self.assertHttpCreated(resp) + pk = Poll.objects.order_by('-id')[0].pk + choice_data = self.choice_data(poll_id=pk) + self.create_choices(choice_data, quantity=3) + vote_data = self.vote_data(poll_id=1, choices=[1]) + vote_data['data'] = { + 'foo' : 'bar', + } + vote_data['comment'] = 'this is a comment' + resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') + self.assertHttpCreated(resp) + rdata = json.loads(self.deserialize(resp)['data']) + rcomment = self.deserialize(resp)['comment'] + self.assertDictEqual(rdata, vote_data['data']) + self.assertEqual(rcomment, vote_data['comment']) + def test_voting_no_choice(self): poll_data = self.poll_data(anonymous=True) resp = self.create_poll(poll_data) From 59d9e778456b5e8ef3f93131935366aae42b932f Mon Sep 17 00:00:00 2001 From: miraculixx Date: Sun, 3 May 2015 23:18:51 +0200 Subject: [PATCH 28/33] bump version --- polls/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/polls/__init__.py b/polls/__init__.py index 020ed73..550f5d0 100644 --- a/polls/__init__.py +++ b/polls/__init__.py @@ -1 +1 @@ -__version__ = '0.2.2' +__version__ = '0.2.3dev' From 8333ab72617edb61a3b766d7ad030b5001dab7b6 Mon Sep 17 00:00:00 2001 From: miraculixx Date: Tue, 29 Mar 2016 01:04:37 +0200 Subject: [PATCH 29/33] migrate to django 1.7, fixed bugs, increased stability fixes: - adopt authorization to new tastypie logic (DjangoAuthorization, default ReadOnlyAuthorization) - limit anonymous voting by ip or clientid in cookie --- .project | 17 ++ .pydevproject | 8 + .settings/org.eclipse.core.resources.prefs | 11 ++ polls/api.py | 155 ++++++++++-------- polls/migrations/0001_initial.py | 116 ++++++++----- polls/models.py | 64 +++++--- polls/south_migrations/0001_initial.py | 49 ++++++ ...o__add_vote__add_field_poll_description.py | 0 .../0003_auto__add_unique_vote_poll_user.py | 0 ...dd_field_vote_datetime__add_field_vote_.py | 0 ...ld_vote_user__del_unique_vote_user_poll.py | 0 ...d_vote_datetime__add_field_vote_created.py | 0 ..._choice__chg_field_poll_reference__add_.py | 0 .../0008_auto__add_field_choice_code.py | 0 .../0009_auto__add_unique_choice_poll_code.py | 0 polls/south_migrations/__init__.py | 0 polls/test/test_api.py | 124 +++++++++----- polls/util.py | 110 +++++++++++++ setup.py | 6 +- 19 files changed, 485 insertions(+), 175 deletions(-) create mode 100644 .project create mode 100644 .pydevproject create mode 100644 .settings/org.eclipse.core.resources.prefs create mode 100644 polls/south_migrations/0001_initial.py rename polls/{migrations => south_migrations}/0002_auto__add_vote__add_field_poll_description.py (100%) rename polls/{migrations => south_migrations}/0003_auto__add_unique_vote_poll_user.py (100%) rename polls/{migrations => south_migrations}/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py (100%) rename polls/{migrations => south_migrations}/0005_auto__chg_field_vote_user__del_unique_vote_user_poll.py (100%) rename polls/{migrations => south_migrations}/0006_auto__del_field_vote_datetime__add_field_vote_created.py (100%) rename polls/{migrations => south_migrations}/0007_auto__add_unique_vote_user_poll_choice__chg_field_poll_reference__add_.py (100%) rename polls/{migrations => south_migrations}/0008_auto__add_field_choice_code.py (100%) rename polls/{migrations => south_migrations}/0009_auto__add_unique_choice_poll_code.py (100%) create mode 100644 polls/south_migrations/__init__.py create mode 100644 polls/util.py diff --git a/.project b/.project new file mode 100644 index 0000000..b1c4236 --- /dev/null +++ b/.project @@ -0,0 +1,17 @@ + + + django-polls + + + + + + org.python.pydev.PyDevBuilder + + + + + + org.python.pydev.pythonNature + + diff --git a/.pydevproject b/.pydevproject new file mode 100644 index 0000000..3d64837 --- /dev/null +++ b/.pydevproject @@ -0,0 +1,8 @@ + + + +/${PROJECT_DIR_NAME} + +python 2.7 +django-polls + diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 0000000..385eb37 --- /dev/null +++ b/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,11 @@ +eclipse.preferences.version=1 +encoding//polls/migrations/0001_initial.py=utf-8 +encoding//polls/south_migrations/0001_initial.py=utf-8 +encoding//polls/south_migrations/0002_auto__add_vote__add_field_poll_description.py=utf-8 +encoding//polls/south_migrations/0003_auto__add_unique_vote_poll_user.py=utf-8 +encoding//polls/south_migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py=utf-8 +encoding//polls/south_migrations/0005_auto__chg_field_vote_user__del_unique_vote_user_poll.py=utf-8 +encoding//polls/south_migrations/0006_auto__del_field_vote_datetime__add_field_vote_created.py=utf-8 +encoding//polls/south_migrations/0007_auto__add_unique_vote_user_poll_choice__chg_field_poll_reference__add_.py=utf-8 +encoding//polls/south_migrations/0008_auto__add_field_choice_code.py=utf-8 +encoding//polls/south_migrations/0009_auto__add_unique_choice_poll_code.py=utf-8 diff --git a/polls/api.py b/polls/api.py index 6fab210..41f4511 100644 --- a/polls/api.py +++ b/polls/api.py @@ -22,15 +22,32 @@ from tastypie import http from tastypie.authentication import MultiAuthentication, BasicAuthentication, SessionAuthentication, \ Authentication -from tastypie.authorization import Authorization, ReadOnlyAuthorization +from tastypie.authorization import Authorization, \ + DjangoAuthorization from tastypie.exceptions import ImmediateHttpResponse -from tastypie.resources import ModelResource, ALL, NamespacedModelResource +from tastypie.resources import ALL, NamespacedModelResource from polls.exceptions import PollInvalidChoice from polls.models import Poll, Choice, Vote +from polls.util import ReasonableDjangoAuthorization, IPAuthentication class UserResource(NamespacedModelResource): + + class Meta: + queryset = get_user_model().objects.all() + allowed_methods = ['get'] + resource_name = 'user' + always_return_data = True + authentication = MultiAuthentication( + BasicAuthentication(), SessionAuthentication()) + authorization = ReasonableDjangoAuthorization(read_detail='') + excludes = ['date_joined', 'password', 'is_superuser', + 'is_staff', 'is_active', 'last_login', 'first_name', 'last_name'] + filtering = { + 'username': ALL, + } + def limit_list_by_user(self, request, object_list): """ limit the request object list to its own profile, except @@ -49,37 +66,38 @@ def get_object_list(self, request): object_list = super(UserResource, self).get_object_list(request) object_list = self.limit_list_by_user(request, object_list) return object_list - - class Meta: - queryset = get_user_model().objects.all() - allowed_methods = ['get'] - resource_name = 'user' - always_return_data = True - authentication = MultiAuthentication(BasicAuthentication(), SessionAuthentication()) - authorization = ReadOnlyAuthorization() - excludes = ['date_joined', 'password', 'is_superuser', 'is_staff', 'is_active', 'last_login', 'first_name', 'last_name'] - filtering = { - 'username': ALL, - } class PollResource(NamespacedModelResource): # POST, GET, PUT # user = fields.ForeignKey(UserResource, 'user') + + class Meta: + queryset = Poll.objects.all() + allowed_methods = ['get', 'post', 'put'] + resource_name = 'poll' + always_return_data = True + # anyone can list and get polls, otherwise Django auth kicks in + authentication = MultiAuthentication( + BasicAuthentication(), SessionAuthentication(), Authentication()) + authorization = ReasonableDjangoAuthorization(read_list='', + read_detail='') + def obj_create(self, bundle, **kwargs): return super(PollResource, self).obj_create(bundle, user=bundle.request.user) - + def dehydrate(self, bundle): choices = Choice.objects.filter(poll=bundle.data['id']) bundle.data['choices'] = [model_to_dict(choice) for choice in choices] return bundle def alter_detail_data_to_serialize(self, request, data): - data.data['already_voted'] = Poll.objects.get(pk=data.data.get('id')).already_voted(user=request.user) + data.data['already_voted'] = Poll.objects.get( + pk=data.data.get('id')).already_voted(user=request.user) return data - + def prepend_urls(self): - """ match by pk or reference """ + """ match by pk or reference """ return [ url(r"^(?P%s)/(?P[0-9]+)/$" % self._meta.resource_name, self.wrap_view('dispatch_detail'), name="api_dispatch_detail"), @@ -87,14 +105,6 @@ def prepend_urls(self): self.wrap_view('dispatch_detail'), name="api_dispatch_detail"), ] - class Meta: - queryset = Poll.objects.all() - allowed_methods = ['get', 'post', 'put'] - resource_name = 'poll' - always_return_data = True - authentication = MultiAuthentication(BasicAuthentication(), SessionAuthentication()) - authorization = Authorization() - class ChoiceResource(NamespacedModelResource): poll = fields.ToOneField(PollResource, 'poll') @@ -102,84 +112,101 @@ class ChoiceResource(NamespacedModelResource): class Meta: queryset = Choice.objects.all() allowed_methods = ['post', 'put'] - authentication = MultiAuthentication(BasicAuthentication(), SessionAuthentication()) - authorization = Authorization() + authentication = MultiAuthentication( + BasicAuthentication(), SessionAuthentication()) + authorization = DjangoAuthorization() resource_name = 'choice' always_return_data = True class VoteResource(NamespacedModelResource): - user = fields.ToOneField(UserResource, 'user', blank=True, null=True, readonly=True) + user = fields.ToOneField( + UserResource, 'user', blank=True, null=True, readonly=True) choice = fields.ToOneField(ChoiceResource, 'choice', readonly=True) poll = fields.ToOneField(PollResource, 'poll', readonly=True) - + + class Meta: + queryset = Vote.objects.all() + allowed_methods = ['post', 'put'] + # by default require authentication but regress for anonymous votes + authentication = IPAuthentication(BasicAuthentication(), + SessionAuthentication(), + Authentication()) + # anyone can vote + authorization = Authorization() + resource_name = 'vote' + always_return_data = True + def obj_create(self, bundle, **kwargs): poll = PollResource().get_via_uri(bundle.data.get('poll')) if not poll.already_voted(bundle.request.user): try: - votes = poll.vote(choices=bundle.data.get('choice'), - data=bundle.data.get('data'), - user=bundle.request.user, - comment=bundle.data.get('comment')) + choices = bundle.data.get('choice') + # convert single-choice into list + if isinstance(choices, basestring): + choices = [choices] + votes = poll.vote(choices=choices, + data=bundle.data.get('data'), + user=bundle.request.user, + comment=bundle.data.get('comment')) except (PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple): - raise ImmediateHttpResponse(response=http.HttpForbidden('not allowed')) + raise ImmediateHttpResponse( + response=http.HttpForbidden('not allowed')) except PollInvalidChoice: - raise ImmediateHttpResponse(response=http.HttpBadRequest('invalid data')) + raise ImmediateHttpResponse( + response=http.HttpBadRequest('invalid data')) else: bundle.obj = votes[0] else: - raise ImmediateHttpResponse(response=http.HttpForbidden('already voted')) + raise ImmediateHttpResponse( + response=http.HttpForbidden('already voted')) return bundle - + def obj_update(self, bundle, **kwargs): poll = PollResource().get_via_uri(bundle.data.get('poll')) # non anonymous votes by the same user can be modified if not poll.is_anonymous and bundle.obj.user == bundle.request.user: bundle.obj.change_vote(choices=bundle.data.get('choice'), - data=bundle.data.get('data'), - user=bundle.request.user) + data=bundle.data.get('data'), + user=bundle.request.user) else: - raise ImmediateHttpResponse(response=http.HttpForbidden('already voted')) + raise ImmediateHttpResponse( + response=http.HttpForbidden('already voted')) def dehydrate(self, bundle): # convert JSON Field bundle = super(VoteResource, self).dehydrate(bundle) bundle.data['data'] = json.dumps(bundle.obj.data) + # represent values as strings + bundle.data['poll'] = self.get_resource_uri(bundle.obj) + bundle.data['choice'] = bundle.obj.choice.code return bundle - - class Meta: - queryset = Vote.objects.all() - allowed_methods = ['post', 'put'] - # by default require authentication but regress for anonymous votes - authentication = MultiAuthentication(BasicAuthentication(), - SessionAuthentication(), - Authentication()) - # authorization = Authorization() - resource_name = 'vote' - always_return_data = True class ResultResource(NamespacedModelResource): + + class Meta: + queryset = Poll.objects.all() + allowed_methods = ['get'] + # anyone can get results + authentication = MultiAuthentication( + BasicAuthentication(), SessionAuthentication(), Authentication()) + authorization = Authorization() + resource_name = 'result' + always_return_data = True + excludes = ['description', 'start_votes', 'end_votes', + 'is_anonymous', 'is_multiple', 'is_closed', 'reference'] + def prepend_urls(self): - """ match by pk or reference """ + """ match by pk or reference """ return [ url(r"^(?P%s)/(?P[0-9]+)/$" % self._meta.resource_name, self.wrap_view('dispatch_detail'), name="api_dispatch_detail"), url(r"^(?P%s)/(?P[\w-]+)/$" % self._meta.resource_name, self.wrap_view('dispatch_detail'), name="api_dispatch_detail"), ] - + def dehydrate(self, bundle): poll = bundle.obj bundle.data['stats'] = poll.get_stats() return bundle - - class Meta: - queryset = Poll.objects.all() - allowed_methods = ['get'] - authentication = MultiAuthentication(BasicAuthentication(), SessionAuthentication()) - authorization = Authorization() - resource_name = 'result' - always_return_data = True - excludes = ['description', 'start_votes', 'end_votes', 'is_anonymous', 'is_multiple', 'is_closed', 'reference'] - diff --git a/polls/migrations/0001_initial.py b/polls/migrations/0001_initial.py index ef2824f..559b5aa 100644 --- a/polls/migrations/0001_initial.py +++ b/polls/migrations/0001_initial.py @@ -1,49 +1,79 @@ # -*- coding: utf-8 -*- -import datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models +from __future__ import unicode_literals +from django.db import models, migrations +import polls.models +import django.utils.timezone +from django.conf import settings +import django_extensions.db.fields.json +import uuid -class Migration(SchemaMigration): - def forwards(self, orm): - # Adding model 'Poll' - db.create_table('polls_poll', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('question', self.gf('django.db.models.fields.CharField')(max_length=255)), - )) - db.send_create_signal('polls', ['Poll']) +class Migration(migrations.Migration): - # Adding model 'Choice' - db.create_table('polls_choice', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('poll', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['polls.Poll'])), - ('choice', self.gf('django.db.models.fields.CharField')(max_length=255)), - )) - db.send_create_signal('polls', ['Choice']) + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] - - def backwards(self, orm): - # Deleting model 'Poll' - db.delete_table('polls_poll') - - # Deleting model 'Choice' - db.delete_table('polls_choice') - - - models = { - 'polls.choice': { - 'Meta': {'object_name': 'Choice'}, - 'choice': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'poll': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['polls.Poll']"}) - }, - 'polls.poll': { - 'Meta': {'object_name': 'Poll'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'question': ('django.db.models.fields.CharField', [], {'max_length': '255'}) - } - } - - complete_apps = ['polls'] \ No newline at end of file + operations = [ + migrations.CreateModel( + name='Choice', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('choice', models.CharField(max_length=255)), + ('code', models.CharField(default=b'', max_length=36, blank=True)), + ], + options={ + 'ordering': ['poll', 'choice'], + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Poll', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('question', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('reference', models.CharField(default=uuid.uuid4, unique=True, max_length=36)), + ('is_anonymous', models.BooleanField(default=False, help_text='Allow to vote for anonymous user')), + ('is_multiple', models.BooleanField(default=False, help_text='Allow to make multiple choices')), + ('is_closed', models.BooleanField(default=False, help_text='Do not accept votes')), + ('start_votes', models.DateTimeField(default=django.utils.timezone.now, help_text='The earliest time votes get accepted')), + ('end_votes', models.DateTimeField(default=polls.models.vote_endtime, help_text='The latest time votes get accepted')), + ], + options={ + 'ordering': ['-start_votes'], + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Vote', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('comment', models.TextField(max_length=144, null=True, blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('data', django_extensions.db.fields.json.JSONField(null=True, blank=True)), + ('choice', models.ForeignKey(to='polls.Choice')), + ('poll', models.ForeignKey(to='polls.Poll')), + ('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True)), + ], + options={ + 'ordering': ['poll', 'choice'], + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='vote', + unique_together=set([('user', 'poll', 'choice')]), + ), + migrations.AddField( + model_name='choice', + name='poll', + field=models.ForeignKey(to='polls.Poll'), + preserve_default=True, + ), + migrations.AlterUniqueTogether( + name='choice', + unique_together=set([('poll', 'code')]), + ), + ] diff --git a/polls/models.py b/polls/models.py index d063939..efcb41e 100644 --- a/polls/models.py +++ b/polls/models.py @@ -7,20 +7,28 @@ from django.utils import timezone from django.utils.text import slugify from django.utils.translation import ugettext_lazy as _ -from django_extensions.db.fields.json import JSONField +from django_extensions.db.fields.json import JSONField from polls.exceptions import PollChoiceRequired, PollInvalidChoice +def vote_endtime(): + return timezone.now() + timedelta(days=5) + + class Poll(models.Model): question = models.CharField(max_length=255) description = models.TextField(blank=True) reference = models.CharField(max_length=36, default=uuid4, unique=True) - is_anonymous = models.BooleanField(default=False, help_text=_('Allow to vote for anonymous user')) - is_multiple = models.BooleanField(default=False, help_text=_('Allow to make multiple choices')) - is_closed = models.BooleanField(default=False, help_text=_('Do not accept votes')) - start_votes = models.DateTimeField(default=timezone.now, help_text=_('The earliest time votes get accepted')) - end_votes = models.DateTimeField(default=(lambda: timezone.now()+timedelta(days=5)), + is_anonymous = models.BooleanField( + default=False, help_text=_('Allow to vote for anonymous user')) + is_multiple = models.BooleanField( + default=False, help_text=_('Allow to make multiple choices')) + is_closed = models.BooleanField( + default=False, help_text=_('Do not accept votes')) + start_votes = models.DateTimeField( + default=timezone.now, help_text=_('The earliest time votes get accepted')) + end_votes = models.DateTimeField(default=vote_endtime, help_text=_('The latest time votes get accepted')) def vote(self, choices, user=None, data=None, comment=None): @@ -32,30 +40,33 @@ def vote(self, choices, user=None, data=None, comment=None): if user is None and not self.is_anonymous: raise PollNotAnonymous if not choices: - raise PollInvalidChoice + raise PollInvalidChoice if len(choices) > 1 and not self.is_multiple: raise PollNotMultiple if len(choices) == 0: raise PollChoiceRequired - # if self.is_anonymous: user = None # pass None, even though user is authenticated + # if self.is_anonymous: user = None # pass None, even though user is + # authenticated votes = [] for choice_id in choices: if isinstance(choice_id, int) or choice_id.isdigit(): query = dict(pk=choice_id) else: query = dict(poll=self, code=choice_id) - try: + try: choice = Choice.objects.get(**query) except: raise PollInvalidChoice - if self.is_anonymous: - user = None - vote = Vote.objects.create(poll=self, user=user, + # we always track the technical user at least by ip or clientid + # to make sure we don't get multiple votes + #if self.is_anonymous: + # user = None + vote = Vote.objects.create(poll=self, user=user, choice=choice, data=data, comment=comment) votes.append(vote) return votes - + def change_vote(self, choices, user=None, data=None): """ this deletes all previous votes of the user and revotes with @@ -79,18 +90,18 @@ def count_percentage(self, as_code=False): total_votes = self.count_total_votes() stats = {} for choice in self.choice_set.all(): - key = choice.code if as_code else choice + key = choice.code if as_code else choice stats[key] = float(choice.count_votes()) / total_votes return stats def count_total_votes(self): votes = sum((choice.count_votes() for choice in self.choice_set.all())) return votes - + def get_stats(self): """ return a statistics object - + returns a dict of { labels : [choice, ...], @@ -98,18 +109,18 @@ def get_stats(self): percentage : [%, ...], } """ - # get statistics as dict of { choice : pct } + # get statistics as dict of { choice : pct } stats = self.count_percentage() - # convert to same indexed labels, codes, percentages + # convert to same indexed labels, codes, percentages labels = [] codes = [] percentage = [] - for c,p in stats.iteritems(): + for c, p in stats.iteritems(): labels.append(c.choice) codes.append(c.code) percentage.append(p) - count = self.count_total_votes() - stats = dict(values=percentage, codes=codes, + count = self.count_total_votes() + stats = dict(values=percentage, codes=codes, labels=labels, votes=count) return stats @@ -117,9 +128,7 @@ def already_voted(self, user): if not self.is_anonymous: if user.is_anonymous(): raise PollNotAnonymous - return self.vote_set.filter(user=user).exists() - else: - return False + return self.vote_set.filter(user=user).exists() def __unicode__(self): return self.question @@ -141,16 +150,17 @@ def count_votes(self): def __unicode__(self): return self.choice - + def save(self, *args, **kwargs): if not self.code: self.code = slugify(unicode(self.choice)) super(Choice, self).save(*args, **kwargs) - + class Meta: unique_together = (('poll', 'code'),) ordering = ['poll', 'choice'] - + + class Vote(models.Model): user = models.ForeignKey(User, blank=True, null=True) poll = models.ForeignKey(Poll) diff --git a/polls/south_migrations/0001_initial.py b/polls/south_migrations/0001_initial.py new file mode 100644 index 0000000..ef2824f --- /dev/null +++ b/polls/south_migrations/0001_initial.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'Poll' + db.create_table('polls_poll', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('question', self.gf('django.db.models.fields.CharField')(max_length=255)), + )) + db.send_create_signal('polls', ['Poll']) + + # Adding model 'Choice' + db.create_table('polls_choice', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('poll', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['polls.Poll'])), + ('choice', self.gf('django.db.models.fields.CharField')(max_length=255)), + )) + db.send_create_signal('polls', ['Choice']) + + + def backwards(self, orm): + # Deleting model 'Poll' + db.delete_table('polls_poll') + + # Deleting model 'Choice' + db.delete_table('polls_choice') + + + models = { + 'polls.choice': { + 'Meta': {'object_name': 'Choice'}, + 'choice': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poll': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['polls.Poll']"}) + }, + 'polls.poll': { + 'Meta': {'object_name': 'Poll'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'question': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + } + } + + complete_apps = ['polls'] \ No newline at end of file diff --git a/polls/migrations/0002_auto__add_vote__add_field_poll_description.py b/polls/south_migrations/0002_auto__add_vote__add_field_poll_description.py similarity index 100% rename from polls/migrations/0002_auto__add_vote__add_field_poll_description.py rename to polls/south_migrations/0002_auto__add_vote__add_field_poll_description.py diff --git a/polls/migrations/0003_auto__add_unique_vote_poll_user.py b/polls/south_migrations/0003_auto__add_unique_vote_poll_user.py similarity index 100% rename from polls/migrations/0003_auto__add_unique_vote_poll_user.py rename to polls/south_migrations/0003_auto__add_unique_vote_poll_user.py diff --git a/polls/migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py b/polls/south_migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py similarity index 100% rename from polls/migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py rename to polls/south_migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py diff --git a/polls/migrations/0005_auto__chg_field_vote_user__del_unique_vote_user_poll.py b/polls/south_migrations/0005_auto__chg_field_vote_user__del_unique_vote_user_poll.py similarity index 100% rename from polls/migrations/0005_auto__chg_field_vote_user__del_unique_vote_user_poll.py rename to polls/south_migrations/0005_auto__chg_field_vote_user__del_unique_vote_user_poll.py diff --git a/polls/migrations/0006_auto__del_field_vote_datetime__add_field_vote_created.py b/polls/south_migrations/0006_auto__del_field_vote_datetime__add_field_vote_created.py similarity index 100% rename from polls/migrations/0006_auto__del_field_vote_datetime__add_field_vote_created.py rename to polls/south_migrations/0006_auto__del_field_vote_datetime__add_field_vote_created.py diff --git a/polls/migrations/0007_auto__add_unique_vote_user_poll_choice__chg_field_poll_reference__add_.py b/polls/south_migrations/0007_auto__add_unique_vote_user_poll_choice__chg_field_poll_reference__add_.py similarity index 100% rename from polls/migrations/0007_auto__add_unique_vote_user_poll_choice__chg_field_poll_reference__add_.py rename to polls/south_migrations/0007_auto__add_unique_vote_user_poll_choice__chg_field_poll_reference__add_.py diff --git a/polls/migrations/0008_auto__add_field_choice_code.py b/polls/south_migrations/0008_auto__add_field_choice_code.py similarity index 100% rename from polls/migrations/0008_auto__add_field_choice_code.py rename to polls/south_migrations/0008_auto__add_field_choice_code.py diff --git a/polls/migrations/0009_auto__add_unique_choice_poll_code.py b/polls/south_migrations/0009_auto__add_unique_choice_poll_code.py similarity index 100% rename from polls/migrations/0009_auto__add_unique_choice_poll_code.py rename to polls/south_migrations/0009_auto__add_unique_choice_poll_code.py diff --git a/polls/south_migrations/__init__.py b/polls/south_migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/polls/test/test_api.py b/polls/test/test_api.py index 3a2f9f9..c68f53f 100644 --- a/polls/test/test_api.py +++ b/polls/test/test_api.py @@ -12,7 +12,8 @@ logger = logging.getLogger(__name__) -URL = '/api/v1' # or '/polls/api/v1' in the case when we don't set 'urls' attribute +# or '/polls/api/v1' in the case when we don't set 'urls' attribute +URL = '/api/v1' class PollsApiTest(ResourceTestCase): @@ -22,11 +23,15 @@ def setUp(self): super(PollsApiTest, self).setUp() self.username = 'test' self.password = 'password' - self.user = User.objects.create_user(self.username, 't@gmail.com', self.password) + self.user = User.objects.create_user( + self.username, 'user@nomail.com', self.password) + self.admin = User.objects.create_user( + 'admin', 'admin@nomail.com', self.password) # user can't add/change/delete poll model without permissions permissions = (Permission.objects.get(codename=p) - for p in ['add_poll', 'change_poll', 'delete_poll']) - self.user.user_permissions.add(*permissions) + for p in ['add_poll', 'change_poll', 'delete_poll', + 'add_choice', 'change_choice', 'delete_choice']) + self.admin.user_permissions.add(*permissions) def tearDown(self): self.api_client.client.logout() @@ -36,17 +41,22 @@ def getURL(self, resource, id=None): return "%s/%s/%s/" % (URL, resource, id) return "%s/%s/" % (URL, resource) - def get_credentials(self): - return self.create_basic(username=self.username, password=self.password) + def get_credentials(self, admin=False): + if admin: + username = self.admin.username + else: + username = self.user.username + return self.create_basic(username=username, password=self.password) def test_create_poll(self): - self.assertTrue(self.user.has_perm('polls.add_poll')) + self.assertTrue(self.admin.has_perm('polls.add_poll')) poll_data = self.poll_data() resp = self.create_poll(poll_data) self.assertHttpCreated(resp) pk = Poll.objects.order_by('-id')[0].pk - resp = self.api_client.get(self.getURL('poll'), authentication=self.get_credentials()) - #logger.debug(resp) + resp = self.api_client.get( + self.getURL('poll'), authentication=self.get_credentials(admin=True)) + # logger.debug(resp) self.assertValidJSONResponse(resp) deserialized = self.deserialize(resp)['objects'] self.assertEqual(deserialized[0], { @@ -78,36 +88,41 @@ def test_put_poll(self): resp = self.create_poll(poll_data) self.assertHttpCreated(resp) pk = Poll.objects.order_by('-id')[0].pk - resp = self.api_client.put(self.getURL('poll', pk), data=poll_data, authentication=self.get_credentials()) + resp = self.api_client.put( + self.getURL('poll', pk), data=poll_data, authentication=self.get_credentials(admin=True)) self.assertHttpOK(resp) - resp = self.api_client.get(self.getURL('poll', pk), authentication=self.get_credentials()) + resp = self.api_client.get( + self.getURL('poll', pk), authentication=self.get_credentials(admin=True)) self.assertEqual(self.deserialize(resp)['is_anonymous'], True) def test_voting(self): # create a poll poll_data = self.poll_data() resp = self.create_poll(poll_data) + self.assertHttpCreated(resp) pk = Poll.objects.order_by('-id')[0].pk choice_data = self.choice_data(poll_id=pk) # create 3 choices self.create_choices(choice_data, quantity=3) - resp = self.api_client.get(self.getURL('poll', pk), authentication=self.get_credentials()) + resp = self.api_client.get( + self.getURL('poll', pk), authentication=self.get_credentials()) + self.assertHttpOK(resp) deserialized = self.deserialize(resp) self.assertEqual(len(deserialized['choices']), 3) # vote choice_pk = Choice.objects.order_by('-id')[1].pk vote_data = self.vote_data(poll_id=pk, choices=[choice_pk]) resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json', - authentication=self.get_credentials()) + authentication=self.get_credentials()) self.assertHttpCreated(resp) resp = self.api_client.get(self.getURL('result', id=pk), format='json', - authentication=self.get_credentials()) + authentication=self.get_credentials()) deserialized = self.deserialize(resp) - self.assertDictEqual(deserialized['stats'], - {u'labels': [u'choice0', u'choice1', u'choice2'], - u'votes': 1, - u'codes': [u'choice0', u'choice1', u'choice2'], - u'values': [0.0, 1.0, 0.0]}) + self.assertDictEqual(deserialized['stats'], + {u'labels': [u'choice0', u'choice1', u'choice2'], + u'votes': 1, + u'codes': [u'choice0', u'choice1', u'choice2'], + u'values': [0.0, 1.0, 0.0]}) def test_anonymous_voting(self): poll_data = self.poll_data(anonymous=True) @@ -117,9 +132,38 @@ def test_anonymous_voting(self): choice_data = self.choice_data(poll_id=pk) self.create_choices(choice_data, quantity=3) vote_data = self.vote_data(poll_id=1, choices=[1]) - resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') self.assertHttpCreated(resp) + def test_anonymous_voting_multiple(self): + poll_data = self.poll_data(anonymous=True) + resp = self.create_poll(poll_data) + self.assertHttpCreated(resp) + pk = Poll.objects.order_by('-id')[0].pk + choice_data = self.choice_data(poll_id=pk) + self.create_choices(choice_data, quantity=3) + vote_data = self.vote_data(poll_id=1, choices=[1]) + # try based on IP + # -- first vote should be ok + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') + self.assertHttpCreated(resp) + # -- second vote should be refused + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') + self.assertHttpForbidden(resp) + # try the same based on client id + # -- first vote should be ok + self.api_client.client.cookies['quickpollscid'] = uuid.uuid4().hex + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') + self.assertHttpCreated(resp) + # -- second vote should be refused + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') + self.assertHttpForbidden(resp) + def test_voting_with_data(self): poll_data = self.poll_data(anonymous=True) resp = self.create_poll(poll_data) @@ -129,14 +173,14 @@ def test_voting_with_data(self): self.create_choices(choice_data, quantity=3) vote_data = self.vote_data(poll_id=1, choices=[1]) vote_data['data'] = { - 'foo' : 'bar' + 'foo': 'bar' } - resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') self.assertHttpCreated(resp) rdata = json.loads(self.deserialize(resp)['data']) self.assertDictEqual(rdata, vote_data['data']) - - + def test_voting_with_comment(self): poll_data = self.poll_data(anonymous=True) resp = self.create_poll(poll_data) @@ -146,16 +190,17 @@ def test_voting_with_comment(self): self.create_choices(choice_data, quantity=3) vote_data = self.vote_data(poll_id=1, choices=[1]) vote_data['data'] = { - 'foo' : 'bar', + 'foo': 'bar', } vote_data['comment'] = 'this is a comment' - resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') self.assertHttpCreated(resp) rdata = json.loads(self.deserialize(resp)['data']) rcomment = self.deserialize(resp)['comment'] self.assertDictEqual(rdata, vote_data['data']) self.assertEqual(rcomment, vote_data['comment']) - + def test_voting_no_choice(self): poll_data = self.poll_data(anonymous=True) resp = self.create_poll(poll_data) @@ -165,12 +210,14 @@ def test_voting_no_choice(self): self.create_choices(choice_data, quantity=3) vote_data = self.vote_data(poll_id=1, choices=[1]) del vote_data['choice'] - resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') self.assertHttpBadRequest(resp) vote_data['choice'] = None - resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') self.assertHttpBadRequest(resp) - + def test_voting_by_code(self): poll_data = self.poll_data(anonymous=True) resp = self.create_poll(poll_data) @@ -181,27 +228,29 @@ def test_voting_by_code(self): vote_data = self.vote_data(poll_id=1, choices=[1]) # invalid choice vote_data['choice'] = ['xchoice'] - resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') self.assertHttpBadRequest(resp) vote_data['choice'] = ['choice1'] - resp = self.api_client.post(self.getURL('vote'), data=vote_data, format='json') + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') self.assertHttpCreated(resp) - + def create_poll(self, poll_data): return self.api_client.post(self.getURL('poll'), format='json', - data=poll_data, authentication=self.get_credentials()) + data=poll_data, authentication=self.get_credentials(admin=True)) def create_choice(self, choice_data): return self.api_client.post(self.getURL('choice'), format='json', - data=choice_data, authentication=self.get_credentials()) + data=choice_data, authentication=self.get_credentials(admin=True)) def create_choices(self, choice_data, quantity): for x in xrange(quantity): choice_data['choice'] = 'choice' + str(x) resp = self.api_client.post(self.getURL('choice'), format='json', - data=choice_data, authentication=self.get_credentials()) + data=choice_data, authentication=self.get_credentials(admin=True)) self.assertHttpCreated(resp) - + def poll_data(self, anonymous=False, multiple=False, closed=False): now = timezone.now() # delete microseconds @@ -228,4 +277,3 @@ def vote_data(self, poll_id, choices): 'choice': choices, 'poll': self.getURL('poll', id=poll_id) } - diff --git a/polls/util.py b/polls/util.py new file mode 100644 index 0000000..159ce6c --- /dev/null +++ b/polls/util.py @@ -0,0 +1,110 @@ +import base64 + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import make_password +from tastypie.authentication import MultiAuthentication +from tastypie.authorization import DjangoAuthorization + + +def get_client_ip(request): + """ + get client ip + """ + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[-1].strip() + else: + ip = request.META.get('REMOTE_ADDR') + return ip + + +def get_user(request, clientid=None): + """ + get a valid user, get or create a user by IP address + + if the user is anonmyous: + this will get or create a user with the given clientid. if the clientid is + not specified it defaults to the ip address + (i.e. no django credentials) + returns the user + + if the user is authenticated: + returns it unchanged + """ + user = request.user + if request.user.is_anonymous(): + User = get_user_model() + username = clientid or get_client_ip(request) + try: + user = User.objects.get(username=username) + except: + unusable_password = make_password(None) + user = User.objects.create_user(username, + email=settings.DEFAULT_FROM_EMAIL, + password=unusable_password) + return user + + +class ReasonableDjangoAuthorization(DjangoAuthorization): + + """ + grant read access based on given read_list and read_detail permissions + + Usage: + # set permission to None to allow public access (no permission checks) + permissions = { + 'read_list' : 'change', + 'read_detail' : 'view' + } + authorization = ReasonableDjangoAuthorization(**permissions) + + Rationale: + by default, tastypie > 0.13 requires the 'change' permission for + any user to GET list or detail, which doesn't make sense in an API + context. see https://github.com/django-tastypie/django-tastypie/issues/1407 + """ + def __init__(self, read_list='change', + read_detail='view'): + self.perm_read_list = read_list + self.perm_read_detail = read_detail + + def read_detail(self, object_list, bundle): + if self.perm_read_detail: + return self.perm_obj_checks(bundle.request, self.perm_read_detail, bundle.obj) + else: + return True + + def read_list(self, object_list, bundle): + if self.perm_read_list: + return self.perm_list_checks(bundle.request, self.perm_read_list, object_list) + else: + return object_list + + +class IPAuthentication(MultiAuthentication): + + """ + an authentication scheme that automatically gets or creates a user based + on the remote ip address if the user cannot be authenticated otherwise. + + works across proxies. if the client provides + a 'quickpollscid' cookie also works for users behind NATs or enterprise + proxies. note that the cookie value is base64 encoded assuming we get + a UUID of some sorts to ensure we get valid usernames. + + Usage: + # use the same as MultiAuthentication() + IPAuthentication(BasicAuthentication(), SessionAuthentication()) + """ + def is_authenticated(self, request, **kwargs): + authed = super(IPAuthentication, self).is_authenticated( + request, **kwargs) + if not authed or request.user.is_anonymous(): + # base64 encode to get uuid's below 30 chars (max length of + # username) + clientid = request.COOKIES.get('quickpollscid', None) + if clientid: + clientid = base64.b64encode(clientid) + request.user = get_user(request, clientid=clientid) + return authed diff --git a/setup.py b/setup.py index 9720e28..264c054 100644 --- a/setup.py +++ b/setup.py @@ -19,8 +19,8 @@ def read(fname): packages=find_packages(), include_package_data=True, install_requires=[ - 'Django>=1.6', - 'django-extensions==1.3.11', - 'django-tastypie==0.12.1', + 'Django>=1.6,<1.8', + 'django-extensions>=1.3.11', + 'django-tastypie>=0.12.1', ], ) From 8d10cf15e8cf487847938a98f77c549fc6fc8793 Mon Sep 17 00:00:00 2001 From: miraculixx Date: Tue, 29 Mar 2016 01:06:47 +0200 Subject: [PATCH 30/33] bumped version --- polls/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/polls/__init__.py b/polls/__init__.py index 550f5d0..abb3790 100644 --- a/polls/__init__.py +++ b/polls/__init__.py @@ -1 +1 @@ -__version__ = '0.2.3dev' +__version__ = '0.2.4dev' From ae67f2ed60dbba90eab942babb49ccf600f08d19 Mon Sep 17 00:00:00 2001 From: miraculixx Date: Sun, 24 Apr 2016 13:06:59 +0200 Subject: [PATCH 31/33] accept votes by poll reference fixes #6 --- polls/api.py | 8 ++++++-- polls/test/test_api.py | 21 ++++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/polls/api.py b/polls/api.py index 41f4511..58c572f 100644 --- a/polls/api.py +++ b/polls/api.py @@ -82,7 +82,10 @@ class Meta: BasicAuthentication(), SessionAuthentication(), Authentication()) authorization = ReasonableDjangoAuthorization(read_list='', read_detail='') - + filtering = { + 'reference': 'exact', + } + def obj_create(self, bundle, **kwargs): return super(PollResource, self).obj_create(bundle, user=bundle.request.user) @@ -178,7 +181,8 @@ def dehydrate(self, bundle): bundle = super(VoteResource, self).dehydrate(bundle) bundle.data['data'] = json.dumps(bundle.obj.data) # represent values as strings - bundle.data['poll'] = self.get_resource_uri(bundle.obj) + bundle.data['poll'] = self.get_resource_uri(bundle.obj.poll) + bundle.data['resource_uri'] = self.get_resource_uri(bundle.obj) bundle.data['choice'] = bundle.obj.choice.code return bundle diff --git a/polls/test/test_api.py b/polls/test/test_api.py index c68f53f..d743d36 100644 --- a/polls/test/test_api.py +++ b/polls/test/test_api.py @@ -135,7 +135,7 @@ def test_anonymous_voting(self): resp = self.api_client.post( self.getURL('vote'), data=vote_data, format='json') self.assertHttpCreated(resp) - + def test_anonymous_voting_multiple(self): poll_data = self.poll_data(anonymous=True) resp = self.create_poll(poll_data) @@ -149,13 +149,13 @@ def test_anonymous_voting_multiple(self): resp = self.api_client.post( self.getURL('vote'), data=vote_data, format='json') self.assertHttpCreated(resp) - # -- second vote should be refused + # -- second vote should be refused resp = self.api_client.post( self.getURL('vote'), data=vote_data, format='json') self.assertHttpForbidden(resp) # try the same based on client id # -- first vote should be ok - self.api_client.client.cookies['quickpollscid'] = uuid.uuid4().hex + self.api_client.client.cookies['quickpollscid'] = uuid.uuid4().hex resp = self.api_client.post( self.getURL('vote'), data=vote_data, format='json') self.assertHttpCreated(resp) @@ -236,6 +236,21 @@ def test_voting_by_code(self): self.getURL('vote'), data=vote_data, format='json') self.assertHttpCreated(resp) + def test_voting_multiple_polls_exist(self): + # create multiple polls with choices + for ref in ['one', 'two']: + poll_data = self.poll_data(anonymous=True) + poll_data['reference'] = ref + resp = self.create_poll(poll_data) + self.assertHttpCreated(resp) + pk = Poll.objects.order_by('-id')[0].pk + choice_data = self.choice_data(poll_id=pk) + self.create_choices(choice_data, quantity=3) + vote_data = self.vote_data(poll_id='two', choices=[1]) + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') + self.assertHttpCreated(resp) + def create_poll(self, poll_data): return self.api_client.post(self.getURL('poll'), format='json', data=poll_data, authentication=self.get_credentials(admin=True)) From ead185dfd5889755c36ada0fce21bc2cb9271640 Mon Sep 17 00:00:00 2001 From: miraculixx Date: Sun, 24 Apr 2016 13:09:11 +0200 Subject: [PATCH 32/33] bumped version --- polls/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/polls/__init__.py b/polls/__init__.py index abb3790..1843c5a 100644 --- a/polls/__init__.py +++ b/polls/__init__.py @@ -1 +1 @@ -__version__ = '0.2.4dev' +__version__ = '0.2.5dev' From 1c0b31655cb82adbc3b8f5c5ab1682fa4bdd80d4 Mon Sep 17 00:00:00 2001 From: miraculixx Date: Sun, 24 Apr 2016 13:55:25 +0200 Subject: [PATCH 33/33] allow multiple votes closes #8 --- polls/admin.py | 3 +- .../migrations/0002_poll_allow_multi_votes.py | 20 +++++++++ polls/migrations/0003_auto_20160424_1140.py | 18 ++++++++ polls/models.py | 9 +++- polls/test/test_api.py | 41 ++++++++++++++++++- 5 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 polls/migrations/0002_poll_allow_multi_votes.py create mode 100644 polls/migrations/0003_auto_20160424_1140.py diff --git a/polls/admin.py b/polls/admin.py index 69ef318..ea1df5f 100644 --- a/polls/admin.py +++ b/polls/admin.py @@ -16,7 +16,8 @@ class PollAdmin(admin.ModelAdmin): class VoteAdmin(admin.ModelAdmin): model = Vote - list_display = ('choice', 'user', 'poll') + list_display = ('choice', 'user', 'poll', 'created') + readonly_fields = ('created',) admin.site.register(Poll, PollAdmin) admin.site.register(Vote, VoteAdmin) diff --git a/polls/migrations/0002_poll_allow_multi_votes.py b/polls/migrations/0002_poll_allow_multi_votes.py new file mode 100644 index 0000000..af01bd0 --- /dev/null +++ b/polls/migrations/0002_poll_allow_multi_votes.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('polls', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='poll', + name='allow_multi_votes', + field=models.BooleanField(default=False, help_text='Allow multiple votes by same user'), + preserve_default=True, + ), + ] diff --git a/polls/migrations/0003_auto_20160424_1140.py b/polls/migrations/0003_auto_20160424_1140.py new file mode 100644 index 0000000..d0c4f1b --- /dev/null +++ b/polls/migrations/0003_auto_20160424_1140.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('polls', '0002_poll_allow_multi_votes'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='vote', + unique_together=set([]), + ), + ] diff --git a/polls/models.py b/polls/models.py index efcb41e..bdecbed 100644 --- a/polls/models.py +++ b/polls/models.py @@ -26,6 +26,8 @@ class Poll(models.Model): default=False, help_text=_('Allow to make multiple choices')) is_closed = models.BooleanField( default=False, help_text=_('Do not accept votes')) + allow_multi_votes = models.BooleanField( + default=False, help_text=_('Allow multiple votes by same user')) start_votes = models.DateTimeField( default=timezone.now, help_text=_('The earliest time votes get accepted')) end_votes = models.DateTimeField(default=vote_endtime, @@ -124,10 +126,14 @@ def get_stats(self): labels=labels, votes=count) return stats - def already_voted(self, user): + def already_voted(self, user): if not self.is_anonymous: if user.is_anonymous(): raise PollNotAnonymous + if self.allow_multi_votes: + # if we allow multiple votes, we don't care how many + # votes this user has already vote + return False return self.vote_set.filter(user=user).exists() def __unicode__(self): @@ -173,5 +179,4 @@ def __unicode__(self): return u'Vote for %s' % self.choice class Meta: - unique_together = ('user', 'poll', 'choice') ordering = ['poll', 'choice'] diff --git a/polls/test/test_api.py b/polls/test/test_api.py index d743d36..8e3879c 100644 --- a/polls/test/test_api.py +++ b/polls/test/test_api.py @@ -71,6 +71,7 @@ def test_create_poll(self): u'resource_uri': deserialized[0]['resource_uri'], u'start_votes': poll_data['start_votes'], u'end_votes': poll_data['end_votes'], + u'allow_multi_votes': False, }) # check that 'reference' is correct uuid string try: @@ -250,7 +251,45 @@ def test_voting_multiple_polls_exist(self): resp = self.api_client.post( self.getURL('vote'), data=vote_data, format='json') self.assertHttpCreated(resp) - + + def test_voting_multiple_votes_not_allowed(self): + # create poll with vote, allow only one vote by user + poll_data = self.poll_data(anonymous=True) + poll_data['allow_multi_votes'] = False + resp = self.create_poll(poll_data) + self.assertHttpCreated(resp) + pk = Poll.objects.order_by('-id')[0].pk + choice_data = self.choice_data(poll_id=pk) + self.create_choices(choice_data, quantity=3) + # try voting twice, second should fail + vote_data = self.vote_data(poll_id=pk, choices=[1]) + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') + self.assertHttpCreated(resp) + vote_data = self.vote_data(poll_id=pk, choices=[1]) + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') + self.assertHttpForbidden(resp) + + def test_voting_multiple_votes_allowed(self): + # create poll with vote, allow multiple multiple votes + poll_data = self.poll_data(anonymous=True) + poll_data['allow_multi_votes'] = True + resp = self.create_poll(poll_data) + self.assertHttpCreated(resp) + pk = Poll.objects.order_by('-id')[0].pk + choice_data = self.choice_data(poll_id=pk) + self.create_choices(choice_data, quantity=3) + # try voting twice, second should fail + vote_data = self.vote_data(poll_id=pk, choices=[1]) + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') + self.assertHttpCreated(resp) + vote_data = self.vote_data(poll_id=pk, choices=[1]) + resp = self.api_client.post( + self.getURL('vote'), data=vote_data, format='json') + self.assertHttpCreated(resp) + def create_poll(self, poll_data): return self.api_client.post(self.getURL('poll'), format='json', data=poll_data, authentication=self.get_credentials(admin=True))