From e520dd99e0508f43215e947d9ca392fa1e110d4f Mon Sep 17 00:00:00 2001 From: Sean Engelhardt Date: Fri, 26 Apr 2019 16:23:48 +0200 Subject: [PATCH] Create assignment PDF service Exports the assignment detail as PDF --- .../core/ui-services/pdf-document.service.ts | 23 ++ .../assignment-detail.component.ts | 6 +- .../assignment-poll.component.ts | 2 +- .../assignments/models/view-assignment.ts | 8 + .../assignment-pdf-export.service.spec.ts | 17 + .../services/assignment-pdf-export.service.ts | 40 +++ .../services/assignment-pdf.service.spec.ts | 17 + .../services/assignment-pdf.service.ts | 314 ++++++++++++++++++ .../services/assignment-poll.service.ts | 4 + .../motions/services/motion-pdf.service.ts | 15 +- .../site/users/services/user-pdf.service.ts | 20 +- 11 files changed, 440 insertions(+), 26 deletions(-) create mode 100644 client/src/app/site/assignments/services/assignment-pdf-export.service.spec.ts create mode 100644 client/src/app/site/assignments/services/assignment-pdf-export.service.ts create mode 100644 client/src/app/site/assignments/services/assignment-pdf.service.spec.ts create mode 100644 client/src/app/site/assignments/services/assignment-pdf.service.ts diff --git a/client/src/app/core/ui-services/pdf-document.service.ts b/client/src/app/core/ui-services/pdf-document.service.ts index 30251e81f..27e2499cf 100644 --- a/client/src/app/core/ui-services/pdf-document.service.ts +++ b/client/src/app/core/ui-services/pdf-document.service.ts @@ -31,6 +31,25 @@ export class PdfDocumentService { */ private imageUrls: string[] = []; + /** + * Table layout that switches the background color every other row. + * @example + * ```ts + * layout: this.pdfDocumentService.switchColorTableLayout + * ``` + */ + public switchColorTableLayout = { + hLineWidth: rowIndex => { + return rowIndex === 1; + }, + vLineWidth: () => { + return 0; + }, + fillColor: rowIndex => { + return rowIndex % 2 === 0 ? '#EEEEEE' : null; + } + }; + /** * Constructor * @@ -489,6 +508,10 @@ export class PdfDocumentService { listChild: { fontSize: 12, margin: [0, 5] + }, + textItem: { + fontSize: 11, + margin: [0, 7] } }; } diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts index b1b825ab1..0649fabfe 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts @@ -8,6 +8,7 @@ import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject } from 'rxjs'; import { Assignment } from 'app/shared/models/assignments/assignment'; +import { AssignmentPdfExportService } from '../../services/assignment-pdf-export.service'; import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollService } from '../../services/assignment-poll.service'; import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service'; @@ -172,7 +173,8 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn public pollService: AssignmentPollService, private agendaRepo: ItemRepositoryService, private tagRepo: TagRepositoryService, - private promptService: PromptService + private promptService: PromptService, + private pdfService: AssignmentPdfExportService ) { super(title, translate, matSnackBar); this.subscriptions.push( @@ -406,7 +408,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn } public onDownloadPdf(): void { - // TODO: Download summary pdf + this.pdfService.exportSingleAssignment(this.assignment); } /** 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 e618495bc..b7cb02718 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 @@ -67,7 +67,7 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit * used in this poll (e.g.) */ public get pollValues(): CalculablePollKey[] { - return this.pollService.pollValues.filter(name => this.poll[name] !== undefined); + return this.pollService.getVoteOptionsByPoll(this.poll); } /** diff --git a/client/src/app/site/assignments/models/view-assignment.ts b/client/src/app/site/assignments/models/view-assignment.ts index 7f8a8a573..3c0fd5bd4 100644 --- a/client/src/app/site/assignments/models/view-assignment.ts +++ b/client/src/app/site/assignments/models/view-assignment.ts @@ -57,6 +57,14 @@ export class ViewAssignment extends BaseAgendaViewModel { return this.assignment.title; } + public get open_posts(): number { + return this.assignment.open_posts; + } + + public get description(): string { + return this.assignment.description; + } + public get candidates(): ViewUser[] { return this._assignmentRelatedUsers.map(aru => aru.user); } diff --git a/client/src/app/site/assignments/services/assignment-pdf-export.service.spec.ts b/client/src/app/site/assignments/services/assignment-pdf-export.service.spec.ts new file mode 100644 index 000000000..0239c5bcb --- /dev/null +++ b/client/src/app/site/assignments/services/assignment-pdf-export.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed } from '@angular/core/testing'; + +import { AssignmentPdfExportService } from './assignment-pdf-export.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('AssignmentPdfExportService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); + + it('should be created', () => { + const service: AssignmentPdfExportService = TestBed.get(AssignmentPdfExportService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/assignments/services/assignment-pdf-export.service.ts b/client/src/app/site/assignments/services/assignment-pdf-export.service.ts new file mode 100644 index 000000000..bb663a4af --- /dev/null +++ b/client/src/app/site/assignments/services/assignment-pdf-export.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; +import { ViewAssignment } from '../models/view-assignment'; +import { AssignmentPdfService } from './assignment-pdf.service'; +import { TranslateService } from '@ngx-translate/core'; +import { PdfDocumentService } from 'app/core/ui-services/pdf-document.service'; + +/** + * Controls PDF export for assignments + */ +@Injectable({ + providedIn: 'root' +}) +export class AssignmentPdfExportService { + /** + * Constructor + * + * @param translate Translate + * @param assignmentPdfService Service for single assignment details + * @param pdfDocumentService Service for PDF document generation + */ + public constructor( + private translate: TranslateService, + private assignmentPdfService: AssignmentPdfService, + private pdfDocumentService: PdfDocumentService + ) {} + + /** + * Generates an pdf out of a given assignment and saves it as file + * + * @param assignment the assignment to export + */ + public exportSingleAssignment(assignment: ViewAssignment): void { + const doc = this.assignmentPdfService.assignmentToDocDef(assignment); + const filename = `${this.translate.instant('Assignments')} ${assignment.title}`; + const metadata = { + title: filename + }; + this.pdfDocumentService.download(doc, filename, metadata); + } +} diff --git a/client/src/app/site/assignments/services/assignment-pdf.service.spec.ts b/client/src/app/site/assignments/services/assignment-pdf.service.spec.ts new file mode 100644 index 000000000..521ec18a7 --- /dev/null +++ b/client/src/app/site/assignments/services/assignment-pdf.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed } from '@angular/core/testing'; + +import { AssignmentPdfService } from './assignment-pdf.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('AssignmentPdfService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); + + it('should be created', () => { + const service: AssignmentPdfService = TestBed.get(AssignmentPdfService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/assignments/services/assignment-pdf.service.ts b/client/src/app/site/assignments/services/assignment-pdf.service.ts new file mode 100644 index 000000000..6d0eb191c --- /dev/null +++ b/client/src/app/site/assignments/services/assignment-pdf.service.ts @@ -0,0 +1,314 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { AssignmentPollService } from './assignment-poll.service'; +import { HtmlToPdfService } from 'app/core/ui-services/html-to-pdf.service'; +import { PdfDocumentService } from 'app/core/ui-services/pdf-document.service'; +import { PollVoteValue } from 'app/core/ui-services/poll.service'; +import { ViewAssignment } from '../models/view-assignment'; +import { ViewAssignmentPollOption } from '../models/view-assignment-poll-option'; +import { ViewAssignmentPoll } from '../models/view-assignment-poll'; + +/** + * Creates a PDF document from a single assignment + */ +@Injectable({ + providedIn: 'root' +}) +export class AssignmentPdfService { + /** + * Will be set to `true` of a person was elected. + * Determines that in indicator is shown under the table + */ + private showIsElected = false; + + /** + * Constructor + * + * @param translate Translate + * @param pollService Get poll information + * @param pdfDocumentService PDF functions + * @param htmlToPdfService Convert the assignment detail html text to pdf + */ + public constructor( + private translate: TranslateService, + private pollService: AssignmentPollService, + private pdfDocumentService: PdfDocumentService, + private htmlToPdfService: HtmlToPdfService + ) {} + + /** + * Main function to control the pdf generation. + * Calls all other functions to generate the PDF in multiple steps + * + * @param assignment the ViewAssignment to create the document for + * @returns a pdfmake compatible document as document + */ + public assignmentToDocDef(assignment: ViewAssignment): object { + const title = this.createTitle(assignment); + const preamble = this.createPreamble(assignment); + const description = this.createDescription(assignment); + const candidateList = this.createCandidateList(assignment); + const pollResult = this.createPollResultTable(assignment); + + return [title, preamble, description, candidateList, pollResult]; + } + + /** + * Creates the title for PDF + * TODO: Cleanup. Should be reused from time to time. Can be in another service + * + * @param assignment the ViewAssignment to create the document for + * @returns the title part of the document + */ + private createTitle(assignment: ViewAssignment): object { + return { + text: assignment.title, + style: 'title' + }; + } + + /** + * Creates the preamble, usually just contains "Number of persons to be elected" + * + * @param assignment the ViewAssignment to create the document for + * @returns the preamble part of the pdf document + */ + private createPreamble(assignment: ViewAssignment): object { + const preambleText = `${this.translate.instant('Number of persons to be elected')}: `; + const memberNumber = '' + assignment.open_posts; + const preamble = { + text: [ + { + text: preambleText, + bold: true, + style: 'textItem' + }, + { + text: memberNumber, + style: 'textItem' + } + ] + }; + return preamble; + } + + /** + * Creates the description part of the document. Also converts the parts of an assignment to PDF + * + * @param assignment the ViewAssignment to create the document for + * @returns the description of the assignment + */ + private createDescription(assignment: ViewAssignment): object { + if (assignment.description) { + const assignmentHtml = this.htmlToPdfService.convertHtml(assignment.description); + + const descriptionText = `${this.translate.instant('Description')}: `; + const description = [ + { + text: descriptionText, + bold: true, + style: 'textItem' + }, + { + text: assignmentHtml, + style: 'textItem', + margin: [10, 0, 0, 0] + } + ]; + return description; + } else { + return {}; + } + } + + /** + * Creates the assignment list + * + * @param assignment the ViewAssignment to create the document for + * @returns the assignment list as PDF document + */ + private createCandidateList(assignment: ViewAssignment): object { + if (assignment.phase !== 2) { + const candidates = assignment.assignmentRelatedUsers.sort((a, b) => a.weight - b.weight); + + const candidatesText = `${this.translate.instant('Candidates')}: `; + const userList = candidates.map(candidate => { + return { + text: candidate.user.full_name, + margin: [0, 0, 0, 10] + }; + }); + + return { + columns: [ + { + text: candidatesText, + bold: true, + width: '25%', + style: 'textItem' + }, + { + ul: userList, + style: 'textItem' + } + ] + }; + } else { + return {}; + } + } + + /** + * 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 + */ + private electedCandidateLine(candidateName: string, pollOption: ViewAssignmentPollOption): 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 + * + * @param assignment the ViewAssignment to create the document for + * @returns the table as pdfmake object + */ + private createPollResultTable(assignment: ViewAssignment): object { + const resultBody = []; + for (let pollIndex = 0; pollIndex < assignment.polls.length; pollIndex++) { + const poll = assignment.polls[pollIndex]; + if (poll.published) { + const pollTableBody = []; + + resultBody.push({ + text: `${this.translate.instant('Ballot')} ${pollIndex + 1}`, + bold: true, + style: 'textItem', + margin: [0, 15, 0, 0] + }); + + pollTableBody.push([ + { + text: this.translate.instant('Candidates'), + style: 'tableHeader' + }, + { + text: this.translate.instant('Votes'), + style: 'tableHeader' + } + ]); + + for (let optionIndex = 0; optionIndex < poll.options.length; optionIndex++) { + const pollOption = poll.options[optionIndex]; + + const candidateName = pollOption.user.full_name; + const votes = pollOption.votes; // 0 = yes, 1 = no, 2 = abstain0 = yes, 1 = no, 2 = abstain + const tableLine = []; + tableLine.push(this.electedCandidateLine(candidateName, pollOption)); + + if (poll.pollmethod === 'votes') { + 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(poll, key)) { + percentLabel = ` (${this.pollService.getValuePercent(poll, key)}%)`; + } + return [ + { + text: conclusionLabel, + style: 'tableConclude' + }, + { + text: specialLabel + percentLabel, + style: 'tableConclude' + } + ]; + }); + + pollTableBody.push(...summaryLine); + + resultBody.push({ + table: { + widths: ['64%', '33%'], + headerRows: 1, + body: pollTableBody + }, + layout: this.pdfDocumentService.switchColorTableLayout + }); + } + } + + // add the legend to the result body + // 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; + } + + /** + * Creates a translated voting result with numbers and percent-value depending in the polloptions + * 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 pollOption the corresponding poll option + * @returns a string a nicer number representation: "Yes 25 (22,2%)" or just "10" + */ + private parseVoteValue( + optionLabel: PollVoteValue, + value: number, + poll: ViewAssignmentPoll, + pollOption: ViewAssignmentPollOption + ): string { + 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); + + resultString += `${label} ${valueString}`; + if (percentNr && !this.pollService.isAbstractOption(poll, pollOption, optionLabel)) { + resultString += ` (${percentNr}%)`; + } + + return `${resultString}\n`; + } +} diff --git a/client/src/app/site/assignments/services/assignment-poll.service.ts b/client/src/app/site/assignments/services/assignment-poll.service.ts index 71b41cffa..5f549e3eb 100644 --- a/client/src/app/site/assignments/services/assignment-poll.service.ts +++ b/client/src/app/site/assignments/services/assignment-poll.service.ts @@ -67,6 +67,10 @@ export class AssignmentPollService extends PollService { .subscribe(base => (this.percentBase = base)); } + public getVoteOptionsByPoll(poll: ViewAssignmentPoll): CalculablePollKey[] { + return this.pollValues.filter(name => poll[name] !== undefined); + } + /** * 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) diff --git a/client/src/app/site/motions/services/motion-pdf.service.ts b/client/src/app/site/motions/services/motion-pdf.service.ts index ffe88112a..8843e0d25 100644 --- a/client/src/app/site/motions/services/motion-pdf.service.ts +++ b/client/src/app/site/motions/services/motion-pdf.service.ts @@ -13,6 +13,7 @@ import { ViewMotion, LineNumberingMode, ChangeRecoMode } from '../models/view-mo import { LinenumberingService } from 'app/core/ui-services/linenumbering.service'; import { MotionCommentSectionRepositoryService } from 'app/core/repositories/motions/motion-comment-section-repository.service'; import { ViewUnifiedChange } from 'app/shared/models/motions/view-unified-change'; +import { PdfDocumentService } from 'app/core/ui-services/pdf-document.service'; /** * Type declaring which strings are valid options for metainfos to be exported into a pdf @@ -52,6 +53,7 @@ export class MotionPdfService { * @param statuteRepo To get formated stature paragraphs * @param changeRecoRepo to get the change recommendations * @param configService Read config variables + * @param pdfDocumentService Global PDF Functions * @param htmlToPdfService To convert HTML text into pdfmake doc def * @param pollService MotionPollService for rendering the polls * @param linenumberingService Line numbers @@ -63,6 +65,7 @@ export class MotionPdfService { private statuteRepo: StatuteParagraphRepositoryService, private changeRecoRepo: ChangeRecommendationRepositoryService, private configService: ConfigService, + private pdfDocumentService: PdfDocumentService, private htmlToPdfService: HtmlToPdfService, private pollService: MotionPollService, private linenumberingService: LinenumberingService, @@ -646,17 +649,7 @@ export class MotionPdfService { dontBreakRows: true, body: callListTableBody.concat(callListRows) }, - layout: { - hLineWidth: rowIndex => { - return rowIndex === 1; - }, - vLineWidth: () => { - return 0; - }, - fillColor: rowIndex => { - return rowIndex % 2 === 0 ? '#EEEEEE' : null; - } - } + layout: this.pdfDocumentService.switchColorTableLayout }; return [title, table]; } diff --git a/client/src/app/site/users/services/user-pdf.service.ts b/client/src/app/site/users/services/user-pdf.service.ts index 1a62c2b6e..8795214d2 100644 --- a/client/src/app/site/users/services/user-pdf.service.ts +++ b/client/src/app/site/users/services/user-pdf.service.ts @@ -4,6 +4,7 @@ import { TranslateService } from '@ngx-translate/core'; import { ConfigService } from 'app/core/ui-services/config.service'; import { ViewUser } from '../models/view-user'; +import { PdfDocumentService } from 'app/core/ui-services/pdf-document.service'; /** * Creates a pdf for a user, containing greetings and initial login information @@ -24,8 +25,13 @@ export class UserPdfService { * * @param translate handle translations * @param configService Read config variables + * @param pdfDocumentService Global PDF Functions */ - public constructor(private translate: TranslateService, private configService: ConfigService) {} + public constructor( + private translate: TranslateService, + private configService: ConfigService, + private pdfDocumentService: PdfDocumentService + ) {} /** * Converts a user to PdfMake doc definition, containing access information @@ -252,17 +258,7 @@ export class UserPdfService { headerRows: 1, body: userTableBody.concat(this.getListUsers(users)) }, - layout: { - hLineWidth: rowIndex => { - return rowIndex === 1; - }, - vLineWidth: () => { - return 0; - }, - fillColor: rowIndex => { - return rowIndex % 2 === 0 ? '#EEEEEE' : null; - } - } + layout: this.pdfDocumentService.switchColorTableLayout }; }