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>
</LocationMatch>
</IfModule>
+<Location /print_template>
+ SetHandler perl-script
+ PerlHandler OpenILS::WWW::PrintTemplate
+ Options +ExecCGI
+ PerlSendHeader On
+ Require all granted
+</Location>
+
<Location /IDL2js>
</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>
},
"load-json-file": {
"version": "2.0.0",
- "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
+ "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
"integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=",
"dev": true,
"requires": {
},
"minimist": {
"version": "1.2.0",
- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+ "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
"integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
"dev": true
},
},
"pify": {
"version": "2.3.0",
- "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
"dev": true
},
"object-visit": "1.0.1"
}
},
+ "material-design-icons": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/material-design-icons/-/material-design-icons-3.0.1.tgz",
+ "integrity": "sha1-mnHEh0chjrylHlGmbaaCA4zct78="
+ },
"math-random": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.1.tgz",
"minimist": "0.0.8"
}
},
+ "moment": {
+ "version": "2.24.0",
+ "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
+ "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg=="
+ },
+ "moment-timezone": {
+ "version": "0.5.26",
+ "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.26.tgz",
+ "integrity": "sha512-sFP4cgEKTCymBBKgoxZjYzlSovC20Y6J7y3nanDc5RoBIXKlZhoYwBoZGe3flwU6A372AcRwScH8KiwV6zjy1g==",
+ "requires": {
+ "moment": "2.24.0"
+ }
+ },
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
}
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;
+ }
}
<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)">
context?: {[fields: string]: any};
}
-interface CustomFieldContext {
+export interface CustomFieldContext {
// Current create/edit/view record
record: IdlObject;
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
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 => {
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;
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>();
}
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);
+ });
+
+ }
}
--- /dev/null
+import {Injectable} from '@angular/core';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+
+/** Service for generating sample data for testing, demo, etc. */
+
+// TODO: I could also imagine this coming from a web service or
+// even a flat file of web-served JSON.
+
+const NOW_DATE = new Date().toISOString();
+
+// Copied from sample of Concerto data set
+const DATA = {
+ au: [
+ {first_given_name: 'Vincent', second_given_name: 'Kenneth', family_name: 'Moran'},
+ {first_given_name: 'Gregory', second_given_name: 'Adam', family_name: 'Jones'},
+ {first_given_name: 'Brittany', second_given_name: 'Geraldine', family_name: 'Walker'},
+ {first_given_name: 'Ernesto', second_given_name: 'Robert', family_name: 'Miller'},
+ {first_given_name: 'Robert', second_given_name: 'Louis', family_name: 'Hill'},
+ {first_given_name: 'Edward', second_given_name: 'Robert', family_name: 'Lopez'},
+ {first_given_name: 'Andrew', second_given_name: 'Alberto', family_name: 'Bell'},
+ {first_given_name: 'Jennifer', second_given_name: 'Dorothy', family_name: 'Mitchell'},
+ {first_given_name: 'Jo', second_given_name: 'Mai', family_name: 'Madden'},
+ {first_given_name: 'Maomi', second_given_name: 'Julie', family_name: 'Harding'}
+ ],
+ ac: [
+ {barcode: '908897239000'},
+ {barcode: '908897239001'},
+ {barcode: '908897239002'},
+ {barcode: '908897239003'},
+ {barcode: '908897239004'},
+ {barcode: '908897239005'},
+ {barcode: '908897239006'},
+ {barcode: '908897239007'},
+ {barcode: '908897239008'},
+ {barcode: '908897239009'}
+ ],
+ aua: [
+ {street1: '1809 Target Way', city: 'Vero beach', state: 'FL', post_code: 32961},
+ {street1: '3481 Facility Island', city: 'Campton', state: 'KY', post_code: 41301},
+ {street1: '5150 Dinner Expressway', city: 'Dodge center', state: 'MN', post_code: 55927},
+ {street1: '8496 Random Trust Points', city: 'Berryville', state: 'VA', post_code: 22611},
+ {street1: '7626 Secret Institute Courts', city: 'Anchorage', state: 'AK', post_code: 99502},
+ {street1: '7044 Regular Index Path', city: 'Livingston', state: 'KY', post_code: 40445},
+ {street1: '3403 Thundering Heat Meadows', city: 'Miami', state: 'FL', post_code: 33157},
+ {street1: '759 Doubtful Government Extension', city: 'Sellersville', state: 'PA', post_code: 18960},
+ {street1: '5431 Japanese Work Rapid', city: 'Society hill', state: 'SC', post_code: 29593},
+ {street1: '5253 Agricultural Exhibition Stravenue', city: 'La place', state: 'IL', post_code: 61936}
+ ],
+ ahr: [
+ {request_time: NOW_DATE, hold_type: 'T', capture_time: null, fulfillment_time: null},
+ {request_time: NOW_DATE, hold_type: 'T', capture_time: null, fulfillment_time: null},
+ {request_time: NOW_DATE, hold_type: 'V', capture_time: null, fulfillment_time: null},
+ {request_time: NOW_DATE, hold_type: 'C', capture_time: null, fulfillment_time: null},
+ {request_time: NOW_DATE, hold_type: 'T', capture_time: null, fulfillment_time: null, frozen: true},
+ {request_time: NOW_DATE, hold_type: 'T', capture_time: NOW_DATE, fulfillment_time: null},
+ {request_time: NOW_DATE, hold_type: 'T', capture_time: NOW_DATE, fulfillment_time: null},
+ {request_time: NOW_DATE, hold_type: 'T', capture_time: NOW_DATE, fulfillment_time: NOW_DATE},
+ {request_time: NOW_DATE, hold_type: 'T', capture_time: NOW_DATE, fulfillment_time: NOW_DATE},
+ {request_time: NOW_DATE, hold_type: 'T', capture_time: NOW_DATE, fulfillment_time: NOW_DATE}
+ ],
+ acp: [
+ {barcode: '208897239000'},
+ {barcode: '208897239001'},
+ {barcode: '208897239002'},
+ {barcode: '208897239003'},
+ {barcode: '208897239004'},
+ {barcode: '208897239005'},
+ {barcode: '208897239006'},
+ {barcode: '208897239007'},
+ {barcode: '208897239008'},
+ {barcode: '208897239009'}
+ ],
+ mwde: [
+ {title: 'Sinidos sinfónicos : an orchestral sampler'},
+ {title: 'Piano concerto, op. 38'},
+ {title: 'Critical entertainments : music old and new'},
+ {title: 'Piano concerto in C major, op. 39'},
+ {title: 'Double concerto in A minor, op. 102 ; Variations on a theme by Haydn, op. 56a ; Tragic overture, op. 81'},
+ {title: 'Trombone concerto (1991) subject: american'},
+ {title: 'Violin concerto no. 2 ; Six duos (from 44 Duos)'},
+ {title: 'Piano concerto no. 1 (1926) ; Rhapsody, op. 1 (1904)'},
+ {title: 'Piano concertos 2 & 3 & the devil makes me?'},
+ {title: 'Composition student recital, April 6, 2000, Huntington University / composition students of Daniel Bédard'},
+ ]
+};
+
+
+@Injectable()
+export class SampleDataService {
+
+ constructor(private idl: IdlService) {}
+
+ randomValue(list: any[], field: string): string {
+ return list[Math.floor(Math.random() * list.length)][field];
+ }
+
+ listOfThings(idlClass: string, count: number = 1): IdlObject[] {
+ if (!(idlClass in DATA)) {
+ throw new Error(`No sample data for class ${idlClass}'`);
+ }
+
+ const things: IdlObject[] = [];
+ for (let i = 0; i < count; i++) {
+ const thing = this.idl.create(idlClass);
+ Object.keys(DATA[idlClass][0]).forEach(field =>
+ thing[field](this.randomValue(DATA[idlClass], field))
+ );
+ things.push(thing);
+ }
+
+ return things;
+ }
+
+ // Returns a random-ish date in the past or the future.
+ randomDate(future: boolean = false): Date {
+ const rando = Math.random() * 10000000000;
+ const time = new Date().getTime();
+ return new Date(future ? time + rando : time - rando);
+ }
+}
+
+
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"
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,
exports: [
],
providers: [
+ SampleDataService
]
})
--- /dev/null
+
+<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>
+
--- /dev/null
+import {Component, OnInit, ViewChild, TemplateRef} from '@angular/core';
+import {Observable} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {ActivatedRoute} from '@angular/router';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {AuthService} from '@eg/core/auth.service';
+import {OrgService} from '@eg/core/org.service';
+import {ComboboxComponent, ComboboxEntry
+ } from '@eg/share/combobox/combobox.component';
+import {PrintService} from '@eg/share/print/print.service';
+import {LocaleService} from '@eg/core/locale.service';
+import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {SampleDataService} from '@eg/share/util/sample-data.service';
+import {OrgFamily} from '@eg/share/org-family-select/org-family-select.component';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+
+/**
+ * Print Template Admin Page
+ */
+
+@Component({
+ templateUrl: 'print-template.component.html'
+})
+
+export class PrintTemplateComponent implements OnInit {
+
+ entries: ComboboxEntry[];
+ template: IdlObject;
+ sampleJson: string;
+ invalidJson = false;
+ localeCode: string;
+ localeEntries: ComboboxEntry[];
+ compiledContent: string;
+ templateCache: {[id: number]: IdlObject} = {};
+ initialOrg: number;
+ selectedOrgs: number[];
+
+ @ViewChild('templateSelector') templateSelector: ComboboxComponent;
+ @ViewChild('tabs') tabs: NgbTabset;
+ @ViewChild('editDialog') editDialog: FmRecordEditorComponent;
+ @ViewChild('confirmDelete') confirmDelete: ConfirmDialogComponent;
+
+ // Define some sample data that can be used for various templates
+ // Data will be filled out via the sample data service.
+ // Keys map to print template names
+ sampleData: any = {
+ patron_address: {},
+ holds_for_bib: {}
+ };
+
+ constructor(
+ private route: ActivatedRoute,
+ private idl: IdlService,
+ private org: OrgService,
+ private pcrud: PcrudService,
+ private auth: AuthService,
+ private locale: LocaleService,
+ private printer: PrintService,
+ private samples: SampleDataService
+ ) {
+ this.entries = [];
+ this.localeEntries = [];
+ }
+
+ ngOnInit() {
+ this.initialOrg = this.auth.user().ws_ou();
+ this.selectedOrgs = [this.initialOrg];
+ this.localeCode = this.locale.currentLocaleCode();
+ this.locale.supportedLocales().subscribe(
+ l => this.localeEntries.push({id: l.code(), label: l.name()}));
+ this.setTemplateInfo().subscribe();
+ this.fleshSampleData();
+ }
+
+ fleshSampleData() {
+
+ // NOTE: server templates work fine with IDL objects, but
+ // vanilla hashes are easier to work with in the admin UI.
+
+ // Classes for which sample data exists
+ const classes = ['au', 'ac', 'aua', 'ahr', 'acp', 'mwde'];
+ const samples: any = {};
+ classes.forEach(class_ => samples[class_] =
+ this.idl.toHash(this.samples.listOfThings(class_, 10)));
+
+ // Wide holds are hashes instead of IDL objects.
+ // Add fields as needed.
+ const wide_holds = [{
+ request_time: this.samples.randomDate().toISOString(),
+ ucard_barcode: samples.ac[0].barcode,
+ usr_family_name: samples.au[0].family_name,
+ usr_alias: samples.au[0].alias,
+ cp_barcode: samples.acp[0].barcode
+ }, {
+ request_time: this.samples.randomDate().toISOString(),
+ ucard_barcode: samples.ac[1].barcode,
+ usr_family_name: samples.au[1].family_name,
+ usr_alias: samples.au[1].alias,
+ cp_barcode: samples.acp[1].barcode
+ }];
+
+ this.sampleData.patron_address = {
+ patron: samples.au[0],
+ address: samples.aua[0]
+ };
+
+ this.sampleData.holds_for_bib = wide_holds;
+ }
+
+ onTabChange(evt: NgbTabChangeEvent) {
+ if (evt.nextId === 'template') {
+ this.refreshPreview();
+ }
+ }
+
+ container(): any {
+ // Only present when its tab is visible
+ return document.getElementById('template-preview-pane');
+ }
+
+ // TODO should the ngModelChange handler fire for org-family-select
+ // even when the values don't change?
+ orgOnChange(family: OrgFamily) {
+ // Avoid reundant server calls.
+ if (!this.sameIds(this.selectedOrgs, family.orgIds)) {
+ this.selectedOrgs = family.orgIds;
+ this.setTemplateInfo().subscribe();
+ }
+ }
+
+ // True if the 2 arrays contain the same contents,
+ // regardless of the order.
+ sameIds(arr1: any[], arr2: any[]): boolean {
+ if (arr1.length !== arr2.length) {
+ return false;
+ }
+ for (let i = 0; i < arr1.length; i++) {
+ if (!arr2.includes(arr1[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ localeOnChange(code: string) {
+ if (code) {
+ this.localeCode = code;
+ this.setTemplateInfo().subscribe();
+ }
+ }
+
+ // Fetch name/id for all templates in range.
+ // Avoid fetching the template content until needed.
+ setTemplateInfo(): Observable<IdlObject> {
+ this.entries = [];
+ this.template = null;
+ this.templateSelector.applyEntryId(null);
+ this.compiledContent = '';
+
+ return this.pcrud.search('cpt',
+ {
+ owner: this.selectedOrgs,
+ locale: this.localeCode
+ }, {
+ select: {cpt: ['id', 'label', 'owner']},
+ order_by: {cpt: 'label'}
+ }
+ ).pipe(map(tmpl => {
+ this.templateCache[tmpl.id()] = tmpl;
+ this.entries.push({id: tmpl.id(), label: tmpl.label()});
+ return tmpl;
+ }));
+ }
+
+ getOwnerName(id: number): string {
+ return this.org.get(this.templateCache[id].owner()).shortname();
+ }
+
+ selectTemplate(id: number) {
+
+ if (id === null) {
+ this.template = null;
+ this.compiledContent = '';
+ return;
+ }
+
+ this.pcrud.retrieve('cpt', id).subscribe(t => {
+ this.template = t;
+ const data = this.sampleData[t.name()];
+ if (data) {
+ this.sampleJson = JSON.stringify(data, null, 2);
+ this.refreshPreview();
+ }
+ });
+ }
+
+ refreshPreview() {
+ if (!this.sampleJson) { return; }
+ this.compiledContent = '';
+
+ let data;
+ try {
+ data = JSON.parse(this.sampleJson);
+ this.invalidJson = false;
+ } catch (E) {
+ this.invalidJson = true;
+ }
+
+ this.printer.compileRemoteTemplate({
+ templateId: this.template.id(),
+ contextData: data,
+ printContext: 'default' // required, has no impact here
+
+ }).then(response => {
+
+ this.compiledContent = response.content;
+ if (response.contentType === 'text/html') {
+ this.container().innerHTML = response.content;
+ } else {
+ // Assumes text/plain or similar
+ this.container().innerHTML = '<pre>' + response.content + '</pre>';
+ }
+ });
+ }
+
+ applyChanges() {
+ this.container().innerHTML = '';
+ this.pcrud.update(this.template).toPromise()
+ .then(() => this.refreshPreview());
+ }
+
+ openEditDialog() {
+ this.editDialog.setRecord(this.template);
+ this.editDialog.mode = 'update';
+ this.editDialog.open({size: 'lg'}).toPromise().then(id => {
+ if (id !== undefined) {
+ const selectedId = this.template.id();
+ this.setTemplateInfo().toPromise().then(
+ _ => this.selectTemplate(selectedId)
+ );
+ }
+ });
+ }
+
+ cloneTemplate() {
+ const tmpl = this.idl.clone(this.template);
+ tmpl.id(null);
+ this.editDialog.setRecord(tmpl);
+ this.editDialog.mode = 'create';
+ this.editDialog.open({size: 'lg'}).toPromise().then(newTmpl => {
+ if (newTmpl !== undefined) {
+ this.setTemplateInfo().toPromise()
+ .then(_ => this.selectTemplate(newTmpl.id()));
+ }
+ });
+ }
+
+ deleteTemplate() {
+ this.confirmDelete.open().subscribe(confirmed => {
+ if (!confirmed) { return; }
+ this.pcrud.remove(this.template).toPromise().then(_ => {
+ this.setTemplateInfo().toPromise()
+ .then(x => this.selectTemplate(null));
+ });
+ });
+ }
+}
+
+
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',
path: 'actor/org_unit_type',
component: OrgUnitTypeComponent
}, {
+ path: 'config/print_template',
+ component: PrintTemplateComponent
+}, {
path: ':schema/:table',
component: BasicAdminPageComponent
}];
<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>
<!-- 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">
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',
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') ?
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'
+ });
+ }
+}
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: [
ReactiveFormsModule
],
providers: [
+ SampleDataService
]
})
</eg-grid>
<eg-fm-record-editor #editDialog idlClass="{{idlClass}}"
+ [fieldOptions]="fieldOptions"
[preloadLinkedValues]="true" readonlyFields="{{readonlyFields}}">
</eg-fm-record-editor>
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';
// 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;
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>
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 */
@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;
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;
);
}
}
+
+ 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'
+ });
+ }
+ });
+ }
}
--- /dev/null
+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;
('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;
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;
( 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'))
;
'ITEM_RENTAL_FEE_REQUIRED.override',
'ITEM_DEPOSIT_PAID.override',
'COPY_STATUS_LOST_AND_PAID.override',
+ 'ADMIN_PRINT_TEMPLATE',
'ITEM_NOT_HOLDABLE.override');
)
);
+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);
--- /dev/null
+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;
+