From 32aaaf5b9e3b03bdae5dc2355728c3b05fe248b0 Mon Sep 17 00:00:00 2001 From: Maximilian Krambach Date: Wed, 26 Oct 2016 18:32:00 +0200 Subject: [PATCH] Majority calculation for assignments. Majorities when config YN(A) and simple voting method. --- CHANGELOG | 1 + openslides/assignments/config_variables.py | 11 + .../assignments/static/js/assignments/site.js | 103 +++++++- .../assignments/assignment-detail.html | 226 ++++++++++-------- openslides/motions/static/js/motions/site.js | 6 +- openslides/poll/static/js/poll/majority.js | 81 +++++-- tests/integration/core/test_viewset.py | 2 +- 7 files changed, 294 insertions(+), 136 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 70c28644e..925a060b2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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. diff --git a/openslides/assignments/config_variables.py b/openslides/assignments/config_variables.py index 16831c49e..5fa2bef05 100644 --- a/openslides/assignments/config_variables.py +++ b/openslides/assignments/config_variables.py @@ -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', diff --git a/openslides/assignments/static/js/assignments/site.js b/openslides/assignments/static/js/assignments/site.js index 940f6a42b..53889f7c6 100644 --- a/openslides/assignments/static/js/assignments/site.js +++ b/openslides/assignments/static/js/assignments/site.js @@ -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 diff --git a/openslides/assignments/static/templates/assignments/assignment-detail.html b/openslides/assignments/static/templates/assignments/assignment-detail.html index b498f4ae1..ecbe9ef72 100644 --- a/openslides/assignments/static/templates/assignments/assignment-detail.html +++ b/openslides/assignments/static/templates/assignments/assignment-detail.html @@ -126,119 +126,133 @@ - - - -
- - - - - Print ballot paper - - - - - - - - -
- - - - -
- - -
- Candidates - - Election method
- One vote per candidate - Yes/No/Abstain per candidate - Yes/No per candidate + index="$index" heading="{{ 'Ballot' | translate }} {{ assignment.polls.length - $index }}"> +
+ + +
+ + + + + Print ballot paper + + + + + + + +
- - - - + + - - - - + + +
Candidates - Votes
- - - -   - - {{ option.candidate.get_full_name() }} +
- -
-
-
- {{ vote.label }}: - {{ vote.value }} {{ vote.percentStr }} -
- + +
+ Candidates + + Election method
+ One vote per candidate + Yes/No/Abstain per candidate + Yes/No per candidate +
+ + +
+
+ Required majority: + + + + + + + - - -
Candidates + Votes + Quorum reached +
+ + + +   + + {{ option.candidate.get_full_name() }} + + + +
+
+ {{ vote.label }}: + {{ vote.value }} {{ vote.percentStr }} +
+ +
- +
+ Quorum not reached, {{ -option.isReached }} votes missing. + Quorum reached, {{ option.isReached }} votes more than needed. - -
- Valid ballots - - {{ poll.getVote('votesvalid').value }} - {{ poll.getVote('votesvalid').percentStr }} -
- Invalid ballots - - {{ poll.getVote('votesinvalid').value }} - {{ poll.getVote('votesinvalid').percentStr }} -
- Casted ballots - - {{ poll.getVote('votescast').value }} - {{ poll.getVote('votescast').percentStr }} -
+ +
+ Valid ballots + + {{ poll.getVote('votesvalid').value }} + {{ poll.getVote('votesvalid').percentStr }} +
+ Invalid ballots + + {{ poll.getVote('votesinvalid').value }} + {{ poll.getVote('votesinvalid').percentStr }} +
+ Casted ballots + + {{ poll.getVote('votescast').value }} + {{ poll.getVote('votescast').percentStr }} +
+ + Calculation impossible +
diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js index a5e1e1d38..0f9b58b26 100644 --- a/openslides/motions/static/js/motions/site.js +++ b/openslides/motions/static/js/motions/site.js @@ -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. diff --git a/openslides/poll/static/js/poll/majority.js b/openslides/poll/static/js/poll/majority.js index d2dd2e1e1..dfe5ad9c3 100644 --- a/openslides/poll/static/js/poll/majority.js +++ b/openslides/poll/static/js/poll/majority.js @@ -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; - }, + } }; } ]); diff --git a/tests/integration/core/test_viewset.py b/tests/integration/core/test_viewset.py index 31b0101e9..35b33e6ae 100644 --- a/tests/integration/core/test_viewset.py +++ b/tests/integration/core/test_viewset.py @@ -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'))