Compare commits

...

49 Commits

Author SHA1 Message Date
84259d37d4 Merge pull request 'Sponsoring contract generation feature/5-sponsoring_contracts' (#10) from feature/5-sponsoring_contracts into dev
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #10
2021-02-07 15:37:51 +00:00
140fda11cc Added optimization args
ref #5
2021-02-07 16:37:24 +01:00
e92820e12f Parellized the chunks
ref #5
2021-02-07 16:28:26 +01:00
4773a5f18c Added sentence about large requests to the /contracts openapi description
ref #5
2021-02-07 16:11:35 +01:00
06dedc0797 Removed puppeteer args
ref #5
2021-02-07 16:07:38 +01:00
785544ca16 Added pdf merging for big requests (over 100 runners)
ref #5
2021-02-07 16:04:56 +01:00
4f191dcb52 Expanded max request body size to 500mb
ref #5
2021-02-07 15:41:53 +01:00
1ced0e3175 Added option to generate empty sponsoring contracts
ref #5
2021-02-07 13:11:54 +01:00
5e1252545b Removed the example data from the fill-in fields
ref #5
2021-02-07 13:04:45 +01:00
2d0b7ce79e Formatting
ref #5
2021-02-07 12:59:11 +01:00
1962499d18 Sorted locales
ref #5
2021-02-07 12:55:40 +01:00
1e67672ef0 Added translation sorting secript
ref #
2021-02-07 12:55:24 +01:00
e401d0ec72 Removed base64 image
ref #5
2021-02-07 12:32:54 +01:00
42443af734 Added automatic img to base64 conversion
ref #5
2021-02-07 12:32:23 +01:00
c07319b250 Now awaiting language change
ref #5
2021-02-06 22:02:16 +01:00
388d8a2dc6 Removed fullname from groups (to be renabled later)
ref #5
2021-02-06 21:48:47 +01:00
ee8ba99cc7 Fixed the controller not waiting for initialization
ref #5
2021-02-06 21:37:56 +01:00
47a05facb3 Moved pdf creatior initialization to new function
ref #5
2021-02-06 21:30:18 +01:00
f755f4f9fb Added full interpolation support to the i18n
ref #5
2021-02-06 21:15:48 +01:00
784d7c656f Added a bunch of english and german translations 🌎
ref #5
2021-02-06 20:33:47 +01:00
e345c36dd0 Added i18n strings
ref #5
2021-02-06 20:33:21 +01:00
c617c40e9d Working Styleing
ref #5
2021-02-06 20:15:33 +01:00
a9e3360ee2 First working (TM) template code
ref #5
2021-02-06 20:04:08 +01:00
13776d1ecb Added first parts of template
ref #5
2021-02-06 19:16:45 +01:00
f69e7779e4 Updated readme
ref #5
2021-02-05 22:23:48 +01:00
8ec5de4877 Merge branch 'feature/5-sponsoring_contracts' of git.odit.services:lfk/document-server into feature/5-sponsoring_contracts 2021-02-05 22:19:50 +01:00
7c3813b94d Now with workin i18n-ally config🥳
ref #5
2021-02-05 22:19:47 +01:00
b4232e51e0 Now with workin i18n-ally config🥳
ref #5
2021-02-05 22:19:38 +01:00
cd51e78282 Added comments
ref #5
2021-02-05 22:02:26 +01:00
f833ae222e Now accepting arrays for sponsoring contract generation
ref #5
2021-02-05 21:56:33 +01:00
4cd437b6af Removed useless await
ref #5
2021-02-05 21:48:47 +01:00
9be3e789a5 Merge branch 'feature/5-sponsoring_contracts' of git.odit.services:lfk/document-server into feature/5-sponsoring_contracts 2021-02-05 21:48:18 +01:00
119102aef8 Updated comments
ref #5
2021-02-05 21:48:15 +01:00
cf9cd298b6 Updated comments
ref #5
2021-02-05 21:47:23 +01:00
05e471878f Updated readme for handlebars
ref #5
2021-02-05 21:29:21 +01:00
6f81566cb8 Implemented language selection by query param
ref #5
2021-02-05 21:27:14 +01:00
a596188c00 Updated template for i18n
ref #5
2021-02-05 21:20:20 +01:00
75eb925267 Added translations using i18next
ref #5
2021-02-05 21:20:05 +01:00
19697692bc Added example locales
ref #5
2021-02-05 21:17:08 +01:00
4acf3e39ce Switched to handlebars for templateing
ref #5
2021-02-05 20:08:47 +01:00
3af76a53e3 first working pdf generation from class 🎉
ref #5
2021-02-05 17:59:26 +01:00
d58453faf9 Edited html template
ref #5
2021-02-05 17:49:42 +01:00
bd6ec6215d Fixed data validation problem
ref #5
2021-02-05 17:48:14 +01:00
96d863bfc8 Merge branch 'feature/5-sponsoring_contracts' of git.odit.services:lfk/document-server into feature/5-sponsoring_contracts
# Conflicts:
#	src/PdfCreator.ts
2021-02-05 14:09:57 +01:00
ba7cedd187 PDF Creator now accepts single instances of class
ref #5
2021-02-05 14:09:44 +01:00
78205ee8c7 PDF Creator now accepts single instances of class
ref #5
2021-02-05 14:09:20 +01:00
041c0ed6bb Added a coupple of real replacement strings to the sponsoring template
ref #5
2021-02-05 13:59:13 +01:00
6121b1e3bf Updated the contract template to use external css
ref #5
2021-02-05 13:57:04 +01:00
5afd26ea22 Switched to puppeteer for pdf generation
ref #5
2021-02-05 13:53:46 +01:00
16 changed files with 427 additions and 113 deletions

1
.gitignore vendored
View File

@ -4,6 +4,7 @@
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/i18n-ally-custom-framework.yml
*.code-workspace *.code-workspace
# Local History for Visual Studio Code # Local History for Visual Studio Code

View File

@ -2,7 +2,10 @@
"recommendations": [ "recommendations": [
"2gua.rainbow-brackets", "2gua.rainbow-brackets",
"christian-kohler.npm-intellisense", "christian-kohler.npm-intellisense",
"remimarsal.prettier-now" "remimarsal.prettier-now",
"lokalise.i18n-ally",
], ],
"unwantedRecommendations": [] "unwantedRecommendations": [
"antfu.i18n-ally"
]
} }

