LP1825851 Server managed/processed print templates
authorBill Erickson <berickxx@gmail.com>
Mon, 15 Apr 2019 22:11:46 +0000 (18:11 -0400)
committerBill Erickson <berickxx@gmail.com>
Mon, 22 Apr 2019 15:57:32 +0000 (08:57 -0700)
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 <berickxx@gmail.com>
25 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/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/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/perlmods/lib/OpenILS/WWW/PrintTemplate.pm [new file with mode: 0644]
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.server-print-templates.sql [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_patron_address.tt2 [deleted file]
Open-ILS/web/js/ui/default/staff/admin/workstation/app.js
Open-ILS/web/js/ui/default/staff/circ/patron/app.js
Open-ILS/web/js/ui/default/staff/services/print.js

index 855159e..316034a 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 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 <start_ip> <end_ip>
index 95d0702..0d32754 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 1f51073..cd78d61 100644 (file)
@@ -12813,6 +12813,35 @@ SELECT  usr,
                </links>
        </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">
+                       <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 56b8b90..468ae2d 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 => {
+
+            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;
+    }
 }
 
index aad65d1..166bc40 100644 (file)
   <div class="modal-body">
     <form #fmEditForm="ngForm" role="form" class="form-validated common-form striped-odd">
       <div class="form-group row" *ngFor="let field of fields">
-        <div class="col-lg-3 offset-lg-1">
+        <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 3e41fa2..56fcf7b 100644 (file)
@@ -18,7 +18,7 @@ interface CustomFieldTemplate {
     context?: {[fields: string]: any};
 }
 
-interface CustomFieldContext {
+export interface CustomFieldContext {
     // Current create/edit/view record
     record: IdlObject;
 
index 4f69949..de27bb8 100644 (file)
@@ -49,35 +49,59 @@ 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) {
+            
+            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 && true /* !this.hatch.isActive */) {
-            // Insert HTML into the browser DOM for in-browser printing only.
+        return promise.then(() => {
 
-            if (printReq.contentType === 'text/plain') {
-                // Wrap text/plain content in pre's to prevent
-                // unintended html formatting.
+            if (printReq.contentType === 'text/plain' && true /* this.hatch.isActive */) {
+                // When adding text output to DOM for rendering, wrap in 
+                // pre to avoid unintended HTML formatting.
                 printReq.text = `<pre>${printReq.text}</pre>`;
             }
 
             this.htmlContainer.innerHTML = printReq.text;
-        }
+        });
     }
 
     // Clear the print data
@@ -120,7 +144,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.hatch.print({
index 5ae6844..d4d0263 100644 (file)
@@ -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<any>;
+    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<PrintRequest>;
 
-    constructor(private store: StoreService) {
+    constructor(
+        private locale: LocaleService,
+        private auth: AuthService,
+        private store: StoreService
+    ) {
         this.onPrintRequest$ = new EventEmitter<PrintRequest>();
     }
 
@@ -37,5 +56,42 @@ 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('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 (file)
index 0000000..71bcd56
--- /dev/null
@@ -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;
+    }
+}
+
+
index 5e6058d..b3da873 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 1f00a8a..312f15e 100644 (file)
@@ -3,10 +3,13 @@ import {StaffCommonModule} from '@eg/staff/common.module';
 import {AdminServerRoutingModule} from './routing.module';
 import {AdminCommonModule} from '@eg/staff/admin/common.module';
 import {AdminServerSplashComponent} from './admin-server-splash.component';
+import {PrintTemplateComponent} from './print-template.component';
+import {SampleDataService} from '@eg/share/util/sample-data.service';
 
 @NgModule({
   declarations: [
-      AdminServerSplashComponent
+      AdminServerSplashComponent,
+      PrintTemplateComponent
   ],
   imports: [
     AdminCommonModule,
@@ -15,6 +18,7 @@ import {AdminServerSplashComponent} from './admin-server-splash.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..9a95f61
--- /dev/null
@@ -0,0 +1,94 @@
+
+<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>
+
+
+<div class="row mb-3">
+  <div class="col-lg-3">
+    <div class="input-group">
+      <div class="input-group-prepend">
+        <span class="input-group-text" i18n>Owner</span>
+      </div>
+      <eg-org-select 
+        [limitPerms]="['ADMIN_PRINT_TEMPLATE']"
+        [initialOrg]="contextOrg"
+        (onChange)="orgOnChange($event)">
+      </eg-org-select>
+    </div>
+  </div>
+  <div class="col-lg-3">
+    <div class="input-group">
+      <div class="input-group-prepend">
+        <span class="input-group-text" i18n>Template</span>
+      </div>
+      <eg-combobox [entries]="entries" #templateSelector
+        (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-6 mt-3">
+          <button class="btn btn-success" (click)="openEditDialog()" i18n>
+            Edit Template Attributes
+          </button>
+          <button class="btn btn-info ml-2" (click)="applyChanges()" i18n>
+            Save Template and Refresh Preview
+          </button>
+        </div>
+      </div>
+      <div class="row mt-2">
+        <div class="col-lg-6">
+          <h4 i18n>
+            Template for "{{template.label()}}"
+            <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..19c9bf0
--- /dev/null
@@ -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 = '<pre>' + response.content + '</pre>';
+            }
+        });
+    }
+
+    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'});
+    }
+}
+
+
index ceb60f2..a38cae0 100644 (file)
@@ -2,11 +2,15 @@ import {NgModule} from '@angular/core';
 import {RouterModule, Routes} from '@angular/router';
 import {AdminServerSplashComponent} from './admin-server-splash.component';
 import {BasicAdminPageComponent} from '@eg/staff/admin/basic-admin-page.component';
+import {PrintTemplateComponent} from './print-template.component';
 
 const routes: Routes = [{
     path: 'splash',
     component: AdminServerSplashComponent
 }, {
+    path: 'config/print_template',
+    component: PrintTemplateComponent
+}, {
     path: ':schema/:table',
     component: BasicAdminPageComponent
 }];
index 84e127e..e78b6cb 100644 (file)
 <h4>PCRUD auto flesh and FormatService detection</h4>
 <div *ngIf="aMetarecord">Fingerprint: {{aMetarecord}}</div>
 
+<h4>Test Server Print Template</h4>
+<button class="btn btn-info" (click)="testServerPrint()">GO</button>
+
index 9b058cd..abb0075 100644 (file)
@@ -15,6 +15,7 @@ import {PrintService} from '@eg/share/print/print.service';
 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 {SampleDataService} from '@eg/share/util/sample-data.service';
 
 @Component({
   templateUrl: 'sandbox.component.html'
@@ -73,7 +74,8 @@ export class SandboxComponent implements OnInit {
         private strings: StringService,
         private toast: ToastService,
         private format: FormatService,
-        private printer: PrintService
+        private printer: PrintService,
+        private samples: SampleDataService
     ) {
     }
 
@@ -223,6 +225,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'
+        });
+    }
+}
 
