Result PDF for Voting

- Add result PDF for Motion and Assignments
- Add "getPercentBase" for Assignment
This commit is contained in:
Sean Engelhardt 2020-02-12 15:03:27 +01:00 committed by FinnStutzenstein
parent 6044c63c28
commit 93dc78c7d6
9 changed files with 124 additions and 314 deletions

View File

@ -1,3 +1,4 @@
import { CalculablePollKey } from 'app/site/polls/services/poll.service';
import { AssignmentOption } from './assignment-option'; import { AssignmentOption } from './assignment-option';
import { BasePoll } from '../poll/base-poll'; import { BasePoll } from '../poll/base-poll';
@ -23,6 +24,16 @@ export class AssignmentPoll extends BasePoll<AssignmentPoll, AssignmentOption> {
public global_abstain: boolean; public global_abstain: boolean;
public description: string; public description: string;
public get pollmethodFields(): CalculablePollKey[] {
if (this.pollmethod === AssignmentPollMethods.YN) {
return ['yes', 'no'];
} else if (this.pollmethod === AssignmentPollMethods.YNA) {
return ['yes', 'no', 'abstain'];
} else if (this.pollmethod === AssignmentPollMethods.Votes) {
return ['yes'];
}
}
public constructor(input?: any) { public constructor(input?: any) {
super(AssignmentPoll.COLLECTIONSTRING, input); super(AssignmentPoll.COLLECTIONSTRING, input);
} }

View File

@ -1,3 +1,4 @@
import { CalculablePollKey } from 'app/site/polls/services/poll.service';
import { BasePoll } from '../poll/base-poll'; import { BasePoll } from '../poll/base-poll';
import { MotionOption } from './motion-option'; import { MotionOption } from './motion-option';
@ -16,6 +17,15 @@ export class MotionPoll extends BasePoll<MotionPoll, MotionOption> {
public motion_id: number; public motion_id: number;
public pollmethod: MotionPollMethods; public pollmethod: MotionPollMethods;
public get pollmethodFields(): CalculablePollKey[] {
const ynField: CalculablePollKey[] = ['yes', 'no'];
if (this.pollmethod === MotionPollMethods.YN) {
return ynField;
} else if (this.pollmethod === MotionPollMethods.YNA) {
return ynField.concat(['abstain']);
}
}
public constructor(input?: any) { public constructor(input?: any) {
super(MotionPoll.COLLECTIONSTRING, input); super(MotionPoll.COLLECTIONSTRING, input);
} }

View File

@ -24,7 +24,6 @@
<div *ngIf="assignment"> <div *ngIf="assignment">
<!-- PDF --> <!-- PDF -->
<button mat-menu-item (click)="onDownloadPdf()"> <button mat-menu-item (click)="onDownloadPdf()">
<!-- TODO: results or description. Results if published -->
<mat-icon>picture_as_pdf</mat-icon> <mat-icon>picture_as_pdf</mat-icon>
<span translate>PDF</span> <span translate>PDF</span>
</button> </button>

View File

@ -2,7 +2,7 @@ import { BehaviorSubject } from 'rxjs';
import { ChartData } from 'app/shared/components/charts/charts.component'; import { ChartData } from 'app/shared/components/charts/charts.component';
import { AssignmentPoll, AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPoll, AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { PollColor, PollState } from 'app/shared/models/poll/base-poll'; import { PercentBase, PollColor, PollState } from 'app/shared/models/poll/base-poll';
import { BaseViewModel } from 'app/site/base/base-view-model'; import { BaseViewModel } from 'app/site/base/base-view-model';
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
import { PollData, ViewBasePoll } from 'app/site/polls/models/view-base-poll'; import { PollData, ViewBasePoll } from 'app/site/polls/models/view-base-poll';
@ -80,7 +80,8 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll> implements
yes: candidate.yes, yes: candidate.yes,
no: candidate.no, no: candidate.no,
abstain: candidate.abstain, abstain: candidate.abstain,
user: candidate.user.full_name user: candidate.user.full_name,
showPercent: true
})) }))
.sort((a, b) => b.yes - a.yes); .sort((a, b) => b.yes - a.yes);
@ -97,8 +98,41 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll> implements
return super.getNextStates(); return super.getNextStates();
} }
private sumOptionsYN(): number {
return this.options.reduce((o, n) => {
o += n.yes > 0 ? n.yes : 0;
o += n.no > 0 ? n.no : 0;
return o;
}, 0);
}
private sumOptionsYNA(): number {
return this.options.reduce((o, n) => {
o += n.abstain > 0 ? n.abstain : 0;
return o;
}, this.sumOptionsYN());
}
public getPercentBase(): number { public getPercentBase(): number {
return 0; const base: PercentBase = this.poll.onehundred_percent_base;
let totalByBase: number;
switch (base) {
case PercentBase.YN:
totalByBase = this.sumOptionsYN();
break;
case PercentBase.YNA:
totalByBase = this.sumOptionsYNA();
break;
case PercentBase.Valid:
totalByBase = this.poll.votesvalid;
break;
case PercentBase.Cast:
totalByBase = this.poll.votescast;
break;
default:
break;
}
return totalByBase;
} }
} }

