LP1825851 Server managed/processed print templates
authorBill Erickson <berickxx@gmail.com>
Mon, 15 Apr 2019 22:11:46 +0000 (18:11 -0400)
committerGalen Charlton <gmc@equinoxinitiative.org>
Thu, 8 Aug 2019 19:47:31 +0000 (15:47 -0400)
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 <berickxx@gmail.com>
Signed-off-by: Kyle Huckins <khuckins@catalyte.io>
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>

28 files changed:
Open-ILS/examples/apache_24/eg_startup.in
Open-ILS/examples/apache_24/eg_vhost.conf.in
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/eg2/package-lock.json
Open-ILS/src/eg2/src/app/core/idl.service.ts
Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html
Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
Open-ILS/src/eg2/src/app/share/print/print.component.ts
Open-ILS/src/eg2/src/app/share/print/print.service.ts
Open-ILS/src/eg2/src/app/share/util/sample-data.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html
Open-ILS/src/eg2/src/app/staff/admin/server/admin-server.module.ts
Open-ILS/src/eg2/src/app/staff/admin/server/print-template.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/print-template.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts
Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html
Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts
Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html
Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts
Open-ILS/src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm [new file with mode: 0644]
Open-ILS/src/sql/Pg/002.schema.config.sql
Open-ILS/src/sql/Pg/800.fkeys.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.server-print-templates.sql [new file with mode: 0644]

index 855159e..0ced7a9 100755 (executable)
@@ -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 <start_ip> <end_ip>
index 6301516..43e1770 100644 (file)
@@ -773,6 +773,14 @@ RewriteRule ^/openurl$ ${openurl:%1} [NE,PT]
     </LocationMatch>
 </IfModule>
 
+<Location /print_template>
+    SetHandler perl-script
+    PerlHandler OpenILS::WWW::PrintTemplate
+    Options +ExecCGI
+    PerlSendHeader On
+    Require all granted 
+</Location>
+
 
 <Location /IDL2js>
 
index d28d708..0a14cb5 100644 (file)
@@ -12822,6 +12822,34 @@ SELECT  usr,
        </fields>
        </class>
 
+       <class id="cpt" controller="open-ils.cstore open-ils.pcrud"
+               oils_obj:fieldmapper="config::print_template" 
+               oils_persist:tablename="config.print_template" 
+               reporter:label="Print Templates">
+               <fields oils_persist:primary="id" oils_persist:sequence="config.print_template_id_seq">
+                       <field name="id" reporter:datatype="id"  reporter:selector="label"/>
+                       <field name="name" reporter:datatype="text" oils_obj:required="true"/>
+                       <field name="label" reporter:datatype="text" oils_obj:required="true" oils_persist:i18n="true"/>
+                       <field reporter:label="Owner" name="owner" oils_obj:required="true" reporter:datatype="link"/>
+                       <field reporter:label="Active" name="active" reporter:datatype="bool"/>
+                       <field reporter:label="Locale" name="locale" reporter:datatype="link"/>
+                       <field name="content_type" reporter:datatype="text"/>
+                       <field name="template" reporter:datatype="text" oils_obj:required="true"/>
+               </fields>
+               <links>
+                       <link field="owner" reltype="has_a" key="id" map="" class="aou"/>
+                       <link field="locale" reltype="has_a" key="id" map="" class="i18n_l"/>
+               </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="ADMIN_PRINT_TEMPLATE" context_field="owner"/>
+                               <retrieve permission="STAFF_LOGIN" context_field="owner"/>
+                               <update permission="ADMIN_PRINT_TEMPLATE" context_field="owner"/>
+                               <delete permission="ADMIN_PRINT_TEMPLATE" context_field="owner"/>
+                       </actions>
+               </permacrud>
+       </class>
+
        <!-- ********************************************************************************************************************* -->
 </IDL>
 
index eacdf93..3a77730 100644 (file)
         },
         "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": {
         },
         "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
         },
         },
         "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
         },
         "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",
         "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",
index 56b8b90..21ec24a 100644 (file)
@@ -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;
+    }
 }
 
index fc11eee..476c261 100644 (file)
@@ -26,7 +26,7 @@
         <div class="col-lg-3">
           <label for="{{idPrefix}}-{{field.name}}">{{field.label}}</label>
         </div>
-        <div class="col-lg-7">
+        <div class="col-lg-9">
 
           <ng-container [ngSwitch]="inputType(field)">
 
index a574e54..6c079b8 100644 (file)
@@ -22,7 +22,7 @@ interface CustomFieldTemplate {
     context?: {[fields: string]: any};
 }
 
