From: Bill Erickson Date: Mon, 15 Apr 2019 22:11:46 +0000 (-0400) Subject: LP1825851 Server managed/processed print templates X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=cabc67d18cd18da7989b9d572e1059ad00cad224;p=working%2FEvergreen.git LP1825851 Server managed/processed print templates Adds a new database table config.print_template (and IDL class) for storing configurable, org-specific print templates. Adds a web service which accepts POSTed print data and generates a print-ready document. Includes example Apache configs. Teaches the AngJS and Angular apps to use the new web service for generting output. Adds and Angular print template administration interface. Example setup using HTML::Restrict for scrubbing unwanted HTML elements and attributes from print documents for security. Signed-off-by: Bill Erickson --- diff --git a/Open-ILS/examples/apache_24/eg_startup.in b/Open-ILS/examples/apache_24/eg_startup.in index 855159e16f..316034a424 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 disable 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 95d0702b6d..0d32754c9c 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 7270ee3be6..462b28117a 100644 --- a/Open-ILS/examples/fm_IDL.xml +++ b/Open-ILS/examples/fm_IDL.xml @@ -12813,6 +12813,35 @@ SELECT usr, + + + + + + + + + + + + + + + + + + + + + + + + + + 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..468ae2dbe3 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 => { + + var 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 => { + let 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 5db749af35..53b6c60976 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 @@ -21,7 +21,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 f25839b1f1..4837affbc6 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 @@ -20,7 +20,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..f9238aa628 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(() => { - if (printReq.contentType === 'text/plain') { + // Insert HTML into the browser DOM for in-browser printing. + if (printReq.text && !this.useHatch()) { + + 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..d4d0263d1c 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,18 @@ 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; + +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 +20,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 +56,42 @@ 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('owner', '' + printReq.templateOwner); + } + formData.append('template_data', js2JSON(printReq.contextData)); + formData.append('locale', this.locale.currentLocaleCode()); + + 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..71bcd56117 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/util/sample-data.service.ts @@ -0,0 +1,65 @@ +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. + +// 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'} + ], + 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} + ] +}; + + +@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; + } +} + + 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 211283e341..a45a0a0746 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"> + + + + + + + + + +
+
+
+
+ Owner +
+ + +
+
+
+
+
+ Template +
+ + +
+
+
+
+
+ Locale +
+ + +
+
+
+ + + + +
+
+ + +
+
+
+
+

+ Template for "{{template.label()}}" + + (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..19c9bf0c4e --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/print-template.component.ts @@ -0,0 +1,182 @@ +import {Component, OnInit, ViewChild, TemplateRef} from '@angular/core'; +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'; + +/** + * Print Template Admin Page + */ + +@Component({ + templateUrl: 'print-template.component.html' +}) + +export class PrintTemplateComponent implements OnInit { + + contextOrg: IdlObject; + entries: ComboboxEntry[]; + template: IdlObject; + sampleJson: string; + localeCode: string; + localeEntries: ComboboxEntry[]; + compiledContent: string; + + @ViewChild('templateSelector') templateSelector: ComboboxComponent; + @ViewChild('tabs') tabs: NgbTabset; + @ViewChild('editDialog') editDialog: FmRecordEditorComponent; + + // Define some sample data that can be used for various templates + // Data will be filled out via the sample data service. + sampleData: any = { + patron_address: { + patron: null, + address: null + } + } + + 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.contextOrg = this.org.get(this.auth.user().ws_ou()); + this.localeCode = this.locale.currentLocaleCode(); + this.locale.supportedLocales().subscribe( + l => this.localeEntries.push({id: l.code(), label: l.name()})); + this.setTemplateInfo(); + this.fleshSampleData(); + } + + fleshSampleData() { + + // NOTE: server templates work fine with IDL objects, but + // vanilla hashes are easier to work with in the admin UI. + const patrons = this.idl.toHash(this.samples.listOfThings('au', 10)); + const addresses = this.idl.toHash(this.samples.listOfThings('aua', 10)); + + this.sampleData.patron_address = { + patron: patrons[0], + address: addresses[0] + }; + } + + onTabChange(evt: NgbTabChangeEvent) { + if (evt.nextId === 'template') { + this.refreshPreview(); + } + } + + container(): any { + // Only present when its tab is visible + return document.getElementById('template-preview-pane'); + } + + orgOnChange(org: IdlObject) { + this.contextOrg = org; + this.setTemplateInfo(); + } + + localeOnChange(code: string) { + if (code) { + this.localeCode = code; + this.setTemplateInfo(); + } + } + + // Fetch name/id for all templates in range. + // Avoid fetching the template content until needed. + setTemplateInfo() { + this.entries = []; + this.template = null; + this.templateSelector.applyEntryId(null); + this.compiledContent = ''; + + this.pcrud.search('cpt', + { + owner: this.contextOrg.id(), + locale: this.localeCode + }, + { + select: {cpt: ['id', 'label']}, + order_by: {cpt: 'label'} + } + ).subscribe(tmpl => + this.entries.push({id: tmpl.id(), label: tmpl.label()}) + ); + } + + selectTemplate(id: number) { + 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); + } catch (E) { + // TODO: i18n/AlertDialog + alert('Invalid Sample Data JSON'); + } + + 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() { + // TODO: PENDING EXTERNAL FIXES + //this.editDialog.record = this.template; + this.editDialog.recordId = this.template.id(); + this.editDialog.mode = 'update'; + this.editDialog.open({size: 'lg'}); + } +} + + 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', @@ -11,6 +12,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/sandbox/sandbox.component.html b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html index 38908aeed6..d77bbcd170 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 @@ -173,6 +173,9 @@ datatype="timestamp" [datePlusTime]="true"> +

Test Server Print Template

+ +

PCRUD auto flesh and FormatService detection

@@ -210,3 +213,5 @@
+ + 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 52931a04e4..505a501080 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 @@ -16,6 +16,7 @@ import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; import {FormatService} from '@eg/core/format.service'; import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component'; import {FormGroup, FormControl} from '@angular/forms'; +import {SampleDataService} from '@eg/share/util/sample-data.service'; @Component({ templateUrl: 'sandbox.component.html' @@ -80,7 +81,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') ? @@ -265,6 +267,21 @@ export class SandboxComponent implements OnInit { .then(txt => this.toast.success(txt)); }, 4000); } -} + 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 ec817d0d51..9a33fa65e7 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 {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {SampleDataService} from '@eg/share/util/sample-data.service'; @NgModule({ declarations: [ @@ -15,6 +16,7 @@ import {FormsModule, 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 ab6c263249..cbff35a640 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 @@ -39,6 +39,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 88f9525d7d..65842b96cd 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'; @@ -67,6 +68,9 @@ export class AdminPageComponent implements OnInit { // Optional comma-separated list of read-only fields @Input() readonlyFields: string; + // 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/perlmods/lib/OpenILS/WWW/PrintTemplate.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm new file mode 100644 index 0000000000..bd9f52b587 --- /dev/null +++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm @@ -0,0 +1,168 @@ +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::Restrict; +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; +my $U = 'OpenILS::Application::AppUtils'; + +my $bs_config; +my $disable_cache; +sub import { + $bs_config = shift; + $disable_cache = shift; +} + +my $init_complete = 0; +sub child_init { + $init_complete = 1; + + OpenSRF::System->bootstrap_client(config_file => $bs_config); + OpenILS::Utils::CStoreEditor->init; # just in case + return Apache2::Const::OK; +} + +# Remove all but the following elements and attributes from text/html +# compiled content. +my $rules = { + b => [qw(class style)], + caption => [qw(class style)], + center => [qw(class style)], + div => [qw(class style)], + em => [qw(class style)], + i => [qw(class style)], + img => [qw(class style src)], + li => [qw(class style)], + ol => [qw(class style)], + p => [qw(class style)], + span => [qw(class style)], + strong => [qw(class style)], + style => [], + sub => [qw(class style)], + sup => [qw(class style)], + table => [qw(class style)], + tbody => [qw(class style)], + td => [qw(class style)], + th => [qw(class style)], + thead => [qw(class style)], + tr => [qw(class style)], + u => [qw(class style)], + ul => [qw(class style)], +}; +my $hr = HTML::Restrict->new(rules => $rules); + +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 $owner = $cgi->param('owner') || $e->requestor->ws_ou; + my $locale = $cgi->param('locale') || 'en-US'; + my $template_id = $cgi->param('template_id'); + my $template_name = $cgi->param('template_name'); + my $template_data = $cgi->param('template_data'); + + return Apache2::Const::HTTP_BAD_REQUEST + unless $template_name || $template_id; + + my $template = find_template($e, $template_id, $template_name, $locale, $owner) + or return Apache2::Const::NOT_FOUND; + + my $data; + eval { $data = OpenSRF::Utils::JSON->JSON2perl($template_data); }; + if ($@) { + $logger->error("Invalid JSON in template compilation: $template_data"); + return Apache2::Const::HTTP_BAD_REQUEST; + } + + my $output = ''; + my $tt = Template->new; + my $tmpl = $template->template; + + my $stat = $tt->process(\$tmpl, {template_data => $data}, \$output); + + if ($stat) { # OK + my $ctype = $template->content_type; + if ($ctype eq 'text/html') { + # Scrub the HTML + $output = $hr->process($output); + } + # 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 Trigger template: $error"); + return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR; + } +} + +# 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 $template_cache{$owner} && + $template_cache{$owner}{$name} && + $template_cache{$owner}{$name}{$locale}; + + while ($owner) { + my ($org) = $U->fetch_org_unit($owner); # internally cached + + my $template = $e->search_config_print_template({ + name => $name, + locale => $locale, + owner => $org->id, + active => 't' + })->[0]; + + if ($template) { + if (!$disable_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; +} + +1; 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..aaa3f9762c --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.server-print-templates.sql @@ -0,0 +1,63 @@ + +BEGIN; + +/* +No longer have to deliver print templates to the client or store as workstatoin settings +-- trade-off is print data is now sent to the server +Can define per-locale templates. +immune to future technology changes +Angular template modification requires recompiling the Angular build. +https://metacpan.org/pod/HTML::Restrict +security concerns of staff modifing templates directly since +they execute in the borwser context. +offline caveat +*/ + +-- 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, owner, label, template) +VALUES ( + 1, 'patron_address', 'en-US', + (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$ +); + +-- TODO: add print template permission + +-- Allow for 1k stock templates +SELECT SETVAL('config.print_template_id_seq'::TEXT, 1000); + +COMMIT; + diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_patron_address.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_patron_address.tt2 deleted file mode 100644 index 55996b2758..0000000000 --- a/Open-ILS/src/templates/staff/share/print_templates/t_patron_address.tt2 +++ /dev/null @@ -1,25 +0,0 @@ - -
-
- {{patron.first_given_name}} - {{patron.second_given_name}} - {{patron.family_name}} -
-
{{address.street1}}
-
{{address.street2}}
-
- {{address.city}}, {{address.state}} {{address.post_code}} -
-
diff --git a/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js b/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js index d863844e3d..0364d2b0e6 100644 --- a/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js +++ b/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js @@ -616,7 +616,7 @@ function($scope , $q , egCore , ngToast) { $scope.template_changed = function() { $scope.print.load_failed = false; - egCore.print.getPrintTemplate($scope.print.template_name) + egCore.print.getPrintTemplate({template: $scope.print.template_name}) .then( function(html) { $scope.print.template_content = html; diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/app.js b/Open-ILS/web/js/ui/default/staff/circ/patron/app.js index a886195050..a2f22b7a06 100644 --- a/Open-ILS/web/js/ui/default/staff/circ/patron/app.js +++ b/Open-ILS/web/js/ui/default/staff/circ/patron/app.js @@ -331,7 +331,7 @@ function($scope, $q , $location , $filter , egCore , egNet , egUser , egAlertDi $scope.print_address = function(addr) { egCore.print.print({ context : 'default', - template : 'patron_address', + template: 'patron_address', scope : { patron : egCore.idl.toHash(patronSvc.current), address : egCore.idl.toHash(addr) diff --git a/Open-ILS/web/js/ui/default/staff/services/print.js b/Open-ILS/web/js/ui/default/staff/services/print.js index d12a6cd84f..1dd163da21 100644 --- a/Open-ILS/web/js/ui/default/staff/services/print.js +++ b/Open-ILS/web/js/ui/default/staff/services/print.js @@ -20,6 +20,7 @@ function($q , $window , $timeout , $http , egHatch , egAuth , egIDL , egOrg , eg service.template_base_path = 'share/print_templates/t_'; + service.server_template_path = '/print_template'; /* * context : 'default', 'receipt','label', etc. @@ -31,6 +32,7 @@ function($q , $window , $timeout , $http , egHatch , egAuth , egIDL , egOrg , eg * show_dialog : boolean, if true, print dialog is shown. This setting * only affects remote printers, since browser printers * do not allow such control + * server_hosted: if true, have the server compile the template for us. */ service.print = function(args) { if (!args) return $q.when(); @@ -38,7 +40,7 @@ function($q , $window , $timeout , $http , egHatch , egAuth , egIDL , egOrg , eg if (args.template) { // fetch the template, then proceed to printing - return service.getPrintTemplate(args.template) + return service.getPrintTemplate(args) .then(function(content) { args.content = content; if (!args.content_type) args.content_type = 'text/html'; @@ -48,12 +50,43 @@ function($q , $window , $timeout , $http , egHatch , egAuth , egIDL , egOrg , eg return service.print_content(args); }); }); - } return service.print_content(args); } + service.compileRemoteTemplate = function(templateName, templateData) { + + var formData = new FormData(); + + formData.append('ses', egAuth.token()); + formData.append('template_name', templateName); + formData.append('template_data', js2JSON(templateData)); + if (OpenSRF.locale) { + // .append() coerces arguments to strings, so if locale + // is null, the server will get the string "null" + formData.append('locale', OpenSRF.locale); + } + + return new Promise((resolve, reject) => { + var 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', service.server_template_path, true); + xhttp.send(formData); + }); + } + // add commonly used attributes to the print scope service.fleshPrintScope = function(scope) { if (!scope) scope = {}; @@ -158,7 +191,12 @@ function($q , $window , $timeout , $http , egHatch , egAuth , egIDL , egOrg , eg }).then(function(content) { // Ingest the content into the page DOM. - return service.ingest_print_content(type, content, printScope); + if (args.server_hosted) { + // Server hosted print templates arrive fully formed. + return service.ingest_print_content(type, null, null, content); + } else { + return service.ingest_print_content(type, content, printScope); + } }).then(function(html) { @@ -200,17 +238,20 @@ function($q , $window , $timeout , $http , egHatch , egAuth , egIDL , egOrg , eg }); } - // loads an HTML print template by name from the server - // If no template is available in local/hatch storage, - // fetch the template as an HTML file from the server. - service.getPrintTemplate = function(name) { + // loads an HTML print template by name from the server If no + // template is available in local/hatch storage, fetch the template + // as an HTML file from the server. if no HTML file is available, + // try loading the content from a server-hosted template. + service.getPrintTemplate = function(args) { var deferred = $q.defer(); + var name = args.template; egHatch.getItem('eg.print.template.' + name) .then(function(html) { if (html) { // we have a locally stored template + console.debug('Found saved template for ' + name); deferred.resolve(html); return; } @@ -219,7 +260,31 @@ function($q , $window , $timeout , $http , egHatch , egAuth , egIDL , egOrg , eg console.debug('fetching template ' + path); $http.get(path).then( - function(data) { deferred.resolve(data.data) }, + function(data) { + + if (data.data.match(/Print Template Not Found/)) { + + // AngJS templates return a dummy template w/ the + // above text if the template is not found instead + // of a 404. + return service.compileRemoteTemplate(name, args.scope) + .then( + function(response) { + console.debug('Found server-hosted template for ' + name); + args.content_type = response.contentType; + args.content = response.content; + deferred.resolve(args.content); + }, + function() { + console.error('unable to locate print template: ' + name); + deferred.reject(); + } + ); + } + + console.debug('Found server template file for ' + name); + deferred.resolve(data.data) + }, function() { console.error('unable to locate print template: ' + name); deferred.reject();