View File

@ -3,7 +3,12 @@ import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { HtmlToPdfService } from 'app/core/pdf-services/html-to-pdf.service'; import { HtmlToPdfService } from 'app/core/pdf-services/html-to-pdf.service';
import { ParsePollNumberPipe } from 'app/shared/pipes/parse-poll-number.pipe';
import { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe';
import { PollPercentBasePipe } from 'app/shared/pipes/poll-percent-base.pipe';
import { PollData } from 'app/site/polls/models/view-base-poll';
import { ViewAssignment } from '../models/view-assignment'; import { ViewAssignment } from '../models/view-assignment';
import { ViewAssignmentPoll } from '../models/view-assignment-poll';
/** /**
* Creates a PDF document from a single assignment * Creates a PDF document from a single assignment
@ -20,7 +25,13 @@ export class AssignmentPdfService {
* @param pdfDocumentService PDF functions * @param pdfDocumentService PDF functions
* @param htmlToPdfService Convert the assignment detail html text to pdf * @param htmlToPdfService Convert the assignment detail html text to pdf
*/ */
public constructor(private translate: TranslateService, private htmlToPdfService: HtmlToPdfService) {} public constructor(
private translate: TranslateService,
private htmlToPdfService: HtmlToPdfService,
private pollKeyVerbose: PollKeyVerbosePipe,
private parsePollNumber: ParsePollNumberPipe,
private pollPercentBase: PollPercentBasePipe
) {}
/** /**
* Main function to control the pdf generation. * Main function to control the pdf generation.
@ -140,44 +151,20 @@ export class AssignmentPdfService {
} }
} }
/**
* Creates a candidate line in the results table
*
* @param candidateName The name of the candidate
* @param pollOption the poll options (yes, no, maybe [...])
* @returns a line in the table
*/
// TODO: type the result.
/*private electedCandidateLine(candidateName: string, pollOption: ViewAssignmentOption): object {
if (pollOption.is_elected) {
this.showIsElected = true;
return {
text: candidateName + '*',
bold: true
};
} else {
return {
text: candidateName
};
}*
}*/
/** /**
* Creates the poll result table for all published polls * Creates the poll result table for all published polls
* *
* @param assignment the ViewAssignment to create the document for * @param assignment the ViewAssignment to create the document for
* @returns the table as pdfmake object * @returns the table as pdfmake object
*/ */
// TODO: type the result
private createPollResultTable(assignment: ViewAssignment): object { private createPollResultTable(assignment: ViewAssignment): object {
/*const resultBody = []; const resultBody = [];
for (let pollIndex = 0; pollIndex < assignment.polls.length; pollIndex++) { for (const poll of assignment.polls) {
const poll = assignment.polls[pollIndex]; if (poll.isPublished) {
if (poll.published) {
const pollTableBody = []; const pollTableBody = [];
resultBody.push({ resultBody.push({
text: `${this.translate.instant('Ballot')} ${pollIndex + 1}`, text: poll.title,
bold: true, bold: true,
style: 'textItem', style: 'textItem',
margin: [0, 15, 0, 0] margin: [0, 15, 0, 0]
@ -194,56 +181,22 @@ export class AssignmentPdfService {
} }
]); ]);
for (let optionIndex = 0; optionIndex < poll.options.length; optionIndex++) { const tableData = poll.generateTableData();
const pollOption = poll.options[optionIndex];
const candidateName = pollOption.user.full_name; for (const pollResult of tableData) {
const votes = pollOption.votes; // 0 = yes, 1 = no, 2 = abstain0 = yes, 1 = no, 2 = abstain const resultLine = this.getPollResult(pollResult, poll);
const tableLine = [];
tableLine.push(this.electedCandidateLine(candidateName, pollOption));
if (poll.pollmethod === 'votes') { const tableLine = [
tableLine.push({
text: this.parseVoteValue(votes[0].value, votes[0].weight, poll, pollOption)
});
} else {
const resultBlock = votes.map(vote =>
this.parseVoteValue(vote.value, vote.weight, poll, pollOption)
);
tableLine.push({
text: resultBlock
});
}
pollTableBody.push(tableLine);
}
// push the result lines
const summaryLine = this.pollService.getVoteOptionsByPoll(poll).map(key => {
// TODO: Refractor into pollService to make this easier.
// Return an object with untranslated lable: string, specialLabel: string and (opt) percent: number
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(this.pollService.calculationDataFromPoll(poll), key)) {
percentLabel = ` (${this.pollService.getValuePercent(
this.pollService.calculationDataFromPoll(poll),
key
)}%)`;
}
return [
{ {
text: conclusionLabel, text: pollResult.user
style: 'tableConclude'
}, },
{ {
text: specialLabel + percentLabel, text: resultLine
style: 'tableConclude'
} }
]; ];
});
pollTableBody.push(...summaryLine); pollTableBody.push(tableLine);
}
resultBody.push({ resultBody.push({
table: { table: {
@ -256,52 +209,19 @@ export class AssignmentPdfService {
} }
} }
// add the legend to the result body return resultBody;
// if (assignment.polls.length > 0 && isElectedSemaphore) {
if (assignment.polls.length > 0 && this.showIsElected) {
resultBody.push({
text: `* = ${this.translate.instant('is elected')}`,
margin: [0, 5, 0, 0]
});
}
return resultBody;*/
throw new Error('TODO');
} }
/** /**
* Creates a translated voting result with numbers and percent-value depending in the polloptions * Converts pollData to a printable string representation
* I.e: "Yes 25 (22,2%)" or just "10"
*
* @param optionLabel Usually Yes or No
* @param value the amount of votes
* @param poll the specific poll
* @param option the corresponding poll option
* @returns a string a nicer number representation: "Yes 25 (22,2%)" or just "10"
*/ */
/*private parseVoteValue( private getPollResult(votingResult: PollData, poll: ViewAssignmentPoll): string {
optionLabel: PollVoteValue, const resultList = poll.pollmethodFields.map(field => {
value: number, const votingKey = this.translate.instant(this.pollKeyVerbose.transform(field));
poll: ViewAssignmentPoll, const resultValue = this.parsePollNumber.transform(votingResult[field]);
option: ViewAssignmentOption const resultInPercent = this.pollPercentBase.transform(votingResult[field], poll);
): string { return `${votingKey}: ${resultValue} ${resultInPercent ? resultInPercent : ''}`;
let resultString = ''; });
const label = this.translate.instant(this.pollService.getLabel(optionLabel)); return resultList.join('\n');
const valueString = this.pollService.getSpecialLabel(value);
const percentNr = this.pollService.getPercent(
this.pollService.calculationDataFromPoll(poll),
option,
optionLabel
);
resultString += `${label} ${valueString}`;
if (
percentNr &&
!this.pollService.isAbstractOption(this.pollService.calculationDataFromPoll(poll), option, optionLabel)
) {
resultString += ` (${percentNr}%)`;
} }
return `${resultString}\n`;
}*/
} }