-interface CustomFieldContext {
+export interface CustomFieldContext {
     // Current create/edit/view record
     record: IdlObject;
 
index e7754ab..ff1c3ed 100644 (file)
@@ -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<any> {
 
         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 = `<pre>${printReq.text}</pre>`;
-            }
+                    printReq.text = `<pre>${printReq.text}</pre>`;
+                }
 
-            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 = `<html><body>${printReq.text}</body></html>`;
+        let html = printReq.text;
+        if (printReq.contentType === 'text/html') {
+            html = `<html><body>${printReq.text}</body></html>`;
+        }
 
         this.serverStore.getItem(`eg.print.config.${printReq.printContext}`)
         .then(config => {
index 5ae6844..abba31c 100644 (file)
@@ -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<any>;
+    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<PrintRequest>;
 
-    constructor(private store: StoreService) {
+    constructor(
+        private locale: LocaleService,
+        private auth: AuthService,
+        private store: StoreService
+    ) {
         this.onPrintRequest$ = new EventEmitter<PrintRequest>();
     }
 
@@ -37,5 +57,48 @@ export class PrintService {
             this.print(req);
         }
     }
+
+    compileRemoteTemplate(printReq: PrintRequest): Promise<PrintTemplateResponse> {
+
+        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 (file)
index 0000000..d159d6d
--- /dev/null
@@ -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);
+    }
+}
+
+
index 99cb478..f71dd2f 100644 (file)
@@ -81,6 +81,9 @@
       url="/eg/staff/admin/server/legacy/permission/grp_tree"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Permissions"  
       routerLink="/staff/admin/server/permission/perm_list"></eg-link-table-link>
+    <!-- Probably should move this to local admin once it's migrated -->
+    <eg-link-table-link i18n-label label="Print Templates"  
+      routerLink="/staff/admin/server/config/print_template"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Remote Accounts"  
       routerLink="/staff/admin/server/config/remote_account"></eg-link-table-link>
     <eg-link-table-link i18n-label label="SMS Carriers"  
index 8e76239..cbbadd3 100644 (file)
@@ -5,11 +5,14 @@ import {AdminServerRoutingModule} from './routing.module';
 import {AdminCommonModule} from '@eg/staff/admin/common.module';
 import {AdminServerSplashComponent} from './admin-server-splash.component';
 import {OrgUnitTypeComponent} from './org-unit-type.component';
+import {PrintTemplateComponent} from './print-template.component';
+import {SampleDataService} from '@eg/share/util/sample-data.service';
 
 @NgModule({
   declarations: [
       AdminServerSplashComponent,
-      OrgUnitTypeComponent
+      OrgUnitTypeComponent,
+      PrintTemplateComponent
   ],
   imports: [
     AdminCommonModule,
@@ -19,6 +22,7 @@ import {OrgUnitTypeComponent} from './org-unit-type.component';
   exports: [
   ],
   providers: [
+    SampleDataService
   ]
 })
 
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/print-template.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/print-template.component.html
new file mode 100644 (file)
index 0000000..585a7af
--- /dev/null
@@ -0,0 +1,110 @@
+
+<eg-title i18n-prefix prefix="Print Template Administration"></eg-title>
+<eg-staff-banner bannerText="Print Template Administration" i18n-bannerText>
+</eg-staff-banner>
+
+<eg-fm-record-editor #editDialog idlClass="cpt" 
+    [preloadLinkedValues]="true" hiddenFields="template">
+</eg-fm-record-editor>
+
+<eg-confirm-dialog #confirmDelete
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Confirm Delete?"
+  dialogBody="Delete Template '{{template ? template.label() : ''}}'?">
+</eg-confirm-dialog>
+
+<div class="row mb-3">
+  <div class="col-lg-4">
+    <eg-org-family-select
+      [selectedOrgId]="initialOrg"
+      [limitPerms]="['ADMIN_PRINT_TEMPLATE']"
+      labelText="Owner" i18n-labelText
+      (ngModelChange)="orgOnChange($event)"
+      ngModel #orgFamily="ngModel">
+    </eg-org-family-select>
+  </div>
+  <div class="col-lg-3">
+    <div class="input-group">
+      <div class="input-group-prepend">
+        <span class="input-group-text" i18n>Template</span>
+      </div>
+      <ng-template #entryTemplate let-r="result" let-owner="getOwnerName">
+        {{r.label}} ({{getOwnerName(r.id)}})
+      </ng-template>
+      <eg-combobox #templateSelector
+        [entries]="entries" [displayTemplate]="entryTemplate"
+        (onChange)="selectTemplate($event ? $event.id : null)">
+      </eg-combobox>
+    </div>
+  </div>
+  <div class="col-lg-3" *ngIf="localeEntries.length > 0">
+    <div class="input-group">
+      <div class="input-group-prepend">
+        <span class="input-group-text" i18n>Locale</span>
+      </div>
+      <eg-combobox [entries]="localeEntries"
+        [startId]="localeCode"
+        (onChange)="localeOnChange($event ? $event.id : null)">
+      </eg-combobox>
+    </div>
+  </div>
+</div>
+
+<ngb-tabset *ngIf="template" #tabs (tabChange)="onTabChange($event)">
+  <ngb-tab title="Template" i18n-title id='template'>
+    <ng-template ngbTabContent>
+      <div class="row">
+        <div class="col-lg-12 mt-3 d-flex">
+          <button class="btn btn-info" (click)="openEditDialog()" i18n>
+            Edit Template Attributes
+          </button>
+          <button class="btn btn-success ml-2" (click)="applyChanges()" i18n>
+            Save Template Changes
+          </button>
+          <button class="btn btn-info ml-2" (click)="cloneTemplate()" i18n>
+            Clone Template
+          </button>
+          <div class="flex-1"> </div>
+          <button class="btn btn-danger ml-2" (click)="deleteTemplate()" i18n>
+            Delete Template
+          </button>
+          <span *ngIf="invalidJson" class="badge badge-danger ml-2" i18n>
+            Invalid Sample JSON!
+          </span>
+        </div>
+      </div>
+      <div class="row mt-2">
+        <div class="col-lg-6">
+          <h4 i18n>
+            Template for "{{template.label()}} ({{getOwnerName(template.id())}})"
+            <span class="pl-2 text-warning" *ngIf="template.active() == 'f'">
+              (Inactive)
+            </span>
+          </h4>
+         <textarea rows="30" class="form-control"
+           spellcheck="false"
+           [ngModel]="template.template()"
+           (ngModelChange)="template.template($event); template.ischanged(true)">
+         </textarea>
+        </div>
+        <div class="col-lg-6">
+          <h4 i18n>Preview</h4>
+          <div class="border border-dark w-100" id="template-preview-pane">
+          </div>
+          <h4 class="mt-3" i18n>Compiled Content</h4>
+          <div class="border border-dark w-100">
+            <pre class="p-1">{{compiledContent}}</pre>
+          </div>
+        </div>
+      </div>
+    </ng-template>
+  </ngb-tab>
+  <ngb-tab title="Sample Data" i18n-title id='data'>
+    <ng-template ngbTabContent>
+      <textarea rows="20" [(ngModel)]="sampleJson" 
+        spellcheck="false" class="form-control">
+      </textarea>
+    </ng-template>
+  </ngb-tab>
+</ngb-tabset>
+
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 (file)
index 0000000..f57df1e
--- /dev/null
@@ -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<IdlObject> {
+        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 = '<pre>' + response.content + '</pre>';
+            }
+        });
+    }
+
+    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));
+            });
+        });
+    }
+}
+
+
index c971ed7..4f9b9ff 100644 (file)
@@ -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
 }];
