Merge pull request #4640 from MaximilianKrambach/pollCalculations

Poll calculations
This commit is contained in:
Maximilian Krambach 2019-05-20 11:59:31 +02:00 committed by GitHub
commit a09269cf2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 211 additions and 180 deletions

View File

@ -1,6 +1,11 @@
import { Deserializer } from '../base/deserializer';
import { PollVoteValue } from 'app/core/ui-services/poll.service';
export interface AssignmentOptionVote {
weight: number;
value: PollVoteValue;
}
/**
* Representation of a poll option
*
@ -11,10 +16,7 @@ export class AssignmentPollOption extends Deserializer {
public id: number; // The AssignmentUser id of the candidate
public candidate_id: number; // the User id of the candidate
public is_elected: boolean;
public votes: {
weight: number; // represented as a string because it's a decimal field
value: PollVoteValue;
}[];
public votes: AssignmentOptionVote[];
public poll_id: number;
public weight: number; // weight to order the display

View File

@ -98,11 +98,6 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
this._assignment = assignment;
this.filterCandidates();
if (this.assignment.polls.length) {
this.assignment.polls.forEach(poll => {
poll.pollBase = this.pollService.getBaseAmount(poll);
});
}
}
/**

View File

@ -7,10 +7,9 @@ import { TranslateService } from '@ngx-translate/core';
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
import { AssignmentPollDialogComponent } from '../assignment-poll-dialog/assignment-poll-dialog.component';
import { AssignmentPollService, AssignmentPercentBase } from '../../services/assignment-poll.service';
import { AssignmentPollService } from '../../services/assignment-poll.service';
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
import { BaseViewComponent } from 'app/site/base/base-view';
import { ConfigService } from 'app/core/ui-services/config.service';
import { MajorityMethod, CalculablePollKey } from 'app/core/ui-services/poll.service';
import { OperatorService } from 'app/core/core-services/operator.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
@ -132,8 +131,7 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
public translate: TranslateService,
public dialog: MatDialog,
private promptService: PromptService,
private formBuilder: FormBuilder,
private config: ConfigService
private formBuilder: FormBuilder
) {
super(titleService, translate, matSnackBar);
}
@ -148,13 +146,6 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
this.descriptionForm = this.formBuilder.group({
description: this.poll ? this.poll.description : ''
});
this.subscriptions.push(
this.config.get<AssignmentPercentBase>('assignments_poll_100_percent_base').subscribe(() => {
if (this.poll) {
this.poll.pollBase = this.pollService.getBaseAmount(this.poll);
}
})
);
}
/**
@ -188,7 +179,11 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
public quorumReached(option: ViewAssignmentPollOption): boolean {
const yesValue = this.poll.pollmethod === 'votes' ? 'Votes' : 'Yes';
const amount = option.votes.find(v => v.value === yesValue).weight;
const yesQuorum = this.pollService.yesQuorum(this.majorityChoice, this.poll, option);
const yesQuorum = this.pollService.yesQuorum(
this.majorityChoice,
this.pollService.calculationDataFromPoll(this.poll),
option
);
return yesQuorum && amount >= yesQuorum;
}
@ -263,7 +258,11 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
*/
public getQuorumReachedString(option: ViewAssignmentPollOption): string {
const name = this.translate.instant(this.majorityChoice.display_name);
const quorum = this.pollService.yesQuorum(this.majorityChoice, this.poll, option);
const quorum = this.pollService.yesQuorum(
this.majorityChoice,
this.pollService.calculationDataFromPoll(this.poll),
option
);
const isReached = this.quorumReached(option)
? this.translate.instant('reached')
: this.translate.instant('not reached');

View File

@ -1,9 +1,9 @@
import { ViewUser } from 'app/site/users/models/view-user';
import { AssignmentPollOption, AssignmentOptionVote } from 'app/shared/models/assignments/assignment-poll-option';
import { BaseViewModel } from 'app/site/base/base-view-model';
import { Updateable } from 'app/site/base/updateable';
import { Identifiable } from 'app/shared/models/base/identifiable';
import { PollVoteValue } from 'app/core/ui-services/poll.service';
import { AssignmentPollOption } from 'app/shared/models/assignments/assignment-poll-option';
import { Updateable } from 'app/site/base/updateable';
import { ViewUser } from 'app/site/users/models/view-user';
/**
* Defines the order the option's votes are sorted in (server might send raw data in any order)
@ -40,10 +40,7 @@ export class ViewAssignmentPollOption implements Identifiable, Updateable {
return this.option.is_elected;
}
public get votes(): {
weight: number;
value: PollVoteValue;
}[] {
public get votes(): AssignmentOptionVote[] {
return this.option.votes.sort((a, b) => votesOrder.indexOf(a.value) - votesOrder.indexOf(b.value));
}

View File

@ -78,13 +78,6 @@ export class ViewAssignmentPoll implements Identifiable, Updateable, Projectable
return this.poll.assignment_id;
}
/**
* storing the base values for percentage calculations,
* to avoid recalculating pollBases too often
* (the calculation iterates through all pollOptions in some use cases)
*/
public pollBase: number;
public constructor(assignmentPoll: AssignmentPoll, assignmentPollOptions: ViewAssignmentPollOption[]) {
this._assignmentPoll = assignmentPoll;
this._assignmentPollOptions = assignmentPollOptions;

View File

@ -243,8 +243,11 @@ export class AssignmentPdfService {
const conclusionLabel = this.translate.instant(this.pollService.getLabel(key));
const specialLabel = this.translate.instant(this.pollService.getSpecialLabel(poll[key]));
let percentLabel = '';
if (!this.pollService.isAbstractValue(poll, key)) {
percentLabel = ` (${this.pollService.getValuePercent(poll, key)}%)`;
if (!this.pollService.isAbstractValue(this.pollService.calculationDataFromPoll(poll), key)) {
percentLabel = ` (${this.pollService.getValuePercent(
this.pollService.calculationDataFromPoll(poll),
key
)}%)`;
}
return [
{
@ -302,10 +305,17 @@ export class AssignmentPdfService {
let resultString = '';
const label = this.translate.instant(this.pollService.getLabel(optionLabel));
const valueString = this.pollService.getSpecialLabel(value);
const percentNr = this.pollService.getPercent(poll, pollOption, optionLabel);
const percentNr = this.pollService.getPercent(
this.pollService.calculationDataFromPoll(poll),
pollOption,
optionLabel
);
resultString += `${label} ${valueString}`;
if (percentNr && !this.pollService.isAbstractOption(poll, pollOption, optionLabel)) {
if (
percentNr &&
!this.pollService.isAbstractOption(this.pollService.calculationDataFromPoll(poll), pollOption, optionLabel)
) {
resultString += ` (${percentNr}%)`;
}

View File

@ -1,5 +1,6 @@
import { Injectable } from '@angular/core';
import { AssignmentOptionVote } from 'app/shared/models/assignments/assignment-poll-option';
import { ConfigService } from 'app/core/ui-services/config.service';
import {
PollService,
@ -15,6 +16,28 @@ type AssignmentPollValues = 'auto' | 'votes' | 'yesnoabstain' | 'yesno';
export type AssignmentPollMethod = 'yn' | 'yna' | 'votes';
export type AssignmentPercentBase = 'YES_NO_ABSTAIN' | 'YES_NO' | 'VALID' | 'CAST' | 'DISABLED';
/**
* interface common to data in a ViewAssignmentPoll and PollSlideData
*
* TODO: simplify
*/
export interface CalculationData {
pollMethod: AssignmentPollMethod;
votesno: number;
votesabstain: number;
votescast: number;
votesvalid: number;
votesinvalid: number;
percentBase?: AssignmentPercentBase;
pollOptions?: {
votes: AssignmentOptionVote[];
}[];
}
interface CalculationOption {
votes: AssignmentOptionVote[];
}
/**
* Vote entries included once for summary (e.g. total votes cast)
*/
@ -75,17 +98,18 @@ export class AssignmentPollService extends PollService {
* Get the base amount for the 100% calculations. Note that some poll methods
* (e.g. yes/no/abstain may have a different percentage base and will return null here)
*
* @param poll
* @param data
* @returns The amount of votes indicating the 100% base
*/
public getBaseAmount(poll: ViewAssignmentPoll): number | null {
switch (this.percentBase) {
public getBaseAmount(data: CalculationData): number | null {
const percentBase = data.percentBase || this.percentBase;
switch (percentBase) {
case 'DISABLED':
return null;
case 'YES_NO':
case 'YES_NO_ABSTAIN':
if (poll.pollmethod === 'votes') {
const yes = poll.options.map(option => {
if (data.pollMethod === 'votes') {
const yes = data.pollOptions.map(option => {
const yesValue = option.votes.find(v => v.value === 'Votes');
return yesValue ? yesValue.weight : -99;
});
@ -99,9 +123,9 @@ export class AssignmentPollService extends PollService {
return null;
}
case 'CAST':
return poll.votescast > 0 && poll.votesinvalid >= 0 ? poll.votescast : null;
return data.votescast > 0 && data.votesinvalid >= 0 ? data.votescast : null;
case 'VALID':
return poll.votesvalid > 0 ? poll.votesvalid : null;
return data.votesvalid > 0 ? data.votesvalid : null;
default:
return null;
}
@ -111,25 +135,25 @@ export class AssignmentPollService extends PollService {
* Get the percentage for an option
*
* @param poll
* @param option
* @param value
* @param data
* @returns a percentage number with two digits, null if the value cannot be calculated
*/
public getPercent(poll: ViewAssignmentPoll, option: ViewAssignmentPollOption, value: PollVoteValue): number | null {
public getPercent(data: CalculationData, option: CalculationOption, key: PollVoteValue): number | null {
const percentBase = data.percentBase || this.percentBase;
let base = 0;
if (this.percentBase === 'DISABLED') {
if (percentBase === 'DISABLED') {
return null;
} else if (this.percentBase === 'VALID') {
base = poll.votesvalid;
} else if (this.percentBase === 'CAST') {
base = poll.votescast;
} else if (percentBase === 'VALID') {
base = data.votesvalid;
} else if (percentBase === 'CAST') {
base = data.votescast;
} else {
base = poll.pollmethod === 'votes' ? poll.pollBase : this.getOptionBaseAmount(poll, option);
base = data.pollMethod === 'votes' ? this.getBaseAmount(data) : this.getOptionBaseAmount(data, option);
}
if (!base || base < 0) {
return null;
}
const vote = option.votes.find(v => v.value === value);
const vote = option.votes.find(v => v.value === key);
if (!vote) {
return null;
}
@ -140,39 +164,53 @@ export class AssignmentPollService extends PollService {
* get the percentage for a non-abstract per-poll value
* TODO: similar code to getPercent. Mergeable?
*
* @param poll the poll this value refers to
* @param data
* @param value a per-poll value (e.g. 'votesvalid')
* @returns a percentage number with two digits, null if the value cannot be calculated
*/
public getValuePercent(poll: ViewAssignmentPoll, value: CalculablePollKey): number | null {
if (!poll.pollBase) {
public getValuePercent(data: CalculationData, value: CalculablePollKey): number | null {
const percentBase = data.percentBase || this.percentBase;
switch (percentBase) {
case 'YES_NO':
case 'YES_NO_ABSTAIN':
case 'DISABLED':
return null;
case 'VALID':
if (value === 'votesinvalid' || value === 'votescast') {
return null;
}
const amount = poll[value];
break;
}
const baseAmount = this.getBaseAmount(data);
if (!baseAmount) {
return null;
}
const amount = data[value];
if (amount === undefined || amount < 0) {
return null;
}
return Math.round(((amount * 100) / poll.pollBase) * 100) / 100;
return Math.round(((amount * 100) / baseAmount) * 100) / 100;
}
/**
* Check if the option in a poll is abstract (percentages should not be calculated)
*
* @param poll
* @param data
* @param option
* @param key (optional) the key to calculate
* @returns true if the poll has no percentages, the poll option is a special value,
* or if the calculations are disabled in the config
*/
public isAbstractOption(poll: ViewAssignmentPoll, option: ViewAssignmentPollOption, key?: PollVoteValue): boolean {
if (this.percentBase === 'DISABLED' || !option.votes || !option.votes.length) {
public isAbstractOption(data: CalculationData, option: ViewAssignmentPollOption, key?: PollVoteValue): boolean {
const percentBase = data.percentBase || this.percentBase;
if (percentBase === 'DISABLED' || !option.votes || !option.votes.length) {
return true;
}
if (key === 'Abstain' && this.percentBase === 'YES_NO') {
if (key === 'Abstain' && percentBase === 'YES_NO') {
return true;
}
if (poll.pollmethod === 'votes') {
return poll.pollBase ? false : true;
if (data.pollMethod === 'votes') {
return this.getBaseAmount(data) > 0 ? false : true;
} else {
return option.votes.some(v => v.weight < 0);
}
@ -182,19 +220,20 @@ export class AssignmentPollService extends PollService {
* Check for abstract (not usable as percentage) options in non-option
* 'meta' values
*
* @param poll
* @param data
* @param value
* @returns true if percentages cannot be calculated
* TODO: Yes, No, etc. in an option will always return true.
* Use {@link isAbstractOption} for these
*/
public isAbstractValue(poll: ViewAssignmentPoll, value: CalculablePollKey): boolean {
if (this.percentBase === 'DISABLED' || !poll.pollBase || !this.pollValues.includes(value)) {
public isAbstractValue(data: CalculationData, value: CalculablePollKey): boolean {
const percentBase = data.percentBase || this.percentBase;
if (percentBase === 'DISABLED' || !this.getBaseAmount(data) || !this.pollValues.includes(value)) {
return true;
}
if (this.percentBase === 'CAST' && poll[value] >= 0) {
if (percentBase === 'CAST' && data[value] >= 0) {
return false;
} else if (this.percentBase === 'VALID' && value === 'votesvalid' && poll[value] > 0) {
} else if (percentBase === 'VALID' && value === 'votesvalid' && data[value] > 0) {
return false;
}
return true;
@ -203,24 +242,27 @@ export class AssignmentPollService extends PollService {
/**
* Calculate the base amount inside an option. Only useful if poll method is not 'votes'
*
* @param data
* @param option
* @returns an positive integer to be used as percentage base, or null
*/
private getOptionBaseAmount(poll: ViewAssignmentPoll, option: ViewAssignmentPollOption): number | null {
if (this.percentBase === 'DISABLED' || poll.pollmethod === 'votes') {
private getOptionBaseAmount(data: CalculationData, option: CalculationOption): number | null {
const percentBase = data.percentBase || this.percentBase;
if (percentBase === 'DISABLED' || data.pollMethod === 'votes') {
return null;
} else if (this.percentBase === 'CAST') {
return poll.votescast > 0 ? poll.votescast : null;
} else if (this.percentBase === 'VALID') {
return poll.votesvalid > 0 ? poll.votesvalid : null;
} else if (percentBase === 'CAST') {
return data.votescast > 0 ? data.votescast : null;
} else if (percentBase === 'VALID') {
return data.votesvalid > 0 ? data.votesvalid : null;
}
const yes = option.votes.find(v => v.value === 'Yes');
const no = option.votes.find(v => v.value === 'No');
if (this.percentBase === 'YES_NO') {
if (percentBase === 'YES_NO') {
if (!yes || yes.weight === undefined || !no || no.weight === undefined) {
return null;
}
return yes.weight >= 0 && no.weight >= 0 ? yes.weight + no.weight : null;
} else if (this.percentBase === 'YES_NO_ABSTAIN') {
} else if (percentBase === 'YES_NO_ABSTAIN') {
const abstain = option.votes.find(v => v.value === 'Abstain');
if (!abstain || abstain.weight === undefined) {
return null;
@ -235,16 +277,32 @@ export class AssignmentPollService extends PollService {
* Get the minimum amount of votes needed for an option to pass the quorum
*
* @param method
* @param poll
* @param data
* @param option
* @returns a positive integer number; may return null if quorum is not calculable
*/
public yesQuorum(
method: MajorityMethod,
poll: ViewAssignmentPoll,
option: ViewAssignmentPollOption
): number | null {
const baseAmount = poll.pollmethod === 'votes' ? poll.pollBase : this.getOptionBaseAmount(poll, option);
public yesQuorum(method: MajorityMethod, data: CalculationData, option: ViewAssignmentPollOption): number | null {
const baseAmount =
data.pollMethod === 'votes' ? this.getBaseAmount(data) : this.getOptionBaseAmount(data, option);
return method.calc(baseAmount);
}
/**
* helper function to tuirn a Poll into calculation data for this service
* TODO: temp until better method to normalize Poll ans PollSlideData is implemented
*
* @param poll
* @returns calculationData ready to be used
*/
public calculationDataFromPoll(poll: ViewAssignmentPoll): CalculationData {
return {
pollMethod: poll.pollmethod,
votesno: poll.votesno,
votesabstain: poll.votesabstain,
votescast: poll.votescast,
votesinvalid: poll.votesinvalid,
votesvalid: poll.votesvalid,
pollOptions: poll.options
};
}
}

View File

@ -5,9 +5,8 @@ export interface PollSlideOption {
user: string;
is_elected: boolean;
votes: {
weight: PollVoteValue;
value: string;
percent?: string;
weight: string;
value: PollVoteValue;
}[];
}

View File

@ -24,39 +24,18 @@
</div>
<div class="option-percents">
<div *ngFor="let vote of option.votes" class="bold">
<span *ngIf="vote.value !== 'Votes'">{{ vote.value | translate }}:</span>
<span>
{{ labelValue(vote.weight) | translate }}
</span>
<span *ngIf="vote.percent">
({{ vote.percent }})
</span>
<span *ngIf="vote.value !== 'Votes'">{{ getLabel(vote.value) }}:</span>
<span> {{ getVotePercent(vote.value, option) }}</span>
</div>
</div>
</div>
</div>
<div *ngIf="data.data.poll.votesvalid !== null" class="row">
<div *ngFor="let value of pollValues" class="row">
<div class="option-name grey">
<span translate>Valid votes</span>
<span>{{ getLabel(value) }}</span>
</div>
<div class="option-percents grey">
{{ labelValue(data.data.poll.votesvalid) | translate }}
</div>
</div>
<div *ngIf="data.data.poll.votesinvalid !== null" class="row">
<div class="option-name grey">
<span translate>Invalid votes</span>
</div>
<div class="option-percents grey">
{{ labelValue(data.data.poll.votesinvalid) | translate }}
</div>
</div>
<div *ngIf="data.data.poll.votescast !== null" class="row">
<div class="option-name grey">
<span translate>Total votes cast</span>
</div>
<div class="option-percents grey">
{{ labelValue(data.data.poll.votescast) | translate }}
{{ getPollPercent(value) }}
</div>
</div>
</div>

View File

@ -1,9 +1,15 @@
import { Component, Input } from '@angular/core';
import { AssignmentPollService, SummaryPollKey } from 'app/site/assignments/services/assignment-poll.service';
import {
AssignmentPollService,
CalculationData,
SummaryPollKey
} from 'app/site/assignments/services/assignment-poll.service';
import { BaseSlideComponent } from 'app/slides/base-slide-component';
import { PollSlideData } from './poll-slide-data';
import { PollSlideData, PollSlideOption } from './poll-slide-data';
import { PollVoteValue, CalculablePollKey } from 'app/core/ui-services/poll.service';
import { SlideData } from 'app/core/core-services/projector-data.service';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'os-poll-slide',
@ -13,85 +19,78 @@ import { SlideData } from 'app/core/core-services/projector-data.service';
export class PollSlideComponent extends BaseSlideComponent<PollSlideData> {
private _data: SlideData<PollSlideData>;
public pollValues: SummaryPollKey[] = ['votesno', 'votesabstain', 'votesvalid', 'votesinvalid', 'votescast'];
private calculationData: CalculationData;
public get pollValues(): SummaryPollKey[] {
if (!this.data) {
return [];
}
const values: SummaryPollKey[] = ['votesno', 'votesabstain', 'votesvalid', 'votesinvalid', 'votescast'];
return values.filter(val => this.data.data.poll[val] !== null);
}
@Input()
public set data(data: SlideData<PollSlideData>) {
this._data = data;
this.setPercents();
this.calculationData = {
pollMethod: data.data.poll.pollmethod,
votesno: parseFloat(data.data.poll.votesno),
votesabstain: parseFloat(data.data.poll.votesabstain),
votescast: parseFloat(data.data.poll.votescast),
votesvalid: parseFloat(data.data.poll.votesvalid),
votesinvalid: parseFloat(data.data.poll.votesinvalid),
pollOptions: data.data.poll.options.map(opt => {
return {
votes: opt.votes.map(vote => {
return {
weight: parseFloat(vote.weight),
value: vote.value
};
})
};
}),
percentBase: data.data.assignments_poll_100_percent_base
};
}
public get data(): SlideData<PollSlideData> {
return this._data;
}
public constructor(private pollService: AssignmentPollService) {
public constructor(private pollService: AssignmentPollService, private translate: TranslateService) {
super();
}
public getVoteString(rawValue: string): string {
const num = parseFloat(rawValue);
if (!isNaN(num)) {
return this.pollService.getSpecialLabel(num);
}
return '-';
/**
* get a vote's numerical or special label, including percent values if these are to
* be displayed
*
* @param key
* @param option
*/
public getVotePercent(key: PollVoteValue, option: PollSlideOption): string {
const calcOption = {
votes: option.votes.map(vote => {
return { weight: parseFloat(vote.weight), value: vote.value };
})
};
const percent = this.pollService.getPercent(this.calculationData, calcOption, key);
const number = this.translate.instant(
this.pollService.getSpecialLabel(parseFloat(option.votes.find(v => v.value === key).weight))
);
return percent === null ? number : `${number} (${percent}%)`;
}
private setPercents(): void {
if (
this.data.data.assignments_poll_100_percent_base === 'DISABLED' ||
!this.data.data.poll.has_votes ||
!this.data.data.poll.options.length
) {
return;
}
for (const option of this.data.data.poll.options) {
for (const vote of option.votes) {
const voteweight = parseFloat(vote.weight);
if (isNaN(voteweight) || voteweight < 0) {
return;
}
let base: number;
switch (this.data.data.assignments_poll_100_percent_base) {
case 'CAST':
base = this.data.data.poll.votescast ? parseFloat(this.data.data.poll.votescast) : 0;
break;
case 'VALID':
base = this.data.data.poll.votesvalid ? parseFloat(this.data.data.poll.votesvalid) : 0;
break;
case 'YES_NO':
case 'YES_NO_ABSTAIN':
const yesOption = option.votes.find(v => v.value === 'Yes');
const yes = yesOption ? parseFloat(yesOption.weight) : -1;
const noOption = option.votes.find(v => v.value === 'No');
const no = noOption ? parseFloat(noOption.weight) : -1;
const absOption = option.votes.find(v => v.value === 'Abstain');
const abs = absOption ? parseFloat(absOption.weight) : -1;
if (this.data.data.assignments_poll_100_percent_base === 'YES_NO_ABSTAIN') {
base = yes >= 0 && no >= 0 && abs >= 0 ? yes + no + abs : 0;
} else {
if (vote.value !== 'Abstain') {
base = yes >= 0 && no >= 0 ? yes + no : 0;
}
}
break;
default:
break;
}
if (base) {
vote.percent = `${Math.round(((parseFloat(vote.weight) * 100) / base) * 100) / 100}%`;
}
}
}
public getPollPercent(key: CalculablePollKey): string {
const percent = this.pollService.getValuePercent(this.calculationData, key);
const number = this.translate.instant(this.pollService.getSpecialLabel(this.calculationData[key]));
return percent === null ? number : `${number} (${percent}%)`;
}
/**
* Converts a number-like string to a simpler, more readable version
*
* @param input a server-sent string representing a numerical value
* @returns either the special label or a cleaned-up number of the inpu string
* @returns a translated label for a key
*/
public labelValue(input: string): string {
return this.pollService.getSpecialLabel(parseFloat(input));
public getLabel(key: CalculablePollKey): string {
return this.translate.instant(this.pollService.getLabel(key));
}
}