[open-ils-commits] [GIT] Evergreen ILS branch master updated. 1c24940dfa0a8bc8830764fd0457fc3572370b7c

Evergreen Git git at git.evergreen-ils.org
Wed Aug 28 17:37:32 EDT 2019


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".

The branch, master has been updated
       via  1c24940dfa0a8bc8830764fd0457fc3572370b7c (commit)
       via  3372aa2b69c75e7d108ba90f34a8c6a4dac6aa0e (commit)
       via  339b46280dbd1f51183c0954336f8284fce8873f (commit)
       via  9468c799091da62e7075bd204a795c528e3b8d26 (commit)
       via  d372c6c204fb3b835bfa8d97ee6f19622a1a6890 (commit)
       via  8995c8394772386df62f82060106a2cf690f7e4f (commit)
       via  5d15571b2c5099af2883f508997182a3b3b6b61a (commit)
       via  884d2a84f6153765f0098dfbb6905e4c7ee251e0 (commit)
      from  731ce74057b803582247ee8cf4c35af02580ca6a (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 1c24940dfa0a8bc8830764fd0457fc3572370b7c
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Thu Aug 8 16:41:14 2019 -0400

    LP#1825851: stamp schema update
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index 59a092a653..5068463049 100644
--- a/Open-ILS/src/sql/Pg/002.schema.config.sql
+++ b/Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -92,7 +92,7 @@ CREATE TRIGGER no_overlapping_deps
     BEFORE INSERT OR UPDATE ON config.db_patch_dependencies
     FOR EACH ROW EXECUTE PROCEDURE evergreen.array_overlap_check ('deprecates');
 
-INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1172', :eg_version); -- berick/gmcharlt
+INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1173', :eg_version); -- berick/khuckins/gmcharlt
 
 CREATE TABLE config.bib_source (
 	id		SERIAL	PRIMARY KEY,
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.server-print-templates.sql b/Open-ILS/src/sql/Pg/upgrade/1173.schema.server-print-templates.sql
similarity index 97%
rename from Open-ILS/src/sql/Pg/upgrade/XXXX.schema.server-print-templates.sql
rename to Open-ILS/src/sql/Pg/upgrade/1173.schema.server-print-templates.sql
index a1a534903f..a6aa7c4095 100644
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.server-print-templates.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/1173.schema.server-print-templates.sql
@@ -1,6 +1,6 @@
 BEGIN;
 
--- SELECT evergreen.upgrade_deps_block_check('TODO', :eg_version);
+SELECT evergreen.upgrade_deps_block_check('1173', :eg_version);
 
 CREATE TABLE config.print_template (
     id           SERIAL PRIMARY KEY,

commit 3372aa2b69c75e7d108ba90f34a8c6a4dac6aa0e
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Aug 1 16:36:31 2019 -0400

    LP1825851 Print template admin misc. repairs/improvements
    
    1. When cloning a template, be sure the cloned template is available in
       the template selector regardless of whether it would have been given
       the current filters.
    
    2. When cloning, set the 'active' flag explicitly to false so the user
       is forced to manually activate.  This prevent unintentional
       activation on in-process templtes.
    
    3. When cloning, clear the owner value so the user is forced to select
       an owner value.
    
    4. Allow the template editor textaread to expand vertically as the
       template gets longer (i.e. adds more new lines).
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Kyle Huckins <khuckins at catalyte.io>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
index 67d73e9cf5..2664112c78 100644
--- a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
@@ -3,7 +3,7 @@
  *  <!-- see also <eg-combobox-entry> -->
  * </eg-combobox>
  */
-import {Component, OnInit, Input, Output, ViewChild, 
+import {Component, OnInit, Input, Output, ViewChild,
     TemplateRef, EventEmitter, ElementRef, forwardRef} from '@angular/core';
 import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
 import {Observable, of, Subject} from 'rxjs';
@@ -210,6 +210,10 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
         }
     }
 
+    hasEntry(entryId: any): boolean {
+        return this.entrylist.filter(e => e.id === entryId)[0] !== undefined;
+    }
+
     onBlur() {
         // When the selected value is a string it means we have either
         // no value (user cleared the input) or a free-text value.
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
index 585a7afab4..1293bc1597 100644
--- 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
@@ -81,7 +81,7 @@
               (Inactive)
             </span>
           </h4>
-         <textarea rows="30" class="form-control"
+         <textarea rows="{{templateRowCount()}}" class="form-control"
            spellcheck="false"
            [ngModel]="template.template()"
            (ngModelChange)="template.template($event); template.ischanged(true)">
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
index f57df1ec91..782efb4a1e 100644
--- 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
@@ -175,10 +175,17 @@ export class PrintTemplateComponent implements OnInit {
     }
 
     getOwnerName(id: number): string {
-        return this.org.get(this.templateCache[id].owner()).shortname();
+        if (this.templateCache[id]) {
+            return this.org.get(this.templateCache[id].owner()).shortname();
+        }
+        return '';
     }
 
-    selectTemplate(id: number) {
+    // If the selected template changes through means other than the
+    // template selecdtor, setting updateSelector=true will force the
+    // template to appear in the selector and get selected, regardless
+    // of whether it would have been fetched with current filters.
+    selectTemplate(id: number, updateSelector?: boolean) {
 
         if (id === null) {
             this.template = null;
@@ -187,7 +194,15 @@ export class PrintTemplateComponent implements OnInit {
         }
 
         this.pcrud.retrieve('cpt', id).subscribe(t => {
-            this.template = t;
+            this.template = this.templateCache[id] = t;
+
+            if (updateSelector) {
+                if (!this.templateSelector.hasEntry(id)) {
+                    this.templateSelector.addEntry({id: id, label: t.label()});
+                }
+                this.templateSelector.applyEntryId(id);
+            }
+
             const data = this.sampleData[t.name()];
             if (data) {
                 this.sampleJson = JSON.stringify(data, null, 2);
@@ -196,6 +211,17 @@ export class PrintTemplateComponent implements OnInit {
         });
     }
 
+    // Allow the template editor textarea to expand vertically as
+    // content is added, with a sane minimum row count
+    templateRowCount(): number {
+        const def = 25;
+        if (this.template && this.template.template()) {
+            return Math.max(def,
+                this.template.template().split(/\n/).length + 2);
+        }
+        return def;
+    }
+
     refreshPreview() {
         if (!this.sampleJson) { return; }
         this.compiledContent = '';
@@ -216,11 +242,13 @@ export class PrintTemplateComponent implements OnInit {
         }).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>';
+            if (this.container()) { // null if on alternate tab
+                if (response.contentType === 'text/html') {
+                    this.container().innerHTML = response.content;
+                } else {
+                    // Assumes text/plain or similar
+                    this.container().innerHTML = '<pre>' + response.content + '</pre>';
+                }
             }
         });
     }
@@ -247,12 +275,14 @@ export class PrintTemplateComponent implements OnInit {
     cloneTemplate() {
         const tmpl = this.idl.clone(this.template);
         tmpl.id(null);
+        tmpl.active(false); // Cloning requires manual activation
+        tmpl.owner(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()));
+                    .then(_ => this.selectTemplate(newTmpl.id(), true));
             }
         });
     }

commit 339b46280dbd1f51183c0954336f8284fce8873f
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Aug 1 16:35:41 2019 -0400

    LP1825851 Print template failure warnings
    
    Display error toasts when an attempt is made to a server-generated print
    template and no active template can be found or the template generation
    failed.
    
    This required moving String and Toast components/services into the base
    module so they could be used by the print components/services.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Kyle Huckins <khuckins at catalyte.io>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/eg2/src/app/common.module.ts b/Open-ILS/src/eg2/src/app/common.module.ts
index ead50f1e54..af1f2b0705 100644
--- a/Open-ILS/src/eg2/src/app/common.module.ts
+++ b/Open-ILS/src/eg2/src/app/common.module.ts
@@ -26,6 +26,11 @@ import {PromptDialogComponent} from '@eg/share/dialog/prompt.component';
 import {ProgressInlineComponent} from '@eg/share/dialog/progress-inline.component';
 import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
 import {BoolDisplayComponent} from '@eg/share/util/bool.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {ToastComponent} from '@eg/share/toast/toast.component';
+import {StringComponent} from '@eg/share/string/string.component';
+import {StringService} from '@eg/share/string/string.service';
+
 
 @NgModule({
   declarations: [
@@ -36,6 +41,8 @@ import {BoolDisplayComponent} from '@eg/share/util/bool.component';
     PromptDialogComponent,
     ProgressInlineComponent,
     ProgressDialogComponent,
+    ToastComponent,
+    StringComponent,
     BoolDisplayComponent
   ],
   imports: [
@@ -61,6 +68,8 @@ import {BoolDisplayComponent} from '@eg/share/util/bool.component';
     ProgressInlineComponent,
     ProgressDialogComponent,
     BoolDisplayComponent,
+    ToastComponent,
+    StringComponent
   ]
 })
 
@@ -72,7 +81,9 @@ export class EgCommonModule {
             ngModule: EgCommonModule,
             providers: [
                 HatchService,
-                PrintService
+                PrintService,
+                StringService,
+                ToastService
             ]
         };
     }
