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/__init__.py b/polls/__init__.py index b794fd4..1843c5a 100644 --- a/polls/__init__.py +++ b/polls/__init__.py @@ -1 +1 @@ -__version__ = '0.1.0' +__version__ = '0.2.5dev' 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/api.py b/polls/api.py new file mode 100644 index 0000000..58c572f --- /dev/null +++ b/polls/api.py @@ -0,0 +1,216 @@ +''' + 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). +''' + +from exceptions import PollClosed, PollNotOpen, PollNotAnonymous, PollNotMultiple +import json + +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, \ + Authentication +from tastypie.authorization import Authorization, \ + DjangoAuthorization +from tastypie.exceptions import ImmediateHttpResponse +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 + 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 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='') + filtering = { + 'reference': 'exact', + } + + 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) + 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 ChoiceResource(NamespacedModelResource): + poll = fields.ToOneField(PollResource, 'poll') + + class Meta: + queryset = Choice.objects.all() + allowed_methods = ['post', 'put'] + 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) + 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: + 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')) + except PollInvalidChoice: + raise ImmediateHttpResponse( + response=http.HttpBadRequest('invalid data')) + 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')) + + 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.poll) + bundle.data['resource_uri'] = self.get_resource_uri(bundle.obj) + bundle.data['choice'] = bundle.obj.choice.code + return bundle + + +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 """ + 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 diff --git a/polls/exceptions.py b/polls/exceptions.py new file mode 100644 index 0000000..74c2aa3 --- /dev/null +++ b/polls/exceptions.py @@ -0,0 +1,6 @@ +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/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/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 3f6d9b9..bdecbed 100644 --- a/polls/models.py +++ b/polls/models.py @@ -1,30 +1,155 @@ -from django.db import models +from datetime import timedelta +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.text import slugify +from django.utils.translation import ugettext_lazy as _ +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')) + 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, + help_text=_('The latest time votes get accepted')) + + def vote(self, choices, user=None, data=None, comment=None): + current_time = timezone.now() + if self.is_closed: + raise PollClosed + 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 + 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(poll=self, code=choice_id) + try: + choice = Choice.objects.get(**query) + except: + raise PollInvalidChoice + # 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 + 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() - def count_total_votes(self): - result = 0 + 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(): - result += choice.count_votes() - return result + 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 - def can_vote(self, user): - return not self.vote_set.filter(user=user).exists() + 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 + 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): return self.question + class Meta: + ordering = ['-start_votes'] + 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() @@ -32,17 +157,26 @@ 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: - ordering = ['choice'] + unique_together = (('poll', 'code'),) + ordering = ['poll', 'choice'] 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) + created = 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')) + ordering = ['poll', 'choice'] 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/south_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 new file mode 100644 index 0000000..8cd1b1d --- /dev/null +++ b/polls/south_migrations/0004_auto__add_field_vote_comment__add_field_vote_datetime__add_field_vote_.py @@ -0,0 +1,157 @@ +# -*- 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=36, blank=True, unique=True), + keep_default=False) + # 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', + 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') + #db.delete_unique(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': '36', 'blank': 'True', 'unique': '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/south_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 new file mode 100644 index 0000000..4c8c9cc --- /dev/null +++ b/polls/south_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/south_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 new file mode 100644 index 0000000..1e11ab3 --- /dev/null +++ b/polls/south_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/south_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 new file mode 100644 index 0000000..4ac9ee3 --- /dev/null +++ b/polls/south_migrations/0007_auto__add_unique_vote_user_poll_choice__chg_field_poll_reference__add_.py @@ -0,0 +1,101 @@ +# -*- 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'] + # 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']) + + # 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/south_migrations/0008_auto__add_field_choice_code.py b/polls/south_migrations/0008_auto__add_field_choice_code.py new file mode 100644 index 0000000..bbd834d --- /dev/null +++ b/polls/south_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/south_migrations/0009_auto__add_unique_choice_poll_code.py b/polls/south_migrations/0009_auto__add_unique_choice_poll_code.py new file mode 100644 index 0000000..bb484bd --- /dev/null +++ b/polls/south_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/south_migrations/__init__.py b/polls/south_migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/polls/templates/polls/poll_detail.html b/polls/templates/polls/poll_detail.html index de49e57..18ae114 100644 --- a/polls/templates/polls/poll_detail.html +++ b/polls/templates/polls/poll_detail.html @@ -1,12 +1,11 @@ {% extends "base.html" %} {% load i18n %} -{% load polls %} {% 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/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..8e3879c --- /dev/null +++ b/polls/test/test_api.py @@ -0,0 +1,333 @@ +from datetime import timedelta +import json +import logging +import uuid + +from django.contrib.auth.models import Permission, User +from django.utils import timezone +from tastypie.test import ResourceTestCase +from tastypie.utils import make_naive + +from polls.models import Poll, Choice + + +logger = logging.getLogger(__name__) +# or '/polls/api/v1' in the case when we don't set 'urls' attribute +URL = '/api/v1' + + +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, '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', + 'add_choice', 'change_choice', 'delete_choice']) + self.admin.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, 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.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(admin=True)) + # logger.debug(resp) + self.assertValidJSONResponse(resp) + deserialized = self.deserialize(resp)['objects'] + self.assertEqual(deserialized[0], { + u'id': pk, + 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'], + u'allow_multi_votes': False, + }) + # 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 + 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(admin=True)) + self.assertHttpOK(resp) + 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()) + 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()) + self.assertHttpCreated(resp) + resp = self.api_client.get(self.getURL('result', id=pk), format='json', + 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]}) + + def test_anonymous_voting(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]) + 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) + 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_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) + 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 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 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)) + + def create_choice(self, choice_data): + return self.api_client.post(self.getURL('choice'), format='json', + 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(admin=True)) + 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': unicode(start_votes.isoformat(), 'utf-8'), + 'end_votes': unicode(end_votes.isoformat(), 'utf-8'), + } + + 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/test/test_models.py b/polls/test/test_models.py new file mode 100644 index 0000000..f2f5315 --- /dev/null +++ b/polls/test/test_models.py @@ -0,0 +1,216 @@ +""" +This file demonstrates writing tests using the unittest module. These will pass +when you run "manage.py test". + +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 +from django.core.urlresolvers import reverse +from polls.models import Poll, Choice, Vote +from polls.exceptions import 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') + poll, self.cids = create_poll_single() + self.poll_pk = poll.pk + + 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=[self.poll_pk])) + self.assertEqual(resp.status_code, 200) + # get nonexistent poll + 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=[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=[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=[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': self.cids[1]}) + + +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') + Choice.objects.all().delete() + + def tearDown(self): + pass + + def test_poll_methods(self): + poll, cids = create_poll_single() + self.assertEqual(Poll.objects.count(), 1) + self.assertEqual(poll.count_choices(), 3) + + def test_single_vote(self): + 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.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.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(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() + poll.vote([cids[0],cids[1]], self.user1) + self.assertRaises(PollNotAnonymous, poll.vote, [cids[1], cids[2]]) + + def test_multiple_vote_stat(self): + poll, cids = create_poll_multiple() + poll.vote([cids[0],cids[1]], self.user1) + 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.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() + 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, cids = create_poll_anonymous_single() + poll.vote([cids[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.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() + 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, cids = create_poll_anonymous_multiple() + poll.vote([cids[1]]) + 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(): + poll = Poll(question='How are you?', description='description') + poll.save() + 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() + 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() + 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() + 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] + + diff --git a/polls/tests.py b/polls/tests.py deleted file mode 100644 index 501deb7..0000000 --- a/polls/tests.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -This file demonstrates writing tests using the unittest module. These will pass -when you run "manage.py test". - -Replace this with more appropriate tests for your application. -""" - -from django.test import TestCase - - -class SimpleTest(TestCase): - def test_basic_addition(self): - """ - Tests that 1 + 1 always equals 2. - """ - self.assertEqual(1 + 1, 2) diff --git a/polls/urls.py b/polls/urls.py index 16c920e..d4739b2 100644 --- a/polls/urls.py +++ b/polls/urls.py @@ -1,11 +1,20 @@ -from django.conf.urls.defaults 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, NamespacedApi +from polls.api import UserResource, PollResource, ChoiceResource, VoteResource, ResultResource +v1_api = NamespacedApi(api_name='v1', urlconf_namespace='polls') +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/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/polls/views.py b/polls/views.py index 5597f31..ae3b9f2 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.already_voted(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) diff --git a/requirements.txt b/requirements.txt index b165490..ba4071b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ -django>=1.4.1 +django>=1.6.0 +django-tastypie +django-extensions==1.5.1 diff --git a/setup.py b/setup.py index 2550757..264c054 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,8 @@ def read(fname): packages=find_packages(), include_package_data=True, install_requires=[ - 'Django', + 'Django>=1.6,<1.8', + 'django-extensions>=1.3.11', + 'django-tastypie>=0.12.1', ], )