index 58910dd..d1ca609 100644 (file)
@@ -2,6 +2,7 @@ import {NgModule} from '@angular/core';
 import {StaffCommonModule} from '@eg/staff/common.module';
 import {SandboxRoutingModule} from './routing.module';
 import {SandboxComponent} from './sandbox.component';
+import {SampleDataService} from '@eg/share/util/sample-data.service';
 
 @NgModule({
   declarations: [
@@ -12,6 +13,7 @@ import {SandboxComponent} from './sandbox.component';
     SandboxRoutingModule,
   ],
   providers: [
+    SampleDataService
   ]
 })
 
index 7a47a3d..cf64086 100644 (file)
@@ -63,6 +63,7 @@
 </eg-grid>
 
 <eg-fm-record-editor #editDialog idlClass="{{idlClass}}" 
+    [fieldOptions]="fieldOptions"
     [preloadLinkedValues]="true" readonlyFields="{{readonlyFields}}">
 </eg-fm-record-editor>
 
index 125d3a0..6f06b09 100644 (file)
@@ -9,7 +9,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';
 
 /**
@@ -65,6 +66,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 (file)
index 0000000..bd9f52b
--- /dev/null
@@ -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 (file)
index 0000000..aaa3f97
--- /dev/null
@@ -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;
+-%]
+<div style="font-size:.7em;">
+  <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$
+);
+
+-- 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 (file)
index 55996b2..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-<!--
-Template for printing a patron address. Fields include:
-
-* first_given_name
-* second_given_name
-* family_name
-* address.street1
-* address.street2
-* address.city
-* address.state
-* address.post_code
-
--->
-<div>
-  <div>
-    {{patron.first_given_name}} 
-    {{patron.second_given_name}} 
-    {{patron.family_name}}
-  </div>
-  <div>{{address.street1}}</div>
-  <div ng-if="address.street2">{{address.street2}}</div>
-  <div>
-    {{address.city}}, {{address.state}} {{address.post_code}}
-  </div>
-</div>
index d863844..0364d2b 100644 (file)
@@ -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;
index d007257..83b4332 100644 (file)
@@ -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)
index d12a6cd..b07c4c8 100644 (file)
@@ -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,39 @@ 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));
+        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 +187,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 +234,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 +256,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();