diff options
author | Magnus Hagander | 2010-04-13 20:13:07 +0000 |
---|---|---|
committer | Magnus Hagander | 2010-04-13 20:13:07 +0000 |
commit | 08f8c44685d84e2bef2e7a263dcc767da701e334 (patch) | |
tree | 1a9be3ec710dbd7fa90818095962780e2d7231a8 | |
parent | 9456315de8c169c9448d05010b7d68d0954b7fa6 (diff) |
Initial version of election management software.
Not yet linked in anywhere, so we can test it properly first.
-rw-r--r-- | media/css/layout.css | 32 | ||||
-rw-r--r-- | postgresqleu/elections/__init__.py | 0 | ||||
-rw-r--r-- | postgresqleu/elections/admin.py | 33 | ||||
-rw-r--r-- | postgresqleu/elections/forms.py | 94 | ||||
-rw-r--r-- | postgresqleu/elections/models.py | 28 | ||||
-rw-r--r-- | postgresqleu/elections/tests.py | 23 | ||||
-rw-r--r-- | postgresqleu/elections/views.py | 129 | ||||
-rw-r--r-- | postgresqleu/settings.py | 1 | ||||
-rw-r--r-- | postgresqleu/urls.py | 7 | ||||
-rw-r--r-- | template/elections/candidate.html | 12 | ||||
-rw-r--r-- | template/elections/form.html | 39 | ||||
-rw-r--r-- | template/elections/home.html | 33 | ||||
-rw-r--r-- | template/elections/memberfourweeks.html | 12 | ||||
-rw-r--r-- | template/elections/mustbemember.html | 12 | ||||
-rw-r--r-- | template/elections/ownvotes.html | 27 | ||||
-rw-r--r-- | template/elections/results.html | 40 |
16 files changed, 522 insertions, 0 deletions
diff --git a/media/css/layout.css b/media/css/layout.css index 46c0bda5..13890455 100644 --- a/media/css/layout.css +++ b/media/css/layout.css @@ -230,3 +230,35 @@ img { vertical-align: top; } +/* Elections */ +div.electionResultRow { + padding: 2px 2px 2px 2px; +} +div.electionHeaderRow { + font-weight: bold; + text-decoration: underline; + padding: 2px 2px 2px 2px; +} +div.electionResultCol1 { + display: inline-block; + width: 150px; +} +div.electionResultCol2 { + display: inline-block; + width: 50px; + text-align: right; +} +div.electionResultCol3 { + display: inline-block; + background-color: blue; +} +div.electionResultCol3Hdr { + display: inline-block; + width: 300px; +} +div.electionSeparator { + display: inline-block; + background-color: red; + height: 2px; + width: 510px; +} diff --git a/postgresqleu/elections/__init__.py b/postgresqleu/elections/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/postgresqleu/elections/__init__.py diff --git a/postgresqleu/elections/admin.py b/postgresqleu/elections/admin.py new file mode 100644 index 00000000..4deb6260 --- /dev/null +++ b/postgresqleu/elections/admin.py @@ -0,0 +1,33 @@ +from django.contrib import admin +from django import forms +from django.forms import ValidationError, ModelForm + +from datetime import datetime + +from models import * + +class VoteAdminForm(ModelForm): + class Meta: + model = Vote + + def clean(self): + raise ValidationError("You really shouldn't edit votes! If you *really* need to fix something broken, do it in the db") + +class VoteAdmin(admin.ModelAdmin): + list_display = ('election', 'voter', 'candidate', 'score') + ordering = ['election', ] + form = VoteAdminForm + + +class ElectionAdmin(admin.ModelAdmin): + list_display = ['name', 'startdate', 'enddate', ] + ordering = ['-startdate', ] + +class CandidateAdmin(admin.ModelAdmin): + list_display = ['name', 'election', ] + list_filter = ['election', ] + ordering = ['name', ] + +admin.site.register(Election, ElectionAdmin) +admin.site.register(Candidate, CandidateAdmin) +admin.site.register(Vote, VoteAdmin) diff --git a/postgresqleu/elections/forms.py b/postgresqleu/elections/forms.py new file mode 100644 index 00000000..0235d5cb --- /dev/null +++ b/postgresqleu/elections/forms.py @@ -0,0 +1,94 @@ +from django import forms +from django.forms.util import ErrorList +from django.db import transaction, connection + +from models import Vote +from postgresqleu.membership.models import MemberLog + +from datetime import datetime + +class VoteForm(forms.Form): + def __init__(self, election, member, *args, **kwargs): + super(VoteForm, self).__init__(*args, **kwargs) + + self.election = election + self.member = member + + self.candidates = election.candidate_set.all().order_by('name') + self.votes = Vote.objects.filter(election=election, voter=member) + + votemap = {} + for vote in self.votes: + votemap[vote.candidate_id] = vote.score + + dropdown = [(x,x) for x in range(1,len(self.candidates)+1)] + dropdown.insert(0, (-1, '** Please rate this candidate')) + + # Dynamically add a dropdown field for each candidate + for candidate in self.candidates: + self.fields['cand%i' % candidate.id] = forms.ChoiceField(choices=dropdown, + label=candidate.name, + required=True, + help_text=candidate.id, + initial=votemap.has_key(candidate.id) and votemap[candidate.id] or -1) + + def clean(self): + # First, make sure all existing fields are actually filled out + for (k,v) in self.cleaned_data.items(): + if k.startswith('cand'): + if v == "-1": + self._errors[k] = ErrorList(["You need to select a score for this candidate!"]) + else: + raise Exception("Invalid field name found: %s" % k) + + # Second, make sure the fields match the candidates + fields = self.cleaned_data.copy() + for candidate in self.candidates: + if fields.has_key("cand%i" % candidate.id): + del fields["cand%i" % candidate.id] + else: + raise Exception("Data for candidate %i is missing" % candidate.id) + + if len(fields) > 0: + raise Exception("Data for candidate not standing for election found!") + + # Finally, verify that all options have been found, and none have been duplicated + options = range(1, len(self.candidates)+1) + for k,v in self.cleaned_data.items(): + if int(v) in options: + # First use is ok. Take it out of the list, so next attempt generates error + del options[options.index(int(v))] + else: + # Not in the list means it was already used! Bad user! + self._errors[k] = ErrorList(["This score has already been given to another candidate"]) + + if len(options) != 0: + raise forms.ValidationError("One or more scores was not properly assigned!") + + return self.cleaned_data + + @transaction.commit_on_success + def save(self): + # Let's see if the old votes are here + if len(self.votes) == 0: + # This is completely new, let's create votes for him + for k,v in self.cleaned_data.items(): + id = int(k[4:]) + Vote(election=self.election, voter=self.member, candidate_id=id, score=v).save() + MemberLog(member=self.member, timestamp=datetime.now(), + message="Voted in election '%s'" % self.election.name).save() + elif len(self.votes) == len(self.candidates): + # Ok, we have one vote for each candidate already, so modify them as necessary + changedany = False + for vote in self.votes: + score = int(self.cleaned_data['cand%i' % vote.candidate_id]) + if vote.score != score: + vote.score = score + vote.save() + changedany = True + + if changedany: + MemberLog(member=self.member, timestamp=datetime.now(), + message="Changed votes in election '%s'" % self.election.name).save() + else: + raise Exception("Invalid number of records found in database, unable to update vote.") diff --git a/postgresqleu/elections/models.py b/postgresqleu/elections/models.py new file mode 100644 index 00000000..20a8b8a3 --- /dev/null +++ b/postgresqleu/elections/models.py @@ -0,0 +1,28 @@ +from django.db import models +from postgresqleu.membership.models import Member + +class Election(models.Model): + name = models.CharField(max_length=100, null=False, blank=False) + startdate = models.DateField(null=False, blank=False) + enddate = models.DateField(null=False, blank=False) + slots = models.IntegerField(null=False, default=1) + isopen = models.BooleanField(null=False, default=False) + resultspublic = models.BooleanField(null=False, default=False) + + def __unicode__(self): + return self.name + +class Candidate(models.Model): + election = models.ForeignKey(Election, null=False, blank=False) + name = models.CharField(max_length=100, null=False, blank=False) + email = models.EmailField(max_length=200, null=False, blank=False) + presentation = models.TextField(null=False, blank=False) + + def __unicode__(self): + return "%s (%s)" % (self.name, self.election) + +class Vote(models.Model): + election = models.ForeignKey(Election, null=False, blank=False) + voter = models.ForeignKey(Member, null=False, blank=False) + candidate = models.ForeignKey(Candidate, null=False, blank=False) + score = models.IntegerField(null=False, blank=False) diff --git a/postgresqleu/elections/tests.py b/postgresqleu/elections/tests.py new file mode 100644 index 00000000..2247054b --- /dev/null +++ b/postgresqleu/elections/tests.py @@ -0,0 +1,23 @@ +""" +This file demonstrates two different styles of tests (one doctest and one +unittest). These will both pass when you run "manage.py test". + +Replace these 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.failUnlessEqual(1 + 1, 2) + +__test__ = {"doctest": """ +Another way to test that 1 + 1 is equal to 2. + +>>> 1 + 1 == 2 +True +"""} + diff --git a/postgresqleu/elections/views.py b/postgresqleu/elections/views.py new file mode 100644 index 00000000..066c605a --- /dev/null +++ b/postgresqleu/elections/views.py @@ -0,0 +1,129 @@ +from django.shortcuts import render_to_response, get_object_or_404 +from django.http import HttpResponseRedirect, Http404 +from django.template import RequestContext +from django.contrib.auth.decorators import login_required, user_passes_test +from django.conf import settings +from django.db import connection + +from models import * +from forms import VoteForm +from datetime import date, timedelta + +def home(request): + elections = Election.objects.filter(isopen=True).order_by('startdate') + open_elections = [e for e in elections if e.startdate<=date.today() and e.enddate>=date.today()] + past_elections = [e for e in elections if e.startdate<date.today() and e.enddate<date.today()] + upcoming_elections = [e for e in elections if e.startdate>date.today()] + + return render_to_response('elections/home.html', { + 'open': open_elections, + 'past': past_elections, + 'upcoming': upcoming_elections, + }, context_instance=RequestContext(request)) + +def election(request, electionid): + if settings.FORCE_SECURE_FORMS and not request.is_secure(): + return HttpResponseRedirect(request.build_absolute_uri().replace('http://','https://',1)) + + election = get_object_or_404(Election, pk=electionid) + if not election.isopen: + raise Http404("This election is not open (yet)") + + if election.startdate > date.today(): + raise Http404("This election has not started yet") + + if election.enddate < date.today(): + # Election is closed, consider publishing the results + if not election.resultspublic: + # If user is an admin, show anyway, otherwise throw an error + if not request.user.is_superuser: + raise Http404("The results for this election isn't published yet.") + + # Ok, so we do have the results. Use a custom query to make sure we get decently formatted data + # and no client-side ORM aggregation + curs = connection.cursor() + curs.execute("SELECT c.name, sum(v.score) AS score FROM elections_candidate c INNER JOIN elections_vote v ON c.id=v.candidate_id WHERE v.election_id=%(election)s AND c.election_id=%(election)s GROUP BY c.name ORDER BY 2 DESC", { + 'election': election.pk, + }) + res = curs.fetchall() + if len(res) == 0: + raise Http404('No results found for this election') + + return render_to_response('elections/results.html', { + 'election': election, + 'topscore': res[0][1], + 'scores': [{'name': r[0], 'score': r[1], 'width': 300*r[1]/res[0][1]} for r in res], + }, context_instance=RequestContext(request)) + + if len(election.candidate_set.all()) <= 0: + raise Http404("This election has no candidates!") + + # Otherwise, we show up the form. This part requires login + if not request.user.is_authenticated(): + return HttpResponseRedirect("/login/?next=%s" % request.path) + + try: + member = Member.objects.get(user=request.user) + + # Make sure that the membership hasn't expired + if member.paiduntil < date.today(): + return render_to_response('elections/mustbemember.html', {}, + context_instance=RequestContext(request)) + + # Verify that the membership is new enough + # Since memberships are valid one year, the date of signup is considered to be + # member.paiduntil - '1 year'. + + if member.paiduntil - timedelta(days=365) > election.startdate - timedelta(days=28): + return render_to_response('elections/memberfourweeks.html', { + 'registered_at': member.paiduntil - timedelta(days=365), + 'mustregbefore': election.startdate - timedelta(days=28), + 'election': election, + }, context_instance=RequestContext(request)) + + except Member.DoesNotExist: + return render_to_response('elections/mustbemember.html', {}, + context_instance=RequestContext(request)) + + if request.method == "POST": + form = VoteForm(election, member, data=request.POST) + if form.is_valid(): + # Save the form + form.save() + else: + # Not a POST, so generate an empty form + form = VoteForm(election, member) + + + return render_to_response('elections/form.html', { + 'form': form, + 'election': election, + }, context_instance=RequestContext(request)) + +def candidate(request, election, candidate): + candidate = get_object_or_404(Candidate, election=election, pk=candidate) + + return render_to_response('elections/candidate.html', { + 'candidate': candidate, + }, context_instance=RequestContext(request)) + +@login_required +def ownvotes(request, electionid): + if settings.FORCE_SECURE_FORMS and not request.is_secure(): + return HttpResponseRedirect(request.build_absolute_uri().replace('http://','https://',1)) + + election = get_object_or_404(Election, pk=electionid) + if not election.isopen: + raise Http404("This election is not open (yet)") + + if election.enddate >= date.today(): + raise Http404("This election has not ended yet") + + member = get_object_or_404(Member, user=request.user) + + votes = Vote.objects.select_related().filter(voter=member, election=election).order_by('-score') + + return render_to_response('elections/ownvotes.html', { + 'election': election, + 'votes': votes, + }, context_instance=RequestContext(request)) diff --git a/postgresqleu/settings.py b/postgresqleu/settings.py index ed12bc54..0abe2851 100644 --- a/postgresqleu/settings.py +++ b/postgresqleu/settings.py @@ -83,6 +83,7 @@ INSTALLED_APPS = ( 'postgresqleu.newsevents', 'postgresqleu.confreg', 'postgresqleu.membership', + 'postgresqleu.elections', ) AUTH_CONNECTION_STRING="override authentication connection string in the local settings file" diff --git a/postgresqleu/urls.py b/postgresqleu/urls.py index 24221410..5c8220cb 100644 --- a/postgresqleu/urls.py +++ b/postgresqleu/urls.py @@ -9,6 +9,7 @@ import postgresqleu.newsevents.views import postgresqleu.views import postgresqleu.confreg.views import postgresqleu.membership.views +import postgresqleu.elections.views from postgresqleu.newsevents.feeds import LatestNews, LatestEvents @@ -56,6 +57,12 @@ urlpatterns = patterns('', (r'^membership/$', postgresqleu.membership.views.home), (r'^community/members/$', postgresqleu.membership.views.userlist), + # Elections + (r'^elections/$', postgresqleu.elections.views.home), + (r'^elections/(\d+)/$', postgresqleu.elections.views.election), + (r'^elections/(\d+)/candidate/(\d+)/$', postgresqleu.elections.views.candidate), + (r'^elections/(\d+)/ownvotes/$', postgresqleu.elections.views.ownvotes), + # This should not happen in production - serve by apache! url(r'^media/(.*)$', 'django.views.static.serve', { 'document_root': '../media', diff --git a/template/elections/candidate.html b/template/elections/candidate.html new file mode 100644 index 00000000..2fd7eb2e --- /dev/null +++ b/template/elections/candidate.html @@ -0,0 +1,12 @@ +{%extends "nav_membership.html"%} +{%load markup%} +{%block title%}Elections{%endblock%} +{%block content%} +<h1>Elections</h1> + +<h2>{{candidate.name}}</h2> + +{{candidate.presentation|markdown:"safe"}} + +<p><a href="../../">Back</a></p> +{%endblock%} diff --git a/template/elections/form.html b/template/elections/form.html new file mode 100644 index 00000000..abd1b12b --- /dev/null +++ b/template/elections/form.html @@ -0,0 +1,39 @@ +{%extends "nav_membership.html"%} +{%block title%}Elections{%endblock%} +{%block content%} +<h1>Elections</h1> + +<p> +To vote in {{election.name}}, rate all the candidates below. All +candidates must be rated for a vote to be valid. Give the highest score to the +candidate you prefer <i>the most</i>, and the lowest score to the candidate you +prefer <i>the least</i>. +</p> +<p> +You can click a candidates name for more information about tihs candidate. +</p> + +{%if form.errors%} +<p> +<b>NOTE!</b> Your submitted form contained errors and has <i>not</b> been saved! +</p> +{%endif%} + +<h2>Your votes</h2> + +<form method="post" action="."> +<table> +{%for field in form%} + <tr {%if field.errors%}bgcolor="red"{%endif%}> + <td><a href="candidate/{{field.help_text}}/">{{field.label_tag}}</a></td> + <td>{{field}}</td> + <td>{%if field.errors%}{{field.errors}}{%endif%}</td> + </tr> +{%endfor%} +</table> + +<input type="submit"> +</form> + +<p><a href="../">Back</a></p> +{%endblock%} diff --git a/template/elections/home.html b/template/elections/home.html new file mode 100644 index 00000000..7ca099c6 --- /dev/null +++ b/template/elections/home.html @@ -0,0 +1,33 @@ +{%extends "nav_membership.html"%} +{%block title%}Elections{%endblock%} +{%block content%} +<h1>Elections</h1> + +<h2>Open elections</h2> +<ul> +{%for e in open %} + <li><a href="{{e.id}}/">{{e.name}}</a> ({{e.startdate}} - {{e.enddate}})</li> +{%empty%} + <li>There are currently no open elections.</li> +{%endfor%} +</ul> + +<h2>Upcoming elections</h2> +<ul> +{%for e in upcoming %} + <li>{{e.name}} (opens {{e.startdate}})</li> +{%empty%} + <li>There are currently no upcoming elections.</li> +{%endfor%} +</ul> + +<h2>Past elections</h2> +<ul> +{%for e in past %} + <li>{%if e.resultspublic%}<a href="{{e.id}}/">{{e.name}}</a>{%else%}{{e.name}} (results are not published yet){%endif%}</li> +{%empty%} + <li>There are currently no past elections.</li> +{%endfor%} +</ul> + +{%endblock%} diff --git a/template/elections/memberfourweeks.html b/template/elections/memberfourweeks.html new file mode 100644 index 00000000..97100c31 --- /dev/null +++ b/template/elections/memberfourweeks.html @@ -0,0 +1,12 @@ +{%extends "nav_membership.html"%} +{%block title%}Elections{%endblock%} +{%block content%} +<h1>Elections</h1> + +<p> +You must have been a member for four weeks before the opening of this election +in order to vote. You became a member {{registered_at}}, which is sooner than +the required {{mustregbefore}}. +</p> + +{%endblock%} diff --git a/template/elections/mustbemember.html b/template/elections/mustbemember.html new file mode 100644 index 00000000..af5b7460 --- /dev/null +++ b/template/elections/mustbemember.html @@ -0,0 +1,12 @@ +{%extends "nav_membership.html"%} +{%block title%}Elections{%endblock%} +{%block content%} +<h1>Elections</h1> + +<p> +You must be an active member of PostgreSQL Europe in order to vote in this election, +and you must have been it for four weeks before the opening of the election. To view +the status of your membership, go to <a href="/membership/">Your Membership</a>. +</p> + +{%endblock%} diff --git a/template/elections/ownvotes.html b/template/elections/ownvotes.html new file mode 100644 index 00000000..325e0c6d --- /dev/null +++ b/template/elections/ownvotes.html @@ -0,0 +1,27 @@ +{%extends "nav_membership.html"%} +{%block title%}Elections{%endblock%} +{%block content%} +<h1>Elections</h1> + +<h2>Your votes - {{election.name}}</h2> +<p> +This is a list of the candidates you voted for in {{elections.name}}, and what score +you gave them. +</p> + +<table border="0"> +<tr> + <th>Candidate</th> + <th>Score</th> +</tr> +{%for vote in votes %} +<tr> + <td>{{vote.candidate.name}}</td> + <td>{{vote.score}}</td> +</tr> +{%endfor%} +</table> + +<p><a href="../">Back</a></p> + +{%endblock%} diff --git a/template/elections/results.html b/template/elections/results.html new file mode 100644 index 00000000..30ee548b --- /dev/null +++ b/template/elections/results.html @@ -0,0 +1,40 @@ +{%extends "nav_membership.html"%} +{%block title%}Elections{%endblock%} +{%block content%} +<h1>Elections</h1> + +<h2>Results - {{election.name}}</h2> +<p> +This election is closed, and these are the results. The red line indicates which candidates +were voted in. If you voted in this election, you can review <a href="ownvotes/">your votes</a>. +</p> +{%if not election.resultspublic%} +<p> +<b> +WARNING! The results for this election has <i>not</i> been published yet! These are preliminary +results, and should be verified and then published! +</b> +</p> +{%endif%} + +<div id="electionResultsWrap"> + <div class="electionHeaderRow"> + <div class="electionResultCol1">Name</div> + <div class="electionResultCol2">Score</div> + <div class="electionResultCol3Hdr">Relative score</div> + </div> + +{%for score in scores%} + <div class="electionResultRow"> + <div class="electionResultCol1">{{score.name}}</div> + <div class="electionResultCol2">{{score.score}}</div> + <div class="electionResultCol3" style="width: {{score.width}}px;"> </div> + </div> + {%ifequal forloop.counter election.slots %} + <div><div class="electionSeparator"> </div></div> + {%endifequal%} +{%endfor%} +</div> + +<p><a href="../">Back</a></p> +{%endblock%} |