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: Assignments:
- Remove unused assignment config to publish winner election results only. - Remove unused assignment config to publish winner election results only.
- Added options to calculate percentages on different bases. - Added options to calculate percentages on different bases.
- Added majority calculation.
Core: Core:
- Added support for big assemblies with lots of users. - Added support for big assemblies with lots of users.

View File

@ -44,6 +44,17 @@ def get_config_variables():
group='Elections', group='Elections',
subgroup='Ballot and ballot papers') 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( yield ConfigVariable(
name='assignments_pdf_ballot_papers_selection', name='assignments_pdf_ballot_papers_selection',
default_value='CUSTOM_NUMBER', default_value='CUSTOM_NUMBER',

View File

@ -5,7 +5,8 @@
angular.module('OpenSlidesApp.assignments.site', [ angular.module('OpenSlidesApp.assignments.site', [
'OpenSlidesApp.assignments', 'OpenSlidesApp.assignments',
'OpenSlidesApp.core.pdf', 'OpenSlidesApp.core.pdf',
'OpenSlidesApp.assignments.pdf' 'OpenSlidesApp.assignments.pdf',
'OpenSlidesApp.poll.majority'
]) ])
.config([ .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', [ .controller('AssignmentListCtrl', [
'$scope', '$scope',
'ngDialog', 'ngDialog',
@ -883,6 +978,12 @@ angular.module('OpenSlidesApp.assignments.site', [
gettext('Number of all participants'); gettext('Number of all participants');
gettext('Use the following custom number'); gettext('Use the following custom number');
gettext('Custom number of ballot papers'); 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('Title for PDF document (all elections)');
gettext('Preamble text for PDF document (all elections)'); gettext('Preamble text for PDF document (all elections)');
//other translations //other translations

View File

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

View File

@ -740,10 +740,10 @@ angular.module('OpenSlidesApp.motions.site', [
.controller('MotionPollDetailCtrl', [ .controller('MotionPollDetailCtrl', [
'$scope', '$scope',
'MajorityMethodChoices', 'MajorityMethodChoices',
'MotionMajority', 'MajorityCalculation',
'Config', 'Config',
'MotionPollDetailCtrlCache', 'MotionPollDetailCtrlCache',
function ($scope, MajorityMethodChoices, MotionMajority, Config, MotionPollDetailCtrlCache) { function ($scope, MajorityMethodChoices, MajorityCalculation, Config, MotionPollDetailCtrlCache) {
// Define choices. // Define choices.
$scope.methodChoices = MajorityMethodChoices; $scope.methodChoices = MajorityMethodChoices;
// TODO: Get $scope.baseChoices from config_variables.py without copying them. // TODO: Get $scope.baseChoices from config_variables.py without copying them.
@ -766,7 +766,7 @@ angular.module('OpenSlidesApp.motions.site', [
// Define result function. // Define result function.
$scope.isReached = 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. // 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', 'MajorityMethods',
function (MajorityMethods) { function (MajorityMethods) {
return { 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 methodFunction = MajorityMethods[method];
var yes = parseInt(votes.yes); var yes = parseInt(votes.yes);
var no = parseInt(votes.no); var no = parseInt(votes.no);
var alt_base = parseInt(sum);
var abstain = parseInt(votes.abstain); var abstain = parseInt(votes.abstain);
var valid = parseInt(votes.votesvalid); var valid = parseInt(votes.votesvalid);
var cast = parseInt(votes.votescast); var cast = parseInt(votes.votescast);
@ -53,31 +80,35 @@ angular.module('OpenSlidesApp.poll.majority', [])
var isValid = function (vote) { var isValid = function (vote) {
return !isNaN(vote) && vote >= 0; return !isNaN(vote) && vote >= 0;
}; };
switch (base) { if (isValid(alt_base) && isValid(yes)) {
case 'YES_NO_ABSTAIN': result = methodFunction(yes, alt_base);
if (isValid(yes) && isValid(no) && isValid(abstain)) { } else {
result = methodFunction(yes, yes + no + abstain); switch (base) {
} case 'YES_NO_ABSTAIN':
break; if (isValid(yes) && isValid(no) && isValid(abstain)) {
case 'YES_NO': result = methodFunction(yes, yes + no + abstain);
if (isValid(yes) && isValid(no)) { }
result = methodFunction(yes, yes + no); break;
} case 'YES_NO':
break; if (isValid(yes) && isValid(no)) {
case 'VALID': result = methodFunction(yes, yes + no);
if (isValid(yes) && isValid(valid)) { }
result = methodFunction(yes, valid); break;
} case 'VALID':
break; if (isValid(yes) && isValid(valid)) {
case 'CAST': result = methodFunction(yes, valid);
if (isValid(yes) && isValid(cast)) { }
result = methodFunction(yes, cast); break;
} case 'CAST':
break; if (isValid(yes) && isValid(cast)) {
// case 'DISABLED': result remains undefined result = methodFunction(yes, cast);
}
break;
// case 'DISABLED': result remains undefined
}
} }
return result; return result;
}, }
}; };
} }
]); ]);

View File

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