Refactored majority calculation. Added cache for MotionPollDetailCtrl.

This commit is contained in:
Norman Jäckel 2016-10-15 18:16:22 +02:00
parent e5e1e3e8ba
commit 45aa4146da
7 changed files with 180 additions and 105 deletions

View File

@ -31,6 +31,7 @@ Motions:
- Added recommendations for motions. - Added recommendations for motions.
- Changed label of former state "commited a bill" to "refered to committee". - Changed label of former state "commited a bill" to "refered to committee".
- Added options to calculate percentages on different bases. - Added options to calculate percentages on different bases.
- Added majority calculation.
Users: Users:
- Added field is_committee and new default group Committees. - Added field is_committee and new default group Committees.

View File

@ -13,7 +13,8 @@ INPUT_TYPE_MAPPING = {
'comments': list, 'comments': list,
'colorpicker': str, 'colorpicker': str,
'datetimepicker': int, 'datetimepicker': int,
'float': float} 'majorityMethod': str,
}
class ConfigHandler: class ConfigHandler:

View File

@ -5,6 +5,7 @@
// The core module for the OpenSlides site // The core module for the OpenSlides site
angular.module('OpenSlidesApp.core.site', [ angular.module('OpenSlidesApp.core.site', [
'OpenSlidesApp.core', 'OpenSlidesApp.core',
'OpenSlidesApp.poll.majority',
'ui.router', 'ui.router',
'angular-loading-bar', 'angular-loading-bar',
'colorpicker.module', 'colorpicker.module',
@ -424,19 +425,19 @@ angular.module('OpenSlidesApp.core.site', [
'Config', 'Config',
'gettextCatalog', 'gettextCatalog',
function($parse, Config, gettextCatalog) { function($parse, Config, gettextCatalog) {
function getHtmlType(type) { var getHtmlType = function (type) {
return { return {
string: 'text', string: 'text',
text: 'textarea', text: 'textarea',
integer: 'number', integer: 'number',
float: 'number',
boolean: 'checkbox', boolean: 'checkbox',
choice: 'choice', choice: 'choice',
comments: 'comments', comments: 'comments',
colorpicker: 'colorpicker', colorpicker: 'colorpicker',
datetimepicker: 'datetimepicker', datetimepicker: 'datetimepicker',
majorityMethod: 'choice',
}[type]; }[type];
} };
return { return {
restrict: 'E', restrict: 'E',
@ -586,11 +587,12 @@ angular.module('OpenSlidesApp.core.site', [
// Config Controller // Config Controller
.controller('ConfigCtrl', [ .controller('ConfigCtrl', [
'$scope', '$scope',
'MajorityMethodChoices',
'Config', 'Config',
'configOptions', 'configOptions',
'gettextCatalog', 'gettextCatalog',
'DateTimePickerTranslation', 'DateTimePickerTranslation',
function($scope, Config, configOptions, gettextCatalog, DateTimePickerTranslation) { function($scope, MajorityMethodChoices, Config, configOptions, gettextCatalog, DateTimePickerTranslation) {
Config.bindAll({}, $scope, 'configs'); Config.bindAll({}, $scope, 'configs');
$scope.configGroups = configOptions.data.config_groups; $scope.configGroups = configOptions.data.config_groups;
$scope.dateTimePickerTranslatedButtons = DateTimePickerTranslation.getButtons(); $scope.dateTimePickerTranslatedButtons = DateTimePickerTranslation.getButtons();
@ -601,7 +603,7 @@ angular.module('OpenSlidesApp.core.site', [
Config.save(key); Config.save(key);
}; };
/* For comments input */ // For comments input
$scope.addComment = function (key, parent) { $scope.addComment = function (key, parent) {
parent.value.push({ parent.value.push({
name: gettextCatalog.getString('New'), name: gettextCatalog.getString('New'),
@ -613,6 +615,26 @@ angular.module('OpenSlidesApp.core.site', [
parent.value.splice(index, 1); parent.value.splice(index, 1);
$scope.save(key, parent.value); $scope.save(key, parent.value);
}; };
// For majority method
angular.forEach(
_.filter($scope.configGroups, function (configGroup) {
return configGroup.name === 'Motions' || configGroup.name === 'Elections';
}),
function (configGroup) {
var configItem;
_.forEach(configGroup.subgroups, function (subgroup) {
configItem = _.find(subgroup.items, ['input_type', 'majorityMethod']);
if (configItem !== undefined) {
// Break the forEach loop if we found something.
return false;
}
});
if (configItem !== undefined) {
configItem.choices = MajorityMethodChoices;
}
}
);
} }
]) ])

View File

@ -1,4 +1,4 @@
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MinValueValidator
from openslides.core.config import ConfigVariable from openslides.core.config import ConfigVariable
@ -186,16 +186,16 @@ def get_config_variables():
group='Motions', group='Motions',
subgroup='Voting and ballot papers') subgroup='Voting and ballot papers')
# TODO: Add server side validation of the choices.
yield ConfigVariable( yield ConfigVariable(
name='motions_poll_default_quorum', name='motions_poll_default_majority_method',
default_value=50, default_value='simple_majority',
input_type='float', input_type='majorityMethod',
label='Quorum for Majority tests', label='Method for majority tests',
help_text='Default percentage that must be surpassed for a motion to be successfull', help_text='Default method to determine whether a motion is successful.',
weight=357, weight=357,
group='Motions', group='Motions',
subgroup='Voting and ballot papers', subgroup='Voting and ballot papers')
validators=(MinValueValidator(0), MaxValueValidator(100),))
yield ConfigVariable( yield ConfigVariable(
name='motions_pdf_ballot_papers_selection', name='motions_pdf_ballot_papers_selection',

View File

@ -6,6 +6,7 @@ angular.module('OpenSlidesApp.motions.site', [
'OpenSlidesApp.motions', 'OpenSlidesApp.motions',
'OpenSlidesApp.motions.diff', 'OpenSlidesApp.motions.diff',
'OpenSlidesApp.motions.motionservices', 'OpenSlidesApp.motions.motionservices',
'OpenSlidesApp.poll.majority',
'OpenSlidesApp.core.pdf', 'OpenSlidesApp.core.pdf',
'OpenSlidesApp.motions.pdf' 'OpenSlidesApp.motions.pdf'
]) ])
@ -479,83 +480,51 @@ angular.module('OpenSlidesApp.motions.site', [
} }
]) ])
// child controller of MotionDetailCtrl for each single poll. // Cache for MotionPollDetailCtrl so that users choices are keeped during user actions (e. g. save poll form).
// TODO for now it is ust needed for majority Tests, which may be moved to a more generic .value('MotionPollDetailCtrlCache', {})
// place later
// Child controller of MotionDetailCtrl for each single poll.
.controller('MotionPollDetailCtrl', [ .controller('MotionPollDetailCtrl', [
'$scope', '$scope',
'MajorityMethodChoices',
'MotionMajority',
'Config', 'Config',
function($scope, Config) { 'MotionPollDetailCtrlCache',
$scope.base = Config.get('motions_poll_100_percent_base').value; function ($scope, MajorityMethodChoices, MotionMajority, Config, MotionPollDetailCtrlCache) {
$scope.basechoices = [{'value': 'YES_NO_ABSTAIN', 'display_name': 'Yes/No/Abstain'}, // Define choices.
{'value': 'YES_NO', 'display_name': 'Yes/No'}, $scope.methodChoices = MajorityMethodChoices;
{'value': 'VALID', 'display_name': 'All valid ballots'}, // TODO: Get $scope.baseChoices from config_variables.py without copying them.
{'value': 'CAST', 'display_name': 'All casted ballots'},
{'value': 'DISABLED', 'display_name': 'Disabled (no percents)'}]; // Setup empty cache with default values.
$scope.quorum = Config.get('motions_poll_default_quorum').value; if (MotionPollDetailCtrlCache[$scope.poll.id] === undefined) {
$scope.isPossible = function() { MotionPollDetailCtrlCache[$scope.poll.id] = {
if ($scope.base == 'CAST' && $scope.poll.votescast > 0) { isMajorityCalculation: true,
return true; isMajorityDetails: false,
} else if ($scope.base == 'VALID' && $scope.poll.votesvalid > 0) { method: $scope.config('motions_poll_default_majority_method'),
return true; base: $scope.config('motions_poll_100_percent_base')
} else if ($scope.base == 'YES_NO_ABSTAIN' &&
(!$scope.poll.yes || $scope.poll.yes >= 0) &&
(!$scope.poll.no ||$scope.poll.no >= 0) &&
(!$scope.poll.abstain || $scope.poll.abstain >= 0) &&
($scope.poll.yes + $scope.poll.no + $scope.poll.abstain > 0)) {
return true;
} else if ($scope.base == 'YES_NO' &&
(!$scope.poll.yes || $scope.poll.yes >= 0) &&
(!$scope.poll.no ||$scope.poll.no >= 0) &&
($scope.poll.yes + $scope.poll.no > 0)) {
return true;
} else {
return false;
}
}; };
// returns an integer. 0 and positive numbers indicate a success and the amount of votes
// in excess, negative numbers are a failure (amount of missing votes), or null in case of error
$scope.isReached = function() {
var basenr;
if ($scope.base == 'CAST' && $scope.poll.votescast > 0) {
basenr = $scope.poll.votescast;
} else if ($scope.base == 'VALID' && $scope.poll.votesvalid > 0) {
basenr = $scope.poll.votesvalid;
} else if ($scope.base == 'YES_NO') {
basenr = 0;
if ($scope.poll.yes > 0) {
basenr = $scope.poll.yes;
}
if ($scope.poll.no > 0) {
basenr = basenr + $scope.poll.no;
}
} else if ($scope.base == 'YES_NO_ABSTAIN') {
basenr = 0;
if ($scope.poll.yes > 0) {
basenr = $scope.poll.yes;
}
if ($scope.poll.no > 0) {
basenr = basenr + $scope.poll.no;
}
if ($scope.poll.abstain > 0) {
basenr = basenr + $scope.poll.abstain;
}
}
if (basenr > 0) {
var needed = Math.ceil(basenr / 100 * $scope.quorum);
if ((basenr / 100 * $scope.quorum) % 1 === 0) {
//the quorum is exactly reached, not passed
needed = needed + 1;
}
if ($scope.poll.yes >= 0) {
var result = $scope.poll.yes - needed;
return result;
}
} else {
return 'undefined';
} }
// Fetch users choices from cache.
$scope.isMajorityCalculation = MotionPollDetailCtrlCache[$scope.poll.id].isMajorityCalculation;
$scope.isMajorityDetails = MotionPollDetailCtrlCache[$scope.poll.id].isMajorityDetails;
$scope.method = MotionPollDetailCtrlCache[$scope.poll.id].method;
$scope.base = MotionPollDetailCtrlCache[$scope.poll.id].base;
// Define result function.
$scope.isReached = function () {
return MotionMajority.isReached($scope.base, $scope.method, $scope.poll);
}; };
//isPlausible: TODO: check if the sums match up
// Save current values to cache on detroy of this controller.
$scope.$on('$destroy', function() {
MotionPollDetailCtrlCache[$scope.poll.id] = {
isMajorityCalculation: $scope.isMajorityCalculation,
isMajorityDetails: $scope.isMajorityDetails,
method: $scope.method,
base: $scope.base
};
});
} }
]) ])
@ -1224,12 +1193,11 @@ angular.module('OpenSlidesApp.motions.site', [
.controller('MotionPollUpdateCtrl', [ .controller('MotionPollUpdateCtrl', [
'$scope', '$scope',
'gettextCatalog', 'gettextCatalog',
'Config',
'MotionPoll', 'MotionPoll',
'MotionPollForm', 'MotionPollForm',
'motionpoll', 'motionpoll',
'voteNumber', 'voteNumber',
function($scope, gettextCatalog, Config, MotionPoll, MotionPollForm, motionpoll, voteNumber) { function($scope, gettextCatalog, MotionPoll, MotionPollForm, motionpoll, voteNumber) {
// set initial values for form model by create deep copy of motionpoll object // set initial values for form model by create deep copy of motionpoll object
// so detail view is not updated while editing poll // so detail view is not updated while editing poll
$scope.model = angular.copy(motionpoll); $scope.model = angular.copy(motionpoll);

View File

@ -166,11 +166,13 @@
<li ng-controller="MotionPollDetailCtrl" ng-repeat="poll in motion.polls" class="spacer" <li ng-controller="MotionPollDetailCtrl" ng-repeat="poll in motion.polls" class="spacer"
ng-if="poll.has_votes || operator.hasPerms('motions.can_manage')"> ng-if="poll.has_votes || operator.hasPerms('motions.can_manage')">
<strong translate-comment='ballot of a motion' translate>Vote</strong> <strong translate-comment='ballot of a motion' translate>Vote</strong>
<!-- Edit poll --> <!-- Edit poll -->
<button os-perms="motions.can_manage" ng-click="openPollDialog(poll, $index+1)" <button os-perms="motions.can_manage" ng-click="openPollDialog(poll, $index+1)"
class="btn btn-default btn-xs" title="{{ 'Edit' | translate }}"> class="btn btn-default btn-xs" title="{{ 'Edit' | translate }}">
<i class="fa fa-pencil"></i> <i class="fa fa-pencil"></i>
</button> </button>
<!-- Delete poll --> <!-- Delete poll -->
<button os-perms="motions.can_manage" class="btn btn-default btn-xs" <button os-perms="motions.can_manage" class="btn btn-default btn-xs"
ng-bootbox-confirm="{{ 'Are you sure you want to delete this poll?' | translate }}" ng-bootbox-confirm="{{ 'Are you sure you want to delete this poll?' | translate }}"
@ -178,31 +180,31 @@
title="{{ 'Delete' | translate }}"> title="{{ 'Delete' | translate }}">
<i class="fa fa-times"></i> <i class="fa fa-times"></i>
</button> </button>
<!-- print poll PDF -->
<!-- Print poll PDF -->
<a ng-click="makePollPDF()" class="btn btn-default btn-xs" <a ng-click="makePollPDF()" class="btn btn-default btn-xs"
title="{{ 'Print ballot paper' | translate }}"> title="{{ 'Print ballot paper' | translate }}">
<i class="fa fa-file-pdf-o"></i> <i class="fa fa-file-pdf-o"></i>
</a> </a>
<!-- template hook for motion poll buttons -->
<!-- Template hook for motion poll buttons -->
<template-hook hook-name="motionPollSmallButtons"></template-hook> <template-hook hook-name="motionPollSmallButtons"></template-hook>
<!--setting for majority calculations-->
<!-- Settings for majority calculations -->
<div class="input-group"> <div class="input-group">
<input type="checkbox" ng-model="isMajorityCalculation"> <input type="checkbox" ng-model="isMajorityCalculation">
<span translate> calculate majorities</span> <span translate>Calculate majority</span>
<a href="#" ng-click="isMajorityDetails = !isMajorityDetails"> <a href="#" ng-click="isMajorityDetails = !isMajorityDetails">
<i class="fa toggle-icon" ng-class="isMajorityDetails ? 'fa-angle-up' : 'fa-angle-down'"></i> <i class="fa toggle-icon" ng-class="isMajorityDetails ? 'fa-angle-up' : 'fa-angle-down'"></i>
</a> </a>
</div> </div>
<div uib-collapse="!isMajorityDetails" ng-cloak> <div uib-collapse="!isMajorityDetails" ng-cloak>
<div class="input-group"> <div class="input-group">
<span translate> Minimal percentage needed:</span> <input type="number" min=0 max=100 step="any" ng-model="quorum" <span translate>Majority method:</span>
ng-model-options="{debounce: 1000}" maxlength=9> % <select ng-model="method" ng-options="option.value as option.display_name for option in methodChoices" />
</div>
<div class="input-group">
<span translate> Calculation base:</span>
<select ng-model="base" ng-options="option.value as option.display_name for option in basechoices"/>
</div> </div>
</div> </div>
<!-- Poll results --> <!-- Poll results -->
<div ng-show="poll.has_votes" class="pollresults"> <div ng-show="poll.has_votes" class="pollresults">
<table class="table"> <table class="table">
@ -273,14 +275,14 @@
<tr ng-if="isMajorityCalculation"> <tr ng-if="isMajorityCalculation">
<td class="icon"> <td class="icon">
<td> <td>
<span class="text-warning" ng-if="isPossible() === false" translate>Calculation impossible</span> <span class="text-warning" ng-if="isReached() === undefined" translate>Calculation impossible</span>
<span class="text-success" ng-if=" isReached() >= 0"> <span class="text-success" ng-if="isReached() >= 0">
<translate>Quorum reached, </translate> <translate>Quorum reached,</translate>
{{isReached()}} <translate>votes more than needed.</translate> {{ isReached() }} <translate>votes more than needed.</translate>
</span> </span>
<span class="text-danger" ng-if="isReached() < 0"> <span class="text-danger" ng-if="isReached() < 0">
<translate>Quorum not reached, </translate> <translate>Quorum not reached,</translate>
{{-(isReached())}} <translate> votes missing.</translate> {{ -(isReached()) }} <translate>votes missing.</translate>
</span> </span>
</table> </table>
</ol> </ol>

View File

@ -0,0 +1,81 @@
(function () {
'use strict';
angular.module('OpenSlidesApp.poll.majority', [])
.value('MajorityMethodChoices', [
{'value': 'simple_majority', 'display_name': 'Simple majority'},
{'value': 'two-thirds_majority', 'display_name': 'Two-thirds majority'},
{'value': 'three-quarters_majority', 'display_name': 'Three-quarters majority'},
])
.factory('MajorityMethods', [
function () {
return {
'simple_majority': function (vote, base) {
return Math.ceil(-(base / 2 - vote)) - 1;
},
'two-thirds_majority': function (vote, base) {
var result = -(base * 2 - vote * 3) / 3;
if (result % 1 !== 0) {
result = Math.ceil(result) - 1;
}
return result;
},
'three-quarters_majority': function (vote, base) {
var result = -(base * 3 - vote * 4) / 4;
if (result % 1 !== 0) {
result = Math.ceil(result) - 1;
}
return result;
},
};
}
])
.factory('MotionMajority', [
'MajorityMethods',
function (MajorityMethods) {
return {
isReached: function (base, method, votes) {
var methodFunction = MajorityMethods[method];
var yes = parseInt(votes.yes);
var no = parseInt(votes.no);
var abstain = parseInt(votes.abstain);
var valid = parseInt(votes.votesvalid);
var cast = parseInt(votes.votescast);
var result;
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
}
return result;
},
};
}
]);
}());