Add preferential voting support. Closes #4

This commit is contained in:
RunasSudo 2017-11-27 22:56:43 +11:00
parent 5f05450ea4
commit c2d3b4ab93
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
13 changed files with 265 additions and 8 deletions

View File

@ -101,6 +101,17 @@ class ApprovalQuestion(Question):
class ApprovalAnswer(Answer): class ApprovalAnswer(Answer):
choices = ListField(IntField()) choices = ListField(IntField())
class PreferentialQuestion(Question):
choices = ListField(StringField())
min_choices = IntField()
max_choices = IntField()
def pretty_answer(self, answer):
return ', '.join([self.choices[choice] for choice in answer.choices])
class PreferentialAnswer(Answer):
choices = ListField(IntField())
class RawResult(Result): class RawResult(Result):
answers = EmbeddedObjectListField() answers = EmbeddedObjectListField()

View File

@ -17,6 +17,7 @@
], ],
"dependencies": { "dependencies": {
"semantic": "semantic-ui#^2.2.13", "semantic": "semantic-ui#^2.2.13",
"nunjucks": "^3.0.1" "nunjucks": "^3.0.1",
"dragula.js": "dragula#^3.7.2"
} }
} }

View File

@ -109,7 +109,7 @@ def setup_test_election():
election.sk = EGPrivateKey.generate() election.sk = EGPrivateKey.generate()
election.public_key = election.sk.public_key election.public_key = election.sk.public_key
question = ApprovalQuestion(prompt='President', choices=['John Smith', 'Joe Bloggs', 'John Q. Public'], min_choices=0, max_choices=2) question = PreferentialQuestion(prompt='President', choices=['John Smith', 'Joe Bloggs', 'John Q. Public'], min_choices=0, max_choices=3)
election.questions.append(question) election.questions.append(question)
question = ApprovalQuestion(prompt='Chairman', choices=['John Doe', 'Andrew Citizen'], min_choices=0, max_choices=1) question = ApprovalQuestion(prompt='Chairman', choices=['John Doe', 'Andrew Citizen'], min_choices=0, max_choices=1)

View File

@ -27,6 +27,12 @@ model_view_map = {
Election: { Election: {
'tabs': 'election/core/tabs.html' 'tabs': 'election/core/tabs.html'
}, },
PreferentialQuestion: {
'view': 'question/preferential/view.html',
'result_raw': 'question/preferential/result_raw.html',
'selections_make': 'question/preferential/selections_make.html',
'selections_review': 'question/preferential/selections_review.html'
},
PSRElection: { PSRElection: {
'tabs': 'election/psr/tabs.html' 'tabs': 'election/psr/tabs.html'
} }

View File

@ -36,3 +36,60 @@
margin-top: 4em; margin-top: 4em;
} }
} }
/* Preferential voting */
.preferential-choices {
padding: 0.5em;
position: relative;
}
.preferential-choices .dragarea {
min-height: 3em;
margin-top: 0.5em;
}
.preferential-choices .dragarea-hint:first-child:last-child {
content: "";
width: calc(100% - 1em);
height: 3em;
box-sizing: border-box;
z-index: -100;
border: 1px dashed #555;
position: absolute;
}
#question-choices-selected {
border: 1px solid #3465a4;
margin-bottom: 0.5em;
}
.preferential-choice {
background-color: #eee;
font-size: 1.1rem;
margin-top: 0.5em;
display: flex;
align-items: center;
min-height: 3em;
}
.preferential-choice:first-child {
margin-top: 0;
}
#question-choices-selected .preferential-choice {
background-color: #e6f1fc;
}
#question-choices-remaining {
border: 1px solid #555;
}
.preferential-choice .number, .preferential-choice .name {
padding: 0.5em 0 0.5em 0.5em;
}
.preferential-choice .number {
width: 2em;
text-align: center;
}

View File

