diff --git a/config/assets.cfg b/config/assets.cfg index 703871e..1884ed4 100644 --- a/config/assets.cfg +++ b/config/assets.cfg @@ -12,7 +12,7 @@ hash-name = false [bootstrap] recipe = hexagonit.recipe.download -url = https://github.com/twbs/bootstrap/releases/download/v3.3.1/bootstrap-3.3.1-dist.zip +url = https://github.com/twbs/bootstrap/releases/download/v3.3.5/bootstrap-3.3.5-dist.zip hash-name = false strip-top-level-dir = true diff --git a/pylab/core/migrations/0001_initial.py b/pylab/core/migrations/0001_initial.py index 91d4dd4..73e8ef2 100644 --- a/pylab/core/migrations/0001_initial.py +++ b/pylab/core/migrations/0001_initial.py @@ -12,6 +12,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('website', '0002_auto_20150728_0303'), ] operations = [ diff --git a/pylab/core/migrations/0006_auto_20150806_0437.py b/pylab/core/migrations/0006_auto_20150806_0437.py new file mode 100644 index 0000000..94856ce --- /dev/null +++ b/pylab/core/migrations/0006_auto_20150806_0437.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings +import django_extensions.db.fields +import django.utils.timezone +import autoslug.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0005_auto_20150730_0231'), + ] + + operations = [ + migrations.CreateModel( + name='Vote', + fields=[ + ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)), + ('created', django_extensions.db.fields.CreationDateTimeField(editable=False, default=django.utils.timezone.now, blank=True)), + ('modified', django_extensions.db.fields.ModificationDateTimeField(editable=False, default=django.utils.timezone.now, blank=True)), + ('vote_time', models.DateTimeField(null=True)), + ('points', models.IntegerField(verbose_name='Points', null=True)), + ('project', models.ForeignKey(to='core.Project')), + ('voter', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='VotingPoll', + fields=[ + ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)), + ('created', django_extensions.db.fields.CreationDateTimeField(editable=False, default=django.utils.timezone.now, blank=True)), + ('modified', django_extensions.db.fields.ModificationDateTimeField(editable=False, default=django.utils.timezone.now, blank=True)), + ('slug', autoslug.fields.AutoSlugField(editable=False)), + ('title', models.CharField(verbose_name='Title', max_length=255)), + ('description', models.TextField(blank=True, verbose_name='Description')), + ('author', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='vote', + name='voting_poll', + field=models.ForeignKey(to='core.VotingPoll'), + ), + ] diff --git a/pylab/core/migrations/0007_auto_20150808_1812.py b/pylab/core/migrations/0007_auto_20150808_1812.py new file mode 100644 index 0000000..9bbfee6 --- /dev/null +++ b/pylab/core/migrations/0007_auto_20150808_1812.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_auto_20150806_0437'), + ] + + operations = [ + migrations.AlterField( + model_name='vote', + name='points', + field=models.PositiveIntegerField(verbose_name='Points', null=True, blank=True), + ), + ] diff --git a/pylab/core/migrations/0008_auto_20150809_0654.py b/pylab/core/migrations/0008_auto_20150809_0654.py new file mode 100644 index 0000000..2a8e6c5 --- /dev/null +++ b/pylab/core/migrations/0008_auto_20150809_0654.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0007_auto_20150808_1812'), + ] + + operations = [ + migrations.RenameField( + model_name='vote', + old_name='vote_time', + new_name='voted', + ), + ] diff --git a/pylab/core/models.py b/pylab/core/models.py index 3a76dcb..589a94b 100644 --- a/pylab/core/models.py +++ b/pylab/core/models.py @@ -56,3 +56,28 @@ def __str__(self): def get_absolute_url(self): return reverse('event-details', args=[self.starts.year, self.starts.month, self.starts.day, self.slug]) + + +class VotingPoll(models.Model): + created = CreationDateTimeField() + modified = ModificationDateTimeField() + author = models.ForeignKey(User) + slug = AutoSlugField(populate_from='title') + title = models.CharField(_("Title"), max_length=255) + description = models.TextField(_("Description"), blank=True) + + def __str__(self): + return self.title + + +class Vote(models.Model): + created = CreationDateTimeField() + modified = ModificationDateTimeField() + voted = models.DateTimeField(null=True) + voting_poll = models.ForeignKey(VotingPoll) + voter = models.ForeignKey(User) + project = models.ForeignKey(Project) + points = models.PositiveIntegerField(_("Points"), null=True, blank=True) + + def __str__(self): + return '%s, %s' % (self.voting_poll.title, self.voter.get_full_name() or self.voter.get_username()) diff --git a/pylab/website/admin.py b/pylab/website/admin.py index e45f3dd..654ec21 100644 --- a/pylab/website/admin.py +++ b/pylab/website/admin.py @@ -10,7 +10,7 @@ import allauth.socialaccount.admin as allauth -from pylab.core.models import Project, Event +import pylab.core.models as core_models class AdminSite(admin.AdminSite): @@ -50,8 +50,10 @@ def get_changeform_initial_data(self, request): site = AdminSite() site.register(auth_models.User, auth_admin.UserAdmin) -site.register(Project) -site.register(Event, EventAdmin) + +site.register(core_models.Project) +site.register(core_models.Event, EventAdmin) +site.register(core_models.VotingPoll) site.register(allauth.SocialApp, allauth.SocialAppAdmin) site.register(allauth.SocialToken, allauth.SocialTokenAdmin) diff --git a/pylab/website/forms.py b/pylab/website/forms.py index 2f8598e..fb0b7f2 100644 --- a/pylab/website/forms.py +++ b/pylab/website/forms.py @@ -1,8 +1,11 @@ +import datetime + from django import forms +from django.forms.models import BaseModelFormSet from django.utils.translation import ugettext from django.utils.translation import ugettext_lazy as _ -from pylab.core.models import Project, Event +from pylab.core.models import Project, Event, Vote class ProjectForm(forms.ModelForm): @@ -49,3 +52,32 @@ def clean(self): if title and starts: self.check_existing_events(title, starts) + + +class VotePointsForm(forms.ModelForm): + class Meta: + model = Vote + fields = ('points',) + widgets = { + 'points': forms.NumberInput(attrs={'max': 99, 'class': 'vote-points-input'}), + } + + def save(self, commit=True, *args, **kwargs): + vote = super(VotePointsForm, self).save(commit=False, *args, **kwargs) + vote.voted = datetime.datetime.now() + vote.save() + + +class BaseTotalPointsFormset(BaseModelFormSet): + def clean(self, *args, **kwargs): + super(BaseTotalPointsFormset, self).clean(*args, **kwargs) + total_points = 0 + + for form in self.forms: + if form.cleaned_data['points']: + total_points += form.cleaned_data['points'] + + if total_points < 0 or total_points > 15: + raise forms.ValidationError(ugettext( + "Sum of voting points is out of bounds. Expected from 0 to 15, but got %s." + ) % total_points) diff --git a/pylab/website/models.py b/pylab/website/models.py deleted file mode 100644 index e69de29..0000000 diff --git a/pylab/website/static/css/main.scss b/pylab/website/static/css/main.scss index 98687e7..e920baa 100644 --- a/pylab/website/static/css/main.scss +++ b/pylab/website/static/css/main.scss @@ -50,3 +50,23 @@ .event-time { padding-left: 16px; } + +.vote-points-input { + width: 38px; + text-align: right; + font-weight: bold; +} + +.vote-points-form { + padding-top: 10px; + padding-left: 30px; + padding-bottom: 30px; +} + +.vote-button { + margin-top: 15px; +} + +#total-points { + display: none; +} diff --git a/pylab/website/static/js/update_points_left.js b/pylab/website/static/js/update_points_left.js new file mode 100644 index 0000000..969d811 --- /dev/null +++ b/pylab/website/static/js/update_points_left.js @@ -0,0 +1,27 @@ +var total_points = Number($('#total-points').html()); +function count_points_left () { + var sum = 0; + $('.vote-points-input').each(function() { sum += Number($(this).val()); }); + return total_points - sum; +}; +$('#points-left').html(count_points_left()); +$('.vote-points-input').each(function() { + this.oldValue = this.value; +}); + +function clear_value_if_zero(elem) { + if (elem.value === "0") { + elem.value = ""; + }; +}; + +$('.vote-points-input').change(function (ev) { + var points_left = count_points_left(); + if (points_left < 0) { + this.value = this.oldValue; + } else { + this.oldValue = this.value; + $('#points-left').html(points_left); + }; + clear_value_if_zero(this); +}); diff --git a/pylab/website/templates/base.html b/pylab/website/templates/base.html index e02d853..83853cd 100644 --- a/pylab/website/templates/base.html +++ b/pylab/website/templates/base.html @@ -55,5 +55,7 @@ + {% block scripts %} + {% endblock %} diff --git a/pylab/website/templates/website/voting_page.html b/pylab/website/templates/website/voting_page.html new file mode 100644 index 0000000..2967c91 --- /dev/null +++ b/pylab/website/templates/website/voting_page.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% load trans from i18n %} +{% load static from staticfiles %} +{% load markdown from websitetags %} + + +{% block content %} + +

