diff --git a/CHANGELOG b/CHANGELOG index c8661eec5..e7bb8e99a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -31,6 +31,7 @@ Motions: - Added recommendations for motions. - Changed label of former state "commited a bill" to "refered to committee". - Added options to calculate percentages on different bases. +- Added majority calculation. Users: - Added field is_committee and new default group Committees. diff --git a/openslides/core/config.py b/openslides/core/config.py index 9952eaab5..3ea38db27 100644 --- a/openslides/core/config.py +++ b/openslides/core/config.py @@ -13,7 +13,8 @@ INPUT_TYPE_MAPPING = { 'comments': list, 'colorpicker': str, 'datetimepicker': int, - 'float': float} + 'majorityMethod': str, +} class ConfigHandler: diff --git a/openslides/core/static/js/core/site.js b/openslides/core/static/js/core/site.js index 77f3e5385..20274ee22 100644 --- a/openslides/core/static/js/core/site.js +++ b/openslides/core/static/js/core/site.js @@ -5,6 +5,7 @@ // The core module for the OpenSlides site angular.module('OpenSlidesApp.core.site', [ 'OpenSlidesApp.core', + 'OpenSlidesApp.poll.majority', 'ui.router', 'angular-loading-bar', 'colorpicker.module', @@ -424,19 +425,19 @@ angular.module('OpenSlidesApp.core.site', [ 'Config', 'gettextCatalog', function($parse, Config, gettextCatalog) { - function getHtmlType(type) { + var getHtmlType = function (type) { return { string: 'text', text: 'textarea', integer: 'number', - float: 'number', boolean: 'checkbox', choice: 'choice', comments: 'comments', colorpicker: 'colorpicker', datetimepicker: 'datetimepicker', + majorityMethod: 'choice', }[type]; - } + }; return { restrict: 'E', @@ -586,11 +587,12 @@ angular.module('OpenSlidesApp.core.site', [ // Config Controller .controller('ConfigCtrl', [ '$scope', + 'MajorityMethodChoices', 'Config', 'configOptions', 'gettextCatalog', 'DateTimePickerTranslation', - function($scope, Config, configOptions, gettextCatalog, DateTimePickerTranslation) { + function($scope, MajorityMethodChoices, Config, configOptions, gettextCatalog, DateTimePickerTranslation) { Config.bindAll({}, $scope, 'configs'); $scope.configGroups = configOptions.data.config_groups; $scope.dateTimePickerTranslatedButtons = DateTimePickerTranslation.getButtons(); @@ -601,7 +603,7 @@ angular.module('OpenSlidesApp.core.site', [ Config.save(key); }; - /* For comments input */ + // For comments input $scope.addComment = function (key, parent) { parent.value.push({ name: gettextCatalog.getString('New'), @@ -613,6 +615,26 @@ angular.module('OpenSlidesApp.core.site', [ parent.value.splice(index, 1); $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; + } + } + ); } ]) diff --git a/openslides/motions/config_variables.py b/openslides/motions/config_variables.py index 98add0983..860c016e7 100644 --- a/openslides/motions/config_variables.py +++ b/openslides/motions/config_variables.py @@ -1,4 +1,4 @@ -from django.core.validators import MaxValueValidator, MinValueValidator +from django.core.validators import MinValueValidator from openslides.core.config import ConfigVariable @@ -186,16 +186,16 @@ def get_config_variables(): group='Motions', subgroup='Voting and ballot papers') + # TODO: Add server side validation of the choices. yield ConfigVariable( - name='motions_poll_default_quorum', - default_value=50, - input_type='float', - label='Quorum for Majority tests', - help_text='Default percentage that must be surpassed for a motion to be successfull', + name='motions_poll_default_majority_method', + default_value='simple_majority', + input_type='majorityMethod', + label='Method for majority tests', + help_text='Default method to determine whether a motion is successful.', weight=357, group='Motions', - subgroup='Voting and ballot papers', - validators=(MinValueValidator(0), MaxValueValidator(100),)) + subgroup='Voting and ballot papers') yield ConfigVariable( name='motions_pdf_ballot_papers_selection', diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js index a7d93afe1..fbea9ca2e 100644 --- a/openslides/motions/static/js/motions/site.js +++ b/openslides/motions/static/js/motions/site.js @@ -6,6 +6,7 @@ angular.module('OpenSlidesApp.motions.site', [ 'OpenSlidesApp.motions', 'OpenSlidesApp.motions.diff', 'OpenSlidesApp.motions.motionservices', + 'OpenSlidesApp.poll.majority', 'OpenSlidesApp.core.pdf', 'OpenSlidesApp.motions.pdf' ]) @@ -479,83 +480,51 @@ angular.module('OpenSlidesApp.motions.site', [ } ]) -// child controller of MotionDetailCtrl for each single poll. -// TODO for now it is ust needed for majority Tests, which may be moved to a more generic -// place later +// Cache for MotionPollDetailCtrl so that users choices are keeped during user actions (e. g. save poll form). +.value('MotionPollDetailCtrlCache', {}) + +// Child controller of MotionDetailCtrl for each single poll. .controller('MotionPollDetailCtrl', [ '$scope', + 'MajorityMethodChoices', + 'MotionMajority', 'Config', - function($scope, Config) { - $scope.base = Config.get('motions_poll_100_percent_base').value; - $scope.basechoices = [{'value': 'YES_NO_ABSTAIN', 'display_name': 'Yes/No/Abstain'}, - {'value': 'YES_NO', 'display_name': 'Yes/No'}, - {'value': 'VALID', 'display_name': 'All valid ballots'}, - {'value': 'CAST', 'display_name': 'All casted ballots'}, - {'value': 'DISABLED', 'display_name': 'Disabled (no percents)'}]; - $scope.quorum = Config.get('motions_poll_default_quorum').value; - $scope.isPossible = function() { - if ($scope.base == 'CAST' && $scope.poll.votescast > 0) { - return true; - } else if ($scope.base == 'VALID' && $scope.poll.votesvalid > 0) { - return true; - } 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; - } + 'MotionPollDetailCtrlCache', + function ($scope, MajorityMethodChoices, MotionMajority, Config, MotionPollDetailCtrlCache) { + // Define choices. + $scope.methodChoices = MajorityMethodChoices; + // TODO: Get $scope.baseChoices from config_variables.py without copying them. + + // Setup empty cache with default values. + if (MotionPollDetailCtrlCache[$scope.poll.id] === undefined) { + MotionPollDetailCtrlCache[$scope.poll.id] = { + isMajorityCalculation: true, + isMajorityDetails: false, + method: $scope.config('motions_poll_default_majority_method'), + base: $scope.config('motions_poll_100_percent_base') + }; + } + + // 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); }; - // 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'; - } - }; - //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', [ '$scope', 'gettextCatalog', - 'Config', 'MotionPoll', 'MotionPollForm', 'motionpoll', '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 // so detail view is not updated while editing poll $scope.model = angular.copy(motionpoll); diff --git a/openslides/motions/static/templates/motions/motion-detail.html b/openslides/motions/static/templates/motions/motion-detail.html index b5439a799..1b640c606 100644 --- a/openslides/motions/static/templates/motions/motion-detail.html +++ b/openslides/motions/static/templates/motions/motion-detail.html @@ -166,11 +166,13 @@
  • Vote + + - + + - + + - + +
    - calculate majorities + Calculate majority
    - Minimal percentage needed: % -
    -
    - Calculation base: -
    +
    @@ -273,14 +275,14 @@
    - Calculation impossible - - Quorum reached, - {{isReached()}} votes more than needed. + Calculation impossible + + Quorum reached, + {{ isReached() }} votes more than needed. - Quorum not reached, - {{-(isReached())}} votes missing. + Quorum not reached, + {{ -(isReached()) }} votes missing.
    diff --git a/openslides/poll/static/js/poll/majority.js b/openslides/poll/static/js/poll/majority.js new file mode 100644 index 000000000..88c7da464 --- /dev/null +++ b/openslides/poll/static/js/poll/majority.js @@ -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; + }, + }; + } +]); + +}());