index 98476aa..9b9fe3c 100644 (file)
@@ -50,6 +50,7 @@
         <ng-template ngbTabContent>
           <eg-holds-grid [recordId]="recordId"
             preFetchSetting="catalog.record.holds.prefetch"
+            printTemplate="holds_for_bib"
             persistKey="cat.catalog.wide_holds"
             [defaultSort]="[{name:'request_time',dir:'asc'}]"
             [initialPickupLib]="currentSearchOrg()"></eg-holds-grid>
index b2d14c1..85585f9 100644 (file)
 
 <!-- printing -->
 
-<button class="btn btn-secondary" (click)="doPrint()">Test Print</button>
-<ng-template #printTemplate let-context>Hello, {{context.world}}!</ng-template>
+<h4>PRINTING</h4>
 
-<button class="btn btn-secondary" (click)="printWithDialog()">Print with dialog</button>
+<div class="d-flex">
+  <div class="mr-2">
+    <button class="btn btn-info" (click)="doPrint()">Test Local Print</button>
+    <ng-template #printTemplate let-context>Hello, {{context.world}}!</ng-template>
+  </div>
+  <div class="mr-2">
+    <button class="btn btn-info" (click)="printWithDialog()">
+      Print with dialog (Hatch Only)
+    </button>
+  </div>
+  <div class="mr-2">
+    <button class="btn btn-info" 
+      (click)="testServerPrint()">Test Server-Generated Print</button>
+  </div>
+</div>
 
 <br/><br/>
 <div class="row">
index c6ea7c3..7b17c2d 100644 (file)
@@ -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'
+        });
+    }
+}
 
index 0937ab0..0fc739e 100644 (file)
@@ -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
   ]
 })
 
index a69dabd..bb39f8b 100644 (file)
@@ -43,6 +43,7 @@
 </eg-grid>
 
 <eg-fm-record-editor #editDialog idlClass="{{idlClass}}" 
+    [fieldOptions]="fieldOptions"
     [preloadLinkedValues]="true" readonlyFields="{{readonlyFields}}">
 </eg-fm-record-editor>
 