View File

@ -0,0 +1,8 @@
languageIds:
- javascript
- html
keyMatchReg:
- '\{\{__ "([a-zA-Z0-9_]+)"\}\}'
monopoly: false
refactorTemplates:
- '{{__ "$1"}}'

11
.vscode/settings.json vendored
View File

@ -5,7 +5,6 @@
"[json]": { "[json]": {
"editor.defaultFormatter": "vscode.json-language-features" "editor.defaultFormatter": "vscode.json-language-features"
}, },
"prettier.enable": false,
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features", "editor.defaultFormatter": "vscode.typescript-language-features",
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
@ -16,5 +15,13 @@
"javascript.preferences.quoteStyle": "single", "javascript.preferences.quoteStyle": "single",
"javascript.preferences.importModuleSpecifierEnding": "minimal", "javascript.preferences.importModuleSpecifierEnding": "minimal",
"typescript.preferences.importModuleSpecifierEnding": "minimal", "typescript.preferences.importModuleSpecifierEnding": "minimal",
"typescript.preferences.includePackageJsonAutoImports": "on" "typescript.preferences.includePackageJsonAutoImports": "on",
"i18n-ally.localesPaths": "src/locales",
"i18n-ally.keystyle": "nested",
"i18n-ally.extract.keygenStrategy":"slug",
"i18n-ally.enabledFrameworks": [
"custom"
],
"i18n-ally.sourceLanguage": "en"
} }