diff --git a/Open-ILS/src/eg2/src/app/share/print/print.component.html b/Open-ILS/src/eg2/src/app/share/print/print.component.html
index 12d05bcbcf..823b5d8c18 100644
--- a/Open-ILS/src/eg2/src/app/share/print/print.component.html
+++ b/Open-ILS/src/eg2/src/app/share/print/print.component.html
@@ -3,6 +3,16 @@ Global print container.
 There should only be one print component active in a page.
 -->
 
+<ng-template #notFound let-name="name" i18n>
+No activate template found named "{{name}}"
+</ng-template>
+<eg-string key='eg.print.template.not_found' [template]="notFound"></eg-string>
+
+<ng-template #notWorking let-name="name" let-id="id" i18n>
+Error generating print content for template name="{{name}}" / id="{{id}}"
+</ng-template>
+<eg-string key='eg.print.template.error' [template]="notWorking"></eg-string>
+
 <div id='eg-print-container'>
   <!-- container for inline template compilation -->
   <ng-container *ngIf="template">
diff --git a/Open-ILS/src/eg2/src/app/share/print/print.component.ts b/Open-ILS/src/eg2/src/app/share/print/print.component.ts
index ff1c3ed1f4..20c056721d 100644
--- a/Open-ILS/src/eg2/src/app/share/print/print.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/print/print.component.ts
@@ -3,6 +3,8 @@ import {PrintService, PrintRequest} from './print.service';
 import {StoreService} from '@eg/core/store.service';
 import {ServerStoreService} from '@eg/core/server-store.service';
 import {HatchService, HatchMessage} from './hatch.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {StringService} from '@eg/share/string/string.service';
 
 @Component({
     selector: 'eg-print',
@@ -30,6 +32,8 @@ export class PrintComponent implements OnInit {
         private store: StoreService,
         private serverStore: ServerStoreService,
         private hatch: HatchService,
+        private toast: ToastService,
+        private strings: StringService,
         private printer: PrintService) {
         this.isPrinting = false;
         this.printQueue = [];
@@ -86,7 +90,24 @@ export class PrintComponent implements OnInit {
                     printReq.contentType = response.contentType;
                 },
                 err => {
-                    console.error('Error compiling template', printReq);
+
+                    if (err && err.notFound) {
+
+                        this.strings.interpolate(
+                            'eg.print.template.not_found',
+                            {name: printReq.templateName}
+                        ).then(msg => this.toast.danger(msg));
+
+                    } else {
+
+                        console.error('Print generation failed', printReq);
+
+                        this.strings.interpolate(
+                            'eg.print.template.error',
+                            {name: printReq.templateName, id: printReq.templateId}
+                        ).then(msg => this.toast.danger(msg));
+                    }
+
                     return Promise.reject(new Error(
                         'Error compiling server-hosted print template'));
                 }
diff --git a/Open-ILS/src/eg2/src/app/share/print/print.service.ts b/Open-ILS/src/eg2/src/app/share/print/print.service.ts
index abba31c331..25ef20606e 100644
--- a/Open-ILS/src/eg2/src/app/share/print/print.service.ts
+++ b/Open-ILS/src/eg2/src/app/share/print/print.service.ts
@@ -90,9 +90,11 @@ export class PrintService {
                             content: xhttp.responseText,
                             contentType: this.getResponseHeader('content-type')
                         });
-                    } else {
-                        reject('Error compiling print template');
+                    } else if (this.status === 404) {
+                        console.error('No active template found: ', printReq);
+                        reject({notFound: true});
                     }
+                    reject({});
                 }
             };
             xhttp.open('POST', PRINT_TEMPLATE_PATH, true);
diff --git a/Open-ILS/src/eg2/src/app/staff/common.module.ts b/Open-ILS/src/eg2/src/app/staff/common.module.ts
index 969ca379aa..12c0ff164c 100644
--- a/Open-ILS/src/eg2/src/app/staff/common.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/common.module.ts
@@ -9,10 +9,6 @@ import {AccessKeyDirective} from '@eg/share/accesskey/accesskey.directive';
 import {AccessKeyService} from '@eg/share/accesskey/accesskey.service';
 import {AccessKeyInfoComponent} from '@eg/share/accesskey/accesskey-info.component';
 import {OpChangeComponent} from '@eg/staff/share/op-change/op-change.component';
-import {ToastService} from '@eg/share/toast/toast.service';
-import {ToastComponent} from '@eg/share/toast/toast.component';
-import {StringComponent} from '@eg/share/string/string.component';
-import {StringService} from '@eg/share/string/string.service';
 import {TitleComponent} from '@eg/share/title/title.component';
 import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
 import {BucketDialogComponent} from '@eg/staff/share/buckets/bucket-dialog.component';
@@ -32,8 +28,6 @@ import {DatetimeValidatorDirective} from '@eg/share/validators/datetime_validato
     OrgFamilySelectComponent,
     AccessKeyDirective,
     AccessKeyInfoComponent,
-    ToastComponent,
-    StringComponent,
     TitleComponent,
     OpChangeComponent,
     FmRecordEditorComponent,
@@ -57,8 +51,6 @@ import {DatetimeValidatorDirective} from '@eg/share/validators/datetime_validato
     OrgFamilySelectComponent,
     AccessKeyDirective,
     AccessKeyInfoComponent,
-    ToastComponent,
-    StringComponent,
     TitleComponent,
     OpChangeComponent,
     FmRecordEditorComponent,
@@ -77,9 +69,7 @@ export class StaffCommonModule {
             ngModule: StaffCommonModule,
             providers: [ // Export staff-wide services
                 AccessKeyService,
-                AudioService,
-                StringService,
-                ToastService
+                AudioService
             ]
         };
     }