index f920d7b..11913c8 100644 (file)
@@ -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<any>;
 
+    // Override field options for create/edit dialog
+    @Input() fieldOptions: {[field: string]: FmFieldOptions};
+
     @ViewChild('grid') grid: GridComponent;
     @ViewChild('editDialog') editDialog: FmRecordEditorComponent;
     @ViewChild('successString') successString: StringComponent;
index d049c28..14f96e5 100644 (file)
         i18-group group="Hold" i18n-label label="Cancel Hold"
         (onClick)="showCancelDialog($event)"></eg-grid-toolbar-action>
 
+      <eg-grid-toolbar-action
+        i18-group group="Hold" i18n-label label="Print Holds"
+        (onClick)="printHolds()"></eg-grid-toolbar-action>
+
       <eg-grid-column i18n-label label="Hold ID" path='id' [index]="true" datatype="id">
       </eg-grid-column>
 
index bdecd41..eb670d0 100644 (file)
@@ -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 (file)
index 0000000..be76da0
--- /dev/null
@@ -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;
index 80581d1..59a092a 100644 (file)
@@ -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;
index 5eb87db..58181cb 100644 (file)
@@ -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;
index b04a650..17c59a9 100644 (file)
@@ -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;
+-%]
+<div>
+  <div>
+    [% patron.first_given_name %] 
+    [% patron.second_given_name %] 
+    [% patron.family_name %]
+  </div>
+  <div>[% addr.street1 %]</div>
+  [% IF addr.street2 %]<div>[% addr.street2 %]</div>[% END %]
+  <div>
+    [% addr.city %], [% addr.state %] [% addr.post_code %]
+  </div>
+</div>
+$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.
+-%]
+<div>
+  <div>Holds for record: [% holds.0.title %]</div>
+  <hr/>
+  <style>#holds-for-bib-table td { padding: 5px; }</style>
+  <table id="holds-for-bib-table">
+    <thead>
+      <tr>
+        <th>Request Date</th>
+        <th>Patron Barcode</th>
+        <th>Patron Last</th>
+        <th>Patron Alias</th>
+        <th>Current Item</th>
+      </tr>
+    </thead>
+    <tbody>
+      [% FOR hold IN holds %]
+      <tr>
+        <td>[% 
+          date.format(helpers.format_date(
+            hold.request_time, staff_org_timezone), '%x %r', locale) 
+        %]</td>
+        <td>[% hold.ucard_barcode %]</td>
+        <td>[% hold.usr_family_name %]</td>
+        <td>[% hold.usr_alias %]</td>
+        <td>[% hold.cp_barcode %]</td>
+      </tr>
+      [% END %]
+    </tbody>
+  </table>
+  <hr/>
+  <div>
+    [% staff_org.shortname %] 
+    [% date.format(helpers.current_date(client_timezone), '%x %r', locale) %]
+  </div>
+  <div>Printed by [% staff.first_given_name %]</div>
+</div>
+<br/>
+
+$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 (file)
index 0000000..a1a5349
--- /dev/null
@@ -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;
+-%]
+<div>
+  <div>
+    [% patron.first_given_name %] 
+    [% patron.second_given_name %] 
+    [% patron.family_name %]
+  </div>
+  <div>[% addr.street1 %]</div>
+  [% IF addr.street2 %]<div>[% addr.street2 %]</div>[% END %]
+  <div>
+    [% addr.city %], [% addr.state %] [% addr.post_code %]
+  </div>
+</div>
+$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.
+-%]
+<div>
+  <div>Holds for record: [% holds.0.title %]</div>
+  <hr/>
+  <style>#holds-for-bib-table td { padding: 5px; }</style>
+  <table id="holds-for-bib-table">
+    <thead>
+      <tr>
+        <th>Request Date</th>
+        <th>Patron Barcode</th>
+        <th>Patron Last</th>
+        <th>Patron Alias</th>
+        <th>Current Item</th>
+      </tr>
+    </thead>
+    <tbody>
+      [% FOR hold IN holds %]
+      <tr>
+        <td>[% 
+          date.format(helpers.format_date(
+            hold.request_time, staff_org_timezone), '%x %r', locale) 
+        %]</td>
+        <td>[% hold.ucard_barcode %]</td>
+        <td>[% hold.usr_family_name %]</td>
+        <td>[% hold.usr_alias %]</td>
+        <td>[% hold.cp_barcode %]</td>
+      </tr>
+      [% END %]
+    </tbody>
+  </table>
+  <hr/>
+  <div>
+    [% staff_org.shortname %] 
+    [% date.format(helpers.current_date(client_timezone), '%x %r', locale) %]
+  </div>
+  <div>Printed by [% staff.first_given_name %]</div>
+</div>
+<br/>
+
+$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;
+