Majority calculation for assignments.

Majorities when config YN(A) and simple voting method.
This commit is contained in:
Maximilian Krambach 2016-10-26 18:32:00 +02:00 committed by Norman Jäckel
parent 9440842e6a
commit 32aaaf5b9e
7 changed files with 294 additions and 136 deletions

View File

@ -15,6 +15,7 @@ Agenda:
Assignments:
- Remove unused assignment config to publish winner election results only.
- Added options to calculate percentages on different bases.
- Added majority calculation.
Core:
- Added support for big assemblies with lots of users.

View File

@ -44,6 +44,17 @@ def get_config_variables():
group='Elections',
subgroup='Ballot and ballot papers')
# TODO: Add server side validation of the choices.
yield ConfigVariable(
name='assignments_poll_default_majority_method',
default_value='simple_majority',
input_type='majorityMethod',
label='Required majority',
help_text='Default method to check whether a candidate has reached the required majority.',
weight=425,
group='Elections',
subgroup='Ballot and ballot papers')
yield ConfigVariable(
name='assignments_pdf_ballot_papers_selection',
default_value='CUSTOM_NUMBER',

View File

@ -5,7 +5,8 @@
angular.module('OpenSlidesApp.assignments.site', [
'OpenSlidesApp.assignments',
'OpenSlidesApp.core.pdf',
'OpenSlidesApp.assignments.pdf'
'OpenSlidesApp.assignments.pdf',
'OpenSlidesApp.poll.majority'
])
.config([
@ -244,6 +245,100 @@ angular.module('OpenSlidesApp.assignments.site', [
}
])
// Cache for AssignmentPollDetailCtrl so that users choices are keeped during user actions (e. g. save poll form).
.value('AssignmentPollDetailCtrlCache', {})
// Child controller of AssignmentDetailCtrl for each single poll.
.controller('AssignmentPollDetailCtrl', [
'$scope',
'MajorityMethodChoices',
'MajorityCalculation',
'Config',
'AssignmentPollDetailCtrlCache',
function ($scope, MajorityMethodChoices, MajorityCalculation, Config, AssignmentPollDetailCtrlCache) {
$scope.poll_options_with_majorities = $scope.poll.options;
// Define choices.
$scope.methodChoices = MajorityMethodChoices;
// TODO: Get $scope.baseChoices from config_variables.py without copying them.
// Setup empty cache with default values.
if (AssignmentPollDetailCtrlCache[$scope.poll.id] === undefined) {
AssignmentPollDetailCtrlCache[$scope.poll.id] = {
isMajorityCalculation: true,
isMajorityDetails: false,
method: $scope.config('assignments_poll_default_majority_method'),
base: $scope.config('assignments_poll_100_percent_base')
};
}
// Fetch users choices from cache.
$scope.isMajorityCalculation = AssignmentPollDetailCtrlCache[$scope.poll.id].isMajorityCalculation;
$scope.isMajorityDetails = AssignmentPollDetailCtrlCache[$scope.poll.id].isMajorityDetails;
$scope.method = AssignmentPollDetailCtrlCache[$scope.poll.id].method;
$scope.base = AssignmentPollDetailCtrlCache[$scope.poll.id].base;
// (re)calculate the base of poll calculations.
$scope.calculateBase = function() {
var base;
switch($scope.base) {
case 'YES_NO_ABSTAIN':
case 'YES_NO':
if ($scope.poll.pollmethod == 'votes') {
base = MajorityCalculation.options_yes_sum($scope.poll);
}
break;
case 'VALID':
base = $scope.poll.votesvalid;
break;
case 'CAST':
base = $scope.poll.votescast;
break;
// case 'DISABLED have no bases
}
return base;
};
// calculates if majority thresholds have been reached. Returns 0+ when reached, or negative int when not reached
$scope.recalculateMajorities = function (method) {
$scope.method = method;
$scope.calculationError = false;
var base_nmbr = $scope.calculateBase();
_.forEach($scope.poll_options_with_majorities, function(option) {
var optionvotes = {};
_.forEach(option.votes, function(vote) {
switch (vote.value) {
case 'Yes':
case 'Votes':
optionvotes.yes = vote.weight;
break;
case 'No':
optionvotes.no = vote.weight;
break;
case 'Abstain':
optionvotes.abstain = vote.weight;
break;
}
});
option.isReached = MajorityCalculation.isReached($scope.base, method, optionvotes, base_nmbr);
if (option.reached === 'undefined'){
$scope.calculationError = true;
}
});
};
$scope.recalculateMajorities($scope.method);
// Save current values to cache on destroy of this controller.
$scope.$on('$destroy', function() {
AssignmentPollDetailCtrlCache[$scope.poll.id] = {
isMajorityCalculation: $scope.isMajorityCalculation,
isMajorityDetails: $scope.isMajorityDetails,
method: $scope.method,
base: $scope.base
};
});
}
])
.controller('AssignmentListCtrl', [
'$scope',
'ngDialog',
@ -883,6 +978,12 @@ angular.module('OpenSlidesApp.assignments.site', [
gettext('Number of all participants');
gettext('Use the following custom number');
gettext('Custom number of ballot papers');
gettext('Required majority');
gettext('Default method to check whether a candidate has reached the required majority.');
gettext('Simple majority');
gettext('Two-thirds majority');
gettext('Three-quarters majority');
gettext('Disabled');
gettext('Title for PDF document (all elections)');
gettext('Preamble text for PDF document (all elections)');
//other translations

View File

@ -126,119 +126,133 @@
<uib-tabset ng-if="assignment.polls.length > 0" class="spacer ballot-tabs" active="activeTab">
<uib-tab ng-repeat="poll in assignment.polls | orderBy:'-id'"
index="$index"
heading="{{ 'Ballot' | translate }} {{ assignment.polls.length - $index }}">
<!-- action buttons -->
<div class="pull-right">
<!-- delete -->
<a class="btn btn-danger btn-xs"
ng-bootbox-confirm="{{ 'Are you sure you want to delete this ballot?' | translate }}"
ng-bootbox-confirm-action="deleteBallot(poll)">
<i class="fa fa-trash"></i>
<translate>Delete</translate>
</a>
</div>
<div os-perms="assignments.can_manage" class="spacer " role="group">
<!-- angular requires to open the link in new tab with "target='_blank'".
Otherwise the pdf url can't be open in same window; angular redirects to "/". -->
<!-- PDF -->
<a ng-click="makePDF_assignmentpoll(poll.id)" target="_blank" class="btn btn-default btn-sm">
<i class="fa fa-file-pdf-o"></i>
<translate>Print ballot paper</translate>
</a>
<!-- Edit -->
<button ng-click="editPollDialog(poll, $index+1)"
class="btn btn-default btn-sm">
<i class="fa fa-pencil"></i>
<translate>Enter votes</translate>
</button>
<!-- Publish -->
<button ng-click="togglePublishBallot(poll)"
ng-class="poll.published ? 'btn-primary' : 'btn-default'"
class="btn btn-sm">
<i class="fa fa-eye"></i>
<translate>Publish</translate>
</button>
<!-- Project -->
<projector-button model="assignment" default-projector-id="defaultProjectorId"
arg="poll.id" content="{{ 'Project' | translate }}">
</projector-button>
</div>
<!-- template hook for assignment poll small buttons -->
<template-hook hook-name="assignmentPollSmallButtons"></template-hook>
<div class="results spacer-top-lg">
<!-- list of candidates of selected poll (without election result) -->
<div ng-if="!poll.has_votes">
<strong translate>Candidates</strong>
<ul class="list-unstyled">
<li ng-repeat="option in poll.options">
<a ui-sref="users.user.detail({id: option.candidate.id})">
{{ option.candidate.get_full_name() }}
</a>
</ul>
<strong translate>Election method</strong><br>
<span ng-if="poll.pollmethod == 'votes'" translate>One vote per candidate</span>
<span ng-if="poll.pollmethod == 'yna'" translate>Yes/No/Abstain per candidate</span>
<span ng-if="poll.pollmethod == 'yn'" translate>Yes/No per candidate</span>
index="$index" heading="{{ 'Ballot' | translate }} {{ assignment.polls.length - $index }}">
<div ng-controller="AssignmentPollDetailCtrl">
<!-- action buttons -->
<div class="pull-right">
<!-- delete -->
<a class="btn btn-danger btn-xs"
ng-bootbox-confirm="{{ 'Are you sure you want to delete this ballot?' | translate }}"
ng-bootbox-confirm-action="deleteBallot(poll)">
<i class="fa fa-trash"></i>
<translate>Delete</translate>
</a>
</div>
<div os-perms="assignments.can_manage" class="spacer " role="group">
<!-- angular requires to open the link in new tab with "target='_blank'".
Otherwise the pdf url can't be open in same window; angular redirects to "/". -->
<!-- PDF -->
<a ng-click="makePDF_assignmentpoll(poll.id)" target="_blank" class="btn btn-default btn-sm">
<i class="fa fa-file-pdf-o"></i>
<translate>Print ballot paper</translate>
</a>
<!-- Edit -->
<button ng-click="editPollDialog(poll, $index+1)"
class="btn btn-default btn-sm">
<i class="fa fa-pencil"></i>
<translate>Enter votes</translate>
</button>
<!-- Publish -->
<button ng-click="togglePublishBallot(poll)"
ng-class="poll.published ? 'btn-primary' : 'btn-default'"
class="btn btn-sm">
<i class="fa fa-eye"></i>
<translate>Publish</translate>
</button>
<!-- Project -->
<projector-button model="assignment" default-projector-id="defaultProjectorId"
arg="poll.id" content="{{ 'Project' | translate }}">
</projector-button>
</div>
<!-- election result of poll -->
<table ng-if="poll.has_votes" class="table table-bordered table-striped minimumTable">
<tr>
<th translate>Candidates
<th translate>Votes</th>
<!-- template hook for assignment poll small buttons -->
<template-hook hook-name="assignmentPollSmallButtons"></template-hook>
<!-- candidates (poll options) -->
<tr ng-repeat="option in poll.options">
<!-- candidate name -->
<td>
<span os-perms="assignments.can_manage">
<i ng-if="option.is_elected" class="fa fa-check-square-o"
ng-click="markElected(option.candidate_id, option.is_elected)"
title="{{ 'is elected' | translate }}"></i>
<i ng-if="!option.is_elected" class="fa fa-square-o"
ng-click="markElected(option.candidate_id, option.is_elected)"
title="{{ 'is not elected' | translate }}"></i>
&nbsp;
</span>
<a ui-sref="users.user.detail({id: option.candidate.id})">{{ option.candidate.get_full_name() }}</a>
<div class="results spacer-top-lg">
<!-- votes -->
<td>
<div ng-init="votes = option.getVotes()">
<div ng-repeat="vote in votes">
<span ng-if="poll.pollmethod == 'yna' || poll.pollmethod == 'yn'">{{ vote.label }}:</span>
{{ vote.value }} {{ vote.percentStr }}
<div ng-if="vote.percentNumber">
<uib-progressbar value="vote.percentNumber" type="success"></uib-progressbar>
<!-- list of candidates of selected poll (without election result) -->
<div ng-if="!poll.has_votes">
<strong translate>Candidates</strong>
<ul class="list-unstyled">
<li ng-repeat="option in options">
<a ui-sref="users.user.detail({id: option.candidate.id})">
{{ option.candidate.get_full_name() }}
</a>
</ul>
<strong translate>Election method</strong><br>
<span ng-if="poll.pollmethod == 'votes'" translate>One vote per candidate</span>
<span ng-if="poll.pollmethod == 'yna'" translate>Yes/No/Abstain per candidate</span>
<span ng-if="poll.pollmethod == 'yn'" translate>Yes/No per candidate</span>
</div>
<!-- Settings for majority calculations -->
<div os-perms="assignments.can_manage" ng-hide="config('assignments_poll_default_majority_method') == 'disabled' || !poll.has_votes" ng-cloak>
<div class="input-group">
<span><translate>Required majority</translate>: </span>
<select ng-model="method"
ng-change="recalculateMajorities(method)" ng-options="option.value as option.display_name | translate for option in methodChoices" />
</div>
</div>
<!-- election result of poll -->
<table ng-if="poll.has_votes" class="table table-bordered table-striped minimumTable spacer">
<tr>
<th translate>Candidates
<th translate>Votes
<th ng-if="method != 'disabled'" translate>Quorum reached
</th>
<!-- candidates (poll options) -->
<tr ng-repeat="option in poll_options_with_majorities">
<!-- candidate name -->
<td>
<span os-perms="assignments.can_manage">
<i ng-if="option.is_elected" class="fa fa-check-square-o"
ng-click="markElected(option.candidate_id, option.is_elected)"
title="{{ 'is elected' | translate }}"></i>
<i ng-if="!option.is_elected" class="fa fa-square-o"
ng-click="markElected(option.candidate_id, option.is_elected)"
title="{{ 'is not elected' | translate }}"></i>
&nbsp;
</span>
<a ui-sref="users.user.detail({id: option.candidate.id})">{{ option.candidate.get_full_name() }}</a>
<!-- votes -->
<td>
<div ng-init="votes = option.getVotes()">
<div ng-repeat="vote in votes">
<span ng-if="poll.pollmethod == 'yna' || poll.pollmethod == 'yn'">{{ vote.label }}:</span>
{{ vote.value }} {{ vote.percentStr }}
<div ng-if="vote.percentNumber">
<uib-progressbar value="vote.percentNumber" type="success"></uib-progressbar>
</div>
</div>
</div>
</div>
<td ng-if="method != 'disabled'">
<span ng-if="option.isReached < 0" class="text-danger" translate> Quorum not reached, {{ -option.isReached }} votes missing.</span>
<span ng-if="option.isReached >= 0" class="text-success" translate> Quorum reached, {{ option.isReached }} votes more than needed.</span>
<!-- total votes (valid/invalid/casts) -->
<tr>
<td>
<translate>Valid ballots</translate>
<td>
{{ poll.getVote('votesvalid').value }}
{{ poll.getVote('votesvalid').percentStr }}
<tr>
<td>
<translate>Invalid ballots</translate>
<td>
{{ poll.getVote('votesinvalid').value }}
{{ poll.getVote('votesinvalid').percentStr }}
<tr class="total bg-info">
<td>
<translate>Casted ballots</translate>
<td>
{{ poll.getVote('votescast').value }}
{{ poll.getVote('votescast').percentStr }}
</table>
<!-- total votes (valid/invalid/casts) -->
<tr>
<td>
<translate>Valid ballots</translate>
<td>
{{ poll.getVote('votesvalid').value }}
{{ poll.getVote('votesvalid').percentStr }}
<tr>
<td>
<translate>Invalid ballots</translate>
<td>
{{ poll.getVote('votesinvalid').value }}
{{ poll.getVote('votesinvalid').percentStr }}
<tr class="total bg-info">
<td>
<translate>Casted ballots</translate>
<td>
{{ poll.getVote('votescast').value }}
{{ poll.getVote('votescast').percentStr }}
</table>
<!-- majority calculation message-->
<span class="text-warning" ng-if="calculationError" translate>Calculation impossible</span>
</div>
</div>
</uib-tab>

View File

@ -740,10 +740,10 @@ angular.module('OpenSlidesApp.motions.site', [
.controller('MotionPollDetailCtrl', [
'$scope',
'MajorityMethodChoices',
'MotionMajority',
'MajorityCalculation',
'Config',
'MotionPollDetailCtrlCache',
function ($scope, MajorityMethodChoices, MotionMajority, Config, MotionPollDetailCtrlCache) {
function ($scope, MajorityMethodChoices, MajorityCalculation, Config, MotionPollDetailCtrlCache) {
// Define choices.
$scope.methodChoices = MajorityMethodChoices;
// TODO: Get $scope.baseChoices from config_variables.py without copying them.
@ -766,7 +766,7 @@ angular.module('OpenSlidesApp.motions.site', [
// Define result function.
$scope.isReached = function () {
return MotionMajority.isReached($scope.base, $scope.method, $scope.poll);
return MajorityCalculation.isReached($scope.base, $scope.method, $scope.poll);
};
// Save current values to cache on detroy of this controller.

View File

@ -38,14 +38,41 @@ angular.module('OpenSlidesApp.poll.majority', [])
}
])
.factory('MotionMajority', [
.factory('MajorityCalculation', [
'MajorityMethods',
function (MajorityMethods) {
return {
isReached: function (base, method, votes) {
//calculate the base for yes-based multi-option polls
options_yes_sum: function(poll){
var yes = 0;
var error = false;
if (poll.options) {
_.forEach(poll.options, function(option) {
_.forEach(option.votes, function(vote) {
if (vote.value == 'Yes' || vote.value == 'Votes'){
if (vote.weight >= 0){
yes = yes + vote.weight;
} else {
error = true;
}
}
});
});
if (!error) {
return yes;
}
} else if (poll.yes && poll.yes >= 0) {
return poll.yes;
} // else: undefined
},
// returns 0 or positive integer if quorum is reached or surpassed
// sum (optional): a different base to calculate with
isReached: function (base, method, votes, sum) {
var methodFunction = MajorityMethods[method];
var yes = parseInt(votes.yes);
var no = parseInt(votes.no);
var alt_base = parseInt(sum);
var abstain = parseInt(votes.abstain);
var valid = parseInt(votes.votesvalid);
var cast = parseInt(votes.votescast);
@ -53,31 +80,35 @@ angular.module('OpenSlidesApp.poll.majority', [])
var isValid = function (vote) {
return !isNaN(vote) && vote >= 0;
};
switch (base) {
case 'YES_NO_ABSTAIN':
if (isValid(yes) && isValid(no) && isValid(abstain)) {
result = methodFunction(yes, yes + no + abstain);
}
break;
case 'YES_NO':
if (isValid(yes) && isValid(no)) {
result = methodFunction(yes, yes + no);
}
break;
case 'VALID':
if (isValid(yes) && isValid(valid)) {
result = methodFunction(yes, valid);
}
break;
case 'CAST':
if (isValid(yes) && isValid(cast)) {
result = methodFunction(yes, cast);
}
break;
// case 'DISABLED': result remains undefined
if (isValid(alt_base) && isValid(yes)) {
result = methodFunction(yes, alt_base);
} else {
switch (base) {
case 'YES_NO_ABSTAIN':
if (isValid(yes) && isValid(no) && isValid(abstain)) {
result = methodFunction(yes, yes + no + abstain);
}
break;
case 'YES_NO':
if (isValid(yes) && isValid(no)) {
result = methodFunction(yes, yes + no);
}
break;
case 'VALID':
if (isValid(yes) && isValid(valid)) {
result = methodFunction(yes, valid);
}
break;
case 'CAST':
if (isValid(yes) && isValid(cast)) {
result = methodFunction(yes, cast);
}
break;
// case 'DISABLED': result remains undefined
}
}
return result;
},
}
};
}
]);

View File

@ -144,7 +144,7 @@ class TestConfigDBQueries(TestCase):
TODO: The last 57 requests are a bug.
"""
with self.assertNumQueries(61):
with self.assertNumQueries(62):
self.client.get(reverse('config-list'))