View File

@ -37,7 +37,7 @@ The basic generation mechanism makes the templates and routes interchangeable (i
> The templates are stored in src/templates by default. > The templates are stored in src/templates by default.
We provide a set of default templates that we use for the ["Lauf für Kaya!" charity run](https://lauf-fuer-kaya.de). We provide a set of default templates that we use for the ["Lauf für Kaya!" charity run](https://lauf-fuer-kaya.de).
We use mustache-style templating strings to fill the templates with real information (exact strings are explained below). We use handlebars for templateing utilizing i18next for translation - the i18n string format in the templates is : `{{__ "string"}}`
You can provide your own templates by replacing the ones we provided before compiling the project or by simply mounting your custom templates into the docker container. You can provide your own templates by replacing the ones we provided before compiling the project or by simply mounting your custom templates into the docker container.
The server currently needs the following templates to work: The server currently needs the following templates to work:
@ -45,12 +45,10 @@ The server currently needs the following templates to work:
### Sponsoring Contracts ### Sponsoring Contracts
| Template String | Type | Optional | Description | Template Data | Type | Optional | Description
| - | - | - | - | - | - | - | -
| runner_firstname | string | ❌ | The runner's first name | runners | array(Runner) | ❌ | The runner objects. We generate a contract for each runner on a new DIN-A5 page.
| runner_middlename | string | ✅ | The runner's middle name
| runner_lastname | string | ❌ | The runner's last name
| runner_id | int | ❌ | The runner's id
## Recommended Editor ## Recommended Editor
@ -59,6 +57,7 @@ The server currently needs the following templates to work:
### Recommended Extensions ### Recommended Extensions
* will be automatically recommended via ./vscode/extensions.json * will be automatically recommended via ./vscode/extensions.json
* we also provide a config for i18n-ally in the .vscode folder
## Staging ## Staging
### Branches & Tags ### Branches & Tags

View File

@ -7,7 +7,8 @@
"dev": "nodemon src/app.ts", "dev": "nodemon src/app.ts",
"build": "rimraf ./dist && tsc", "build": "rimraf ./dist && tsc",
"licenses:export": "license-exporter --markdown", "licenses:export": "license-exporter --markdown",
"release": "release-it --only-version" "release": "release-it --only-version",
"translations:sort": "node sort_translations.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -39,13 +40,20 @@
"license": "CC-BY-NC-SA-4.0", "license": "CC-BY-NC-SA-4.0",
"dependencies": { "dependencies": {
"@odit/class-validator-jsonschema": "^2.1.1", "@odit/class-validator-jsonschema": "^2.1.1",
"axios": "^0.21.1",
"cheerio": "^1.0.0-rc.5",
"class-transformer": "0.3.1", "class-transformer": "0.3.1",
"class-validator": "^0.13.1", "class-validator": "^0.13.1",
"consola": "^2.15.0", "consola": "^2.15.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"express": "^4.17.1", "express": "^4.17.1",
"html-pdf": "^2.2.0", "handlebars": "^4.7.6",
"i18next": "^19.8.7",
"i18next-fs-backend": "^1.0.8",
"mime-types": "^2.1.28",
"pdf-lib": "^1.16.0",
"puppeteer": "^7.0.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"routing-controllers": "^0.9.0-alpha.6", "routing-controllers": "^0.9.0-alpha.6",
"routing-controllers-openapi": "^2.2.0" "routing-controllers-openapi": "^2.2.0"
@ -54,6 +62,7 @@
"@odit/license-exporter": "^0.0.9", "@odit/license-exporter": "^0.0.9",
"@types/express": "^4.17.11", "@types/express": "^4.17.11",
"@types/node": "^14.14.22", "@types/node": "^14.14.22",
"@types/puppeteer": "^5.4.3",
"nodemon": "^2.0.7", "nodemon": "^2.0.7",
"release-it": "^14.2.2", "release-it": "^14.2.2",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",

16
sort_translations.js Normal file
View File

@ -0,0 +1,16 @@
const fs = require('fs');
// get all language files
const files = fs.readdirSync('./src/locales/');
files.forEach((f) => {
// read file as object
const unordered = JSON.parse(fs.readFileSync(`src/locales/${f}`));
// order object by keys alpabetically A-Z
const ordered = Object.keys(unordered).sort().reduce((obj, key) => {
obj[key] = unordered[key];
return obj;
}, {});
// format output as json for commit diff compatibility
const out = JSON.stringify(ordered, 0, 4);
// write output file
fs.writeFileSync(`src/locales/${f}`, out);
});

View File

@ -1,56 +1,197 @@
import axios from 'axios';
import cheerio from "cheerio";
import fs from "fs"; import fs from "fs";
import pdf_converter from "html-pdf"; import Handlebars from 'handlebars';
import i18next from "i18next";
import Backend from 'i18next-fs-backend';
import mime from "mime-types";
import path from 'path'; import path from 'path';
import { Stream } from 'stream'; import { PDFDocument } from 'pdf-lib';
import puppeteer from "puppeteer";
import { Runner } from './models/Runner';
import { RunnerGroup } from './models/RunnerGroup';
/** /**
* This class is responsible for all things pdf creation. * This class is responsible for all things pdf creation.
* This uses the html templates from src/templates. * This uses the html templates from src/templates.
*/ */
export class PdfCreator { export class PdfCreator {
private templateDir = path.join(__dirname, '/templates'); private templateDir = path.join(__dirname, '/templates');
private browser;
private static interpolations = { eventname: "Lauf für Kaya! 2021", sponsoring_receipt_minimum_amount: '10', currency_symbol: "€" }
//TODO: Accept the runner class /**
public async generateSponsoringContract(): Promise<Pdf> { * Main constructor.
let template = fs.readFileSync(`${this.templateDir}/sponsoring_contract.html`, 'utf8'); * Initializes i18n(ext), Handlebars and puppeteer.
template = template.replace("{{Runner Name}}", "lelele"); */
return new Pdf(await pdf_converter.create(template, { format: "A5", orientation: "landscape" })); constructor() {
} this.init();
}
/**
* This class is a wrapper for the pdf objects created by html-pdf.
* It offers typed conversion to Buffer and Stream.
*/
export class Pdf {
content: any;
constructor(pdf: any) {
this.content = pdf;
} }
/** /**
* Promise wrapper function that resolves the toBuffer promise for pdf generation. * Main constructor.
* Initializes i18n(ext), Handlebars and puppeteer.
*/ */
public async toBuffer(): Promise<Buffer> { public async init() {
let promise = await new Promise<Buffer>((resolve, reject) => { const minimal_args = [
this.content.toBuffer(function (err, buffer: Buffer) { '--autoplay-policy=user-gesture-required',
resolve(buffer); '--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-breakpad',
'--disable-client-side-phishing-detection',
'--disable-component-update',
'--disable-default-apps',
'--disable-dev-shm-usage',
'--disable-domain-reliability',
'--disable-extensions',
'--disable-features=AudioServiceOutOfProcess',
'--disable-hang-monitor',
'--disable-ipc-flooding-protection',
'--disable-notifications',
'--disable-offer-store-unmasked-wallet-cards',
'--disable-popup-blocking',
'--disable-print-preview',
'--disable-prompt-on-repost',
'--disable-renderer-backgrounding',
'--disable-speech-api',
'--disable-sync',
'--hide-scrollbars',
'--ignore-gpu-blacklist',
'--metrics-recording-only',
'--mute-audio',
'--no-default-browser-check',
'--no-first-run',
'--no-pings',
'--no-zygote',
'--password-store=basic',
'--use-gl=swiftshader'
];
await i18next
.use(Backend)
.init({
fallbackLng: 'en',
lng: 'en',
backend: {
loadPath: path.join(__dirname, '/locales/{{lng}}.json')
}
}); });
}); await Handlebars.registerHelper('__',
return await promise; function (str) {
return i18next.t(str, PdfCreator.interpolations).toString();
}
);
this.browser = await puppeteer.launch({ headless: true, args: minimal_args });
} }
/** /**
* Promise wrapper function that resolves the toStream promise for pdf generation. * Generate sponsoring contract pdfs.
* @param runner The runner you want to generate the contracts for.
* @param locale The locale used for the contracts (default:en)
*/ */
public async toStream(): Promise<Stream> { public async generateSponsoringContract(runners: Runner[], locale: string = "en"): Promise<Buffer> {
let promise = await new Promise<Stream>((resolve, reject) => { if (runners.length == 1 && Object.keys(runners[0]).length == 0) {
this.content.toStream(function (err, stream: Stream) { runners[0] = this.generateEmptyRunner();
resolve(stream); }
if (runners.length > 50) {
let pdf_promises = new Array<Promise<Buffer>>();
let i, j;
for (i = 0, j = runners.length; i < j; i += 50) {
let chunk = runners.slice(i, i + 50);
pdf_promises.push(this.generateSponsoringContract(chunk, locale));
}
const pdfs = await Promise.all(pdf_promises);
return await this.mergePdfs(pdfs);
}
await i18next.changeLanguage(locale);
const template_source = fs.readFileSync(`${this.templateDir}/sponsoring_contract.html`, 'utf8');
const template = Handlebars.compile(template_source);
const result = template({ runners })
const pdf = await this.renderPdf(result, { format: "A5", landscape: true });
return pdf
}
/**
* Converts all images in html to base64.
* Works with image files in the template directory or images from urls.
* @param html The html string whoms images shall get replaced.
*/
public async imgToBase64(html): Promise<string> {
const $ = cheerio.load(html)
$('img').each(async (index, element) => {
let imgsrc = $(element).attr("src");
const img_type = mime.lookup(imgsrc);
if (!(img_type.includes("image"))) {
throw new Error("File is not image mime type");
}
let image;
if (imgsrc.startsWith("http")) {
image = (await axios.get(imgsrc)).data;
image = Buffer.from(image).toString('base64');
}
else {
if (imgsrc.startsWith("./")) {
imgsrc = imgsrc.replace("./", "");
}
image = fs.readFileSync(`${this.templateDir}/${imgsrc}`, { encoding: "base64" });
}
image = `data:${img_type};base64,${image}`
$(element).attr("src", image)
})
return $.html();
}
/**
* This method manages the creation of pdfs via puppeteer.
* @param html The HTML that should get rendered.
* @param options Puppeteer PDF option (eg: {format: "A4"})
*/
public async renderPdf(html: string, options): Promise<any> {
html = await this.imgToBase64(html);
let page = await this.browser.newPage();
await page.setContent(html);
const pdf = await page.pdf(options);
await page.close();
return pdf;
}
/**
* Merges multiple pdfs into one.
* @param pdfs The pdfs you want to merge as an buffer array.
* @returns The merged pdf as a buffer.
*/
private async mergePdfs(pdfs: Buffer[]): Promise<Buffer> {
const mergedPdf = await PDFDocument.create();
for (const pdfBuffer of pdfs) {
const pdf = await PDFDocument.load(pdfBuffer);
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
copiedPages.forEach((page) => {
mergedPdf.addPage(page);
}); });
}); }
return await promise;
return <Buffer>(await mergedPdf.save());
}
/**
* Generates a new dummy runner with halfspaces for all strings.
* Can be used to generate empty sponsoring contracts.
* @returns A new runner object that apears to be empty.
*/
private generateEmptyRunner(): Runner {
let group = new RunnerGroup();
group.id = 0;
group.name = "";
let runner = new Runner();
runner.id = 0;
runner.firstname = "";
runner.lastname = "";
runner.group = group;
return runner;
} }
} }

View File

@ -1,20 +1,30 @@
import { ContentType, Controller, Get } from 'routing-controllers'; import { Body, JsonController, Post, QueryParam, Res } from 'routing-controllers';
import { OpenAPI } from 'routing-controllers-openapi'; import { OpenAPI } from 'routing-controllers-openapi';
import { Runner } from '../models/Runner';
import { PdfCreator } from '../PdfCreator'; import { PdfCreator } from '../PdfCreator';
@Controller() /**
* The pdf controller handels all endpoints concerning pdf generation.
* It therefore is the hearth of the document-generation server's endpoints.
* All endpoints have to accept a locale query-param to support i18n.
*/
@JsonController()
export class PdfController { export class PdfController {
private pdf: PdfCreator; private pdf: PdfCreator = new PdfCreator();
constructor() { private initialized: boolean = false;
this.pdf = new PdfCreator();
}
@Get('/contracts') @Post('/contracts')
@ContentType("application/pdf") @OpenAPI({ description: "Generate Sponsoring contract pdfs from runner objects.<br>You can choose your prefered locale by passing the 'locale' query-param.<br> If you provide more than 100 runenrs this could take a moment or two (we tested up to 1000 runners in about 70sec so far)." })
@OpenAPI({ description: "Generate Sponsoring contract pdfs from runner objects." }) async generateContracts(@Body({ validate: true, options: { limit: "500mb" } }) runners: Runner | Runner[], @Res() res: any, @QueryParam("locale") locale: string) {
async generateContracts() { if (!this.initialized) {
//TODO: Accept the real classes await this.pdf.init();
const contracts = await this.pdf.generateSponsoringContract(); this.initialized = true;
return await contracts.toBuffer(); }
if (!Array.isArray(runners)) {
runners = [runners];
}
const contracts = await this.pdf.generateSponsoringContract(runners, locale);
res.setHeader('content-type', 'application/pdf');
return contracts;
} }
} }

View File

@ -2,6 +2,9 @@ import { Get, JsonController } from 'routing-controllers';
import { OpenAPI } from 'routing-controllers-openapi'; import { OpenAPI } from 'routing-controllers-openapi';
import { config } from '../config'; import { config } from '../config';
/**
* The statuscontroller provides simple endpoints concerning basic information about the server.
*/
@JsonController() @JsonController()
export class StatusController { export class StatusController {
@Get('/version') @Get('/version')

20
src/locales/de.json Normal file
View File

@ -0,0 +1,20 @@
{
"address": "Adresse",
"city": "Stadt",
"date": "Datum",
"firstname": "Vorname",
"group": "Team/Klasse",
"house_number": "Hausnummer",
"id": "ID",
"lastname": "Nachname",
"location": "Ort",
"please_use_blockletters": "Bitte in DRUCKBUCHSTABEN schreiben",
"postalcode": "Postleitzahl",
"signature": "Unterschrift",
"sponsor": "Sponsor",
"sponsoring_address_condition": "Muss ausgefüllt werden, wenn Sie eine Spendenquittung benötigen - Spendenquittungen können erst ab einem Gesamtbetrag von {{sponsoring_receipt_minimum_amount}}{{currency_symbol}} ausgestellt werden",
"sponsoring_amount_per_distance": "mit einem Betrag von _____{{currency_symbol}} pro gelaufenem Kilometer zu unterstützen.",
"sponsoring_subtitle": "Ich/Wir sind bereit anlässlich des {{eventname}}",
"sponsoring_title": "Sponsoringerklärung",
"street": "Straße"
}

19
src/locales/en.json Normal file
View File

@ -0,0 +1,19 @@
{
"address": "Address",
"city": "City",
"date": "date",
"firstname": "First name",
"group": "Team/class",
"house_number": "House number",
"lastname": "Last name",
"location": "Location",
"please_use_blockletters": "Please write in BLOCK LETTERS.",
"postalcode": "Postal code",
"signature": "Signature",
"sponsor": "sponsor",
"sponsoring_address_condition": "You have to provide an address if you want a donation receipt - Donation receipts can't be issued for total donation amounts under {{sponsoring_receipt_minimum_amount}}{{currency_symbol}}",
"sponsoring_amount_per_distance": "with the amount of _____{{currency_symbol}} per kilometer run.",
"sponsoring_subtitle": "On the ocation of the {{eventname}} I/We want to support",
"sponsoring_title": "Sponsoring contract",
"street": "Street"
}

View File

@ -1,6 +1,18 @@
import { import {
IsInt, IsInt,
IsNotEmpty,
IsObject, IsObject,
IsOptional,
IsPositive,
IsString IsString
} from "class-validator"; } from "class-validator";
import { RunnerGroup } from './RunnerGroup'; import { RunnerGroup } from './RunnerGroup';
@ -13,24 +25,28 @@ export class Runner {
* The runner's id. * The runner's id.
*/ */
@IsInt() @IsInt()
@IsPositive()
id: number; id: number;
/** /**
* The runner's first name. * The runner's first name.
*/ */
@IsString() @IsString()
@IsNotEmpty()
firstname: string; firstname: string;
/** /**
* The runner's middle name. * The runner's middle name.
*/ */
@IsString() @IsString()
@IsOptional()
middlename?: string; middlename?: string;
/** /**
* The runner's last name. * The runner's last name.
*/ */
@IsString() @IsString()
@IsNotEmpty()
lastname: string; lastname: string;
/** /**

View File

@ -1,15 +1,15 @@
import { IsInt, IsNotEmpty, IsObject, IsOptional, IsString } from "class-validator"; import { IsInt, IsNotEmpty, IsObject, IsOptional, IsPositive, IsString } from "class-validator";
/** /**
* Defines the runner group class - a simplified version of the backend's ResponseRunnerTeam/-Organization * Defines the runner group class - a simplified version of the backend's ResponseRunnerTeam/-Organization
*/ */
export abstract class RunnerGroup { export class RunnerGroup {
/** /**
* The group's id. * The group's id.
*/ */
@IsInt() @IsInt()
@IsNotEmpty() @IsPositive()
id: number;; id: number;
/** /**
* The group's name. * The group's name.
@ -24,13 +24,5 @@ export abstract class RunnerGroup {
*/ */
@IsObject() @IsObject()
@IsOptional() @IsOptional()
parentGroup?: RunnerGroup parentGroup?: RunnerGroup;
/**
* Returns the groups full name in the format: org.name/team.name (or just org).
*/
public get fullName(): string {
if (!this.parentGroup) { return this.name; }
return `${this.name}/${this.parentGroup.fullName}`;
}
} }

View File

@ -1,45 +1,115 @@
<html> <html>
<head>
<meta charset="utf8">
<title>Sponsoring contract</title>
<style>
html, body {
margin: 0;
padding: 0;
font-family: 'Sackers Gothic Std';
font-weight: 500;
background: rgb(241,241,241);
-webkit-print-color-adjust: exact;
box-sizing: border-box;
}
.page { <head>
position: relative; <meta charset="utf8">
height: 148mm; <title>Sponsoring contract</title>
width: 210mm; <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
display: block; <style>
background: white; .sheet {
page-break-after: auto; margin: 0;
margin: 50px; overflow: hidden;
overflow: hidden; position: relative;
} box-sizing: border-box;
page-break-after: always;
}
@media print { body.A5.landscape .sheet {
body { width: 210mm;
background: white; height: 147mm
} }
.page { .column {
margin: 0; margin-bottom: -20;
height: 100%; }
width: 100%; </style>
} </head>
}
</style> <body class="A5 landscape">
</head> {{#each runners}}
<body> <div class="sheet">
<div class="page"> <img id="header_img" width="100%" src="sponsoringheader.png" />
<p style="font-size: 100vw;">{{Runner Name}}</p> <div style=" padding: 0 1rem 0 1rem;">
<div class="columns" style="padding-bottom: 0;">
<div class="column is-two-fifths">
<p style="font-size: large; font-weight: bold;">{{__ "sponsoring_title"}}</p>
</div>
<div class="column">
<p style="font-size: x-small; vertical-align: revert; margin-top: auto;">{{__ "please_use_blockletters"}}
</p>
</div>
</div>
<p> {{__ "sponsoring_subtitle"}} </p>
<div class="columns" style="padding-top: 0;">
<div class="column is-8">
<span style="border-bottom: 1px solid; width: 100%; display: block;">{{this.firstname}}
{{this.middlename}}</span>
<p style="font-size: x-small; display: block;">{{__ "firstname"}}</p>
</div>
<div class="column is-2">
<span style="border-bottom: 1px solid; width: 100%; display: block;">{{this.id}}</span>
<p style="font-size: x-small; display: block;">ID</p>
</div>
<div class="column">
<!-- CODE Here -->
</div>
</div>
<div class="columns">
<div class="column is-6">
<span style="border-bottom: 1px solid; width: 100%; display: block;">{{this.lastname}}</span>
<p style="font-size: x-small; display: block;">{{__ "lastname"}}</p>
</div>
<div class="column is-6">
<span style="border-bottom: 1px solid; width: 100%; display: block;">{{this.group.name}}</span>
<p style="font-size: x-small; display: block;">{{__ "group"}}</p>
</div>
</div>
<p>{{__ "sponsoring_amount_per_distance"}}</p>
<div class="columns">
<div class="column is-6">
<span style="border-bottom: 1px solid; width: 100%; display: block;"></span>
<p style="font-size: x-small; display: block;">{{__ "lastname"}}</p>
</div>
<div class="column is-6">
<span style="border-bottom: 1px solid; width: 100%; display: block;"></span>
<p style="font-size: x-small; display: block;">{{__ "firstname"}}</p>
</div>
</div>
<p style="font-size: medium;">{{__ "address"}} ({{__ "sponsor"}})</p>
<p style="font-size: x-small;">({{__ "sponsoring_address_condition"}})</p>
<div class="columns">
<div class="column is-8">
<span style="border-bottom: 1px solid; width: 100%; display: block;"></span>
<p style="font-size: x-small; display: block;">{{__ "street"}}</p>
</div>
<div class="column is-4">
<span style="border-bottom: 1px solid; width: 100%; display: block;"></span>
<p style="font-size: x-small; display: block;">{{__ "house_number"}}</p>
</div>
</div>
<div class="columns">
<div class="column is-4">
<span style="border-bottom: 1px solid; width: 100%; display: block;"></span>
<p style="font-size: x-small; display: block;">{{__ "postalcode"}}</p>
</div>
<div class="column is-8">
<span style="border-bottom: 1px solid; width: 100%; display: block;"></span>
<p style="font-size: x-small; display: block;">{{__ "city"}}</p>
</div>
</div>
<br>
<div class="columns">
<div class="column is-7">
<span style="border-bottom: 1px solid; width: 100%; display: block;"></span>
<p style="font-size: x-small; display: block;">{{__ "location"}}, {{__ "date"}}</p>
</div>
<div class="column is-5">
<span style="border-bottom: 1px solid; width: 100%; display: block;"></span>
<p style="font-size: x-small; display: block;">{{__ "signature"}}</p>
</div>
</div>
</div> </div>
</body> </div>
{{/each}}
</body>
</html> </html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB