Merge pull request #4651 from MaximilianKrambach/assignmentBallots

Assignment ballots (and refactoring motion ballots)
This commit is contained in:
Emanuel Schütze 2019-06-05 17:24:42 +02:00 committed by GitHub
commit 6895b5db67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 468 additions and 198 deletions

View File

@ -0,0 +1,242 @@
import { ConfigService } from 'app/core/ui-services/config.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
/**
* Server side ballot choice definitions.
* Server-defined methods to determine the number of ballots to print
* Options are:
* - NUMBER_OF_DELEGATES Amount of users belonging to the predefined 'delegates' group (group id 2)
* - NUMBER_OF_ALL_PARTICIPANTS The amount of all registered users
* - CUSTOM_NUMBER a given number of ballots (see {@link ballotCustomCount})
*/
export type BallotCountChoices = 'NUMBER_OF_DELEGATES' | 'NUMBER_OF_ALL_PARTICIPANTS' | 'CUSTOM_NUMBER';
/**
* Workaround data definitions. The implementation for the different model's classes might have different needs,
* so some data might not be required.
*
*/
export interface AbstractPollData {
title: string;
subtitle?: string;
sheetend: number; // should reflect the vertical size of one ballot on the paper
poll?: ViewAssignmentPoll; // TODO ugly workaround because assignment poll needs the poll on ballot level
}
export abstract class PollPdfService {
/**
* Definition of method to decide which amount of ballots to print. The implementations
* are expected to fetch this information from the configuration service
* @see BallotCountChoices
*/
protected ballotCountSelection: BallotCountChoices;
/**
* An arbitrary number of ballots to print, if {@link ballotCountSelection} is set
* to CUSTOM_NUMBER. Value is expected to be fetched from the configuration`
*/
protected ballotCustomCount: number;
/**
* The event name (as set in config `general_event_name`)
*/
protected eventName: string;
/**
* The url of the logo to be printed (as set in config `logo_pdf_ballot_paper`)
*/
protected logo: string;
/**
* Contructor. Subscribes to the logo path and event name
* @param configService Configzuration
* @param userRepo user Repository for determining the number of ballots
*/
public constructor(protected configService: ConfigService, protected userRepo: UserRepositoryService) {
this.configService.get<string>('general_event_name').subscribe(name => (this.eventName = name));
this.configService.get<{ path?: string }>('logo_pdf_ballot_paper').subscribe(url => {
if (url && url.path) {
if (url.path.indexOf('/') === 0) {
url.path = url.path.substr(1); // remove prepending slash
}
this.logo = url.path;
}
});
}
/**
* Get the amount of ballots to be printed
*
* @returns the amount of ballots, depending on the config settings
*/
protected getBallotCount(): number {
switch (this.ballotCountSelection) {
case 'NUMBER_OF_ALL_PARTICIPANTS':
return this.userRepo.getViewModelList().length;
case 'NUMBER_OF_DELEGATES':
return this.userRepo.getViewModelList().filter(user => user.groups_id && user.groups_id.includes(2))
.length;
case 'CUSTOM_NUMBER':
return this.ballotCustomCount;
default:
throw new Error('Amount of ballots cannot be computed');
}
}
/**
* Creates an entry for an option (a label with a circle)
*
* @returns pdfMake definitions
*/
protected createBallotOption(decision: string): { margin: number[]; columns: object[] } {
const BallotCircleDimensions = { yDistance: 6, size: 8 };
return {
margin: [40 + BallotCircleDimensions.size, 10, 0, 0],
columns: [
{
width: 15,
canvas: this.drawCircle(BallotCircleDimensions.yDistance, BallotCircleDimensions.size)
},
{
width: 'auto',
text: decision
}
]
};
}
/**
* Helper to draw a circle on its position on the ballot paper
*
* @param y vertical offset
* @param size the size of the circle
* @returns an array containing one circle definition for pdfMake
*/
private drawCircle(y: number, size: number): object[] {
return [
{
type: 'ellipse',
x: 0,
y: y,
lineColor: 'black',
r1: size,
r2: size
}
];
}
/**
* Abstract function for creating a single ballot with header and all options
*
* @param data AbstractPollData
* @returns pdfmake definitions
*/
protected abstract createBallot(data: AbstractPollData): object;
/**
* Create a createPdf definition for the correct amount of ballots
*
* @param rowsPerPage (precalculated) value of pair of ballots fitting on one page.
* A value too high might result in phantom items split onto several pages
* @param data predefined data to be used
* @returns pdfmake definitions
*/
protected getPages(rowsPerPage: number, data: AbstractPollData): object {
const amount = this.getBallotCount();
const fullpages = Math.floor(amount / (rowsPerPage * 2));
let partialpageEntries = amount % (rowsPerPage * 2);
const content: object[] = [];
for (let i = 0; i < fullpages; i++) {
const body = [];
for (let j = 0; j < rowsPerPage; j++) {
body.push([this.createBallot(data), this.createBallot(data)]);
}
content.push({
table: {
headerRows: 1,
widths: ['*', '*'],
body: body,
pageBreak: 'after'
},
rowsperpage: rowsPerPage
});
}
if (partialpageEntries) {
const partialPageBody = [];
while (partialpageEntries > 1) {
partialPageBody.push([this.createBallot(data), this.createBallot(data)]);
partialpageEntries -= 2;
}
if (partialpageEntries === 1) {
partialPageBody.push([this.createBallot(data), '']);
}
content.push({
table: {
headerRows: 1,
widths: ['50%', '50%'],
body: partialPageBody
},
rowsperpage: rowsPerPage
});
}
return content;
}
/**
* get a pdfMake header definition with the event name and an optional logo
*
* @returns pdfMake definitions
*/
protected getHeader(): object {
const columns: object[] = [];
columns.push({
text: this.eventName,
fontSize: 8,
alignment: 'left',
width: '60%'
});
if (this.logo) {
columns.push({
image: this.logo,
fit: [90, 25],
alignment: 'right',
width: '40%'
});
}
return {
color: '#555',
fontSize: 10,
margin: [30, 10, 10, -10], // [left, top, right, bottom]
columns: columns,
columnGap: 5
};
}
/**
* create a pdfmake definition for a title entry
*
* @param title
* @returns pdfmake definition
*/
protected getTitle(title: string): object {
return {
text: title,
style: 'title'
};
}
/**
* create a pdfmake definition for a subtitle entry
*
* @param subtitle
* @returns pdfmake definition
*/
protected getSubtitle(subtitle: string): object {
return {
text: subtitle,
style: 'description'
};
}
}

View File

@ -5,8 +5,8 @@ import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; 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 { AssignmentPollDialogComponent } from '../assignment-poll-dialog/assignment-poll-dialog.component';
import { AssignmentPollPdfService } from '../../services/assignment-poll-pdf.service';
import { AssignmentPollService } from '../../services/assignment-poll.service'; import { AssignmentPollService } from '../../services/assignment-poll.service';
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service'; import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
import { BaseViewComponent } from 'app/site/base/base-view'; import { BaseViewComponent } from 'app/site/base/base-view';
@ -121,6 +121,7 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
* @param translate Translation service * @param translate Translation service
* @param dialog MatDialog for the vote entering dialog * @param dialog MatDialog for the vote entering dialog
* @param promptService Prompts for confirmation dialogs * @param promptService Prompts for confirmation dialogs
* @param pdfService pdf service
*/ */
public constructor( public constructor(
titleService: Title, titleService: Title,
@ -131,7 +132,8 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
public translate: TranslateService, public translate: TranslateService,
public dialog: MatDialog, public dialog: MatDialog,
private promptService: PromptService, private promptService: PromptService,
private formBuilder: FormBuilder private formBuilder: FormBuilder,
private pdfService: AssignmentPollPdfService
) { ) {
super(titleService, translate, matSnackBar); super(titleService, translate, matSnackBar);
} }
@ -163,10 +165,9 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
/** /**
* Print the PDF of this poll with the corresponding options and numbers * Print the PDF of this poll with the corresponding options and numbers
* *
* TODO Print the ballots for this poll.
*/ */
public printBallot(poll: AssignmentPoll): void { public printBallot(): void {
this.raiseError('Not yet implemented'); this.pdfService.printBallots(this.poll);
} }
/** /**

View File

@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { AssignmentPollPdfService } from './assignment-poll-pdf.service';
import { E2EImportsModule } from 'e2e-imports.module';
describe('MotionPdfService', () => {
beforeEach(() =>
TestBed.configureTestingModule({
imports: [E2EImportsModule]
})
);
it('should be created', () => {
const service: AssignmentPollPdfService = TestBed.get(AssignmentPollPdfService);
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,188 @@
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { ConfigService } from 'app/core/ui-services/config.service';
import { PdfDocumentService } from 'app/core/ui-services/pdf-document.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
import { ViewAssignmentPoll } from '../models/view-assignment-poll';
import { AssignmentPollMethod } from './assignment-poll.service';
import { PollPdfService, BallotCountChoices, AbstractPollData } from 'app/core/core-services/poll-pdf-service';
/**
* Creates a pdf for a motion poll. Takes as input any motionPoll
* Provides the public method `printBallots(motionPoll)` which should be convenient to use.
*
* @example
* ```ts
* this.AssignmentPollPdfService.printBallots(this.poll);
* ```
*/
@Injectable({
providedIn: 'root'
})
export class AssignmentPollPdfService extends PollPdfService {
/**
* Constructor. Subscribes to configuration values
*
* @param translate handle translations
* @param motionRepo get parent motions
* @param configService Read config variables
* @param userRepo User repository for counting amount of ballots needed
* @param pdfService the pdf document creation service
*/
public constructor(
private translate: TranslateService,
private assignmentRepo: AssignmentRepositoryService,
configService: ConfigService,
userRepo: UserRepositoryService,
private pdfService: PdfDocumentService
) {
super(configService, userRepo);
this.configService
.get<number>('assignments_pdf_ballot_papers_number')
.subscribe(count => (this.ballotCustomCount = count));
this.configService
.get<BallotCountChoices>('assignments_pdf_ballot_papers_selection')
.subscribe(selection => (this.ballotCountSelection = selection));
}
/**
* Triggers a pdf creation for this poll's ballots. Currently, only ballots
* for a limited amount of candidates will return useful pdfs:
* - about 15 candidates (method: yes/no and yes/no/abstain)
* - about 29 candidates (one vote per candidate)
*
* @param motionPoll: The poll this ballot refers to
* @param title (optional) a different title
* @param subtitle (optional) a different subtitle
*/
public printBallots(poll: ViewAssignmentPoll, title?: string, subtitle?: string): void {
const assignment = this.assignmentRepo.getViewModel(poll.assignment_id);
const fileName = `${this.translate.instant('Election')} - ${assignment.getTitle()} - ${this.translate.instant(
'ballot-paper' // TODO proper title (second election?)
)}`;
if (!title) {
title = assignment.getTitle();
}
if (!subtitle) {
subtitle = '';
}
if (assignment.polls.length > 1) {
subtitle = `${this.translate.instant('Ballot')} ${assignment.polls.length} ${subtitle}`;
}
if (subtitle.length > 90) {
subtitle = subtitle.substring(0, 90) + '...';
}
let rowsPerPage = 1;
if (poll.pollmethod === 'votes') {
if (poll.options.length <= 2) {
rowsPerPage = 4;
} else if (poll.options.length <= 5) {
rowsPerPage = 3;
} else if (poll.options.length <= 10) {
rowsPerPage = 2;
} else {
rowsPerPage = 1;
}
} else {
if (poll.options.length <= 2) {
rowsPerPage = 4;
} else if (poll.options.length <= 3) {
rowsPerPage = 3;
} else if (poll.options.length <= 7) {
rowsPerPage = 2;
} else {
// up to 15 candidates
rowsPerPage = 1;
}
}
const sheetEnd = Math.floor(417 / rowsPerPage);
this.pdfService.downloadWithBallotPaper(
this.getPages(rowsPerPage, { sheetend: sheetEnd, title: title, subtitle: subtitle, poll: poll }),
fileName,
this.logo
);
}
/**
* Creates one ballot in it's position on the page. Note that creating once
* and then pasting the result several times does not work
*
* @param title The identifier of the motion
* @param subtitle The actual motion title
*/
protected createBallot(data: AbstractPollData): object {
return {
columns: [
{
width: 1,
margin: [0, data.sheetend],
text: ''
},
{
width: '*',
stack: [
this.getHeader(),
this.getTitle(data.title),
this.getSubtitle(data.subtitle),
this.createPollHint(data.poll),
this.createCandidateFields(data.poll)
],
margin: [0, 0, 0, 0]
}
]
};
}
private createCandidateFields(poll: ViewAssignmentPoll): object {
const candidates = poll.options.sort((a, b) => {
return a.weight - b.weight;
});
const resultObject = candidates.map(cand => {
return poll.pollmethod === 'votes'
? this.createBallotOption(cand.user.full_name)
: this.createYNBallotEntry(cand.user.full_name, poll.pollmethod);
});
if (poll.pollmethod === 'votes') {
const noEntry = this.createBallotOption(this.translate.instant('No'));
noEntry.margin[1] = 25;
resultObject.push(noEntry);
}
return resultObject;
}
private createYNBallotEntry(option: string, method: AssignmentPollMethod): object {
const choices = method === 'yna' ? ['Yes', 'No', 'Abstain'] : ['Yes', 'No'];
const columnstack = choices.map(choice => {
return {
width: 'auto',
stack: [this.createBallotOption(this.translate.instant(choice))]
};
});
return [
{
text: option,
margin: [40, 10, 0, 0]
},
{
width: 'auto',
columns: columnstack
}
];
}
/**
* Generates the poll description
*
* @param poll
* @returns pdfMake definitions
*/
private createPollHint(poll: ViewAssignmentPoll): object {
return {
text: poll.description || '',
style: 'description'
};
}
}