View File

@ -68,8 +68,12 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
} }
]; ];
public get result(): ViewMotionOption {
return this.options[0];
}
public get hasVotes(): boolean { public get hasVotes(): boolean {
return !!this.options[0].votes.length; return !!this.result.votes.length;
} }
public initChartLabels(): string[] { public initChartLabels(): string[] {
@ -130,7 +134,7 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
} }
public anySpecialVotes(): boolean { public anySpecialVotes(): boolean {
return this.options[0].yes < 0 || this.options[0].no < 0 || this.options[0].abstain < 0; return this.result.yes < 0 || this.result.no < 0 || this.result.abstain < 0;
} }
/** /**
@ -145,23 +149,22 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
public getPercentBase(): number { public getPercentBase(): number {
const base: PercentBase = this.poll.onehundred_percent_base; const base: PercentBase = this.poll.onehundred_percent_base;
const options = this.options[0];
let totalByBase: number; let totalByBase: number;
switch (base) { switch (base) {
case PercentBase.YN: case PercentBase.YN:
if (options.yes >= 0 && options.no >= 0) { if (this.result.yes >= 0 && this.result.no >= 0) {
totalByBase = options.sumYN(); totalByBase = this.result.sumYN();
} }
break; break;
case PercentBase.YNA: case PercentBase.YNA:
if (options.yes >= 0 && options.no >= 0 && options.abstain >= 0) { if (this.result.yes >= 0 && this.result.no >= 0 && this.result.abstain >= 0) {
totalByBase = options.sumYNA(); totalByBase = this.result.sumYNA();
} }
break; break;
case PercentBase.Valid: case PercentBase.Valid:
// auslagern // auslagern
if (options.yes >= 0 && options.no >= 0 && options.abstain >= 0) { if (this.result.yes >= 0 && this.result.no >= 0 && this.result.abstain >= 0) {
totalByBase = this.poll.votesvalid; totalByBase = this.poll.votesvalid;
} }
break; break;

View File

@ -55,7 +55,6 @@ export class MotionPollDetailComponent extends BasePollDetailComponent<ViewMotio
public openDialog(): void { public openDialog(): void {
this.pollDialog.openDialog(this.poll); this.pollDialog.openDialog(this.poll);
console.log('this.poll: ', this.poll.hasVotes);
} }
protected onDeleted(): void { protected onDeleted(): void {

View File

@ -11,6 +11,9 @@ import { StatuteParagraphRepositoryService } from 'app/core/repositories/motions
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { LinenumberingService } from 'app/core/ui-services/linenumbering.service'; import { LinenumberingService } from 'app/core/ui-services/linenumbering.service';
import { ViewUnifiedChange, ViewUnifiedChangeType } from 'app/shared/models/motions/view-unified-change'; import { ViewUnifiedChange, ViewUnifiedChangeType } from 'app/shared/models/motions/view-unified-change';
import { ParsePollNumberPipe } from 'app/shared/pipes/parse-poll-number.pipe';
import { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe';
import { PollPercentBasePipe } from 'app/shared/pipes/poll-percent-base.pipe';
import { getRecommendationTypeName } from 'app/shared/utils/recommendation-type-names'; import { getRecommendationTypeName } from 'app/shared/utils/recommendation-type-names';
import { MotionExportInfo } from './motion-export.service'; import { MotionExportInfo } from './motion-export.service';
import { ChangeRecoMode, InfoToExport, LineNumberingMode, PERSONAL_NOTE_ID } from '../motions.constants'; import { ChangeRecoMode, InfoToExport, LineNumberingMode, PERSONAL_NOTE_ID } from '../motions.constants';
@ -61,7 +64,10 @@ export class MotionPdfService {
private pdfDocumentService: PdfDocumentService, private pdfDocumentService: PdfDocumentService,
private htmlToPdfService: HtmlToPdfService, private htmlToPdfService: HtmlToPdfService,
private linenumberingService: LinenumberingService, private linenumberingService: LinenumberingService,
private commentRepo: MotionCommentSectionRepositoryService private commentRepo: MotionCommentSectionRepositoryService,
private pollKeyVerbose: PollKeyVerbosePipe,
private pollPercentBase: PollPercentBasePipe,
private parsePollNumber: ParsePollNumberPipe
) {} ) {}
/** /**
@ -362,31 +368,20 @@ export class MotionPdfService {
const column1 = []; const column1 = [];
const column2 = []; const column2 = [];
const column3 = []; const column3 = [];
motion.polls.map((poll, index) => { motion.polls.forEach(poll => {
/*if (poll.has_votes) { if (poll.hasVotes) {
if (motion.motion.polls.length > 1) { const tableData = poll.generateTableData();
column1.push(index + 1 + '. ' + this.translate.instant('Vote')); tableData.forEach(votingResult => {
column2.push(''); const resultKey = this.translate.instant(this.pollKeyVerbose.transform(votingResult.key));
column3.push(''); const resultValue = this.parsePollNumber.transform(votingResult.value);
column1.push(`${resultKey}:`);
column2.push(resultValue);
if (votingResult.showPercent) {
const resultInPercent = this.pollPercentBase.transform(votingResult.value, poll);
column3.push(resultInPercent);
} }
const values: CalculablePollKey[] = ['yes', 'no', 'abstain'];
if (poll.votesvalid) {
values.push('votesvalid');
}
if (poll.votesinvalid) {
values.push('votesinvalid');
}
if (poll.votescast) {
values.push('votescast');
}
values.map(value => {
column1.push(`${this.translate.instant(this.pollService.getLabel(value))}:`);
column2.push(`${this.translate.instant(this.pollService.getSpecialLabel(poll[value]))}`);
this.pollService.isAbstractValue(poll, value)
? column3.push('')
: column3.push(`(${this.pollService.calculatePercentage(poll, value)} %)`);
}); });
}*/ }
}); });
metaTableBody.push([ metaTableBody.push([
{ {
@ -653,15 +648,6 @@ export class MotionPdfService {
margin: [0, 25, 0, 10] margin: [0, 25, 0, 10]
}); });
// determine the width of the reason depending on line numbering
// currently not used
// let columnWidth: string;
// if (lnMode === LineNumberingMode.Outside) {
// columnWidth = '80%';
// } else {
// columnWidth = '100%';
// }
reason.push(this.htmlToPdfService.addPlainText(motion.reason)); reason.push(this.htmlToPdfService.addPlainText(motion.reason));
return reason; return reason;

View File

@ -132,24 +132,6 @@ export abstract class PollService {
.subscribe(settings => (this.isElectronicVotingEnabled = settings.ENABLE_ELECTRONIC_VOTING)); .subscribe(settings => (this.isElectronicVotingEnabled = settings.ENABLE_ELECTRONIC_VOTING));
} }
/**
* retrieve special labels for a poll value
* {@link specialPollVotes}. Positive values will return as string
* representation of themselves
*
* @param value check value for special numbers
* @returns the label for a non-positive value, according to
*/
public getSpecialLabel(value: number): string {
// if (value >= 0) {
// return value.toString();
// // TODO: toLocaleString(lang); but translateService is not usable here, thus lang is not well defined
// }
// const vote = this.specialPollVotes.find(special => special[0] === value);
// return vote ? vote[1] : 'Undocumented special (negative) value';
return '';
}
/** /**
* Assigns the default poll data to the object. To be extended in subclasses * Assigns the default poll data to the object. To be extended in subclasses
* @param poll the poll/object to fill * @param poll the poll/object to fill
@ -160,140 +142,6 @@ export abstract class PollService {
poll.type = PollType.Analog; poll.type = PollType.Analog;
} }
/**
* Calculates the percentage the given key reaches.
*
* @param poll
* @param key
* @returns a percentage number with two digits, null if the value cannot be calculated (consider 0 !== null)
*/
public calculatePercentage(poll: ViewBasePoll, key: CalculablePollKey): number | null {
const baseNumber = this.getBaseAmount(poll);
if (!baseNumber) {
return null;
}
switch (key) {
case 'abstain':
if (poll.onehundred_percent_base === PercentBase.YN) {
return null;
}
break;
case 'votesinvalid':
if (poll.onehundred_percent_base !== PercentBase.Cast) {
return null;
}
break;
case 'votesvalid':
if (![PercentBase.Cast, PercentBase.Valid].includes(poll.onehundred_percent_base)) {
return null;
}
break;
case 'votescast':
if (poll.onehundred_percent_base !== PercentBase.Cast) {
return null;
}
}
return Math.round(((poll[key] * 100) / baseNumber) * 100) / 100;
}
/**
* Gets the number representing 100 percent for a given MotionPoll, depending
* on the configuration and the votes given.
*
* @param poll
* @returns the positive number representing 100 percent of the poll, 0 if
* the base cannot be calculated
*/
public getBaseAmount(poll: ViewBasePoll): number {
/*if (!poll) {
return 0;
}
switch (this.percentBase) {
case 'CAST':
if (!poll.votescast) {
return 0;
}
if (poll.votesinvalid < 0) {
return 0;
}
return poll.votescast;
case 'VALID':
if (poll.yes < 0 || poll.no < 0 || poll.abstain < 0) {
return 0;
}
return poll.votesvalid ? poll.votesvalid : 0;
case 'YES_NO_ABSTAIN':
if (poll.yes < 0 || poll.no < 0 || poll.abstain < 0) {
return 0;
}
return poll.yes + poll.no + poll.abstain;
case 'YES_NO':
if (poll.yes < 0 || poll.no < 0 || poll.abstain === -1) {
// It is not allowed to set 'Abstain' to 'majority' but exclude it from calculation.
// Setting 'Abstain' to 'undocumented' is possible, of course.
return 0;
}
return poll.yes + poll.no;
}*/
return 0;
}
/**
* Calculates which number is needed for the quorum to be surpassed
* TODO: Methods still hard coded to mirror the server's.
*
* @param poll
* @param method (optional) majority calculation method. If none is given,
* the default as set in the config will be used.
* @returns the first integer number larger than the required majority,
* undefined if a quorum cannot be calculated.
*/
public calculateQuorum(poll: ViewBasePoll, method?: string): number {
if (!method) {
method = this.defaultMajorityMethod;
}
const baseNumber = this.getBaseAmount(poll);
if (!baseNumber) {
return undefined;
}
const calc = PollMajorityMethod.find(m => m.value === method);
return calc && calc.calc ? calc.calc(baseNumber) : null;
}
/**
* Determines if a value is abstract (percentages cannot be calculated)
*
* @param poll
* @param value
* @returns true if the percentages should not be calculated
*/
public isAbstractValue(poll: ViewBasePoll, value: CalculablePollKey): boolean {
// if (this.getBaseAmount(poll) === 0) {
// return true;
// }
// switch (this.percentBase) {
// case 'YES_NO':
// if (['votescast', 'votesinvalid', 'votesvalid', 'abstain'].includes(value)) {
// return true;
// }
// break;
// case 'YES_NO_ABSTAIN':
// if (['votescast', 'votesinvalid', 'votesvalid'].includes(value)) {
// return true;
// }
// break;
// case 'VALID':
// if (['votesinvalid', 'votescast'].includes(value)) {
// return true;
// }
// break;
// }
// if (poll[value] < 0) {
// return true;
// }
return false;
}
public getVerboseNameForValue(key: string, value: string): string { public getVerboseNameForValue(key: string, value: string): string {
switch (key) { switch (key) {
case 'majority_method': case 'majority_method':