{{ voting_poll.title }}

+ +

{{ voting_poll.description|markdown }}

+ +
+ {% trans "Points left: " %} + + {{ total_points }} + +
{% csrf_token %} + {{ formset.non_form_errors }} + {{ formset.management_form }} + {% for form in formset %} +
+ {{ form.errors }} +

+ {% for hidden in form.hidden_fields %} + {{ hidden }} + {% endfor %} + {% for field in form.visible_fields %} +

+ {{ field.errors }} + {{ field }} {{ form.instance.project.title }} +
+ {% endfor %} +

+
+ {% endfor %} + +
+
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} + diff --git a/pylab/website/templatetags/debug.py b/pylab/website/templatetags/debug.py new file mode 100644 index 0000000..ae1d9fe --- /dev/null +++ b/pylab/website/templatetags/debug.py @@ -0,0 +1,10 @@ +import ipdb + +from django import template + +register = template.Library() + + +@register.simple_tag(name='pdb', takes_context=True) +def pdb(context, *args, **kwargs): # pylint: disable=unused-argument + ipdb.set_trace() diff --git a/pylab/website/tests/test_projects.py b/pylab/website/tests/test_projects.py new file mode 100644 index 0000000..13c1757 --- /dev/null +++ b/pylab/website/tests/test_projects.py @@ -0,0 +1,42 @@ +import django_webtest + +import django.contrib.auth.models as auth_models + +from pylab.core.models import Project + + +class StaticPagesTests(django_webtest.WebTest): + def setUp(self): + super().setUp() + auth_models.User.objects.create_user('u1') + + def test_project_creation_and_editing(self): + resp = self.app.get('/projects/create/', user='u1') + self.assertEqual(resp.status_int, 200) + + resp.form['title'] = 'Test project' + resp.form['description'] = 'Test description' + resp = resp.form.submit() + self.assertEqual(resp.status_int, 302) + + resp = self.app.get('/projects/test-project/', user='u1') + self.assertEqual(resp.status_int, 200) + + self.assertEqual( + list(Project.objects.values_list('title', 'description')), + [('Test project', 'Test description')] + ) + + resp = self.app.get('/projects/test-project/update/', user='u1') + resp.form['title'] = 'Test project2' + resp.form['description'] = 'Test description2' + resp = resp.form.submit() + self.assertEqual(resp.status_int, 302) + + self.assertEqual( + list(Project.objects.values_list('title', 'description')), + [('Test project2', 'Test description2')] + ) + + resp = self.app.get('/projects/test-project/', user='u1') + self.assertEqual(resp.status_int, 200) diff --git a/pylab/website/tests/test_index_page.py b/pylab/website/tests/test_static_pages.py similarity index 57% rename from pylab/website/tests/test_index_page.py rename to pylab/website/tests/test_static_pages.py index 8cb957e..bfd3444 100644 --- a/pylab/website/tests/test_index_page.py +++ b/pylab/website/tests/test_static_pages.py @@ -3,7 +3,7 @@ import django.contrib.auth.models as auth_models -class IndexPageTests(django_webtest.WebTest): +class StaticPagesTests(django_webtest.WebTest): def setUp(self): super().setUp() auth_models.User.objects.create_user('u1') @@ -15,3 +15,11 @@ def test_index_page_with_anonymous_user(self): def test_index_page_with_logged_in_user(self): resp = self.app.get('/', user='u1') self.assertEqual(resp.status_int, 200) + + def test_about_page_with_anonymous_user(self): + resp = self.app.get('/about/') + self.assertEqual(resp.status_int, 200) + + def test_about_page_with_logged_in_user(self): + resp = self.app.get('/about/', user='u1') + self.assertEqual(resp.status_int, 200) diff --git a/pylab/website/tests/test_voting.py b/pylab/website/tests/test_voting.py new file mode 100644 index 0000000..34c1eaf --- /dev/null +++ b/pylab/website/tests/test_voting.py @@ -0,0 +1,49 @@ +import datetime + +from django_webtest import WebTest + +from django.contrib.auth.models import User + +from pylab.core.models import Project, VotingPoll, Vote + + +class VotingTests(WebTest): + + def test_voting_page(self): + u1 = User.objects.create(username='u1') + + p1 = Project.objects.create(author=u1, title='Test title 1', description='Test description') + p2 = Project.objects.create(author=u1, title='Test title 2', description='Test description') + + vp = VotingPoll.objects.create(author=u1, title='Test voting poll', description='Test description') + + Vote.objects.create(voter=u1, voting_poll=vp, project=p1) + Vote.objects.create(voter=u1, voting_poll=vp, project=p2) + + resp = self.app.get('/vote/test-voting-poll/', user='u1') + self.assertEqual(resp.status_int, 200) + + time_before_vote = datetime.datetime.now() + + resp.form['form-0-points'].value = 3 + resp.form['form-1-points'].value = 2 + resp = resp.form.submit() + self.assertEqual(resp.status_int, 302) + + time_after_vote = datetime.datetime.now() + + self.assertEqual(list(Vote.objects.values_list('points', flat=True)), [3, 2]) + + for v in Vote.objects.all(): + self.assertLess(time_before_vote, v.voted) + self.assertGreater(time_after_vote, v.voted) + + resp = self.app.get('/vote/test-voting-poll/', user='u1') + self.assertEqual(resp.status_int, 200) + + time_before_vote = datetime.datetime.now() + + resp.form['form-0-points'].value = 30 # Vote points sum should be less or equal to 15 + resp.form['form-1-points'].value = 20 + resp = resp.form.submit() + self.assertEqual(resp.status_int, 200) diff --git a/pylab/website/urls.py b/pylab/website/urls.py index 39dd7cb..1da3a68 100644 --- a/pylab/website/urls.py +++ b/pylab/website/urls.py @@ -15,6 +15,7 @@ url(r'^events/%s/$' % event, views.event_details, name='event-details'), url(r'^events/%s/create-next-weekly-event/$' % event, views.create_weekly_event, name='create-weekly-event'), url(r'^about/$', views.about, name='about'), + url(r'^vote/(?P%s)/$' % slug, views.voting_page, name='voting-page'), ] urlpatterns += [ diff --git a/pylab/website/views.py b/pylab/website/views.py index 4b6ddc3..36d0f9a 100644 --- a/pylab/website/views.py +++ b/pylab/website/views.py @@ -1,11 +1,12 @@ -from django.shortcuts import render -from django.shortcuts import redirect +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.forms.models import modelformset_factory from django.shortcuts import get_object_or_404 +from django.shortcuts import redirect +from django.shortcuts import render from django.utils.translation import ugettext -from django.contrib.auth.decorators import login_required -from django.contrib import messages -from pylab.core.models import Project, Event +from pylab.core.models import Project, Event, Vote, VotingPoll from pylab.website.helpers import formrenderer from pylab.website.helpers.decorators import superuser_required from pylab.website.services import weeklyevents @@ -95,3 +96,31 @@ def create_weekly_event(request, year, month, day, slug): submit=ugettext("Announce"), ), }) + + +@login_required +def voting_page(request, voting_poll_slug): + voting_poll = get_object_or_404(VotingPoll, slug=voting_poll_slug) + total_points = 15 + VotePointsFormSet = modelformset_factory( + Vote, + form=website_forms.VotePointsForm, + formset=website_forms.BaseTotalPointsFormset, + extra=0, + ) + vote_qs = Vote.objects.filter(voter=request.user, voting_poll__slug=voting_poll_slug) + + if request.method == 'POST': + formset = VotePointsFormSet(request.POST, queryset=vote_qs) + if formset.is_valid(): + formset.save() + messages.success(request, ugettext("Vote for ā€ž%sā€œ voting poll was saved successfully." % voting_poll)) + return redirect('project-list') + else: + formset = VotePointsFormSet(queryset=vote_qs) + + return render(request, 'website/voting_page.html', { + 'voting_poll': voting_poll, + 'formset': formset, + 'total_points': total_points, + }) diff --git a/setup.py b/setup.py index 411b73f..c0ebfe8 100644 --- a/setup.py +++ b/setup.py @@ -17,6 +17,7 @@ 'django-allauth', 'factory_boy', 'fake-factory', + 'ipdb', 'unidecode', 'markdown', 'yattag',