From d372c6c204fb3b835bfa8d97ee6f19622a1a6890 Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Mon, 15 Apr 2019 18:11:46 -0400 Subject: [PATCH] LP1825851 Server managed/processed print templates Adds a new database table config.print_template (and IDL class) for storing configurable, org- and locale-specific print templates. Adds a web service which accepts POSTed print data and generates a print-ready document. Includes example Apache configs. Teaches the Angular app to use the new web service for generting print output. Adds and Angular print template administration interface. Adds HTML::Defang for scrubbing unwanted HTML elements and attributes from print documents for security. Add the new ADMIN_PRINT_TEMPLATE permission to the Circ Admin group at System level as a default. Adds 2 templates, a simple patron_address tepmlate (pending Angular port of patron UIs) and a 'Holds for Bib Record' template, accessible from the Angular staff catalog Holds interface. Signed-off-by: Bill Erickson Signed-off-by: Kyle Huckins Signed-off-by: Galen Charlton --- Open-ILS/examples/apache_24/eg_startup.in | 3 + Open-ILS/examples/apache_24/eg_vhost.conf.in | 8 + Open-ILS/examples/fm_IDL.xml | 28 ++ Open-ILS/src/eg2/package-lock.json | 24 +- Open-ILS/src/eg2/src/app/core/idl.service.ts | 40 +++ .../share/fm-editor/fm-editor.component.html | 2 +- .../share/fm-editor/fm-editor.component.ts | 2 +- .../src/app/share/print/print.component.ts | 66 +++-- .../eg2/src/app/share/print/print.service.ts | 65 ++++- .../src/app/share/util/sample-data.service.ts | 122 ++++++++ .../server/admin-server-splash.component.html | 3 + .../staff/admin/server/admin-server.module.ts | 6 +- .../server/print-template.component.html | 110 +++++++ .../admin/server/print-template.component.ts | 271 ++++++++++++++++++ .../app/staff/admin/server/routing.module.ts | 4 + .../catalog/record/record.component.html | 1 + .../app/staff/sandbox/sandbox.component.html | 19 +- .../app/staff/sandbox/sandbox.component.ts | 21 +- .../src/app/staff/sandbox/sandbox.module.ts | 2 + .../admin-page/admin-page.component.html | 1 + .../share/admin-page/admin-page.component.ts | 6 +- .../app/staff/share/holds/grid.component.html | 4 + .../app/staff/share/holds/grid.component.ts | 33 ++- .../perlmods/lib/OpenILS/WWW/PrintTemplate.pm | 217 ++++++++++++++ Open-ILS/src/sql/Pg/002.schema.config.sql | 14 + Open-ILS/src/sql/Pg/800.fkeys.sql | 3 + Open-ILS/src/sql/Pg/950.data.seed-values.sql | 97 ++++++- .../XXXX.schema.server-print-templates.sql | 106 +++++++ 28 files changed, 1245 insertions(+), 33 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/share/util/sample-data.service.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/server/print-template.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/server/print-template.component.ts create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.schema.server-print-templates.sql diff --git a/Open-ILS/examples/apache_24/eg_startup.in b/Open-ILS/examples/apache_24/eg_startup.in index 855159e16f..0ced7a9787 100755 --- a/Open-ILS/examples/apache_24/eg_startup.in +++ b/Open-ILS/examples/apache_24/eg_startup.in @@ -15,6 +15,9 @@ use OpenILS::WWW::IDL2js ('@sysconfdir@/opensrf_core.xml'); use OpenILS::WWW::FlatFielder; use OpenILS::WWW::PhoneList ('@sysconfdir@/opensrf_core.xml'); +# Pass second argument of '1' to enable template caching. +use OpenILS::WWW::PrintTemplate ('/openils/conf/opensrf_core.xml', 0); + # - Uncomment the following 2 lines to make use of the IP redirection code # - The IP file should to contain a map with the following format: # - actor.org_unit.shortname diff --git a/Open-ILS/examples/apache_24/eg_vhost.conf.in b/Open-ILS/examples/apache_24/eg_vhost.conf.in index 6301516f81..43e17704ee 100644 --- a/Open-ILS/examples/apache_24/eg_vhost.conf.in +++ b/Open-ILS/examples/apache_24/eg_vhost.conf.in @@ -773,6 +773,14 @@ RewriteRule ^/openurl$ ${openurl:%1} [NE,PT] + + SetHandler perl-script + PerlHandler OpenILS::WWW::PrintTemplate + Options +ExecCGI + PerlSendHeader On + Require all granted + + diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml index d28d7084d7..0a14cb5f14 100644 --- a/Open-ILS/examples/fm_IDL.xml +++ b/Open-ILS/examples/fm_IDL.xml @@ -12822,6 +12822,34 @@ SELECT usr, + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/package-lock.json b/Open-ILS/src/eg2/package-lock.json index eacdf93d66..3a77730fb4 100644 --- a/Open-ILS/src/eg2/package-lock.json +++ b/Open-ILS/src/eg2/package-lock.json @@ -956,7 +956,7 @@ }, "load-json-file": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", "dev": true, "requires": { @@ -977,7 +977,7 @@ }, "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true }, @@ -1016,7 +1016,7 @@ }, "pify": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true }, @@ -7600,6 +7600,11 @@ "object-visit": "1.0.1" } }, + "material-design-icons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/material-design-icons/-/material-design-icons-3.0.1.tgz", + "integrity": "sha1-mnHEh0chjrylHlGmbaaCA4zct78=" + }, "math-random": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.1.tgz", @@ -7873,6 +7878,19 @@ "minimist": "0.0.8" } }, + "moment": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", + "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" + }, + "moment-timezone": { + "version": "0.5.26", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.26.tgz", + "integrity": "sha512-sFP4cgEKTCymBBKgoxZjYzlSovC20Y6J7y3nanDc5RoBIXKlZhoYwBoZGe3flwU6A372AcRwScH8KiwV6zjy1g==", + "requires": { + "moment": "2.24.0" + } + }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", diff --git a/Open-ILS/src/eg2/src/app/core/idl.service.ts b/Open-ILS/src/eg2/src/app/core/idl.service.ts index 56b8b90e1f..21ec24a274 100644 --- a/Open-ILS/src/eg2/src/app/core/idl.service.ts +++ b/Open-ILS/src/eg2/src/app/core/idl.service.ts @@ -156,5 +156,45 @@ export class IdlService { } return null; } + + toHash(obj: any, flatten?: boolean): any { + + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => this.toHash(item)); + } + + const fieldNames = obj._isfieldmapper ? + Object.keys(this.classes[obj.classname].field_map) : + Object.keys(obj); + + const hash: any = {}; + fieldNames.forEach(field => { + + const val = this.toHash( + typeof obj[field] === 'function' ? obj[field]() : obj[field], + flatten + ); + + if (val === undefined) { return; } + + if (flatten && val !== null && + typeof val === 'object' && !Array.isArray(val)) { + + Object.keys(val).forEach(key => { + const fname = field + '.' + key; + hash[fname] = val[key]; + }); + + } else { + hash[field] = val; + } + }); + + return hash; + } } diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html index fc11eee6c2..476c261c34 100644 --- a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html +++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html @@ -26,7 +26,7 @@
-
+
diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts index a574e5491d..6c079b8bba 100644 --- a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts +++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts @@ -22,7 +22,7 @@ interface CustomFieldTemplate { context?: {[fields: string]: any}; } -interface CustomFieldContext { +export interface CustomFieldContext { // Current create/edit/view record record: IdlObject; diff --git a/Open-ILS/src/eg2/src/app/share/print/print.component.ts b/Open-ILS/src/eg2/src/app/share/print/print.component.ts index e7754abbd3..ff1c3ed1f4 100644 --- a/Open-ILS/src/eg2/src/app/share/print/print.component.ts +++ b/Open-ILS/src/eg2/src/app/share/print/print.component.ts @@ -53,35 +53,64 @@ export class PrintComponent implements OnInit { this.isPrinting = true; - this.applyTemplate(printReq); - - // Give templates a chance to render before printing - setTimeout(() => { - this.dispatchPrint(printReq); - this.reset(); + this.applyTemplate(printReq).then(() => { + // Give templates a chance to render before printing + setTimeout(() => { + this.dispatchPrint(printReq); + this.reset(); + }); }); } - applyTemplate(printReq: PrintRequest) { + applyTemplate(printReq: PrintRequest): Promise { if (printReq.template) { - // Inline template. Let Angular do the interpolationwork. + // Local Angular template. this.template = printReq.template; this.context = {$implicit: printReq.contextData}; - return; + return Promise.resolve(); + } + + let promise; + + // Precompiled text + if (printReq.text) { + promise = Promise.resolve(); + + } else if (printReq.templateName || printReq.templateId) { + // Server-compiled template + + promise = this.printer.compileRemoteTemplate(printReq).then( + response => { + printReq.text = response.content; + printReq.contentType = response.contentType; + }, + err => { + console.error('Error compiling template', printReq); + return Promise.reject(new Error( + 'Error compiling server-hosted print template')); + } + ); + + } else { + console.error('Cannot find template', printReq); + return Promise.reject(new Error('Cannot find print template')); } - if (printReq.text && !this.useHatch()) { - // Insert HTML into the browser DOM for in-browser printing only. + return promise.then(() => { + + // Insert HTML into the browser DOM for in-browser printing. + if (printReq.text && !this.useHatch()) { - if (printReq.contentType === 'text/plain') { + if (printReq.contentType === 'text/plain') { // Wrap text/plain content in pre's to prevent // unintended html formatting. - printReq.text = `
${printReq.text}
`; - } + printReq.text = `
${printReq.text}
`; + } - this.htmlContainer.innerHTML = printReq.text; - } + this.htmlContainer.innerHTML = printReq.text; + } + }); } // Clear the print data @@ -129,7 +158,10 @@ export class PrintComponent implements OnInit { printViaHatch(printReq: PrintRequest) { // Send a full HTML document to Hatch - const html = `${printReq.text}`; + let html = printReq.text; + if (printReq.contentType === 'text/html') { + html = `${printReq.text}`; + } this.serverStore.getItem(`eg.print.config.${printReq.printContext}`) .then(config => { diff --git a/Open-ILS/src/eg2/src/app/share/print/print.service.ts b/Open-ILS/src/eg2/src/app/share/print/print.service.ts index 5ae6844dfd..abba31c331 100644 --- a/Open-ILS/src/eg2/src/app/share/print/print.service.ts +++ b/Open-ILS/src/eg2/src/app/share/print/print.service.ts @@ -1,8 +1,19 @@ import {Injectable, EventEmitter, TemplateRef} from '@angular/core'; +import {tap} from 'rxjs/operators'; import {StoreService} from '@eg/core/store.service'; +import {LocaleService} from '@eg/core/locale.service'; +import {AuthService} from '@eg/core/auth.service'; + +declare var js2JSON: (jsThing: any) => string; +declare var OpenSRF; + +const PRINT_TEMPLATE_PATH = '/print_template'; export interface PrintRequest { template?: TemplateRef; + templateName?: string; + templateOwner?: number; // org unit ID, follows ancestors + templateId?: number; // useful for testing templates contextData?: any; text?: string; printContext: string; @@ -10,12 +21,21 @@ export interface PrintRequest { showDialog?: boolean; } +export interface PrintTemplateResponse { + contentType: string; + content: string; +} + @Injectable() export class PrintService { onPrintRequest$: EventEmitter; - constructor(private store: StoreService) { + constructor( + private locale: LocaleService, + private auth: AuthService, + private store: StoreService + ) { this.onPrintRequest$ = new EventEmitter(); } @@ -37,5 +57,48 @@ export class PrintService { this.print(req); } } + + compileRemoteTemplate(printReq: PrintRequest): Promise { + + const formData: FormData = new FormData(); + + formData.append('ses', this.auth.token()); + if (printReq.templateName) { + formData.append('template_name', printReq.templateName); + } + if (printReq.templateId) { + formData.append('template_id', '' + printReq.templateId); + } + if (printReq.templateOwner) { + formData.append('template_owner', '' + printReq.templateOwner); + } + formData.append('template_data', js2JSON(printReq.contextData)); + formData.append('template_locale', this.locale.currentLocaleCode()); + + // Sometimes we want to know the time zone of the browser/user, + // regardless of any org unit settings. + if (OpenSRF.tz) { + formData.append('client_timezone', OpenSRF.tz); + } + + return new Promise((resolve, reject) => { + const xhttp = new XMLHttpRequest(); + xhttp.onreadystatechange = function() { + if (this.readyState === 4) { + if (this.status === 200) { + resolve({ + content: xhttp.responseText, + contentType: this.getResponseHeader('content-type') + }); + } else { + reject('Error compiling print template'); + } + } + }; + xhttp.open('POST', PRINT_TEMPLATE_PATH, true); + xhttp.send(formData); + }); + + } } diff --git a/Open-ILS/src/eg2/src/app/share/util/sample-data.service.ts b/Open-ILS/src/eg2/src/app/share/util/sample-data.service.ts new file mode 100644 index 0000000000..d159d6d6ba --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/util/sample-data.service.ts @@ -0,0 +1,122 @@ +import {Injectable} from '@angular/core'; +import {IdlService, IdlObject} from '@eg/core/idl.service'; + +/** Service for generating sample data for testing, demo, etc. */ + +// TODO: I could also imagine this coming from a web service or +// even a flat file of web-served JSON. + +const NOW_DATE = new Date().toISOString(); + +// Copied from sample of Concerto data set +const DATA = { + au: [ + {first_given_name: 'Vincent', second_given_name: 'Kenneth', family_name: 'Moran'}, + {first_given_name: 'Gregory', second_given_name: 'Adam', family_name: 'Jones'}, + {first_given_name: 'Brittany', second_given_name: 'Geraldine', family_name: 'Walker'}, + {first_given_name: 'Ernesto', second_given_name: 'Robert', family_name: 'Miller'}, + {first_given_name: 'Robert', second_given_name: 'Louis', family_name: 'Hill'}, + {first_given_name: 'Edward', second_given_name: 'Robert', family_name: 'Lopez'}, + {first_given_name: 'Andrew', second_given_name: 'Alberto', family_name: 'Bell'}, + {first_given_name: 'Jennifer', second_given_name: 'Dorothy', family_name: 'Mitchell'}, + {first_given_name: 'Jo', second_given_name: 'Mai', family_name: 'Madden'}, + {first_given_name: 'Maomi', second_given_name: 'Julie', family_name: 'Harding'} + ], + ac: [ + {barcode: '908897239000'}, + {barcode: '908897239001'}, + {barcode: '908897239002'}, + {barcode: '908897239003'}, + {barcode: '908897239004'}, + {barcode: '908897239005'}, + {barcode: '908897239006'}, + {barcode: '908897239007'}, + {barcode: '908897239008'}, + {barcode: '908897239009'} + ], + aua: [ + {street1: '1809 Target Way', city: 'Vero beach', state: 'FL', post_code: 32961}, + {street1: '3481 Facility Island', city: 'Campton', state: 'KY', post_code: 41301}, + {street1: '5150 Dinner Expressway', city: 'Dodge center', state: 'MN', post_code: 55927}, + {street1: '8496 Random Trust Points', city: 'Berryville', state: 'VA', post_code: 22611}, + {street1: '7626 Secret Institute Courts', city: 'Anchorage', state: 'AK', post_code: 99502}, + {street1: '7044 Regular Index Path', city: 'Livingston', state: 'KY', post_code: 40445}, + {street1: '3403 Thundering Heat Meadows', city: 'Miami', state: 'FL', post_code: 33157}, + {street1: '759 Doubtful Government Extension', city: 'Sellersville', state: 'PA', post_code: 18960}, + {street1: '5431 Japanese Work Rapid', city: 'Society hill', state: 'SC', post_code: 29593}, + {street1: '5253 Agricultural Exhibition Stravenue', city: 'La place', state: 'IL', post_code: 61936} + ], + ahr: [ + {request_time: NOW_DATE, hold_type: 'T', capture_time: null, fulfillment_time: null}, + {request_time: NOW_DATE, hold_type: 'T', capture_time: null, fulfillment_time: null}, + {request_time: NOW_DATE, hold_type: 'V', capture_time: null, fulfillment_time: null}, + {request_time: NOW_DATE, hold_type: 'C', capture_time: null, fulfillment_time: null}, + {request_time: NOW_DATE, hold_type: 'T', capture_time: null, fulfillment_time: null, frozen: true}, + {request_time: NOW_DATE, hold_type: 'T', capture_time: NOW_DATE, fulfillment_time: null}, + {request_time: NOW_DATE, hold_type: 'T', capture_time: NOW_DATE, fulfillment_time: null}, + {request_time: NOW_DATE, hold_type: 'T', capture_time: NOW_DATE, fulfillment_time: NOW_DATE}, + {request_time: NOW_DATE, hold_type: 'T', capture_time: NOW_DATE, fulfillment_time: NOW_DATE}, + {request_time: NOW_DATE, hold_type: 'T', capture_time: NOW_DATE, fulfillment_time: NOW_DATE} + ], + acp: [ + {barcode: '208897239000'}, + {barcode: '208897239001'}, + {barcode: '208897239002'}, + {barcode: '208897239003'}, + {barcode: '208897239004'}, + {barcode: '208897239005'}, + {barcode: '208897239006'}, + {barcode: '208897239007'}, + {barcode: '208897239008'}, + {barcode: '208897239009'} + ], + mwde: [ + {title: 'Sinidos sinfónicos : an orchestral sampler'}, + {title: 'Piano concerto, op. 38'}, + {title: 'Critical entertainments : music old and new'}, + {title: 'Piano concerto in C major, op. 39'}, + {title: 'Double concerto in A minor, op. 102 ; Variations on a theme by Haydn, op. 56a ; Tragic overture, op. 81'}, + {title: 'Trombone concerto (1991) subject: american'}, + {title: 'Violin concerto no. 2 ; Six duos (from 44 Duos)'}, + {title: 'Piano concerto no. 1 (1926) ; Rhapsody, op. 1 (1904)'}, + {title: 'Piano concertos 2 & 3 & the devil makes me?'}, + {title: 'Composition student recital, April 6, 2000, Huntington University / composition students of Daniel Bédard'}, + ] +}; + + +@Injectable() +export class SampleDataService { + + constructor(private idl: IdlService) {} + + randomValue(list: any[], field: string): string { + return list[Math.floor(Math.random() * list.length)][field]; + } + + listOfThings(idlClass: string, count: number = 1): IdlObject[] { + if (!(idlClass in DATA)) { + throw new Error(`No sample data for class ${idlClass}'`); + } + + const things: IdlObject[] = []; + for (let i = 0; i < count; i++) { + const thing = this.idl.create(idlClass); + Object.keys(DATA[idlClass][0]).forEach(field => + thing[field](this.randomValue(DATA[idlClass], field)) + ); + things.push(thing); + } + + return things; + } + + // Returns a random-ish date in the past or the future. + randomDate(future: boolean = false): Date { + const rando = Math.random() * 10000000000; + const time = new Date().getTime(); + return new Date(future ? time + rando : time - rando); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html index 99cb4781b7..f71dd2fd04 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html @@ -81,6 +81,9 @@ url="/eg/staff/admin/server/legacy/permission/grp_tree"> + + + + + + + + + + + +
+
+ + +
+
+
+
+ Template +
+ + {{r.label}} ({{getOwnerName(r.id)}}) + + + +
+
+
+
+
+ Locale +
+ + +
+
+
+ + + + +
+
+ + + +
+ + + Invalid Sample JSON! + +
+
+
+
+

+ Template for "{{template.label()}} ({{getOwnerName(template.id())}})" + + (Inactive) + +

+ +
+
+

Preview

+
+
+

Compiled Content

+
+
{{compiledContent}}
+
+
+
+
+
+ + + + + +
+ diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/print-template.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/print-template.component.ts new file mode 100644 index 0000000000..f57df1ec91 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/print-template.component.ts @@ -0,0 +1,271 @@ +import {Component, OnInit, ViewChild, TemplateRef} from '@angular/core'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {ActivatedRoute} from '@angular/router'; +import {IdlService, IdlObject} from '@eg/core/idl.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {AuthService} from '@eg/core/auth.service'; +import {OrgService} from '@eg/core/org.service'; +import {ComboboxComponent, ComboboxEntry + } from '@eg/share/combobox/combobox.component'; +import {PrintService} from '@eg/share/print/print.service'; +import {LocaleService} from '@eg/core/locale.service'; +import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap'; +import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component'; +import {SampleDataService} from '@eg/share/util/sample-data.service'; +import {OrgFamily} from '@eg/share/org-family-select/org-family-select.component'; +import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component'; + +/** + * Print Template Admin Page + */ + +@Component({ + templateUrl: 'print-template.component.html' +}) + +export class PrintTemplateComponent implements OnInit { + + entries: ComboboxEntry[]; + template: IdlObject; + sampleJson: string; + invalidJson = false; + localeCode: string; + localeEntries: ComboboxEntry[]; + compiledContent: string; + templateCache: {[id: number]: IdlObject} = {}; + initialOrg: number; + selectedOrgs: number[]; + + @ViewChild('templateSelector') templateSelector: ComboboxComponent; + @ViewChild('tabs') tabs: NgbTabset; + @ViewChild('editDialog') editDialog: FmRecordEditorComponent; + @ViewChild('confirmDelete') confirmDelete: ConfirmDialogComponent; + + // Define some sample data that can be used for various templates + // Data will be filled out via the sample data service. + // Keys map to print template names + sampleData: any = { + patron_address: {}, + holds_for_bib: {} + }; + + constructor( + private route: ActivatedRoute, + private idl: IdlService, + private org: OrgService, + private pcrud: PcrudService, + private auth: AuthService, + private locale: LocaleService, + private printer: PrintService, + private samples: SampleDataService + ) { + this.entries = []; + this.localeEntries = []; + } + + ngOnInit() { + this.initialOrg = this.auth.user().ws_ou(); + this.selectedOrgs = [this.initialOrg]; + this.localeCode = this.locale.currentLocaleCode(); + this.locale.supportedLocales().subscribe( + l => this.localeEntries.push({id: l.code(), label: l.name()})); + this.setTemplateInfo().subscribe(); + this.fleshSampleData(); + } + + fleshSampleData() { + + // NOTE: server templates work fine with IDL objects, but + // vanilla hashes are easier to work with in the admin UI. + + // Classes for which sample data exists + const classes = ['au', 'ac', 'aua', 'ahr', 'acp', 'mwde']; + const samples: any = {}; + classes.forEach(class_ => samples[class_] = + this.idl.toHash(this.samples.listOfThings(class_, 10))); + + // Wide holds are hashes instead of IDL objects. + // Add fields as needed. + const wide_holds = [{ + request_time: this.samples.randomDate().toISOString(), + ucard_barcode: samples.ac[0].barcode, + usr_family_name: samples.au[0].family_name, + usr_alias: samples.au[0].alias, + cp_barcode: samples.acp[0].barcode + }, { + request_time: this.samples.randomDate().toISOString(), + ucard_barcode: samples.ac[1].barcode, + usr_family_name: samples.au[1].family_name, + usr_alias: samples.au[1].alias, + cp_barcode: samples.acp[1].barcode + }]; + + this.sampleData.patron_address = { + patron: samples.au[0], + address: samples.aua[0] + }; + + this.sampleData.holds_for_bib = wide_holds; + } + + onTabChange(evt: NgbTabChangeEvent) { + if (evt.nextId === 'template') { + this.refreshPreview(); + } + } + + container(): any { + // Only present when its tab is visible + return document.getElementById('template-preview-pane'); + } + + // TODO should the ngModelChange handler fire for org-family-select + // even when the values don't change? + orgOnChange(family: OrgFamily) { + // Avoid reundant server calls. + if (!this.sameIds(this.selectedOrgs, family.orgIds)) { + this.selectedOrgs = family.orgIds; + this.setTemplateInfo().subscribe(); + } + } + + // True if the 2 arrays contain the same contents, + // regardless of the order. + sameIds(arr1: any[], arr2: any[]): boolean { + if (arr1.length !== arr2.length) { + return false; + } + for (let i = 0; i < arr1.length; i++) { + if (!arr2.includes(arr1[i])) { + return false; + } + } + return true; + } + + localeOnChange(code: string) { + if (code) { + this.localeCode = code; + this.setTemplateInfo().subscribe(); + } + } + + // Fetch name/id for all templates in range. + // Avoid fetching the template content until needed. + setTemplateInfo(): Observable { + this.entries = []; + this.template = null; + this.templateSelector.applyEntryId(null); + this.compiledContent = ''; + + return this.pcrud.search('cpt', + { + owner: this.selectedOrgs, + locale: this.localeCode + }, { + select: {cpt: ['id', 'label', 'owner']}, + order_by: {cpt: 'label'} + } + ).pipe(map(tmpl => { + this.templateCache[tmpl.id()] = tmpl; + this.entries.push({id: tmpl.id(), label: tmpl.label()}); + return tmpl; + })); + } + + getOwnerName(id: number): string { + return this.org.get(this.templateCache[id].owner()).shortname(); + } + + selectTemplate(id: number) { + + if (id === null) { + this.template = null; + this.compiledContent = ''; + return; + } + + this.pcrud.retrieve('cpt', id).subscribe(t => { + this.template = t; + const data = this.sampleData[t.name()]; + if (data) { + this.sampleJson = JSON.stringify(data, null, 2); + this.refreshPreview(); + } + }); + } + + refreshPreview() { + if (!this.sampleJson) { return; } + this.compiledContent = ''; + + let data; + try { + data = JSON.parse(this.sampleJson); + this.invalidJson = false; + } catch (E) { + this.invalidJson = true; + } + + this.printer.compileRemoteTemplate({ + templateId: this.template.id(), + contextData: data, + printContext: 'default' // required, has no impact here + + }).then(response => { + + this.compiledContent = response.content; + if (response.contentType === 'text/html') { + this.container().innerHTML = response.content; + } else { + // Assumes text/plain or similar + this.container().innerHTML = '
' + response.content + '
'; + } + }); + } + + applyChanges() { + this.container().innerHTML = ''; + this.pcrud.update(this.template).toPromise() + .then(() => this.refreshPreview()); + } + + openEditDialog() { + this.editDialog.setRecord(this.template); + this.editDialog.mode = 'update'; + this.editDialog.open({size: 'lg'}).toPromise().then(id => { + if (id !== undefined) { + const selectedId = this.template.id(); + this.setTemplateInfo().toPromise().then( + _ => this.selectTemplate(selectedId) + ); + } + }); + } + + cloneTemplate() { + const tmpl = this.idl.clone(this.template); + tmpl.id(null); + this.editDialog.setRecord(tmpl); + this.editDialog.mode = 'create'; + this.editDialog.open({size: 'lg'}).toPromise().then(newTmpl => { + if (newTmpl !== undefined) { + this.setTemplateInfo().toPromise() + .then(_ => this.selectTemplate(newTmpl.id())); + } + }); + } + + deleteTemplate() { + this.confirmDelete.open().subscribe(confirmed => { + if (!confirmed) { return; } + this.pcrud.remove(this.template).toPromise().then(_ => { + this.setTemplateInfo().toPromise() + .then(x => this.selectTemplate(null)); + }); + }); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts index c971ed74a7..4f9b9ff366 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts @@ -3,6 +3,7 @@ import {RouterModule, Routes} from '@angular/router'; import {AdminServerSplashComponent} from './admin-server-splash.component'; import {BasicAdminPageComponent} from '@eg/staff/admin/basic-admin-page.component'; import {OrgUnitTypeComponent} from './org-unit-type.component'; +import {PrintTemplateComponent} from './print-template.component'; const routes: Routes = [{ path: 'splash', @@ -10,6 +11,9 @@ const routes: Routes = [{ }, { path: 'actor/org_unit_type', component: OrgUnitTypeComponent +}, { + path: 'config/print_template', + component: PrintTemplateComponent }, { path: ':schema/:table', component: BasicAdminPageComponent diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html index 98476aacb8..9b9fe3cbab 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html @@ -50,6 +50,7 @@ diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html index b2d14c1b95..85585f9836 100644 --- a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html +++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html @@ -142,10 +142,23 @@ - -Hello, {{context.world}}! +

PRINTING

- +
+
+ + Hello, {{context.world}}! +
+
+ +
+
+ +
+


diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts index c6ea7c3f17..7b17c2d33c 100644 --- a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts @@ -21,6 +21,7 @@ import {FormatService} from '@eg/core/format.service'; import {StringComponent} from '@eg/share/string/string.component'; import {GridComponent} from '@eg/share/grid/grid.component'; import * as Moment from 'moment-timezone'; +import {SampleDataService} from '@eg/share/util/sample-data.service'; @Component({ templateUrl: 'sandbox.component.html', @@ -112,7 +113,8 @@ export class SandboxComponent implements OnInit { private strings: StringService, private toast: ToastService, private format: FormatService, - private printer: PrintService + private printer: PrintService, + private samples: SampleDataService ) { // BroadcastChannel is not yet defined in PhantomJS and elsewhere this.sbChannel = (typeof BroadcastChannel === 'undefined') ? @@ -412,6 +414,21 @@ export class SandboxComponent implements OnInit { d.setDate(d.getDate() - 7); return d; } -} + testServerPrint() { + + // Note these values can be IDL objects or plain hashes. + const templateData = { + patron: this.samples.listOfThings('au')[0], + address: this.samples.listOfThings('aua')[0] + }; + + // NOTE: eventually this will be baked into the print service. + this.printer.print({ + templateName: 'patron_address', + contextData: templateData, + printContext: 'default' + }); + } +} diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts index 0937ab0ee3..0fc739e7cf 100644 --- a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts @@ -3,6 +3,7 @@ import {StaffCommonModule} from '@eg/staff/common.module'; import {SandboxRoutingModule} from './routing.module'; import {SandboxComponent} from './sandbox.component'; import {ReactiveFormsModule} from '@angular/forms'; +import {SampleDataService} from '@eg/share/util/sample-data.service'; @NgModule({ declarations: [ @@ -14,6 +15,7 @@ import {ReactiveFormsModule} from '@angular/forms'; ReactiveFormsModule ], providers: [ + SampleDataService ] }) diff --git a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html index a69dabd001..bb39f8bbdd 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html @@ -43,6 +43,7 @@ diff --git a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts index f920d7b3c2..11913c8991 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts @@ -10,7 +10,8 @@ import {PcrudService} from '@eg/core/pcrud.service'; import {OrgService} from '@eg/core/org.service'; import {PermService} from '@eg/core/perm.service'; import {AuthService} from '@eg/core/auth.service'; -import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component'; +import {FmRecordEditorComponent, FmFieldOptions + } from '@eg/share/fm-editor/fm-editor.component'; import {StringComponent} from '@eg/share/string/string.component'; import {OrgFamily} from '@eg/share/org-family-select/org-family-select.component'; @@ -71,6 +72,9 @@ export class AdminPageComponent implements OnInit { // be added to the page, above the grid. @Input() helpTemplate: TemplateRef; + // Override field options for create/edit dialog + @Input() fieldOptions: {[field: string]: FmFieldOptions}; + @ViewChild('grid') grid: GridComponent; @ViewChild('editDialog') editDialog: FmRecordEditorComponent; @ViewChild('successString') successString: StringComponent; diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html index d049c28019..14f96e5755 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html @@ -79,6 +79,10 @@ i18-group group="Hold" i18n-label label="Cancel Hold" (onClick)="showCancelDialog($event)"> + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts index bdecd41afb..eb670d0b70 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts @@ -18,6 +18,7 @@ import {HoldRetargetDialogComponent import {HoldTransferDialogComponent} from './transfer-dialog.component'; import {HoldCancelDialogComponent} from './cancel-dialog.component'; import {HoldManageDialogComponent} from './manage-dialog.component'; +import {PrintService} from '@eg/share/print/print.service'; /** Holds grid with access to detail page and other actions */ @@ -35,7 +36,10 @@ export class HoldsGridComponent implements OnInit { @Input() persistKey: string; @Input() preFetchSetting: string; - // If set, all holds are fetched on grid load and sorting/paging all + + @Input() printTemplate: string; + + // If set, all holds are fetched on grid load and sorting/paging all // happens in the client. If false, sorting and paging occur on // the server. enablePreFetch: boolean; @@ -111,7 +115,8 @@ export class HoldsGridComponent implements OnInit { private net: NetService, private org: OrgService, private store: ServerStoreService, - private auth: AuthService + private auth: AuthService, + private printer: PrintService ) { this.gridDataSource = new GridDataSource(); this.enablePreFetch = null; @@ -389,6 +394,30 @@ export class HoldsGridComponent implements OnInit { ); } } + + printHolds() { + // Request a page with no limit to get all of the wide holds for + // printing. Call requestPage() directly instead of grid.reload() + // since we may already have the data. + + const pager = new Pager(); + pager.offset = 0; + pager.limit = null; + + if (this.gridDataSource.sort.length === 0) { + this.gridDataSource.sort = this.defaultSort; + } + + this.gridDataSource.requestPage(pager).then(() => { + if (this.gridDataSource.data.length > 0) { + this.printer.print({ + templateName: this.printTemplate || 'holds_for_bib', + contextData: this.gridDataSource.data, + printContext: 'default' + }); + } + }); + } } diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm new file mode 100644 index 0000000000..be76da0ce4 --- /dev/null +++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm @@ -0,0 +1,217 @@ +package OpenILS::WWW::PrintTemplate; +use strict; use warnings; +use Apache2::Const -compile => + qw(OK FORBIDDEN NOT_FOUND HTTP_INTERNAL_SERVER_ERROR HTTP_BAD_REQUEST); +use Apache2::RequestRec; +use CGI; +use HTML::Defang; +use DateTime; +use DateTime::Format::ISO8601; +use Unicode::Normalize; +use OpenSRF::Utils::JSON; +use OpenSRF::System; +use OpenSRF::Utils::SettingsClient; +use OpenILS::Utils::CStoreEditor q/:funcs/; +use OpenSRF::Utils::Logger q/$logger/; +use OpenILS::Application::AppUtils; +use OpenILS::Utils::DateTime qw/:datetime/; + +my $U = 'OpenILS::Application::AppUtils'; +my $helpers; + +my $bs_config; +my $enable_cache; # Enable process-level template caching +sub import { + $bs_config = shift; + $enable_cache = shift; +} + +my $init_complete = 0; +sub child_init { + $init_complete = 1; + + OpenSRF::System->bootstrap_client(config_file => $bs_config); + OpenILS::Utils::CStoreEditor->init; + return Apache2::Const::OK; +} + +# HTML scrubber +# https://metacpan.org/pod/HTML::Defang +my $defang = HTML::Defang->new; + +sub handler { + my $r = shift; + my $cgi = CGI->new; + + child_init() unless $init_complete; + + my $auth = $cgi->param('ses') || + $cgi->cookie('eg.auth.token') || $cgi->cookie('ses'); + + my $e = new_editor(authtoken => $auth); + + # Requires staff login + return Apache2::Const::FORBIDDEN + unless $e->checkauth && $e->requestor->wsid; + + # Let pcrud handle the authz + $e->personality('open-ils.pcrud'); + + my $tmpl_owner = $cgi->param('template_owner') || $e->requestor->ws_ou; + my $tmpl_locale = $cgi->param('template_locale') || 'en-US'; + my $tmpl_id = $cgi->param('template_id'); + my $tmpl_name = $cgi->param('template_name'); + my $tmpl_data = $cgi->param('template_data'); + my $client_timezone = $cgi->param('client_timezone'); + + return Apache2::Const::HTTP_BAD_REQUEST unless $tmpl_name || $tmpl_id; + + my $template = + find_template($e, $tmpl_id, $tmpl_name, $tmpl_locale, $tmpl_owner) + or return Apache2::Const::NOT_FOUND; + + my $data; + eval { $data = OpenSRF::Utils::JSON->JSON2perl($tmpl_data); }; + if ($@) { + $logger->error("Invalid JSON in template compilation: $tmpl_data"); + return Apache2::Const::HTTP_BAD_REQUEST; + } + + my ($staff_org) = $U->fetch_org_unit($e->requestor->ws_ou); + + my $output = ''; + my $tt = Template->new; + my $tmpl = $template->template; + + my $context = { + template_locale => $tmpl_locale, + client_timezone => $client_timezone, + staff => $e->requestor, + staff_org => $staff_org, + staff_org_timezone => get_org_timezone($e, $staff_org->id), + helpers => $helpers, + template_data => $data + }; + + my $stat = $tt->process(\$tmpl, $context, \$output); + + if ($stat) { # OK + my $ctype = $template->content_type; + if ($ctype eq 'text/html') { + $output = $defang->defang($output); # Scrub the HTML + } + # TODO + # client current expects content type to only contain type. + # $r->content_type("$ctype; encoding=utf8"); + $r->content_type($ctype); + $r->print($output); + return Apache2::Const::OK; + + } else { + + (my $error = $tt->error) =~ s/\n/ /og; + $logger->error("Error processing print template: $error"); + return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR; + } +} + +my %org_timezone_cache; +sub get_org_timezone { + my ($e, $org_id) = @_; + + if (!$org_timezone_cache{$org_id}) { + + # open-ils.auth call required since our $e is in pcrud mode. + my $value = $U->simplereq( + 'open-ils.actor', + 'open-ils.actor.ou_setting.ancestor_default', + $org_id, 'lib.timezone'); + + $org_timezone_cache{$org_id} = $value ? $value->{value} : + DateTime->now(time_zone => 'local')->time_zone->name; + } + + return $org_timezone_cache{$org_id}; +} + + +# Find the template closest to the specific org unit owner. +my %template_cache; +sub find_template { + my ($e, $template_id, $name, $locale, $owner) = @_; + + if ($template_id) { + # Requesting by ID, generally used for testing, + # always pulls the latest value and ignores the active flag + return $e->retrieve_config_print_template($template_id); + } + + return $template_cache{$owner}{$name}{$locale} + if $enable_cache && + $template_cache{$owner} && + $template_cache{$owner}{$name} && + $template_cache{$owner}{$name}{$locale}; + + while ($owner) { + my ($org) = $U->fetch_org_unit($owner); # cached in AppUtils + + my $template = $e->search_config_print_template({ + name => $name, + locale => $locale, + owner => $org->id, + active => 't' + })->[0]; + + if ($template) { + + if ($enable_cache) { + $template_cache{$owner} = {} unless $template_cache{$owner}; + $template_cache{$owner}{$name} = {} + unless $template_cache{$owner}{$name}; + $template_cache{$owner}{$name}{$locale} = $template; + } + + return $template; + } + + $owner = $org->parent_ou; + } + + return undef; +} + +# Utility / helper functions passed into every template + +$helpers = { + + # turns a date w/ optional timezone modifier into something + # TT can understand + format_date => sub { + my $date = shift; + my $tz = shift; + + $date = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($date)); + $date->set_time_zone($tz) if $tz; + + return sprintf( + "%0.2d:%0.2d:%0.2d %0.2d-%0.2d-%0.4d", + $date->hour, + $date->minute, + $date->second, + $date->day, + $date->month, + $date->year + ); + }, + + current_date => sub { + my $tz = shift || 'local'; + my $date = DateTime->now(time_zone => $tz); + return $helpers->{format_date}->($date); + } +}; + + + + +1; diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql index 80581d11d8..59a092a653 100644 --- a/Open-ILS/src/sql/Pg/002.schema.config.sql +++ b/Open-ILS/src/sql/Pg/002.schema.config.sql @@ -1335,4 +1335,18 @@ INSERT INTO config.hold_type (hold_type,description) VALUES ('P','Part Hold') ; +CREATE TABLE config.print_template ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + label TEXT NOT NULL, -- i18n + owner INT NOT NULL, -- REFERENCES actor.org_unit (id) + active BOOLEAN NOT NULL DEFAULT FALSE, + locale TEXT REFERENCES config.i18n_locale(code) + ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED, + content_type TEXT NOT NULL DEFAULT 'text/html', + template TEXT NOT NULL, + CONSTRAINT name_once_per_lib UNIQUE (owner, name), + CONSTRAINT label_once_per_lib UNIQUE (owner, label) +); + COMMIT; diff --git a/Open-ILS/src/sql/Pg/800.fkeys.sql b/Open-ILS/src/sql/Pg/800.fkeys.sql index 5eb87db706..58181cb21d 100644 --- a/Open-ILS/src/sql/Pg/800.fkeys.sql +++ b/Open-ILS/src/sql/Pg/800.fkeys.sql @@ -258,4 +258,7 @@ ALTER TABLE config.marc_subfield ADD CONSTRAINT config_marc_subfield_owner_fkey ALTER TABLE config.copy_tag_type ADD CONSTRAINT copy_tag_type_owner_fkey FOREIGN KEY (owner) REFERENCES actor.org_unit(id) DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE config.print_template ADD CONSTRAINT cpt_owner_fkey + FOREIGN KEY (owner) REFERENCES actor.org_unit(id) DEFERRABLE INITIALLY DEFERRED; + COMMIT; diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql index b04a650cf4..17c59a99a2 100644 --- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql +++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql @@ -1915,7 +1915,9 @@ INSERT INTO permission.perm_list ( id, code, description ) VALUES ( 609, 'MANAGE_CUSTOM_PERM_GRP_TREE', oils_i18n_gettext( 609, 'Allows a user to manage custom permission group lists.', 'ppl', 'description' )), ( 610, 'CLEAR_PURCHASE_REQUEST', oils_i18n_gettext(610, - 'Clear Completed User Purchase Requests', 'ppl', 'description')) + 'Clear Completed User Purchase Requests', 'ppl', 'description')), + ( 611, 'ADMIN_PRINT_TEMPLATE', oils_i18n_gettext(611, + 'Modify print templates', 'ppl', 'description')) ; @@ -2514,6 +2516,7 @@ INSERT INTO permission.grp_perm_map (grp, perm, depth, grantable) 'ITEM_RENTAL_FEE_REQUIRED.override', 'ITEM_DEPOSIT_PAID.override', 'COPY_STATUS_LOST_AND_PAID.override', + 'ADMIN_PRINT_TEMPLATE', 'ITEM_NOT_HOLDABLE.override'); @@ -20041,3 +20044,95 @@ VALUES ( ) ); +INSERT INTO config.workstation_setting_type + (name, grp, datatype, label) +VALUES ( + 'eg.grid.circ.patron.group_members', 'gui', 'object', + oils_i18n_gettext( + 'eg.grid.circ.patron.group_members', + 'Grid Config: circ.patron.group_members', + 'cwst', 'label') +); + +INSERT INTO config.print_template + (id, name, locale, active, owner, label, template) +VALUES ( + 1, 'patron_address', 'en-US', FALSE, + (SELECT id FROM actor.org_unit WHERE parent_ou IS NULL), + oils_i18n_gettext(1, 'Address Label', 'cpt', 'label'), +$TEMPLATE$ +[%- + SET patron = template_data.patron; + SET addr = template_data.address; +-%] +
+
+ [% patron.first_given_name %] + [% patron.second_given_name %] + [% patron.family_name %] +
+
[% addr.street1 %]
+ [% IF addr.street2 %]
[% addr.street2 %]
[% END %] +
+ [% addr.city %], [% addr.state %] [% addr.post_code %] +
+
+$TEMPLATE$ +); + +INSERT INTO config.print_template + (id, name, locale, active, owner, label, template) +VALUES ( + 2, 'holds_for_bib', 'en-US', FALSE, + (SELECT id FROM actor.org_unit WHERE parent_ou IS NULL), + oils_i18n_gettext(2, 'Holds for Bib Record', 'cpt', 'label'), +$TEMPLATE$ +[%- + USE date; + SET holds = template_data; + # template_data is an arry of wide_hold hashes. +-%] +
+
Holds for record: [% holds.0.title %]
+
+ + + + + + + + + + + + + [% FOR hold IN holds %] + + + + + + + + [% END %] + +
Request DatePatron BarcodePatron LastPatron AliasCurrent Item
[% + date.format(helpers.format_date( + hold.request_time, staff_org_timezone), '%x %r', locale) + %][% hold.ucard_barcode %][% hold.usr_family_name %][% hold.usr_alias %][% hold.cp_barcode %]
+
+
+ [% staff_org.shortname %] + [% date.format(helpers.current_date(client_timezone), '%x %r', locale) %] +
+
Printed by [% staff.first_given_name %]
+
+
+ +$TEMPLATE$ +); + + +-- Allow for 1k stock templates +SELECT SETVAL('config.print_template_id_seq'::TEXT, 1000); diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.server-print-templates.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.server-print-templates.sql new file mode 100644 index 0000000000..a1a534903f --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.server-print-templates.sql @@ -0,0 +1,106 @@ +BEGIN; + +-- SELECT evergreen.upgrade_deps_block_check('TODO', :eg_version); + +CREATE TABLE config.print_template ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, -- programatic name + label TEXT NOT NULL, -- i18n + owner INT NOT NULL REFERENCES actor.org_unit (id), + active BOOLEAN NOT NULL DEFAULT FALSE, + locale TEXT REFERENCES config.i18n_locale(code) + ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED, + content_type TEXT NOT NULL DEFAULT 'text/html', + template TEXT NOT NULL, + CONSTRAINT name_once_per_lib UNIQUE (owner, name), + CONSTRAINT label_once_per_lib UNIQUE (owner, label) +); + +INSERT INTO config.print_template + (id, name, locale, active, owner, label, template) +VALUES ( + 1, 'patron_address', 'en-US', FALSE, + (SELECT id FROM actor.org_unit WHERE parent_ou IS NULL), + oils_i18n_gettext(1, 'Address Label', 'cpt', 'label'), +$TEMPLATE$ +[%- + SET patron = template_data.patron; + SET addr = template_data.address; +-%] +
+
+ [% patron.first_given_name %] + [% patron.second_given_name %] + [% patron.family_name %] +
+
[% addr.street1 %]
+ [% IF addr.street2 %]
[% addr.street2 %]
[% END %] +
+ [% addr.city %], [% addr.state %] [% addr.post_code %] +
+
+$TEMPLATE$ +); + +INSERT INTO config.print_template + (id, name, locale, active, owner, label, template) +VALUES ( + 2, 'holds_for_bib', 'en-US', FALSE, + (SELECT id FROM actor.org_unit WHERE parent_ou IS NULL), + oils_i18n_gettext(2, 'Holds for Bib Record', 'cpt', 'label'), +$TEMPLATE$ +[%- + USE date; + SET holds = template_data; + # template_data is an arry of wide_hold hashes. +-%] +
+
Holds for record: [% holds.0.title %]
+
+ + + + + + + + + + + + + [% FOR hold IN holds %] + + + + + + + + [% END %] + +
Request DatePatron BarcodePatron LastPatron AliasCurrent Item
[% + date.format(helpers.format_date( + hold.request_time, staff_org_timezone), '%x %r', locale) + %][% hold.ucard_barcode %][% hold.usr_family_name %][% hold.usr_alias %][% hold.cp_barcode %]
+
+
+ [% staff_org.shortname %] + [% date.format(helpers.current_date(client_timezone), '%x %r', locale) %] +
+
Printed by [% staff.first_given_name %]
+
+
+ +$TEMPLATE$ +); + +-- Allow for 1k stock templates +SELECT SETVAL('config.print_template_id_seq'::TEXT, 1000); + +INSERT INTO permission.perm_list (id, code, description) +VALUES (611, 'ADMIN_PRINT_TEMPLATE', + oils_i18n_gettext(611, 'Modify print templates', 'ppl', 'description')); + +COMMIT; + -- 2.43.2