diff --git a/client/src/app/core/core-services/poll-pdf-service.ts b/client/src/app/core/core-services/poll-pdf-service.ts new file mode 100644 index 000000000..2347418c8 --- /dev/null +++ b/client/src/app/core/core-services/poll-pdf-service.ts @@ -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('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' + }; + } +} diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts index 54d8aca7a..4ba766525 100644 --- a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts +++ b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts @@ -5,8 +5,8 @@ import { Title } from '@angular/platform-browser'; 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 { AssignmentPollPdfService } from '../../services/assignment-poll-pdf.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'; @@ -121,6 +121,7 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit * @param translate Translation service * @param dialog MatDialog for the vote entering dialog * @param promptService Prompts for confirmation dialogs + * @param pdfService pdf service */ public constructor( titleService: Title, @@ -131,7 +132,8 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit public translate: TranslateService, public dialog: MatDialog, private promptService: PromptService, - private formBuilder: FormBuilder + private formBuilder: FormBuilder, + private pdfService: AssignmentPollPdfService ) { 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 * - * TODO Print the ballots for this poll. */ - public printBallot(poll: AssignmentPoll): void { - this.raiseError('Not yet implemented'); + public printBallot(): void { + this.pdfService.printBallots(this.poll); } /** diff --git a/client/src/app/site/assignments/services/assignment-poll-pdf.service.spec.ts b/client/src/app/site/assignments/services/assignment-poll-pdf.service.spec.ts new file mode 100644 index 000000000..ba8f4435b --- /dev/null +++ b/client/src/app/site/assignments/services/assignment-poll-pdf.service.spec.ts @@ -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(); + }); +}); diff --git a/client/src/app/site/assignments/services/assignment-poll-pdf.service.ts b/client/src/app/site/assignments/services/assignment-poll-pdf.service.ts new file mode 100644 index 000000000..c8ad58629 --- /dev/null +++ b/client/src/app/site/assignments/services/assignment-poll-pdf.service.ts @@ -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('assignments_pdf_ballot_papers_number') + .subscribe(count => (this.ballotCustomCount = count)); + this.configService + .get('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' + }; + } +} diff --git a/client/src/app/site/motions/services/motion-poll-pdf.service.ts b/client/src/app/site/motions/services/motion-poll-pdf.service.ts index 74050fc9b..d45b979d0 100644 --- a/client/src/app/site/motions/services/motion-poll-pdf.service.ts +++ b/client/src/app/site/motions/services/motion-poll-pdf.service.ts @@ -6,6 +6,7 @@ import { ConfigService } from 'app/core/ui-services/config.service'; import { MotionPoll } from 'app/shared/models/motions/motion-poll'; import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.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'; 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({ providedIn: 'root' }) -export class MotionPollPdfService { - /** - * 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; - +export class MotionPollPdfService extends PollPdfService { /** * Constructor. Subscribes to configuration values * @@ -62,25 +36,17 @@ export class MotionPollPdfService { public constructor( private translate: TranslateService, private motionRepo: MotionRepositoryService, - private configService: ConfigService, - private userRepo: UserRepositoryService, + configService: ConfigService, + userRepo: UserRepositoryService, private pdfService: PdfDocumentService ) { + super(configService, userRepo); this.configService .get('motions_pdf_ballot_papers_number') .subscribe(count => (this.ballotCustomCount = count)); this.configService .get('motions_pdf_ballot_papers_selection') .subscribe(selection => (this.ballotCountSelection = selection)); - this.configService.get('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) { subtitle = subtitle.substring(0, 90) + '...'; } - this.pdfService.downloadWithBallotPaper(this.getContent(title, subtitle), fileName, this.logo); - } - - /** - * @returns the amount of ballots that are to be printed, depending n the - * 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 - }; + const rowsPerPage = 4; + this.pdfService.downloadWithBallotPaper( + this.getPages(rowsPerPage, { sheetend: 40, title: title, subtitle: subtitle }), + fileName, + this.logo + ); } /** @@ -247,44 +96,17 @@ export class MotionPollPdfService { * @param title The identifier of the motion * @param subtitle The actual motion title */ - private createBallot(title: string, subtitle: string): any { - const sheetend = 40; + protected createBallot(data: AbstractPollData): any { return { stack: [ this.getHeader(), - { - text: title, - style: 'title' - }, - { - text: subtitle, - style: 'description' - }, + this.getTitle(data.title), + this.getSubtitle(data.subtitle), this.createBallotOption(this.translate.instant('Yes')), this.createBallotOption(this.translate.instant('No')), 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 - } - ]; - } }