@ -18,7 +18,7 @@
<h2>{{ questionNum + 1 }}. {{ election.questions.__getitem__(questionNum).prompt }}</h2> <h2>{{ questionNum + 1 }}. {{ election.questions.__getitem__(questionNum).prompt }}</h2>
<p><small>Vote for between {{ election.questions.__getitem__(questionNum).min_choices }} and {{ election.questions.__getitem__(questionNum).max_choices }} candidates. Click the check-boxes to the left of the candidates' names to make your selection, then click the ‘Continue’ button. If you make a mistake, click the check-boxes again to clear your selection.</small></p> <p><small>Vote for between {{ election.questions.__getitem__(questionNum).min_choices }} and {{ election.questions.__getitem__(questionNum).max_choices }} choices. Click the check-boxes to the left of the choices to make your selection, then click the ‘Continue’ button. If you make a mistake, click the check-boxes again to clear your selection.</small></p>
<div id="question-choices" class="ui form" style="margin-bottom: 1em;"> <div id="question-choices" class="ui form" style="margin-bottom: 1em;">
<div class="grouped fields"> <div class="grouped fields">
@ -34,7 +34,7 @@
</div> </div>
<div class="ui hidden message" id="message-max-choices"> <div class="ui hidden message" id="message-max-choices">
<p>You have now selected the maximum allowed number of candidates. If you wish to change your selections, you must first use the check-boxes to deselect a candidate.</p> <p>You have now selected the maximum allowed number of choices. If you wish to change your selections, you must first use the check-boxes to deselect a choice.</p>
</div> </div>
<script> <script>
@ -52,7 +52,7 @@
// Fill in ballot with previous selections // Fill in ballot with previous selections
if (booth.answers[booth.questionNum]) { if (booth.answers[booth.questionNum]) {
for (var selection of booth.answers.__getitem__(booth.questionNum).value.choices) { // Answer already serialised for (var selection of booth.answers[booth.questionNum].value.choices) { // Answer already serialised
$("#question-choice-" + selection).prop("checked", true); $("#question-choice-" + selection).prop("checked", true);
} }
choicesChanged(); choicesChanged();
@ -61,7 +61,7 @@
function saveSelections() { function saveSelections() {
selections = []; selections = [];
$("#question-choices input:checked").each(function(i, el) { $("#question-choices input:checked").each(function(i, el) {
selections.push(parseInt(el.id.substring("question-choice-".length))) selections.push(parseInt(el.id.substring("question-choice-".length)));
}); });
answer = eosjs.eos.base.election.__all__.ApprovalAnswer(eosjs.__kwargtrans__({choices: selections})); answer = eosjs.eos.base.election.__all__.ApprovalAnswer(eosjs.__kwargtrans__({choices: selections}));
booth.answers[booth.questionNum] = eosjs.eos.core.objects.__all__.EosObject.serialise_and_wrap(answer); booth.answers[booth.questionNum] = eosjs.eos.core.objects.__all__.EosObject.serialise_and_wrap(answer);

View File

@ -25,13 +25,13 @@
{% else %} {% else %}
<div class="item"> <div class="item">
<i class="warning circle icon"></i> <i class="warning circle icon"></i>
<div class="content">No candidates selected</div> <div class="content">No choices selected</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% if booth.answers[loop.index0].value.choices.length < question.max_choices %} {% if booth.answers[loop.index0].value.choices.length < question.max_choices %}
<div class="ui warning message"> <div class="ui warning message">
<p>You have selected fewer than the maximum allowed number of candidates. If this was not your intention, please click the ‘Back’ button below now, and correct your selections.</p> <p>You have selected fewer than the maximum allowed number of choices. If this was not your intention, please click the ‘Back’ button below now, and correct your selections.</p>
</div> </div>
{% endif %} {% endif %}

View File

@ -0,0 +1,80 @@
{#
Eos - Verifiable elections
Copyright © 2017 RunasSudo (Yingtong Li)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
<h2>{{ questionNum + 1 }}. {{ election.questions.__getitem__(questionNum).prompt }}</h2>
<p><small>Vote for between {{ election.questions.__getitem__(questionNum).min_choices }} and {{ election.questions.__getitem__(questionNum).max_choices }} choices. Click and drag the choices from the grey box to the blue box in order from most-preferred to least-preferred. It is in your best interests to vote for as many choices as you can.</small></p>
<div id="question-choices-selected" class="preferential-choices">
<div style="color: #3465a4;">Options voted for:</div>
<div class="dragarea">
<div class="dragarea-hint"></div>
</div>
</div>
<div id="question-choices-remaining" class="preferential-choices">
<div>Options not yet voted for:</div>
<div class="dragarea">
<div class="dragarea-hint"></div>
{% for choice in election.questions.__getitem__(questionNum).choices.impl %}
<div class="preferential-choice" data-choiceno="{{ loop.index0 }}">
<div class="number"></div>
<div class="name">{{ choice }}</div>
</div>
{% endfor %}
</div>
</div>
<div class="ui hidden message" id="message-max-choices">
<p>You have now selected the maximum allowed number of choices. If you wish to change your selections, you must first use the check-boxes to deselect a choice.</p>
</div>
<script>
function choicesChanged() {
// Recalculate numbers
$(".preferential-choices .preferential-choice .number").each(function(i, el) {
$(el).text("");
});
$("#question-choices-selected .preferential-choice .number").each(function(i, el) {
$(el).text(i + 1);
});
}
// Fill in ballot with previous selections
if (booth.answers[booth.questionNum]) {
for (var selection of booth.answers[booth.questionNum].value.choices) { // Answer already serialised
$(".preferential-choice[data-choiceno=" + selection + "]").detach().appendTo("#question-choices-selected .dragarea");
}
choicesChanged();
}
var dragulaChoices = dragula([document.querySelector("#question-choices-selected .dragarea"), document.querySelector("#question-choices-remaining .dragarea")], { moves: function(el, source, handle, sibling) { return !("dragarea-hint" in el.classList); } });
dragulaChoices.on("drop", function(el, target, source, sibling) {
choicesChanged();
});
function saveSelections() {
selections = [];
$("#question-choices-selected .preferential-choice").each(function(i, el) {
selections.push(parseInt(el.dataset.choiceno));
});
answer = eosjs.eos.base.election.__all__.PreferentialAnswer(eosjs.__kwargtrans__({choices: selections}));
booth.answers[booth.questionNum] = eosjs.eos.core.objects.__all__.EosObject.serialise_and_wrap(answer);
}
</script>

View File

@ -0,0 +1,40 @@
{#
Eos - Verifiable elections
Copyright © 2017 RunasSudo (Yingtong Li)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% if booth.answers[loop.index0].value.choices.length > 0 %}
<div class="ui ordered list">
{% for choice in booth.answers[loop.index0].value.choices %}
<div class="item">
<div class="content">{{ question.choices.__getitem__(choice) }}</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="ui list">
<div class="item">
<i class="warning circle icon"></i>
<div class="content">No choices selected</div>
</div>
</div>
{% endif %}
{% if booth.answers[loop.index0].value.choices.length < question.max_choices %}
<div class="ui warning message">
<p>You have selected fewer than the maximum allowed number of choices. If this was not your intention, please click the ‘Back’ button below now, and correct your selections.</p>
</div>
{% endif %}

View File

@ -20,6 +20,11 @@
{% block title %}{{ election.name }} – Voting booth{% endblock %} {% block title %}{{ election.name }} – Voting booth{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='bower_components/dragula.js/dist/dragula.min.css') }}" type="text/css">
{% endblock %}
{% block content %} {% block content %}
<div class="ui container" id="booth-content"> <div class="ui container" id="booth-content">
<div class="ui active text loader">Loading voting booth. Please wait.</div> <div class="ui active text loader">Loading voting booth. Please wait.</div>
@ -28,8 +33,12 @@
{% block basecontent %} {% block basecontent %}
{{ super() }} {{ super() }}
<script src="{{ url_for('static', filename='bower_components/nunjucks/browser/nunjucks.min.js') }}"></script> <script src="{{ url_for('static', filename='bower_components/nunjucks/browser/nunjucks.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/eosjs.js') }}"></script> <script src="{{ url_for('static', filename='js/eosjs.js') }}"></script>
<script src="{{ url_for('static', filename='bower_components/dragula.js/dist/dragula.min.js') }}"></script>
<script> <script>
var templates = {}; var templates = {};
var election = null; var election = null;

View File

@ -16,6 +16,8 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
#} #}
<p><small>Approval voting. Vote for between {{ question.min_choices }} and {{ question.max_choices }} choices.</small></p>
<ul class="ui list"> <ul class="ui list">
{% for choice in question.choices %} {% for choice in question.choices %}
<li>{{ choice }}</li> <li>{{ choice }}</li>

View File

@ -0,0 +1,26 @@
{#
Eos - Verifiable elections
Copyright © 2017 RunasSudo (Yingtong Li)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
<table class="ui celled table">
{% for answer, num in election.results[loop.index0].count() %}
<tr>
<td>{{ question.pretty_answer(answer) }}</td>
<td>{{ num }}</td>
</tr>
{% endfor %}
</table>

View File

@ -0,0 +1,25 @@
{#
Eos - Verifiable elections
Copyright © 2017 RunasSudo (Yingtong Li)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
<p><small>Preferential voting. Vote for between {{ question.min_choices }} and {{ question.max_choices }} choices.</small></p>
<ul class="ui list">
{% for choice in question.choices %}
<li>{{ choice }}</li>
{% endfor %}
</ul>