View File

@ -6,6 +6,7 @@ import { ConfigService } from 'app/core/ui-services/config.service';
import { MotionPoll } from 'app/shared/models/motions/motion-poll'; import { MotionPoll } from 'app/shared/models/motions/motion-poll';
import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service'; import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service';
import { PdfDocumentService } from 'app/core/ui-services/pdf-document.service'; import { PdfDocumentService } from 'app/core/ui-services/pdf-document.service';
import { PollPdfService, AbstractPollData } from 'app/core/core-services/poll-pdf-service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
type BallotCountChoices = 'NUMBER_OF_DELEGATES' | 'NUMBER_OF_ALL_PARTICIPANTS' | 'CUSTOM_NUMBER'; type BallotCountChoices = 'NUMBER_OF_DELEGATES' | 'NUMBER_OF_ALL_PARTICIPANTS' | 'CUSTOM_NUMBER';
@ -22,34 +23,7 @@ type BallotCountChoices = 'NUMBER_OF_DELEGATES' | 'NUMBER_OF_ALL_PARTICIPANTS' |
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class MotionPollPdfService { export class MotionPollPdfService extends PollPdfService {
/**
* The method to determine the number of ballots to print. Value is
* decided in configuration service as `motions_pdf_ballot_papers_selection`.
* Options are:
*
* - NUMBER_OF_DELEGATES Amount of users belonging to the predefined 'delegates' group (group id 2)
* - NUMBER_OF_ALL_PARTICIPANTS The amount of all registered users
* - CUSTOM_NUMBER a given number of ballots (see {@link ballotCustomCount})
*/
private ballotCountSelection: BallotCountChoices;
/**
* An arbitrary number of ballots to print, if {@link ballotCountSection} is set
* to CUSTOM_NUMBER. Value is fetched from the configuration value `motions_pdf_ballot_papers_number`
*/
private ballotCustomCount: number;
/**
* The event name (as set in config `general_event_name`)
*/
private eventName: string;
/**
* The url of the logo to be printed (as set in config `logo_pdf_ballot_paper`)
*/
private logo: string;
/** /**
* Constructor. Subscribes to configuration values * Constructor. Subscribes to configuration values
* *
@ -62,25 +36,17 @@ export class MotionPollPdfService {
public constructor( public constructor(
private translate: TranslateService, private translate: TranslateService,
private motionRepo: MotionRepositoryService, private motionRepo: MotionRepositoryService,
private configService: ConfigService, configService: ConfigService,
private userRepo: UserRepositoryService, userRepo: UserRepositoryService,
private pdfService: PdfDocumentService private pdfService: PdfDocumentService
) { ) {
super(configService, userRepo);
this.configService this.configService
.get<number>('motions_pdf_ballot_papers_number') .get<number>('motions_pdf_ballot_papers_number')
.subscribe(count => (this.ballotCustomCount = count)); .subscribe(count => (this.ballotCustomCount = count));
this.configService this.configService
.get<BallotCountChoices>('motions_pdf_ballot_papers_selection') .get<BallotCountChoices>('motions_pdf_ballot_papers_selection')
.subscribe(selection => (this.ballotCountSelection = selection)); .subscribe(selection => (this.ballotCountSelection = selection));
this.configService.get<string>('general_event_name').subscribe(name => (this.eventName = name));
this.configService.get<{ path?: string }>('logo_pdf_ballot_paper').subscribe(url => {
if (url && url.path) {
if (url.path.indexOf('/') === 0) {
url.path = url.path.substr(1); // remove prepending slash
}
this.logo = url.path;
}
});
} }
/** /**
@ -115,129 +81,12 @@ export class MotionPollPdfService {
if (subtitle.length > 90) { if (subtitle.length > 90) {
subtitle = subtitle.substring(0, 90) + '...'; subtitle = subtitle.substring(0, 90) + '...';
} }
this.pdfService.downloadWithBallotPaper(this.getContent(title, subtitle), fileName, this.logo); const rowsPerPage = 4;
} this.pdfService.downloadWithBallotPaper(
this.getPages(rowsPerPage, { sheetend: 40, title: title, subtitle: subtitle }),
/** fileName,
* @returns the amount of ballots that are to be printed, depending n the this.logo
* config settings );
*/
private getBallotCount(): number {
switch (this.ballotCountSelection) {
case 'NUMBER_OF_ALL_PARTICIPANTS':
return this.userRepo.getViewModelList().length;
case 'NUMBER_OF_DELEGATES':
return this.userRepo.getViewModelList().filter(user => user.groups_id && user.groups_id.includes(2))
.length;
case 'CUSTOM_NUMBER':
return this.ballotCustomCount;
}
}
/**
* Creates an entry for an option (a label with a circle)
*
* @returns pdfMake definitions
*/
private createBallotOption(decision: string): object {
const BallotCircleDimensions = { yDistance: 6, size: 8 };
return {
margin: [40 + BallotCircleDimensions.size, 10, 0, 0],
columns: [
{
width: 15,
canvas: this.drawCircle(BallotCircleDimensions.yDistance, BallotCircleDimensions.size)
},
{
width: 'auto',
text: decision
}
]
};
}
/**
* Create a createPdf definition for the correct amount of ballots
* with 8 ballots per page
*
* @param title: first, bold line for the ballot.
* @param subtitle: second line for the ballot.
* @returns an array of content objects defining pdfMake instructions
*/
private getContent(title: string, subtitle: string): object[] {
const content = [];
const amount = this.getBallotCount();
const fullpages = Math.floor(amount / 8);
let partialpageEntries = amount % 8;
for (let i = 0; i < fullpages; i++) {
content.push({
table: {
headerRows: 1,
widths: ['*', '*'],
body: [
[this.createBallot(title, subtitle), this.createBallot(title, subtitle)],
[this.createBallot(title, subtitle), this.createBallot(title, subtitle)],
[this.createBallot(title, subtitle), this.createBallot(title, subtitle)],
[this.createBallot(title, subtitle), this.createBallot(title, subtitle)]
],
pageBreak: 'after'
},
// layout: '{{ballot-placeholder-to-insert-functions-here}}',
rowsperpage: 4
});
}
if (partialpageEntries) {
const partialPageBody = [];
while (partialpageEntries > 1) {
partialPageBody.push([this.createBallot(title, subtitle), this.createBallot(title, subtitle)]);
partialpageEntries -= 2;
}
if (partialpageEntries === 1) {
partialPageBody.push([this.createBallot(title, subtitle), '']);
}
content.push({
table: {
headerRows: 1,
widths: ['50%', '50%'],
body: partialPageBody
},
// layout: '{{ballot-placeholder-to-insert-functions-here}}',
rowsperpage: 4
});
}
return content;
}
/**
* get a pdfMake header definition with the event name and an optional logo
*
* @returns pdfMake definitions
*/
private getHeader(): object {
const columns: object[] = [];
columns.push({
text: this.eventName,
fontSize: 8,
alignment: 'left',
width: '60%'
});
if (this.logo) {
columns.push({
image: this.logo,
fit: [90, 25],
alignment: 'right',
width: '40%'
});
}
return {
color: '#555',
fontSize: 10,
margin: [30, 10, 10, -10], // [left, top, right, bottom]
columns: columns,
columnGap: 5
};
} }
/** /**
@ -247,44 +96,17 @@ export class MotionPollPdfService {
* @param title The identifier of the motion * @param title The identifier of the motion
* @param subtitle The actual motion title * @param subtitle The actual motion title
*/ */
private createBallot(title: string, subtitle: string): any { protected createBallot(data: AbstractPollData): any {
const sheetend = 40;
return { return {
stack: [ stack: [
this.getHeader(), this.getHeader(),
{ this.getTitle(data.title),
text: title, this.getSubtitle(data.subtitle),
style: 'title'
},
{
text: subtitle,
style: 'description'
},
this.createBallotOption(this.translate.instant('Yes')), this.createBallotOption(this.translate.instant('Yes')),
this.createBallotOption(this.translate.instant('No')), this.createBallotOption(this.translate.instant('No')),
this.createBallotOption(this.translate.instant('Abstain')) this.createBallotOption(this.translate.instant('Abstain'))
], ],
margin: [0, 0, 0, sheetend] margin: [0, 0, 0, data.sheetend]
}; };
} }
/**
* Helper to draw a circle on its position on the ballot paper
*
* @param y vertical offset
* @param size the size of the circle
* @returns an array containing one circle definition for pdfMake
*/
private drawCircle(y: number, size: number): object[] {
return [
{
type: 'ellipse',
x: 0,
y: y,
lineColor: 'black',
r1: size,
r2: size
}
];
}
} }