Merge pull request #2557 from matakuka/majority_assignment

Majority calculation for assignments
This commit is contained in:
Norman Jäckel 2016-12-01 14:41:41 +01:00 committed by GitHub
commit e81eeb4af9
10 changed files with 452 additions and 307 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

@ -9,97 +9,55 @@ angular.module('OpenSlidesApp.assignments', [])
'jsDataModel', 'jsDataModel',
'gettextCatalog', 'gettextCatalog',
'Config', 'Config',
function (DS, jsDataModel, gettextCatalog, Config) { 'MajorityMethods',
function (DS, jsDataModel, gettextCatalog, Config, MajorityMethods) {
return DS.defineResource({ return DS.defineResource({
name: 'assignments/polloption', name: 'assignments/polloption',
useClass: jsDataModel, useClass: jsDataModel,
methods: { methods: {
getVotes: function () { getVotes: function () {
if (!this.poll.has_votes) { if (!this.poll.has_votes) {
// Return undefined if this poll has no votes.
return; return;
} }
var poll = this.poll;
var votes = []; // Initial values for the option
var config = Config.get('assignments_poll_100_percent_base').value; var votes = [],
var impossible = false; config = Config.get('assignments_poll_100_percent_base').value;
var yes = null, no = null, abstain = null;
angular.forEach(this.votes, function(vote) { var base = this.poll.getPercentBase(config);
if (vote.value == "Yes" || vote.value == "Votes") { if (typeof base === 'object') {
yes = vote.weight; // this.poll.pollmethod === 'yna'
} else if (vote.value == "No") { base = base[this.id];
no = vote.weight;
} else if (vote.value == "Abstain") {
abstain = vote.weight;
}
});
//calculation for several candidates without yes/no options
var do_sum_of_all = false;
var sum_of_votes = 0;
if (poll.options.length > 1 && poll.pollmethod == 'votes') {
do_sum_of_all = true;
} }
if (do_sum_of_all === true) {
angular.forEach(poll.options, function(option) { _.forEach(this.votes, function (vote) {
angular.forEach(option.votes, function(vote) { // Initial values for the vote
if (vote.value == "Votes") { var value = '',
if (vote.weight >= 0 ) { percentStr = '',
sum_of_votes = sum_of_votes + vote.weight; percentNumber;
} else {
impossible = true; // Check for special value
}
}
});
});
}
angular.forEach(this.votes, function(vote) {
// check for special value
var value;
switch (vote.weight) { switch (vote.weight) {
case -1: case -1:
value = gettextCatalog.getString('majority'); value = gettextCatalog.getString('majority');
impossible = true;
break; break;
case -2: case -2:
value = gettextCatalog.getString('undocumented'); value = gettextCatalog.getString('undocumented');
impossible = true;
break; break;
default: default:
if (vote.weight >= 0) { if (vote.weight >= 0) {
value = vote.weight; value = vote.weight;
} else { } else {
value = 0; value = 0; // Vote was not defined. Set value to 0.
} }
break;
} }
// calculate percent value
var percentStr, percentNumber, base; // Special case where to skip percents
if (config == "VALID") { var skipPercents = config === 'YES_NO' && vote.value === 'Abstain';
if (poll.votesvalid && poll.votesvalid > 0) {
base = poll.votesvalid; if (base && !skipPercents) {
}
} else if ( config == "CAST") {
if (poll.votescast && poll.votescast > 0) {
base = poll.votescast;
}
} else if (config == "YES_NO" && !impossible) {
if (vote.value == "Yes" || vote.value == "No" || vote.value == "Votes"){
if (do_sum_of_all) {
base = sum_of_votes;
} else {
base = yes + no;
}
}
} else if (config == "YES_NO_ABSTAIN" && !impossible) {
if (do_sum_of_all) {
base = sum_of_votes;
} else {
base = yes + no + abstain;
}
}
if (base !== 'undefined' && vote.weight >= 0) {
percentNumber = Math.round(vote.weight * 100 / base * 10) / 10; percentNumber = Math.round(vote.weight * 100 / base * 10) / 10;
}
if (percentNumber >= 0 && percentNumber !== 'undefined') {
percentStr = "(" + percentNumber + "%)"; percentStr = "(" + percentNumber + "%)";
} }
votes.push({ votes.push({
@ -110,6 +68,45 @@ angular.module('OpenSlidesApp.assignments', [])
}); });
}); });
return votes; return votes;
},
// Returns 0 or positive integer if quorum is reached or surpassed.
// Returns negativ integer if quorum is not reached.
// Returns undefined if we can not calculate the quorum.
isReached: function (method) {
if (!this.poll.has_votes) {
// Return undefined if this poll has no votes.
return;
}
var isReached;
var config = Config.get('assignments_poll_100_percent_base').value;
var base = this.poll.getPercentBase(config);
if (typeof base === 'object') {
// this.poll.pollmethod === 'yna'
base = base[this.id];
}
if (base) {
// Provide result only if base is not undefined and not 0.
isReached = MajorityMethods[method](this.getVoteYes(), base);
}
return isReached;
},
// Returns the weight for the vote or the vote 'yes' in case of YNA poll method.
getVoteYes: function () {
var voteYes = 0;
if (this.poll.pollmethod === 'yna') {
var voteObj = _.find(this.votes, function (vote) {
return vote.value === 'Yes';
});
if (voteObj) {
voteYes = voteObj.weight;
}
} else {
// pollmethod === 'votes'
voteYes = this.votes[0].weight;
}
return voteYes;
} }
}, },
relations: { relations: {
@ -144,10 +141,88 @@ angular.module('OpenSlidesApp.assignments', [])
getResourceName: function () { getResourceName: function () {
return name; return name;
}, },
// returns object with value and percent (for votes valid/invalid/cast only)
// Returns percent base. Returns undefined if calculation is not possible in general.
getPercentBase: function (config, type) {
var base;
switch (config) {
case 'CAST':
if (this.votescast <= 0 || this.votesinvalid < 0) {
// It would be OK to check only this.votescast < 0 because 0
// is checked again later but this is a little bit faster.
break;
}
base = this.votescast;
/* falls through */
case 'VALID':
if (this.votesvalid < 0) {
base = void 0;
break;
}
if (typeof base === 'undefined' && type !== 'votescast' && type !== 'votesinvalid') {
base = this.votesvalid;
}
/* falls through */
case 'YES_NO_ABSTAIN':
case 'YES_NO':
if (this.pollmethod === 'yna') {
if (typeof base === 'undefined' && type !== 'votescast' && type !== 'votesinvalid' && type !== 'votesvalid') {
base = {};
_.forEach(this.options, function (option) {
var allVotes = option.votes;
if (config === 'YES_NO') {
allVotes = _.filter(allVotes, function (vote) {
// Extract abstain votes in case of YES_NO percent base.
// Do not extract abstain vote if it is set to majority so the predicate later
// fails and therefor we get an undefined base. Reason: It should not be possible
// to set abstain to majority and nevertheless calculate percents of yes and no.
return vote.value !== 'Abstain' || vote.weight === -1;
});
}
var predicate = function (vote) {
return vote.weight < 0;
};
if (_.findIndex(allVotes, predicate) === -1) {
base[option.id] = _.reduce(allVotes, function (sum, vote) {
return sum + vote.weight;
}, 0);
}
});
}
} else {
// this.pollmethod === 'votes'
var predicate = function (option) {
return option.votes[0].weight < 0;
};
if (_.findIndex(this.options, predicate) !== -1) {
base = void 0;
} else {
if (typeof base === 'undefined' && type !== 'votescast' && type !== 'votesinvalid' && type !== 'votesvalid') {
base = _.reduce(this.options, function (sum, option) {
return sum + option.votes[0].weight;
}, 0);
}
}
}
}
return base;
},
// Returns object with value and percent for this poll (for votes valid/invalid/cast only).
getVote: function (type) { getVote: function (type) {
var value, percentStr, vote; if (!this.has_votes) {
switch(type) { // Return undefined if this poll has no votes.
return;
}
// Initial values
var value = '',
percentStr = '',
percentNumber,
vote,
config = Config.get('assignments_poll_100_percent_base').value;
switch (type) {
case 'votesinvalid': case 'votesinvalid':
vote = this.votesinvalid; vote = this.votesinvalid;
break; break;
@ -158,35 +233,34 @@ angular.module('OpenSlidesApp.assignments', [])
vote = this.votescast; vote = this.votescast;
break; break;
} }
if (this.has_votes && vote) {
switch (vote) { // Check special values
case -1: switch (vote) {
value = gettextCatalog.getString('majority'); case -1:
break; value = gettextCatalog.getString('majority');
case -2: break;
value = gettextCatalog.getString('undocumented'); case -2:
break; value = gettextCatalog.getString('undocumented');
default: break;
default:
if (vote >= 0) {
value = vote; value = vote;
} } else {
if (vote >= 0) { value = 0; // value was not defined
var config = Config.get('assignments_poll_100_percent_base').value;
var percentNumber;
if (config == "CAST" && this.votescast && this.votescast > 0) {
percentNumber = Math.round(vote * 100 / this.votescast * 10) / 10;
} else if (config == "VALID" && this.votesvalid && this.votesvalid >= 0) {
if (type === 'votesvalid'){
percentNumber = Math.round(vote * 100 / this.votesvalid * 10) / 10;
}
} }
if (percentNumber !== 'undefined' && percentNumber >= 0 && percentNumber <=100) { }
percentStr = "(" + percentNumber + "%)";
} // Calculate percent value
} var base = this.getPercentBase(config, type);
if (base) {
percentNumber = Math.round(vote * 100 / (base) * 10) / 10;
percentStr = '(' + percentNumber + ' %)';
} }
return { return {
'value': value, 'value': value,
'percentStr': percentStr 'percentStr': percentStr,
'percentNumber': percentNumber,
'display': value + ' ' + percentStr
}; };
} }
}, },

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,47 @@ 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',
'Config',
'AssignmentPollDetailCtrlCache',
function ($scope, MajorityMethodChoices, Config, AssignmentPollDetailCtrlCache) {
// Define choices.
$scope.methodChoices = MajorityMethodChoices;
// TODO: Get $scope.baseChoices from config_variables.py without copying them.
// Setup empty cache with default values.
if (typeof AssignmentPollDetailCtrlCache[$scope.poll.id] === 'undefined') {
AssignmentPollDetailCtrlCache[$scope.poll.id] = {
method: $scope.config('assignments_poll_default_majority_method'),
};
}
// Fetch users choices from cache.
$scope.method = AssignmentPollDetailCtrlCache[$scope.poll.id].method;
$scope.recalculateMajorities = function (method) {
$scope.method = method;
_.forEach($scope.poll.options, function (option) {
option.majorityReached = option.isReached(method);
});
};
$scope.recalculateMajorities($scope.method);
// Save current values to cache on destroy of this controller.
$scope.$on('$destroy', function() {
AssignmentPollDetailCtrlCache[$scope.poll.id] = {
method: $scope.method,
};
});
}
])
.controller('AssignmentListCtrl', [ .controller('AssignmentListCtrl', [
'$scope', '$scope',
'ngDialog', 'ngDialog',
@ -883,6 +925,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,136 @@
<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 poll.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-show="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 translate ng-hide="method === 'disabled'">Quorum
</th>
<!-- 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>
<!-- 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 >= 0">
<uib-progressbar value="vote.percentNumber" type="success"></uib-progressbar>
</div>
</div> </div>
</div> </div>
</div> <td ng-hide="method === 'disabled'">
<span ng-if="option.majorityReached >= 0" class="text-success" translate>
Quorum ({{ option.getVoteYes() - option.majorityReached }}) reached.
</span>
<span ng-if="option.majorityReached < 0" class="text-danger" translate>
Quorum ({{ option.getVoteYes() - option.majorityReached }}) not reached.
</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>
</div>
</div> </div>
</uib-tab> </uib-tab>

View File

@ -7,6 +7,7 @@ angular.module('OpenSlidesApp.motions', [
'OpenSlidesApp.motions.lineNumbering', 'OpenSlidesApp.motions.lineNumbering',
'OpenSlidesApp.motions.diff', 'OpenSlidesApp.motions.diff',
'OpenSlidesApp.motions.DOCX', 'OpenSlidesApp.motions.DOCX',
'OpenSlidesApp.poll.majority',
'OpenSlidesApp.users', 'OpenSlidesApp.users',
]) ])
@ -66,7 +67,8 @@ angular.module('OpenSlidesApp.motions', [
'DS', 'DS',
'gettextCatalog', 'gettextCatalog',
'Config', 'Config',
function (DS, gettextCatalog, Config) { 'MajorityMethods',
function (DS, gettextCatalog, Config, MajorityMethods) {
return DS.defineResource({ return DS.defineResource({
name: 'motions/motionpoll', name: 'motions/motionpoll',
relations: { relations: {
@ -78,61 +80,84 @@ angular.module('OpenSlidesApp.motions', [
} }
}, },
methods: { methods: {
// returns object with value and percent // Returns percent base. Returns undefined if calculation is not possible in general.
getPercentBase: function (config, type) {
var base;
switch (config) {
case 'CAST':
if (this.votescast <= 0 || this.votesinvalid < 0) {
// It would be OK to check only this.votescast < 0 because 0
// is checked again later but this is a little bit faster.
break;
}
base = this.votescast;
/* falls through */
case 'VALID':
if (this.votesvalid < 0) {
base = void 0;
break;
}
if (typeof base === 'undefined' && type !== 'votescast' && type !== 'votesinvalid') {
base = this.votesvalid;
}
/* falls through */
case 'YES_NO_ABSTAIN':
if (this.abstain < 0) {
base = void 0;
break;
}
if (typeof base === 'undefined' && type !== 'votescast' && type !== 'votesinvalid' && type !== 'votesvalid') {
base = this.yes + this.no + this.abstain;
}
/* falls through */
case 'YES_NO':
if (this.yes < 0 || this.no < 0 || this.abstain === -1 ) {
// It is not allowed to set 'Abstain' to 'majority' but exclude it from calculation.
// Setting 'Abstain' to 'undocumented' is possible, of course.
base = void 0;
break;
}
if (typeof base === 'undefined' && (type === 'yes' || type === 'no')) {
base = this.yes + this.no;
}
}
return base;
},
// Returns object with value and percent for this poll.
getVote: function (vote, type) { getVote: function (vote, type) {
if (!this.has_votes) { if (!this.has_votes) {
// Return undefined if this poll has no votes.
return; return;
} }
var impossible = false;
var value = ''; // Initial values
var value = '',
percentStr = '',
percentNumber,
config = Config.get('motions_poll_100_percent_base').value;
// Check special values
switch (vote) { switch (vote) {
case -1: case -1:
value = gettextCatalog.getString('majority'); value = gettextCatalog.getString('majority');
impossible = true;
break; break;
case -2: case -2:
value = gettextCatalog.getString('undocumented'); value = gettextCatalog.getString('undocumented');
impossible = true;
break; break;
default: default:
if (vote >= 0) { if (vote >= 0) {
value = vote; value = vote;
} else { } else {
value = 0; //value was not defined value = 0; // Vote was not defined. Set value to 0.
} }
break;
} }
// 100% base impossible if at leat one value has set an
// speacial value (-1 or -2). // Calculate percent value
if (this.yes < 0 || this.no < 0 || this.abstain < 0) { var base = this.getPercentBase(config, type);
impossible = true; if (base) {
}
// calculate percent value
var config = Config.get('motions_poll_100_percent_base').value;
var percentStr = '';
var percentNumber = null;
var base = null;
if (!impossible) {
if (config == "YES_NO_ABSTAIN") {
if (type == 'yes' || type == 'no' || type == 'abstain') {
base = this.yes + this.no + this.abstain;
}
} else if (config == "YES_NO") {
if (type == 'yes' || type == 'no') {
base = this.yes + this.no;
}
} else if (config == "VALID" && type !== 'votescast' && type !== 'votesinvalid' &&
this.votesvalid > 0) {
base = this.votesvalid;
} else if (config == "CAST" && this.votescast > 0) {
base = this.votescast;
}
}
if (base !== null) {
percentNumber = Math.round(vote * 100 / (base) * 10) / 10; percentNumber = Math.round(vote * 100 / (base) * 10) / 10;
} percentStr = '(' + percentNumber + ' %)';
if (percentNumber !== null) {
percentStr = "(" + percentNumber + "%)";
} }
return { return {
'value': value, 'value': value,
@ -140,6 +165,25 @@ angular.module('OpenSlidesApp.motions', [
'percentNumber': percentNumber, 'percentNumber': percentNumber,
'display': value + ' ' + percentStr 'display': value + ' ' + percentStr
}; };
},
// Returns 0 or positive integer if quorum is reached or surpassed.
// Returns negativ integer if quorum is not reached.
// Returns undefined if we can not calculate the quorum.
isReached: function (method) {
if (!this.has_votes) {
// Return undefined if this poll has no votes.
return;
}
var isReached;
var config = Config.get('motions_poll_100_percent_base').value;
var base = this.getPercentBase(config, 'yes');
if (base) {
// Provide result only if base is not undefined and not 0.
isReached = MajorityMethods[method](this.yes, base);
}
return isReached;
} }
} }
}); });
@ -246,7 +290,7 @@ angular.module('OpenSlidesApp.motions', [
for (var i = 0; i < changes.length; i++) { for (var i = 0; i < changes.length; i++) {
var change = changes[i]; var change = changes[i];
if (statusCompareCb === undefined || statusCompareCb(change.rejected)) { if (typeof statusCompareCb === 'undefined' || statusCompareCb(change.rejected)) {
html = lineNumberingService.insertLineNumbers(html, lineLength); html = lineNumberingService.insertLineNumbers(html, lineLength);
html = diffService.replaceLines(html, change.text, change.line_from, change.line_to); html = diffService.replaceLines(html, change.text, change.line_from, change.line_to);
} }

View File

@ -740,42 +740,37 @@ angular.module('OpenSlidesApp.motions.site', [
.controller('MotionPollDetailCtrl', [ .controller('MotionPollDetailCtrl', [
'$scope', '$scope',
'MajorityMethodChoices', 'MajorityMethodChoices',
'MotionMajority',
'Config', 'Config',
'MotionPollDetailCtrlCache', 'MotionPollDetailCtrlCache',
function ($scope, MajorityMethodChoices, MotionMajority, Config, MotionPollDetailCtrlCache) { function ($scope, MajorityMethodChoices, 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.
// Setup empty cache with default values. // Setup empty cache with default values.
if (MotionPollDetailCtrlCache[$scope.poll.id] === undefined) { if (typeof MotionPollDetailCtrlCache[$scope.poll.id] === 'undefined') {
MotionPollDetailCtrlCache[$scope.poll.id] = { MotionPollDetailCtrlCache[$scope.poll.id] = {
isMajorityCalculation: true,
isMajorityDetails: false,
method: $scope.config('motions_poll_default_majority_method'), method: $scope.config('motions_poll_default_majority_method'),
base: $scope.config('motions_poll_100_percent_base')
}; };
} }
// Fetch users choices from cache. // 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.method = MotionPollDetailCtrlCache[$scope.poll.id].method;
$scope.base = MotionPollDetailCtrlCache[$scope.poll.id].base;
// Define result function. // Define result function.
$scope.isReached = function () { $scope.isReached = function () {
return MotionMajority.isReached($scope.base, $scope.method, $scope.poll); return $scope.poll.isReached($scope.method);
};
// Define template controll function
$scope.hideMajorityCalculation = function () {
return typeof $scope.isReached() === 'undefined' && $scope.method !== 'disabled';
}; };
// Save current values to cache on detroy of this controller. // Save current values to cache on detroy of this controller.
$scope.$on('$destroy', function() { $scope.$on('$destroy', function() {
MotionPollDetailCtrlCache[$scope.poll.id] = { MotionPollDetailCtrlCache[$scope.poll.id] = {
isMajorityCalculation: $scope.isMajorityCalculation,
isMajorityDetails: $scope.isMajorityDetails,
method: $scope.method, method: $scope.method,
base: $scope.base
}; };
}); });
} }
@ -1146,7 +1141,7 @@ angular.module('OpenSlidesApp.motions.site', [
// open dialog for new amendment // open dialog for new amendment
$scope.newAmendment = function () { $scope.newAmendment = function () {
var dialog = MotionForm.getDialog(); var dialog = MotionForm.getDialog();
if (dialog.scope === undefined) { if (typeof dialog.scope === 'undefined') {
dialog.scope = {}; dialog.scope = {};
} }
dialog.scope = $scope; dialog.scope = $scope;

View File

@ -295,13 +295,12 @@
<td> <td>
<td> <td>
<div os-perms="motions.can_manage" <div os-perms="motions.can_manage"
ng-hide="config('motions_poll_default_majority_method') == 'disabled' || ng-hide="hideMajorityCalculation()"
isReached() === undefined" ng-cloak> ng-cloak>
<div class="input-group"> <div class="input-group">
<span><translate>Required majority</translate>: </span> <span><translate>Required majority</translate>: </span>
<select ng-init="config('motions_poll_default_majority_method')" <select ng-model="$parent.method"
ng-model="$parent.method" ng-options="option.value as option.display_name | translate for option in methodChoices">
ng-options="option.value as option.display_name | translate for option in methodChoices">
</div> </div>
</div> </div>
<tr> <tr>
@ -309,10 +308,10 @@
<td> <td>
<div os-perms="motions.can_manage"> <div os-perms="motions.can_manage">
<span class="text-success" ng-if="isReached() >= 0" translate> <span class="text-success" ng-if="isReached() >= 0" translate>
Quorum reached, {{ isReached() }} votes more than needed. Quorum ({{ voteYes.value - isReached() }}) reached.
</span> </span>
<span class="text-danger" ng-if="isReached() < 0" translate> <span class="text-danger" ng-if="isReached() < 0" translate>
Quorum not reached, {{ -(isReached()) }} votes missing. Quorum ({{ voteYes.value - isReached() }}) not reached.
</span> </span>
</div> </div>

View File

@ -36,50 +36,6 @@ angular.module('OpenSlidesApp.poll.majority', [])
}, },
}; };
} }
])
.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;
},
};
}
]); ]);
}()); }());

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'))