summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMagnus Hagander2010-04-13 20:13:07 +0000
committerMagnus Hagander2010-04-13 20:13:07 +0000
commit08f8c44685d84e2bef2e7a263dcc767da701e334 (patch)
tree1a9be3ec710dbd7fa90818095962780e2d7231a8
parent9456315de8c169c9448d05010b7d68d0954b7fa6 (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.css32
-rw-r--r--postgresqleu/elections/__init__.py0
-rw-r--r--postgresqleu/elections/admin.py33
-rw-r--r--postgresqleu/elections/forms.py94
-rw-r--r--postgresqleu/elections/models.py28
-rw-r--r--postgresqleu/elections/tests.py23
-rw-r--r--postgresqleu/elections/views.py129
-rw-r--r--postgresqleu/settings.py1
-rw-r--r--postgresqleu/urls.py7
-rw-r--r--template/elections/candidate.html12
-rw-r--r--template/elections/form.html39
-rw-r--r--template/elections/home.html33
-rw-r--r--template/elections/memberfourweeks.html12
-rw-r--r--template/elections/mustbemember.html12
-rw-r--r--template/elections/ownvotes.html27
-rw-r--r--template/elections/results.html40
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;">&nbsp;</div>
+ </div>
+ {%ifequal forloop.counter election.slots %}
+ <div><div class="electionSeparator">&nbsp;</div></div>
+ {%endifequal%}
+{%endfor%}
+</div>
+
+<p><a href="../">Back</a></p>
+{%endblock%}