commit 9468c799091da62e7075bd204a795c528e3b8d26
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Jul 12 17:11:55 2019 -0400

    LP1825851 Server print templates Release Notes
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Kyle Huckins <khuckins at catalyte.io>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/docs/RELEASE_NOTES_NEXT/Administration/server-print-templates.adoc b/docs/RELEASE_NOTES_NEXT/Administration/server-print-templates.adoc
new file mode 100644
index 0000000000..f1d8e7ad56
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/Administration/server-print-templates.adoc
@@ -0,0 +1,59 @@
+Server-Managed Print Templates for Angular
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Adds support for generating print content via server-side web service.  
+Server print templates are implemented as Template Toolkit and content
+is compiled and generated on the server, based on runtime data provided
+by clients.
+
+Feature includes a new Angular admin interface for testing and editing
+server-managed print templates.  The UI is accessed under Admin =>
+Server Administration => Print Templates, though the menu entry may be
+moved to Admin => Local Administration, once Local Admin is migrated
+to Angular.
+
+Two sample templates are included to demonstrate the format and 
+functionality.  The `Holds For Bib Record` template may be tested by
+navigating to the record holds tab in the Angular staff catalog 
+(/eg2/en-US/staff/catalog/record/<record-id>/holds) and chose the 
+`Print Holds` grid action.
+
+Apache Configuration
+++++++++++++++++++++
+
+Apply Apache configuration changes to eg_vhost.conf and eg_startup.
+
+* Add to eg_vhost.conf
+[source,conf]
+---------------------------------------------------------------------------
+<Location /print_template>
+    SetHandler perl-script
+    PerlHandler OpenILS::WWW::PrintTemplate
+    Options +ExecCGI
+    PerlSendHeader On
+    Require all granted
+</Location>
+---------------------------------------------------------------------------
+
+* Add to eg_startup
+
+[source,conf]
+---------------------------------------------------------------------------
+# Pass second argument of '1' to enable process-level template caching.
+use OpenILS::WWW::PrintTemplate ('/openils/conf/opensrf_core.xml', 0); 
+---------------------------------------------------------------------------
+
+New Perl Dependency
++++++++++++++++++++
+
+A new Perl module `HTML::Defang` is required for cleansing generated HTML 
+of executable code for security purposes.  The dependency is added to 
+the Makefile.install process for new builds.  Existing Evergreen instances
+will need the dependency manually installed.
+
+Installing on (for example) Ubuntu:
+
+[source,conf]
+---------------------------------------------------------------------------
+sudo apt-get install libhtml-defang-perl
+---------------------------------------------------------------------------

commit d372c6c204fb3b835bfa8d97ee6f19622a1a6890
Author: Bill Erickson <berickxx at gmail.com>
Date:   Mon Apr 15 18:11:46 2019 -0400

    LP1825851 Server managed/processed print templates
    
    Adds a new database table config.print_template (and IDL class) for
    storing configurable, org- and locale-specific print templates.
    
    Adds a web service which accepts POSTed print data and generates a
    print-ready document.  Includes example Apache configs.
    
    Teaches the Angular app to use the new web service for generting
    print output.
    
    Adds and Angular print template administration interface.
    
    Adds HTML::Defang for scrubbing unwanted HTML elements and attributes
    from print documents for security.
    
    Add the new ADMIN_PRINT_TEMPLATE permission to the Circ Admin group at
    System level as a default.
    
    Adds 2 templates, a simple patron_address tepmlate (pending Angular port
    of patron UIs) and a 'Holds for Bib Record' template, accessible from
    the Angular staff catalog Holds interface.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Kyle Huckins <khuckins at catalyte.io>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/examples/apache_24/eg_startup.in b/Open-ILS/examples/apache_24/eg_startup.in
index 855159e16f..0ced7a9787 100755
--- a/Open-ILS/examples/apache_24/eg_startup.in
+++ b/Open-ILS/examples/apache_24/eg_startup.in
@@ -15,6 +15,9 @@ use OpenILS::WWW::IDL2js ('@sysconfdir@/opensrf_core.xml');
 use OpenILS::WWW::FlatFielder;
 use OpenILS::WWW::PhoneList ('@sysconfdir@/opensrf_core.xml');
 
+# Pass second argument of '1' to enable template caching.
+use OpenILS::WWW::PrintTemplate ('/openils/conf/opensrf_core.xml', 0);
+
 # - Uncomment the following 2 lines to make use of the IP redirection code
 # - The IP file should to contain a map with the following format:
 # - actor.org_unit.shortname <start_ip> <end_ip>
diff --git a/Open-ILS/examples/apache_24/eg_vhost.conf.in b/Open-ILS/examples/apache_24/eg_vhost.conf.in
index 6301516f81..43e17704ee 100644
--- a/Open-ILS/examples/apache_24/eg_vhost.conf.in
+++ b/Open-ILS/examples/apache_24/eg_vhost.conf.in
@@ -773,6 +773,14 @@ RewriteRule ^/openurl$ ${openurl:%1} [NE,PT]
     </LocationMatch>
 </IfModule>
 
+<Location /print_template>
+    SetHandler perl-script
+    PerlHandler OpenILS::WWW::PrintTemplate
+    Options +ExecCGI
+    PerlSendHeader On
+    Require all granted 
+</Location>
+
 
 <Location /IDL2js>
 
diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml
index d28d7084d7..0a14cb5f14 100644
--- a/Open-ILS/examples/fm_IDL.xml
+++ b/Open-ILS/examples/fm_IDL.xml
@@ -12822,6 +12822,34 @@ SELECT  usr,
     	</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>
 
diff --git a/Open-ILS/src/eg2/package-lock.json b/Open-ILS/src/eg2/package-lock.json
index eacdf93d66..3a77730fb4 100644
--- a/Open-ILS/src/eg2/package-lock.json
+++ b/Open-ILS/src/eg2/package-lock.json
@@ -956,7 +956,7 @@
         },
         "load-json-file": {
           "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
+          "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
           "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=",
           "dev": true,
           "requires": {
@@ -977,7 +977,7 @@
         },
         "minimist": {
           "version": "1.2.0",
-          "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+          "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
           "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
           "dev": true
         },
@@ -1016,7 +1016,7 @@
         },
         "pify": {
           "version": "2.3.0",
-          "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+          "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
           "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
           "dev": true
         },
@@ -7600,6 +7600,11 @@
         "object-visit": "1.0.1"
       }
     },
+    "material-design-icons": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/material-design-icons/-/material-design-icons-3.0.1.tgz",
+      "integrity": "sha1-mnHEh0chjrylHlGmbaaCA4zct78="
+    },
     "math-random": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.1.tgz",
@@ -7873,6 +7878,19 @@
         "minimist": "0.0.8"
       }
     },
+    "moment": {
+      "version": "2.24.0",
+      "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
+      "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg=="
+    },
+    "moment-timezone": {
+      "version": "0.5.26",
+      "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.26.tgz",
+      "integrity": "sha512-sFP4cgEKTCymBBKgoxZjYzlSovC20Y6J7y3nanDc5RoBIXKlZhoYwBoZGe3flwU6A372AcRwScH8KiwV6zjy1g==",
+      "requires": {
+        "moment": "2.24.0"
+      }
+    },
     "move-concurrently": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
diff --git a/Open-ILS/src/eg2/src/app/core/idl.service.ts b/Open-ILS/src/eg2/src/app/core/idl.service.ts
index 56b8b90e1f..21ec24a274 100644
--- a/Open-ILS/src/eg2/src/app/core/idl.service.ts
+++ b/Open-ILS/src/eg2/src/app/core/idl.service.ts
@@ -156,5 +156,45 @@ export class IdlService {
         }
         return null;
     }
+
+    toHash(obj: any, flatten?: boolean): any {
+
+        if (typeof obj !== 'object' || obj === null) {
+            return obj;
+        }
+
+        if (Array.isArray(obj)) {
+            return obj.map(item => this.toHash(item));
+        }
+
+        const fieldNames = obj._isfieldmapper ?
+            Object.keys(this.classes[obj.classname].field_map) :
+            Object.keys(obj);
+
+        const hash: any = {};
+        fieldNames.forEach(field => {
+
+            const val = this.toHash(
+                typeof obj[field] === 'function' ?  obj[field]() : obj[field],
+                flatten
+            );
+
+            if (val === undefined) { return; }
+
+            if (flatten && val !== null &&
+                typeof val === 'object' && !Array.isArray(val)) {
+
+                Object.keys(val).forEach(key => {
+                    const fname = field + '.' + key;
+                    hash[fname] = val[key];
+                });
+
+            } else {
+                hash[field] = val;
+            }
+        });
+
+        return hash;
+    }
 }
 
diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html
index fc11eee6c2..476c261c34 100644
--- a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html
+++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html
@@ -26,7 +26,7 @@
         <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)">
 
diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
index a574e5491d..6c079b8bba 100644
--- a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
@@ -22,7 +22,7 @@ interface CustomFieldTemplate {
     context?: {[fields: string]: any};
 }
 
-interface CustomFieldContext {
+export interface CustomFieldContext {
     // Current create/edit/view record
     record: IdlObject;
 
diff --git a/Open-ILS/src/eg2/src/app/share/print/print.component.ts b/Open-ILS/src/eg2/src/app/share/print/print.component.ts
index e7754abbd3..ff1c3ed1f4 100644
--- a/Open-ILS/src/eg2/src/app/share/print/print.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/print/print.component.ts
@@ -53,35 +53,64 @@ export class PrintComponent implements OnInit {
 
         this.isPrinting = true;
 
-        this.applyTemplate(printReq);
-
-        // Give templates a chance to render before printing
-        setTimeout(() => {
-            this.dispatchPrint(printReq);
-            this.reset();
+        this.applyTemplate(printReq).then(() => {
+            // Give templates a chance to render before printing
+            setTimeout(() => {
+                this.dispatchPrint(printReq);
+                this.reset();
+            });
         });
     }
 
-    applyTemplate(printReq: PrintRequest) {
+    applyTemplate(printReq: PrintRequest): Promise<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 => {
diff --git a/Open-ILS/src/eg2/src/app/share/print/print.service.ts b/Open-ILS/src/eg2/src/app/share/print/print.service.ts
index 5ae6844dfd..abba31c331 100644
--- a/Open-ILS/src/eg2/src/app/share/print/print.service.ts
+++ b/Open-ILS/src/eg2/src/app/share/print/print.service.ts
@@ -1,8 +1,19 @@
 import {Injectable, EventEmitter, TemplateRef} from '@angular/core';
+import {tap} from 'rxjs/operators';
 import {StoreService} from '@eg/core/store.service';
+import {LocaleService} from '@eg/core/locale.service';
+import {AuthService} from '@eg/core/auth.service';
+
+declare var js2JSON: (jsThing: any) => string;
+declare var OpenSRF;
+
+const PRINT_TEMPLATE_PATH = '/print_template';
 
 export interface PrintRequest {
     template?: TemplateRef<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
index 0000000000..d159d6d6ba
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/util/sample-data.service.ts
@@ -0,0 +1,122 @@
+import {Injectable} from '@angular/core';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+
+/** Service for generating sample data for testing, demo, etc. */
+
+// TODO: I could also imagine this coming from a web service or
+// even a flat file of web-served JSON.
+
+const NOW_DATE = new Date().toISOString();
+
+// Copied from sample of Concerto data set
+const DATA = {
+    au: [
+        {first_given_name: 'Vincent',  second_given_name: 'Kenneth',   family_name: 'Moran'},
+        {first_given_name: 'Gregory',  second_given_name: 'Adam',      family_name: 'Jones'},
+        {first_given_name: 'Brittany', second_given_name: 'Geraldine', family_name: 'Walker'},
+        {first_given_name: 'Ernesto',  second_given_name: 'Robert',    family_name: 'Miller'},
+        {first_given_name: 'Robert',   second_given_name: 'Louis',     family_name: 'Hill'},
+        {first_given_name: 'Edward',   second_given_name: 'Robert',    family_name: 'Lopez'},
+        {first_given_name: 'Andrew',   second_given_name: 'Alberto',   family_name: 'Bell'},
+        {first_given_name: 'Jennifer', second_given_name: 'Dorothy',   family_name: 'Mitchell'},
+        {first_given_name: 'Jo',       second_given_name: 'Mai',       family_name: 'Madden'},
+        {first_given_name: 'Maomi',    second_given_name: 'Julie',     family_name: 'Harding'}
+    ],
+    ac: [
+        {barcode: '908897239000'},
+        {barcode: '908897239001'},
+        {barcode: '908897239002'},
+        {barcode: '908897239003'},
+        {barcode: '908897239004'},
+        {barcode: '908897239005'},
+        {barcode: '908897239006'},
+        {barcode: '908897239007'},
+        {barcode: '908897239008'},
+        {barcode: '908897239009'}
+    ],
+    aua: [
+        {street1: '1809 Target Way', city: 'Vero beach', state: 'FL', post_code: 32961},
+        {street1: '3481 Facility Island', city: 'Campton', state: 'KY', post_code: 41301},
+        {street1: '5150 Dinner Expressway', city: 'Dodge center', state: 'MN', post_code: 55927},
+        {street1: '8496 Random Trust Points', city: 'Berryville', state: 'VA', post_code: 22611},
+        {street1: '7626 Secret Institute Courts', city: 'Anchorage', state: 'AK', post_code: 99502},
+        {street1: '7044 Regular Index Path', city: 'Livingston', state: 'KY', post_code: 40445},
+        {street1: '3403 Thundering Heat Meadows', city: 'Miami', state: 'FL', post_code: 33157},
+        {street1: '759 Doubtful Government Extension', city: 'Sellersville', state: 'PA', post_code: 18960},
+        {street1: '5431 Japanese Work Rapid', city: 'Society hill', state: 'SC', post_code: 29593},
+        {street1: '5253 Agricultural Exhibition Stravenue', city: 'La place', state: 'IL', post_code: 61936}
+    ],
+    ahr: [
+        {request_time: NOW_DATE, hold_type: 'T', capture_time: null,     fulfillment_time: null},
+        {request_time: NOW_DATE, hold_type: 'T', capture_time: null,     fulfillment_time: null},
+        {request_time: NOW_DATE, hold_type: 'V', capture_time: null,     fulfillment_time: null},
+        {request_time: NOW_DATE, hold_type: 'C', capture_time: null,     fulfillment_time: null},
+        {request_time: NOW_DATE, hold_type: 'T', capture_time: null,     fulfillment_time: null, frozen: true},
+        {request_time: NOW_DATE, hold_type: 'T', capture_time: NOW_DATE, fulfillment_time: null},
+        {request_time: NOW_DATE, hold_type: 'T', capture_time: NOW_DATE, fulfillment_time: null},
+        {request_time: NOW_DATE, hold_type: 'T', capture_time: NOW_DATE, fulfillment_time: NOW_DATE},
+        {request_time: NOW_DATE, hold_type: 'T', capture_time: NOW_DATE, fulfillment_time: NOW_DATE},
+        {request_time: NOW_DATE, hold_type: 'T', capture_time: NOW_DATE, fulfillment_time: NOW_DATE}
+    ],
+    acp: [
+        {barcode: '208897239000'},
+        {barcode: '208897239001'},
+        {barcode: '208897239002'},
+        {barcode: '208897239003'},
+        {barcode: '208897239004'},
+        {barcode: '208897239005'},
+        {barcode: '208897239006'},
+        {barcode: '208897239007'},
+        {barcode: '208897239008'},
+        {barcode: '208897239009'}
+    ],
+    mwde: [
+        {title: 'Sinidos sinfónicos : an orchestral sampler'},
+        {title: 'Piano concerto, op. 38'},
+        {title: 'Critical entertainments : music old and new'},
+        {title: 'Piano concerto in C major, op. 39'},
+        {title: 'Double concerto in A minor, op. 102 ; Variations on a theme by Haydn, op. 56a ; Tragic overture, op. 81'},
+        {title: 'Trombone concerto (1991) subject: american'},
+        {title: 'Violin concerto no. 2 ; Six duos (from 44 Duos)'},
+        {title: 'Piano concerto no. 1 (1926) ; Rhapsody, op. 1 (1904)'},
+        {title: 'Piano concertos 2 & 3 & the devil makes me?'},
+        {title: 'Composition student recital, April 6, 2000, Huntington University / composition students of Daniel Bédard'},
+    ]
+};
+
+
+ at Injectable()
+export class SampleDataService {
+
+    constructor(private idl: IdlService) {}
+
+    randomValue(list: any[], field: string): string {
+        return list[Math.floor(Math.random() * list.length)][field];
+    }
+
+    listOfThings(idlClass: string, count: number = 1): IdlObject[] {
+        if (!(idlClass in DATA)) {
+            throw new Error(`No sample data for class ${idlClass}'`);
+        }
+
+        const things: IdlObject[] = [];
+        for (let i = 0; i < count; i++) {
+            const thing = this.idl.create(idlClass);
+            Object.keys(DATA[idlClass][0]).forEach(field =>
+                thing[field](this.randomValue(DATA[idlClass], field))
+            );
+            things.push(thing);
+        }
+
+        return things;
+    }
+
+    // Returns a random-ish date in the past or the future.
+    randomDate(future: boolean = false): Date {
+        const rando = Math.random() * 10000000000;
+        const time = new Date().getTime();
+        return new Date(future ? time + rando : time - rando);
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html
index 99cb4781b7..f71dd2fd04 100644
--- a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html
@@ -81,6 +81,9 @@
       url="/eg/staff/admin/server/legacy/permission/grp_tree"></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"  
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server.module.ts
index 8e76239e8b..cbbadd38d6 100644
--- a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server.module.ts
@@ -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
index 0000000000..585a7afab4
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/admin/server/print-template.component.html
@@ -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
index 0000000000..f57df1ec91
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/admin/server/print-template.component.ts
@@ -0,0 +1,271 @@
+import {Component, OnInit, ViewChild, TemplateRef} from '@angular/core';
+import {Observable} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {ActivatedRoute} from '@angular/router';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {AuthService} from '@eg/core/auth.service';
+import {OrgService} from '@eg/core/org.service';
+import {ComboboxComponent, ComboboxEntry
+    } from '@eg/share/combobox/combobox.component';
+import {PrintService} from '@eg/share/print/print.service';
+import {LocaleService} from '@eg/core/locale.service';
+import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {SampleDataService} from '@eg/share/util/sample-data.service';
+import {OrgFamily} from '@eg/share/org-family-select/org-family-select.component';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+
+/**
+ * Print Template Admin Page
+ */
+
+ at 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));
+            });
+        });
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts
index c971ed74a7..4f9b9ff366 100644
--- a/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts
@@ -3,6 +3,7 @@ import {RouterModule, Routes} from '@angular/router';
 import {AdminServerSplashComponent} from './admin-server-splash.component';
 import {BasicAdminPageComponent} from '@eg/staff/admin/basic-admin-page.component';
 import {OrgUnitTypeComponent} from './org-unit-type.component';
+import {PrintTemplateComponent} from './print-template.component';
 
 const routes: Routes = [{
     path: 'splash',
@@ -11,6 +12,9 @@ const routes: Routes = [{
     path: 'actor/org_unit_type',
     component: OrgUnitTypeComponent
 }, {
+    path: 'config/print_template',
+    component: PrintTemplateComponent
+}, {
     path: ':schema/:table',
     component: BasicAdminPageComponent
 }];
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
index 98476aacb8..9b9fe3cbab 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
@@ -50,6 +50,7 @@
         <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>
diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
index b2d14c1b95..85585f9836 100644
--- a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
@@ -142,10 +142,23 @@
 
 <!-- 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">
diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts
index c6ea7c3f17..7b17c2d33c 100644
--- a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts
@@ -21,6 +21,7 @@ import {FormatService} from '@eg/core/format.service';
 import {StringComponent} from '@eg/share/string/string.component';
 import {GridComponent} from '@eg/share/grid/grid.component';
 import * as Moment from 'moment-timezone';
+import {SampleDataService} from '@eg/share/util/sample-data.service';
 
 @Component({
   templateUrl: 'sandbox.component.html',
@@ -112,7 +113,8 @@ export class SandboxComponent implements OnInit {
         private strings: StringService,
         private toast: ToastService,
         private format: FormatService,
-        private printer: PrintService
+        private printer: PrintService,
+        private samples: SampleDataService
     ) {
         // BroadcastChannel is not yet defined in PhantomJS and elsewhere
         this.sbChannel = (typeof BroadcastChannel === 'undefined') ?
@@ -412,6 +414,21 @@ export class SandboxComponent implements OnInit {
         d.setDate(d.getDate() - 7);
         return d;
     }
-}
 
+    testServerPrint() {
+
+        // Note these values can be IDL objects or plain hashes.
+        const templateData = {
+            patron:  this.samples.listOfThings('au')[0],
+            address: this.samples.listOfThings('aua')[0]
+        };
+
+        // NOTE: eventually this will be baked into the print service.
+        this.printer.print({
+            templateName: 'patron_address',
+            contextData: templateData,
+            printContext: 'default'
+        });
+    }
+}
 
diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts
index 0937ab0ee3..0fc739e7cf 100644
--- a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts
@@ -3,6 +3,7 @@ import {StaffCommonModule} from '@eg/staff/common.module';
 import {SandboxRoutingModule} from './routing.module';
 import {SandboxComponent} from './sandbox.component';
 import {ReactiveFormsModule} from '@angular/forms';
+import {SampleDataService} from '@eg/share/util/sample-data.service';
 
 @NgModule({
   declarations: [
@@ -14,6 +15,7 @@ import {ReactiveFormsModule} from '@angular/forms';
     ReactiveFormsModule
   ],
   providers: [
+    SampleDataService
   ]
 })
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html
index a69dabd001..bb39f8bbdd 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html
@@ -43,6 +43,7 @@
 </eg-grid>
 
 <eg-fm-record-editor #editDialog idlClass="{{idlClass}}" 
+    [fieldOptions]="fieldOptions"
     [preloadLinkedValues]="true" readonlyFields="{{readonlyFields}}">
 </eg-fm-record-editor>
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts
index f920d7b3c2..11913c8991 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts
@@ -10,7 +10,8 @@ import {PcrudService} from '@eg/core/pcrud.service';
 import {OrgService} from '@eg/core/org.service';
 import {PermService} from '@eg/core/perm.service';
 import {AuthService} from '@eg/core/auth.service';
-import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {FmRecordEditorComponent, FmFieldOptions
+    } from '@eg/share/fm-editor/fm-editor.component';
 import {StringComponent} from '@eg/share/string/string.component';
 import {OrgFamily} from '@eg/share/org-family-select/org-family-select.component';
 
@@ -71,6 +72,9 @@ export class AdminPageComponent implements OnInit {
     // be added to the page, above the grid.
     @Input() helpTemplate: TemplateRef<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;
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html
index d049c28019..14f96e5755 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html
@@ -79,6 +79,10 @@
         i18-group group="Hold" i18n-label label="Cancel Hold"
         (onClick)="showCancelDialog($event)"></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>
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts
index bdecd41afb..eb670d0b70 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts
@@ -18,6 +18,7 @@ import {HoldRetargetDialogComponent
 import {HoldTransferDialogComponent} from './transfer-dialog.component';
 import {HoldCancelDialogComponent} from './cancel-dialog.component';
 import {HoldManageDialogComponent} from './manage-dialog.component';
+import {PrintService} from '@eg/share/print/print.service';
 
 /** Holds grid with access to detail page and other actions */
 
@@ -35,7 +36,10 @@ export class HoldsGridComponent implements OnInit {
     @Input() persistKey: string;
 
     @Input() preFetchSetting: string;
-        // If set, all holds are fetched on grid load and sorting/paging all
+
+    @Input() printTemplate: string;
+
+    // If set, all holds are fetched on grid load and sorting/paging all
     // happens in the client.  If false, sorting and paging occur on
     // the server.
     enablePreFetch: boolean;
@@ -111,7 +115,8 @@ export class HoldsGridComponent implements OnInit {
         private net: NetService,
         private org: OrgService,
         private store: ServerStoreService,
-        private auth: AuthService
+        private auth: AuthService,
+        private printer: PrintService
     ) {
         this.gridDataSource = new GridDataSource();
         this.enablePreFetch = null;
@@ -389,6 +394,30 @@ export class HoldsGridComponent implements OnInit {
             );
         }
     }
+
+    printHolds() {
+        // Request a page with no limit to get all of the wide holds for
+        // printing.  Call requestPage() directly instead of grid.reload()
+        // since we may already have the data.
+
+        const pager = new Pager();
+        pager.offset = 0;
+        pager.limit = null;
+
+        if (this.gridDataSource.sort.length === 0) {
+            this.gridDataSource.sort = this.defaultSort;
+        }
+
+        this.gridDataSource.requestPage(pager).then(() => {
+            if (this.gridDataSource.data.length > 0) {
+                this.printer.print({
+                    templateName: this.printTemplate || 'holds_for_bib',
+                    contextData: this.gridDataSource.data,
+                    printContext: 'default'
+                });
+            }
+        });
+    }
 }
 
 
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm
new file mode 100644
index 0000000000..be76da0ce4
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm
@@ -0,0 +1,217 @@
+package OpenILS::WWW::PrintTemplate;
+use strict; use warnings;
+use Apache2::Const -compile => 
+    qw(OK FORBIDDEN NOT_FOUND HTTP_INTERNAL_SERVER_ERROR HTTP_BAD_REQUEST);
+use Apache2::RequestRec;
+use CGI;
+use HTML::Defang;
+use DateTime;
+use DateTime::Format::ISO8601;
+use Unicode::Normalize;
+use OpenSRF::Utils::JSON;
+use OpenSRF::System;
+use OpenSRF::Utils::SettingsClient;
+use OpenILS::Utils::CStoreEditor q/:funcs/;
+use OpenSRF::Utils::Logger q/$logger/;
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::DateTime qw/:datetime/;
+
+my $U = 'OpenILS::Application::AppUtils';
+my $helpers;
+
+my $bs_config;
+my $enable_cache; # Enable process-level template caching
+sub import {
+    $bs_config = shift;
+    $enable_cache = shift;
+}
+
+my $init_complete = 0;
+sub child_init {
+    $init_complete = 1;
+
+    OpenSRF::System->bootstrap_client(config_file => $bs_config);
+    OpenILS::Utils::CStoreEditor->init;
+    return Apache2::Const::OK;
+}
+
+# HTML scrubber
+# https://metacpan.org/pod/HTML::Defang
+my $defang = HTML::Defang->new;
+
+sub handler {
+    my $r = shift;
+    my $cgi = CGI->new;
+
+    child_init() unless $init_complete;
+
+    my $auth = $cgi->param('ses') || 
+        $cgi->cookie('eg.auth.token') || $cgi->cookie('ses');
+
+    my $e = new_editor(authtoken => $auth);
+
+    # Requires staff login
+    return Apache2::Const::FORBIDDEN 
+        unless $e->checkauth && $e->requestor->wsid;
+
+    # Let pcrud handle the authz
+    $e->personality('open-ils.pcrud');
+
+    my $tmpl_owner = $cgi->param('template_owner') || $e->requestor->ws_ou;
+    my $tmpl_locale = $cgi->param('template_locale') || 'en-US';
+    my $tmpl_id = $cgi->param('template_id');
+    my $tmpl_name = $cgi->param('template_name');
+    my $tmpl_data = $cgi->param('template_data');
+    my $client_timezone = $cgi->param('client_timezone');
+
+    return Apache2::Const::HTTP_BAD_REQUEST unless $tmpl_name || $tmpl_id;
+
+    my $template = 
+        find_template($e, $tmpl_id, $tmpl_name, $tmpl_locale, $tmpl_owner)
+        or return Apache2::Const::NOT_FOUND;
+
+    my $data;
+    eval { $data = OpenSRF::Utils::JSON->JSON2perl($tmpl_data); };
+    if ($@) {
+        $logger->error("Invalid JSON in template compilation: $tmpl_data");
+        return Apache2::Const::HTTP_BAD_REQUEST;
+    }
+
+    my ($staff_org) = $U->fetch_org_unit($e->requestor->ws_ou);
+
+    my $output = '';
+    my $tt = Template->new;
+    my $tmpl = $template->template;
+
+    my $context = {
+        template_locale => $tmpl_locale,
+        client_timezone => $client_timezone,
+        staff => $e->requestor,
+        staff_org => $staff_org,
+        staff_org_timezone => get_org_timezone($e, $staff_org->id),
+        helpers => $helpers,
+        template_data => $data
+    };
+
+    my $stat = $tt->process(\$tmpl, $context, \$output);
+
+    if ($stat) { # OK
+        my $ctype = $template->content_type;
+        if ($ctype eq 'text/html') {
+            $output = $defang->defang($output); # Scrub the HTML
+        }
+        # TODO
+        # client current expects content type to only contain type.
+        # $r->content_type("$ctype; encoding=utf8");
+        $r->content_type($ctype);
+        $r->print($output);
+        return Apache2::Const::OK;
+
+    } else {
+
+        (my $error = $tt->error) =~ s/\n/ /og;
+        $logger->error("Error processing print template: $error");
+        return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR;
+    }
+}
+
+my %org_timezone_cache;
+sub get_org_timezone {
+    my ($e, $org_id) = @_;
+
+    if (!$org_timezone_cache{$org_id}) {
+
+        # open-ils.auth call required since our $e is in pcrud mode.
+        my $value = $U->simplereq(
+            'open-ils.actor',
+            'open-ils.actor.ou_setting.ancestor_default', 
+            $org_id, 'lib.timezone');
+
+        $org_timezone_cache{$org_id} = $value ? $value->{value} : 
+            DateTime->now(time_zone => 'local')->time_zone->name;
+    }
+
+    return $org_timezone_cache{$org_id};
+}
+
+
+# Find the template closest to the specific org unit owner.
+my %template_cache;
+sub find_template {
+    my ($e, $template_id, $name, $locale, $owner) = @_;
+
+    if ($template_id) {
+        # Requesting by ID, generally used for testing, 
+        # always pulls the latest value and ignores the active flag
+        return $e->retrieve_config_print_template($template_id);
+    }
+
+    return  $template_cache{$owner}{$name}{$locale}
+        if  $enable_cache &&
+            $template_cache{$owner} && 
+            $template_cache{$owner}{$name} &&
+            $template_cache{$owner}{$name}{$locale};
+
+    while ($owner) {
+        my ($org) = $U->fetch_org_unit($owner); # cached in AppUtils
+        
+        my $template = $e->search_config_print_template({
+            name => $name, 
+            locale => $locale, 
+            owner => $org->id,
+            active => 't'
+        })->[0];
+
+        if ($template) {
+
+            if ($enable_cache) {
+                $template_cache{$owner} = {} unless $template_cache{$owner};
+                $template_cache{$owner}{$name} = {} 
+                    unless $template_cache{$owner}{$name};
+                $template_cache{$owner}{$name}{$locale} = $template;
+            }
+
+            return $template;
+        }
+
+        $owner = $org->parent_ou;
+    }
+
+    return undef;
+}
+
+# Utility / helper functions passed into every template
+
+$helpers = {
+
+    # turns a date w/ optional timezone modifier into something 
+    # TT can understand
+    format_date => sub {
+        my $date = shift;
+        my $tz = shift;
+
+        $date = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($date));
+        $date->set_time_zone($tz) if $tz;
+
+        return sprintf(
+            "%0.2d:%0.2d:%0.2d %0.2d-%0.2d-%0.4d",
+            $date->hour,
+            $date->minute,
+            $date->second,
+            $date->day,
+            $date->month,
+            $date->year
+        );
+    },
+
+    current_date => sub {
+        my $tz = shift || 'local';
+        my $date = DateTime->now(time_zone => $tz);
+        return $helpers->{format_date}->($date);
+    }
+};
+
+
+
+
+1;
diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index 80581d11d8..59a092a653 100644
--- a/Open-ILS/src/sql/Pg/002.schema.config.sql
+++ b/Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -1335,4 +1335,18 @@ INSERT INTO config.hold_type (hold_type,description) VALUES
     ('P','Part Hold')
 ;
 
+CREATE TABLE config.print_template (
+    id           SERIAL PRIMARY KEY,
+    name         TEXT NOT NULL, 
+    label        TEXT NOT NULL, -- i18n
+    owner        INT NOT NULL, -- REFERENCES actor.org_unit (id)
+    active       BOOLEAN NOT NULL DEFAULT FALSE,
+    locale       TEXT REFERENCES config.i18n_locale(code) 
+                 ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+    content_type TEXT NOT NULL DEFAULT 'text/html',
+    template     TEXT NOT NULL,
+    CONSTRAINT   name_once_per_lib UNIQUE (owner, name),
+    CONSTRAINT   label_once_per_lib UNIQUE (owner, label)
+);
+
 COMMIT;
diff --git a/Open-ILS/src/sql/Pg/800.fkeys.sql b/Open-ILS/src/sql/Pg/800.fkeys.sql
index 5eb87db706..58181cb21d 100644
--- a/Open-ILS/src/sql/Pg/800.fkeys.sql
+++ b/Open-ILS/src/sql/Pg/800.fkeys.sql
@@ -258,4 +258,7 @@ ALTER TABLE config.marc_subfield ADD CONSTRAINT config_marc_subfield_owner_fkey
 
 ALTER TABLE config.copy_tag_type ADD CONSTRAINT copy_tag_type_owner_fkey FOREIGN KEY (owner) REFERENCES  actor.org_unit(id) DEFERRABLE INITIALLY DEFERRED;
 
+ALTER TABLE config.print_template ADD CONSTRAINT cpt_owner_fkey 
+    FOREIGN KEY (owner) REFERENCES  actor.org_unit(id) DEFERRABLE INITIALLY DEFERRED;
+
 COMMIT;
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index b04a650cf4..17c59a99a2 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -1915,7 +1915,9 @@ INSERT INTO permission.perm_list ( id, code, description ) VALUES
  ( 609, 'MANAGE_CUSTOM_PERM_GRP_TREE', oils_i18n_gettext( 609,
     'Allows a user to manage custom permission group lists.', 'ppl', 'description' )),
  ( 610, 'CLEAR_PURCHASE_REQUEST', oils_i18n_gettext(610,
-    'Clear Completed User Purchase Requests', 'ppl', 'description'))
+    'Clear Completed User Purchase Requests', 'ppl', 'description')),
+ ( 611, 'ADMIN_PRINT_TEMPLATE', oils_i18n_gettext(611,
+    'Modify print templates', 'ppl', 'description'))
 ;
 
 
@@ -2514,6 +2516,7 @@ INSERT INTO permission.grp_perm_map (grp, perm, depth, grantable)
 			'ITEM_RENTAL_FEE_REQUIRED.override',
 			'ITEM_DEPOSIT_PAID.override',
 			'COPY_STATUS_LOST_AND_PAID.override',
+			'ADMIN_PRINT_TEMPLATE',
 			'ITEM_NOT_HOLDABLE.override');
 
 
@@ -20041,3 +20044,95 @@ VALUES (
     )
 );
 
+INSERT INTO config.workstation_setting_type
+    (name, grp, datatype, label)
+VALUES (
+    'eg.grid.circ.patron.group_members', 'gui', 'object',
+    oils_i18n_gettext(
+    'eg.grid.circ.patron.group_members',
+    'Grid Config: circ.patron.group_members',
+    'cwst', 'label')
+);
+
+INSERT INTO config.print_template 
+    (id, name, locale, active, owner, label, template) 
+VALUES (
+    1, 'patron_address', 'en-US', FALSE,
+    (SELECT id FROM actor.org_unit WHERE parent_ou IS NULL),
+    oils_i18n_gettext(1, 'Address Label', 'cpt', 'label'),
+$TEMPLATE$
+[%-
+    SET patron = template_data.patron;
+    SET addr = template_data.address;
+-%]
+<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
index 0000000000..a1a534903f
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.server-print-templates.sql
@@ -0,0 +1,106 @@
+BEGIN;
+
+-- SELECT evergreen.upgrade_deps_block_check('TODO', :eg_version);
+
+CREATE TABLE config.print_template (
+    id           SERIAL PRIMARY KEY,
+    name         TEXT NOT NULL, -- programatic name
+    label        TEXT NOT NULL, -- i18n
+    owner        INT NOT NULL REFERENCES actor.org_unit (id),
+    active       BOOLEAN NOT NULL DEFAULT FALSE,
+    locale       TEXT REFERENCES config.i18n_locale(code) 
+                 ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+    content_type TEXT NOT NULL DEFAULT 'text/html',
+    template     TEXT NOT NULL,
+	CONSTRAINT   name_once_per_lib UNIQUE (owner, name),
+	CONSTRAINT   label_once_per_lib UNIQUE (owner, label)
+);
+
+INSERT INTO config.print_template 
+    (id, name, locale, active, owner, label, template) 
+VALUES (
+    1, 'patron_address', 'en-US', FALSE,
+    (SELECT id FROM actor.org_unit WHERE parent_ou IS NULL),
+    oils_i18n_gettext(1, 'Address Label', 'cpt', 'label'),
+$TEMPLATE$
+[%-
+    SET patron = template_data.patron;
+    SET addr = template_data.address;
+-%]
+<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;
+

commit 8995c8394772386df62f82060106a2cf690f7e4f
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri May 24 12:38:34 2019 -0400

    LP1825851 Add Perl HTML::Defang dependency
    
    Added dependency to installer makefile.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Kyle Huckins <khuckins at catalyte.io>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/extras/install/Makefile.debian-jessie b/Open-ILS/src/extras/install/Makefile.debian-jessie
index d1c3b44200..55bb457da2 100644
--- a/Open-ILS/src/extras/install/Makefile.debian-jessie
+++ b/Open-ILS/src/extras/install/Makefile.debian-jessie
@@ -73,6 +73,7 @@ export DEBS = \
 	libsoap-lite-perl\
 	libbz2-dev\
 	libparse-recdescent-perl\
+	libhtml-defang-perl\
 	yaz
 
 export DEB_APACHE_MODS = \
diff --git a/Open-ILS/src/extras/install/Makefile.debian-stretch b/Open-ILS/src/extras/install/Makefile.debian-stretch
index 036d41e7fe..2076124a14 100644
--- a/Open-ILS/src/extras/install/Makefile.debian-stretch
+++ b/Open-ILS/src/extras/install/Makefile.debian-stretch
@@ -72,6 +72,7 @@ export DEBS = \
 	libsoap-lite-perl\
 	libbz2-dev\
 	libparse-recdescent-perl\
+	libhtml-defang-perl\
 	yaz
 
 export DEB_APACHE_MODS = \
diff --git a/Open-ILS/src/extras/install/Makefile.fedora b/Open-ILS/src/extras/install/Makefile.fedora
index a0902fafe4..aebec12913 100644
--- a/Open-ILS/src/extras/install/Makefile.fedora
+++ b/Open-ILS/src/extras/install/Makefile.fedora
@@ -63,6 +63,7 @@ FEDORA_RPMS = \
 	perl-Text-CSV \
 	perl-Text-CSV_XS \
 	perl-XML-Writer \
+	perl-HTML-Defang \
 	postgresql-devel \
 	readline-devel \
 	tcp_wrappers-devel \
diff --git a/Open-ILS/src/extras/install/Makefile.ubuntu-bionic b/Open-ILS/src/extras/install/Makefile.ubuntu-bionic
index adc3c54c45..268502703a 100644
--- a/Open-ILS/src/extras/install/Makefile.ubuntu-bionic
+++ b/Open-ILS/src/extras/install/Makefile.ubuntu-bionic
@@ -69,6 +69,7 @@ export DEBS = \
 	libsoap-lite-perl\
 	libbz2-dev\
 	libparse-recdescent-perl\
+	libhtml-defang-perl\
 	yaz
 
 export DEB_APACHE_MODS = \
diff --git a/Open-ILS/src/extras/install/Makefile.ubuntu-xenial b/Open-ILS/src/extras/install/Makefile.ubuntu-xenial
index d92cb518a1..0f6ed65d38 100644
--- a/Open-ILS/src/extras/install/Makefile.ubuntu-xenial
+++ b/Open-ILS/src/extras/install/Makefile.ubuntu-xenial
@@ -72,6 +72,7 @@ export DEBS = \
 	libsoap-lite-perl\
 	libbz2-dev\
 	libparse-recdescent-perl\
+	libhtml-defang-perl\
 	yaz
 
 export DEB_APACHE_MODS = \

commit 5d15571b2c5099af2883f508997182a3b3b6b61a
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Jul 12 12:37:59 2019 -0400

    LP1825851 Combobox display template option
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Kyle Huckins <khuckins at catalyte.io>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html
index 0f0afde823..83df22e20a 100644
--- a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html
+++ b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html
@@ -1,6 +1,6 @@
 
 <!-- todo disabled -->
-<ng-template #displayTemplate let-r="result">
+<ng-template #defaultDisplayTemplate let-r="result">
 {{r.label || r.id}}
 </ng-template>
 
@@ -14,7 +14,7 @@
     [required]="isRequired"
     [(ngModel)]="selected" 
     [ngbTypeahead]="filter"
-    [resultTemplate]="displayTemplate"
+    [resultTemplate]=" displayTemplate || defaultDisplayTemplate"
     [inputFormatter]="formatDisplayString"
     (click)="onClick($event)"
     (blur)="onBlur()"
diff --git a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
index 0696a989fb..67d73e9cf5 100644
--- a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
@@ -3,7 +3,8 @@
  *  <!-- see also <eg-combobox-entry> -->
  * </eg-combobox>
  */
-import {Component, OnInit, Input, Output, ViewChild, EventEmitter, ElementRef, forwardRef} from '@angular/core';
+import {Component, OnInit, Input, Output, ViewChild, 
+    TemplateRef, EventEmitter, ElementRef, forwardRef} from '@angular/core';
 import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
 import {Observable, of, Subject} from 'rxjs';
 import {map, tap, reduce, mergeMap, mapTo, debounceTime, distinctUntilChanged, merge, filter} from 'rxjs/operators';
@@ -99,6 +100,9 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
         }
     }
 
+    // When provided use this as the display template for each entry.
+    @Input() displayTemplate: TemplateRef<any>;
+
     // Emitted when the value is changed via UI.
     // When the UI value is cleared, null is emitted.
     @Output() onChange: EventEmitter<ComboboxEntry>;

commit 884d2a84f6153765f0098dfbb6905e4c7ee251e0
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Jul 11 17:20:45 2019 -0400

    LP1825851 CStoreEditor instance specific personality
    
    Allow applying a CStoreEditor personality to individual editor instances
    without overwriting the default / process-wide personality.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Kyle Huckins <khuckins at catalyte.io>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Utils/CStoreEditor.pm b/Open-ILS/src/perlmods/lib/OpenILS/Utils/CStoreEditor.pm
index e381b21996..dab85c0a19 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Utils/CStoreEditor.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Utils/CStoreEditor.pm
@@ -52,9 +52,25 @@ our $personality = 'open-ils.cstore';
 
 sub personality { 
     my( $self, $app ) = @_;
-    $personality = $app if $app;
-    init() if $app; # rewrite if we changed personalities
-    return $personality;
+
+    if (ref($self)) {
+        # Instance-specific personality
+
+        if ($app) {
+            $self->{personality} = $app;
+            init();
+        }
+        return $self->{personality} || $personality;
+
+    } else {
+        # Process default personality
+
+        if ($app) {
+            $personality = $app;
+            init();
+        }
+        return $personality;
+    }
 }
 
 sub import {

-----------------------------------------------------------------------

Summary of changes:
 Open-ILS/examples/apache_24/eg_startup.in          |   3 +
 Open-ILS/examples/apache_24/eg_vhost.conf.in       |   8 +
 Open-ILS/examples/fm_IDL.xml                       |  28 ++
 Open-ILS/src/eg2/package-lock.json                 |  24 +-
 Open-ILS/src/eg2/src/app/common.module.ts          |  13 +-
 Open-ILS/src/eg2/src/app/core/idl.service.ts       |  40 +++
 .../src/app/share/combobox/combobox.component.html |   4 +-
 .../src/app/share/combobox/combobox.component.ts   |  10 +-
 .../app/share/fm-editor/fm-editor.component.html   |   2 +-
 .../src/app/share/fm-editor/fm-editor.component.ts |   2 +-
 .../eg2/src/app/share/print/print.component.html   |  10 +
 .../src/eg2/src/app/share/print/print.component.ts |  87 ++++--
 .../src/eg2/src/app/share/print/print.service.ts   |  67 ++++-
 .../eg2/src/app/share/util/sample-data.service.ts  | 122 +++++++++
 .../server/admin-server-splash.component.html      |   3 +
 .../app/staff/admin/server/admin-server.module.ts  |   6 +-
 .../admin/server/print-template.component.html     | 110 ++++++++
 .../staff/admin/server/print-template.component.ts | 301 +++++++++++++++++++++
 .../src/app/staff/admin/server/routing.module.ts   |   4 +
 .../app/staff/catalog/record/record.component.html |   1 +
 Open-ILS/src/eg2/src/app/staff/common.module.ts    |  12 +-
 .../src/app/staff/sandbox/sandbox.component.html   |  19 +-
 .../eg2/src/app/staff/sandbox/sandbox.component.ts |  21 +-
 .../eg2/src/app/staff/sandbox/sandbox.module.ts    |   2 +
 .../share/admin-page/admin-page.component.html     |   1 +
 .../staff/share/admin-page/admin-page.component.ts |   6 +-
 .../src/app/staff/share/holds/grid.component.html  |   4 +
 .../src/app/staff/share/holds/grid.component.ts    |  33 ++-
 Open-ILS/src/extras/install/Makefile.debian-jessie |   1 +
 .../src/extras/install/Makefile.debian-stretch     |   1 +
 Open-ILS/src/extras/install/Makefile.fedora        |   1 +
 Open-ILS/src/extras/install/Makefile.ubuntu-bionic |   1 +
 Open-ILS/src/extras/install/Makefile.ubuntu-xenial |   1 +
 .../src/perlmods/lib/OpenILS/Utils/CStoreEditor.pm |  22 +-
 .../src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm  | 217 +++++++++++++++
 Open-ILS/src/sql/Pg/002.schema.config.sql          |  16 +-
 Open-ILS/src/sql/Pg/800.fkeys.sql                  |   3 +
 Open-ILS/src/sql/Pg/950.data.seed-values.sql       |  97 ++++++-
 .../upgrade/1173.schema.server-print-templates.sql | 106 ++++++++
 .../Administration/server-print-templates.adoc     |  59 ++++
 40 files changed, 1416 insertions(+), 52 deletions(-)
 create mode 100644 Open-ILS/src/eg2/src/app/share/util/sample-data.service.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/server/print-template.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/server/print-template.component.ts
 create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/1173.schema.server-print-templates.sql
 create mode 100644 docs/RELEASE_NOTES_NEXT/Administration/server-print-templates.adoc


hooks/post-receive
-- 
Evergreen ILS




More information about the open-ils-commits mailing list