LP1901930 SIP2Mediator back-end support & release notes
authorBill Erickson <berickxx@gmail.com>
Wed, 11 Mar 2020 16:23:00 +0000 (12:23 -0400)
committerBill Erickson <berickxx@gmail.com>
Tue, 18 May 2021 21:02:20 +0000 (17:02 -0400)
For more, see

https://wiki.evergreen-ils.org/doku.php?id=evergreen-admin:sip2mediator

Signed-off-by: Bill Erickson <berickxx@gmail.com>
34 files changed:
Open-ILS/examples/apache_24/eg_startup.in
Open-ILS/examples/apache_24/eg_vhost.conf.in
Open-ILS/examples/fm_IDL.xml
Open-ILS/examples/opensrf.xml.example
Open-ILS/examples/opensrf_core.xml.example
Open-ILS/src/eg2/src/app/share/dialog/prompt.component.html
Open-ILS/src/eg2/src/app/share/dialog/prompt.component.ts
Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html
Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts
Open-ILS/src/eg2/src/app/staff/admin/server/sip/account-list.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/sip/account-list.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/sip/account.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/sip/account.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/sip/account.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/sip/delete-group-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/sip/delete-group-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/sip/routing.module.ts [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Admin.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Checkin.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Checkout.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Common.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Hold.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Item.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Patron.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Payment.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Session.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/WWW/SIP2Mediator.pm [new file with mode: 0644]
Open-ILS/src/sql/Pg/410.schema.sip.sql [new file with mode: 0644]
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/sql_file_manifest
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.sip-config.sql [new file with mode: 0644]
docs/RELEASE_NOTES_NEXT/SIP/sip2-mediator-support.adoc [new file with mode: 0644]

index 67d7ca9..13f95f2 100755 (executable)
@@ -19,6 +19,8 @@ use OpenILS::WWW::RemoteAuth ('@sysconfdir@/opensrf_core.xml', 'OpenILS::WWW::Re
 # Pass second argument of '1' to enable template caching.
 use OpenILS::WWW::PrintTemplate ('@sysconfdir@/opensrf_core.xml', 0);
 
+use OpenILS::WWW::SIP2Mediator ('@sysconfdir@/opensrf_core.xml');
+
 # - Uncomment the following 2 lines to make use of the IP redirection code
 # - The IP file should to contain a map with the following format:
 # - actor.org_unit.shortname <start_ip> <end_ip>
index a6d07e8..23ac2c5 100644 (file)
@@ -619,6 +619,13 @@ RewriteRule ^/conify/([a-z]{2}-[A-Z]{2})/global/(.*)$ /conify/global/$2 [E=local
     Require all granted 
 </Location>
 
+<Location /sip2-mediator>
+    SetHandler perl-script
+    PerlHandler OpenILS::WWW::SIP2Mediator
+    Options +ExecCGI
+    Require all granted
+</Location>
+
 # OpenURL 0.1 searching based on OpenSearch
 RewriteMap openurl prg:@bindir@/openurl_map.pl
 RewriteCond %{QUERY_STRING} (^.*$)
index 242c357..09ae619 100644 (file)
@@ -13521,6 +13521,113 @@ SELECT  usr,
                </permacrud>
        </class>
 
+       <class id="sipsetg" controller="open-ils.cstore open-ils.pcrud"
+               oils_obj:fieldmapper="sip::setting_group" 
+               oils_persist:tablename="sip.setting_group" 
+               reporter:label="SIP Settings Group">
+               <fields oils_persist:primary="id" oils_persist:sequence="sip.setting_group_id_seq">
+                       <field name="id" reporter:datatype="id" reporter:label="ID" reporter:selector="label"/>
+                       <field name="label" reporter:datatype="text" reporter:label="Label" oils_obj:required="true"/>
+                       <field name="institution" reporter:datatype="text" reporter:label="SIP Institution" oils_obj:required="true"/>
+                       <field name="settings" reporter:datatype="link" reporter:label="Settings" oils_persist:virtual="true"/>
+               </fields>
+               <links>
+                       <link field="settings" reltype="has_many" key="setting_group" map="" class="sipset"/>
+               </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="SIP_ADMIN" global_required="true"/>
+                               <retrieve permission="SIP_ADMIN" global_required="true"/>
+                               <update permission="SIP_ADMIN" global_required="true"/>
+                               <delete permission="SIP_ADMIN" global_required="true"/>
+                       </actions>
+               </permacrud>
+       </class>
+       <class id="sipset" controller="open-ils.cstore open-ils.pcrud"
+               oils_obj:fieldmapper="sip::setting" 
+               oils_persist:tablename="sip.setting" 
+               reporter:label="SIP Settings">
+               <fields oils_persist:primary="id" oils_persist:sequence="sip.setting_id_seq">
+                       <field name="id" reporter:datatype="id" reporter:label="ID" reporter:selector="name"/>
+                       <field name="setting_group" reporter:datatype="link" reporter:label="Settings Group" oils_obj:required="true" config_field="true"/>
+                       <field name="name" reporter:datatype="text" reporter:label="Name" oils_obj:required="true"/>
+                       <field name="description" reporter:datatype="text" reporter:label="Description" oils_obj:required="true"/>
+                       <field name="value" reporter:datatype="text" reporter:label="Value" oils_obj:required="true"/>
+               </fields>
+               <links>
+                       <link field="setting_group" reltype="has_a" key="id" map="" class="sipsetg"/>
+               </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="SIP_ADMIN" global_required="true"/>
+                               <retrieve permission="SIP_ADMIN" global_required="true"/>
+                               <update permission="SIP_ADMIN" global_required="true"/>
+                               <delete permission="SIP_ADMIN" global_required="true"/>
+                       </actions>
+               </permacrud>
+       </class>
+       <class id="sipacc" controller="open-ils.cstore open-ils.pcrud"
+               oils_obj:fieldmapper="sip::account" 
+               oils_persist:tablename="sip.account" 
+               reporter:label="SIP Account">
+               <fields oils_persist:primary="id" oils_persist:sequence="sip.account_id_seq">
+                       <field name="id" reporter:datatype="id" reporter:label="ID" reporter:selector="sip_username"/>
+                       <field name="enabled" reporter:datatype="bool" reporter:label="Enabled"/>
+                       <field name="setting_group" reporter:datatype="link" reporter:label="Settings Group" oils_obj:required="true"/>
+                       <field name="sip_username" reporter:datatype="text" reporter:label="SIP Username" oils_obj:required="true"/>
+                       <field name="usr" reporter:datatype="link" reporter:label="ILS User" oils_obj:required="true"/>
+                       <field name="workstation" reporter:datatype="link" reporter:label="Workstation"/>
+                       <field name="transient" reporter:datatype="bool" reporter:label="Transient"/>
+                       <field name="activity_who" reporter:datatype="text" reporter:label="Activity Who"/>
+                       <field name="sip_password" reporter:datatype="id" reporter:label="SIP Password" oils_persist:virtual="true"/>
+               </fields>
+               <links>
+                       <link field="usr" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="workstation" reltype="has_a" key="id" map="" class="aws"/>
+                       <link field="setting_group" reltype="has_a" key="id" map="" class="sipsetg"/>
+               </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="SIP_ADMIN" global_required="true"/>
+                               <retrieve permission="SIP_ADMIN" global_required="true"/>
+                               <update permission="SIP_ADMIN" global_required="true"/>
+                               <delete permission="SIP_ADMIN" global_required="true"/>
+                       </actions>
+               </permacrud>
+       </class>
+       <class id="sipses" controller="open-ils.cstore"
+               oils_obj:fieldmapper="sip::session" 
+               oils_persist:tablename="sip.session" 
+               reporter:label="SIP Session">
+               <fields oils_persist:primary="key">
+                       <field name="key" reporter:datatype="text" reporter:label="SIP Session Key"/>
+                       <field name="ils_token" reporter:datatype="text" reporter:label="ILS Auth Token"/>
+                       <field name="account" reporter:datatype="link" reporter:label="SIP Account"/>
+                       <field name="create_time" reporter:label="Create Time" reporter:datatype="timestamp"/>
+               </fields>
+               <links>
+                       <link field="account" reltype="has_a" key="id" map="" class="sipacc"/>
+               </links>
+       </class>
+       <class id="sipsm" controller="open-ils.cstore open-ils.pcrud"
+               oils_obj:fieldmapper="sip::screen_message" 
+               oils_persist:tablename="sip.screen_message" 
+               reporter:label="SIP Screen Message">
+               <fields oils_persist:primary="key">
+                       <field name="key" reporter:datatype="text" reporter:label="Message Key"/>
+                       <field name="message" reporter:datatype="text" reporter:label="Message" oils_persist:i18n="true"/>
+               </fields>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="SIP_ADMIN" global_required="true"/>
+                               <retrieve />
+                               <update permission="SIP_ADMIN" global_required="true"/>
+                               <delete permission="SIP_ADMIN" global_required="true"/>
+                       </actions>
+               </permacrud>
+       </class>
+
+
        <!-- ********************************************************************************************************************* -->
 </IDL>
 
index c713fa3..0b65b01 100644 (file)
@@ -1310,6 +1310,26 @@ vim:et:ts=4:sw=4:
                 </unix_config>
             </open-ils.courses>
 
+            <open-ils.sip2>
+                <keepalive>5</keepalive>
+                <stateless>1</stateless>
+                <language>perl</language>
+                <implementation>OpenILS::Application::SIP2</implementation>
+                <max_requests>100</max_requests>
+                <unix_config>
+                    <unix_sock>sip2_unix.sock</unix_sock>
+                    <unix_pid>sip2_unix.pid</unix_pid>
+                    <unix_log>sip2_unix.log</unix_log>
+                    <max_requests>1000</max_requests>
+                    <min_children>1</min_children>
+                    <max_children>15</max_children>
+                    <min_spare_children>1</min_spare_children>
+                    <max_spare_children>5</max_spare_children>
+                </unix_config>
+                <app_settings>
+                </app_settings>
+            </open-ils.sip2>
+
             <open-ils.curbside>
                 <keepalive>5</keepalive>
                 <stateless>1</stateless>
@@ -1329,6 +1349,7 @@ vim:et:ts=4:sw=4:
                 <app_settings>
                 </app_settings>
             </open-ils.curbside>
+
         </apps>
     </default>
 
@@ -1379,6 +1400,7 @@ vim:et:ts=4:sw=4:
                 <appname>open-ils.courses</appname>
                 <appname>open-ils.curbside</appname>
                 <appname>open-ils.geo</appname>
+                <appname>open-ils.sip2</appname>
             </activeapps>
         </localhost>
     </hosts>
index aeba4ad..e00ff20 100644 (file)
@@ -40,6 +40,7 @@ Example OpenSRF bootstrap configuration file for Evergreen
           <service>open-ils.vandelay</service>
           <service>open-ils.serial</service>
           <service>open-ils.ebook_api</service>
+          <service>open-ils.sip2</service>
         </services>
       </router>
 
index 17a6b50..ca47961 100644 (file)
@@ -9,7 +9,7 @@
   <div class="modal-body">
     <p>{{dialogBody}}</p>
     <div class="text-center">
-        <input class="form-control" [(ngModel)]="promptValue"/>
+        <input type="{{promptType}}" class="form-control" [(ngModel)]="promptValue"/>
     </div>
   </div>
   <div class="modal-footer">
index ab7f77e..a5d8eca 100644 (file)
@@ -14,6 +14,8 @@ export class PromptDialogComponent extends DialogComponent {
     @Input() public dialogBody: string;
     // Value to return to the caller
     @Input() public promptValue: string;
+    // 'password', etc.
+    @Input() promptType = 'text';
 }
 
 
index 4ac2e2e..dc2dc5c 100644 (file)
@@ -144,7 +144,11 @@ export class FmRecordEditorComponent
     @Input() hideBanner: boolean;
 
     // do not close dialog on error saving record
-    @Input() remainOpenOnError: false;
+    @Input() remainOpenOnError = false;
+
+    // Avoid making any pcrud calls.  Instead return the modified object
+    // to the caller via recordSaved Output and dialog close().
+    @Input() inPlaceMode = false;
 
     // Emit the modified object when the save action completes.
     @Output() recordSaved = new EventEmitter<IdlObject>();
@@ -184,7 +188,7 @@ export class FmRecordEditorComponent
 
     // custom function for munging the record before it gets saved;
     // will get passed mode and the record itself
-    @Input() preSave: Function;
+    @Input() preSave: (mode: string, recToSave: IdlObject) => void;
 
     // recordId and record getters and setters.
     // Note that setting the this.recordId to NULL does not clear the
@@ -613,6 +617,19 @@ export class FmRecordEditorComponent
             this.preSave(this.mode, recToSave);
         }
         this.convertDatatypesToIdl(recToSave);
+
+        if (this.inPlaceMode) {
+            this.recordSaved.emit(recToSave);
+            if (this.fmEditForm) {
+                this.fmEditForm.form.markAsPristine();
+            }
+            if (this.isDialog()) {
+                this.record = undefined;
+                this.close(recToSave);
+            }
+            return;
+        }
+
         this.pcrud[this.mode]([recToSave]).toPromise().then(
             result => {
                 this.recordSaved.emit(result);
index c6a4108..6a4aa01 100644 (file)
       routerLink="/staff/admin/server/config/remote_account"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Remote Authentication Profiles"  
       routerLink="/staff/admin/server/config/remoteauth_profile"></eg-link-table-link>
+    <eg-link-table-link i18n-label label="SIP Accounts"  
+      routerLink="/staff/admin/server/sip/account"></eg-link-table-link>
+    <eg-link-table-link i18n-label label="SIP Screen Messages"  
+      routerLink="/staff/admin/server/sip/screen_message"></eg-link-table-link>
     <eg-link-table-link i18n-label label="SMS Carriers"  
       routerLink="/staff/admin/server/config/sms_carrier"></eg-link-table-link>
     <eg-link-table-link i18n-label label="User Activity Types"  
index caadbcb..0798ad4 100644 (file)
@@ -70,6 +70,15 @@ const routes: Routes = [{
     data: [{schema: 'asset',
         table: 'call_number_suffix', readonlyFields: 'label_sortkey'}]
 }, {
+    path: 'sip/account',
+    loadChildren: () =>
+      import('./sip/account.module').then(m => m.SipAccountModule)
+}, {
+    path: 'sip/screen_message',
+    component: BasicAdminPageComponent,
+    data: [{schema: 'sip',
+        table: 'screen_message', readonlyFields: 'key'}]
+}, {
     path: ':schema/:table',
     component: BasicAdminPageComponent
 }];
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/sip/account-list.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/sip/account-list.component.html
new file mode 100644 (file)
index 0000000..32ecad1
--- /dev/null
@@ -0,0 +1,18 @@
+<eg-staff-banner bannerText="SIP Accounts" i18n-bannerText></eg-staff-banner>
+
+<eg-confirm-dialog #confirmDelete
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Delete Accounts"
+  dialogBody="Delete selected SIP accounts?">
+</eg-confirm-dialog>
+
+<eg-grid #grid idlClass="sipacc" [dataSource]="gridSource" 
+  persistKey="admin.server.sip.account-list"
+  [stickyHeader]="true" [sortable]="true" (onRowActivate)="openAccount($event)">
+  <eg-grid-toolbar-button label="New Account" i18n-label
+    (onClick)="newAccount()"></eg-grid-toolbar-button>
+  <eg-grid-toolbar-action label="Delete Selected" i18n-label 
+    (onClick)="deleteSelected($event)">
+  </eg-grid-toolbar-action>
+</eg-grid>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/sip/account-list.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/sip/account-list.component.ts
new file mode 100644 (file)
index 0000000..dcf204e
--- /dev/null
@@ -0,0 +1,76 @@
+import {Component, Input, ViewChild, OnInit} from '@angular/core';
+import {Router} from '@angular/router';
+import {Observable, of} from 'rxjs';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {StringService} from '@eg/share/string/string.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {Pager} from '@eg/share/util/pager';
+
+@Component({
+    templateUrl: './account-list.component.html'
+})
+export class SipAccountListComponent implements OnInit {
+
+    gridSource: GridDataSource = new GridDataSource();
+    @ViewChild('grid') grid: GridComponent;
+    @ViewChild('confirmDelete') confirmDelete: ConfirmDialogComponent;
+
+    constructor(
+        private router: Router,
+        private pcrud: PcrudService
+    ) {}
+
+    ngOnInit() {
+        this.gridSource.getRows = (pager: Pager, sort: any[]) => {
+            return this.fetchAccounts(pager, sort);
+        };
+    }
+
+    fetchAccounts(pager: Pager, sort: any[]): Observable<any> {
+
+        const orderBy: any = {sisacc: 'sip_username'};
+        if (sort.length) {
+            orderBy.sisacc = sort[0].name + ' ' + sort[0].dir;
+        }
+
+        return this.pcrud.retrieveAll('sipacc', {
+            offset: pager.offset,
+            limit: pager.limit,
+            order_by: orderBy,
+            flesh: 1,
+            flesh_fields: {sipacc: ['usr', 'setting_group', 'workstation']}
+        });
+    }
+
+    openAccount(row: any) {
+        this.router.navigate([`/staff/admin/server/sip/account/${row.id()}`]);
+    }
+
+    newAccount() {
+        this.router.navigate([`/staff/admin/server/sip/account/new`]);
+    }
+
+    deleteSelected(rows: any[]) {
+        if (rows.length === 0) { return; }
+
+        this.confirmDelete.open().subscribe(confirmed => {
+            if (confirmed) {
+                rows.forEach(row => row.isdeleted(true));
+                this.pcrud.autoApply(rows).toPromise().then(_ => {
+                    this.gridSource.reset();
+                    this.grid.reload();
+                });
+            }
+        });
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/sip/account.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/sip/account.component.html
new file mode 100644 (file)
index 0000000..a05c0b7
--- /dev/null
@@ -0,0 +1,112 @@
+<eg-staff-banner bannerText="SIP Account" i18n-bannerText></eg-staff-banner>
+
+<a routerLink="/staff/admin/server/sip/account">
+  <button class="btn btn-outline-dark label-with-material-icon">
+    <span class="material-icons">reply</span>
+    <span i18n>SIP Accounts</span>
+  </button>
+</a>
+
+<eg-sip-group-delete-dialog *ngIf="account && account.setting_group()"
+  #deleteGroupDialog
+  [group]="account.setting_group()" [settingGroups]="settingGroups">
+</eg-sip-group-delete-dialog>
+
+<eg-fm-record-editor #cloneDialog idlClass="sipsetg" mode="create" 
+  hiddenFields="id" fieldOrder="label,institution">
+</eg-fm-record-editor>
+
+<eg-fm-record-editor #settingDialog idlClass="sipset" mode="update" 
+  hiddenFields="id,setting_group" fieldOrder="name,description,value"
+  [fieldOptions]="{name:{isReadonly:true}}">
+</eg-fm-record-editor>
+
+<ng-container *ngIf="createMode">
+  <eg-prompt-dialog #passwordDialog i18n-dialogTitle i18n-dialogBody
+    dialogTitle="Create SIP Password" i18n-dialogBody="Create a new password"
+    promptType="password">
+  </eg-prompt-dialog>
+</ng-container>
+
+<ng-container *ngIf="!createMode">
+  <eg-prompt-dialog #passwordDialog i18n-dialogTitle i18n-dialogBody
+    dialogTitle="Create SIP Password" i18n-dialogBody="Create a new password"
+    promptType="password">
+  </eg-prompt-dialog>
+</ng-container>
+
+<div class="row mt-2" *ngIf="account">
+  <div class="col-lg-7">
+
+    <ng-template #usrTemplate>
+      <eg-combobox #usrCbox [entries]="usrCboxEntries" required="true"
+        (onChange)="usrChanged($event)"
+        [selectedId]="usrId" [asyncDataSource]="usrCboxSource">
+      </eg-combobox>
+    </ng-template>
+
+    <ng-template #grpTemplate>
+      <div class="form-inline">
+        <eg-combobox #grpCbox required="true" 
+          [selectedId]="account.setting_group() ? account.setting_group().id() : null"
+          [entries]="settingGroups" (onChange)="grpChanged($event)">
+        </eg-combobox>
+        <button class="btn btn-outline-info ml-2" 
+          [disabled]="!account.setting_group()" (click)="openCloneDialog()" 
+          i18n>Clone</button>
+        <button class="btn btn-outline-danger ml-2" (click)="openDeleteDialog()" 
+          [disabled]="!account.setting_group() || account.setting_group().id() == 1" 
+          i18n>Delete</button>
+      </div>
+    </ng-template>
+
+    <ng-template #sipUsernameTemplate>
+      <div class="form-inline">
+        <input type="text" class="form-control"
+          [ngModel]="account.sip_username()"
+          (ngModelChange)="account.sip_username($event)">
+        <button class="btn btn-outline-dark ml-2" 
+          [ngClass]="{'border-danger' : createMode && !account.sip_password()}"
+          (click)="setPassword()">Set Password</button>
+      </div>
+    </ng-template>
+
+    <eg-fm-record-editor #editor
+      idlClass="sipacc" [mode]="createMode ? 'create' : 'update'" 
+      hiddenFields="id" displayMode="inline" [inPlaceMode]="true"
+      fieldOrder="sip_username,sip_password,setting_group,usr,workstation,transient,activity_who,enabled"
+      [fieldOptions]="{
+        setting_group:{customTemplate:{template:grpTemplate}},
+        sip_username:{customTemplate:{template:sipUsernameTemplate}},
+        usr:{customTemplate:{template:usrTemplate}}}"
+      [preSave]="accountPreSave" [recordId]="!createMode ? accountId : null"
+      (recordSaved)="accountSaved($event)">
+    </eg-fm-record-editor>
+  </div>
+  <div class="col-lg-5">
+    <ul>
+      <li i18n>Save account changes before modifying individual settings.</li>
+      <li i18n>Setting values must be entered as valid JSON.</li>
+      <li i18n>The "Default Settings" group cannot be modified.</li>
+      <li i18n>The same "SIP Institution" value may be used for multiple groups.</li>
+    </ul>
+  </div>
+</div>
+
+<div class="row" *ngIf="account && account.setting_group()">
+
+  <div class="col-lg-12 border-top mt-2 pt-2">
+    <h4 class="mb-2" i18n>Settings For Group 
+      <span class="font-weight-bold">'{{account.setting_group().label()}}'</span>
+    </h4>
+
+    <eg-grid #settingGrid idlClass="sipset" [dataSource]="settingsSource" 
+      [sortable]="true" (onRowActivate)="editSetting($event)"
+      persistKey="admin.server.sip.account.settings" hideFields="id,setting_group">
+      <eg-grid-toolbar-action label="Edit Selected" i18n-label 
+        (onClick)="editFirstSetting($event)">
+      </eg-grid-toolbar-action>
+    </eg-grid>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/sip/account.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/sip/account.component.ts
new file mode 100644 (file)
index 0000000..5b279d3
--- /dev/null
@@ -0,0 +1,270 @@
+import {Component, Input, ViewChild, OnInit} from '@angular/core';
+import {Router, ActivatedRoute} from '@angular/router';
+import {Observable, of} from 'rxjs';
+import {map, tap, switchMap, catchError} from 'rxjs/operators';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {EventService} from '@eg/core/event.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {StringService} from '@eg/share/string/string.service';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {PromptDialogComponent} from '@eg/share/dialog/prompt.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {ComboboxEntry, ComboboxComponent} from '@eg/share/combobox/combobox.component';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {Pager} from '@eg/share/util/pager';
+
+@Component({
+    templateUrl: './account.component.html'
+})
+export class SipAccountComponent implements OnInit {
+
+    accountId: number;
+    account: IdlObject;
+    usrCboxSource: (term: string) => Observable<ComboboxEntry>;
+    usrCboxEntries: ComboboxEntry[];
+    settingGroups: ComboboxEntry[];
+    usrId: number;
+    settingsSource: GridDataSource = new GridDataSource();
+    deleteGroupAccounts: IdlObject[] = [];
+    accountPreSave: (mode: string, account: IdlObject) => void;
+    createMode = false;
+
+    @ViewChild('cloneDialog') cloneDialog: FmRecordEditorComponent;
+    @ViewChild('settingDialog') settingDialog: FmRecordEditorComponent;
+    @ViewChild('settingGrid') settingGrid: GridComponent;
+    @ViewChild('deleteGroupDialog') deleteGroupDialog: DialogComponent;
+    @ViewChild('passwordDialog') passwordDialog: PromptDialogComponent;
+
+    constructor(
+        private route: ActivatedRoute,
+        private router: Router,
+        private idl: IdlService,
+        private net: NetService,
+        private auth: AuthService,
+        private evt: EventService,
+        private pcrud: PcrudService
+    ) {}
+
+    ngOnInit() {
+
+        this.route.paramMap.subscribe(params => {
+            if (params.get('id') === 'new') {
+                this.account = this.idl.create('sipacc'); // dummy
+                this.createMode = true;
+                return;
+            }
+
+            this.accountId = Number(params.get('id'));
+            this.loadAccount().toPromise(); // force it to run
+        });
+
+        this.fetchGroups();
+
+        this.usrCboxSource = term => {
+
+            const filter: any = {deleted: 'f', active: 't'};
+
+            if (this.account && this.account.usr()) {
+                filter['-or'] = [
+                    {id: this.account.usr().id()},
+                    {usrname: {'ilike': `%${term}%`}}
+                ];
+            } else {
+                filter.usrname = {'ilike': `%${term}%`};
+            }
+
+            return this.pcrud.search('au', filter, {
+                order_by: {au: 'usrname'},
+                limit: 50 // Avoid huge lists
+            }
+            ).pipe(map(user => {
+                return {id: user.id(), label: user.usrname()};
+            }));
+        };
+
+        this.settingsSource.getRows = (pager: Pager, sort: any[]) => {
+            if (!this.account && this.account.setting_group()) {
+                return of();
+            }
+
+            const orderBy: any = {sipset: 'name'};
+            if (sort.length) {
+                orderBy.sipset = sort[0].name + ' ' + sort[0].dir;
+            }
+
+            return this.pcrud.search('sipset',
+                {setting_group: this.account.setting_group().id()},
+                {order_by: orderBy},
+            );
+        };
+
+        this.accountPreSave = (mode: string, account: IdlObject) => {
+            // Migrate data collected from custom templates into
+            // the object to be saved.
+            account.setting_group(this.account.setting_group().id());
+            account.usr(this.account.usr().id());
+            account.sip_username(this.account.sip_username());
+            account.sip_password(this.account.sip_password());
+        };
+    }
+
+    fetchGroups() {
+        this.pcrud.retrieveAll('sipsetg',
+            {order_by: {sipsetg: 'label'}}, {atomic: true})
+        .subscribe(grps => {
+            this.settingGroups =
+                grps.map(g => ({id: g.id(), label: g.label()}));
+        });
+    }
+
+    loadAccount(): Observable<any> {
+        return this.pcrud.retrieve('sipacc', this.accountId, {
+            flesh: 2,
+            flesh_fields: {
+                sipacc: ['usr', 'setting_group', 'workstation'],
+                sipsetg: ['settings']
+            }}, {authoritative: true}
+        ).pipe(tap(acc => {
+            this.account = acc;
+            this.usrId = acc.usr().id();
+            this.usrCboxEntries =
+                [{id: acc.usr().id(), label: acc.usr().usrname()}];
+        }));
+    }
+
+    grpChanged(entry: ComboboxEntry) {
+
+        if (!entry) {
+            this.account.setting_group(null);
+            return;
+        }
+
+        this.pcrud.retrieve('sipsetg', entry.id,
+            {flesh: 1, flesh_fields: {sipsetg: ['settings']}})
+        .subscribe(grp => {
+            this.account.setting_group(grp);
+            if (this.settingGrid) {
+                this.settingGrid.reload();
+            }
+        });
+    }
+
+    usrChanged(entry: ComboboxEntry) {
+        if (!entry) {
+            this.account.usr(null);
+            return;
+        }
+
+        this.pcrud.retrieve('au', entry.id)
+            .subscribe(usr => this.account.usr(usr));
+    }
+
+
+    // Create a new setting group
+    // Clone the settings for the currently selected group into the new group
+    // Point our account at the new group.
+    openCloneDialog() {
+        this.cloneDialog.open().subscribe(resp => {
+            if (!resp) { return; }
+
+            this.settingGroups.unshift({id: resp.id(), label: resp.label()});
+
+            const settings = this.account.setting_group().settings()
+                .map(setting => {
+                    const clone = this.idl.clone(setting);
+                    clone.setting_group(resp.id());
+                    clone.isnew(true);
+                    clone.id(null);
+                    return clone;
+                });
+
+            // avoid de-fleshing the group on the active account
+            const modified = this.idl.clone(this.account);
+            modified.setting_group(resp.id());
+            modified.ischanged(true);
+
+            this.pcrud.autoApply(settings.concat(modified)).toPromise()
+            .then(_ => this.refreshAccount());
+        });
+    }
+
+    openDeleteDialog() {
+        this.deleteGroupDialog.open().subscribe(
+            ok => {
+                if (ok) {
+                    this.refreshAccount();
+                }
+            }
+        );
+    }
+
+    accountSaved(account) {
+
+        if (this.createMode) {
+            account.isnew(true);
+        } else {
+            account.ischanged(true);
+        }
+
+        this.net.request('open-ils.sip2',
+            'open-ils.sip2.account.cud', this.auth.token(), account)
+        .subscribe(acc => {
+
+            const evt = this.evt.parse(acc);
+
+            if (evt) {
+                console.error(evt);
+                return;
+            }
+
+            if (this.createMode) {
+                this.router.navigate(
+                    [`/staff/admin/server/sip/account/${acc.id()}`]);
+            } else {
+                this.refreshAccount();
+            }
+        });
+    }
+
+    editFirstSetting(rows: any) {
+        if (rows.length > 0) {
+            this.editSetting(rows[0]);
+        }
+    }
+
+    refreshAccount() {
+        this.loadAccount().subscribe(_ => {
+            setTimeout(() => {
+                if (this.settingGrid) {
+                    this.settingGrid.reload();
+                }
+            });
+        });
+    }
+
+    editSetting(row: any) {
+        // Default Settings group is read-only
+        if (row.setting_group() === 1) { return; }
+
+        this.settingDialog.record = this.idl.clone(row);
+        this.settingDialog.open().subscribe(
+            ok => this.refreshAccount(),
+            err => {} // todo toast
+        );
+    }
+
+
+    setPassword() {
+        this.passwordDialog.open().subscribe(value => {
+            // API will translate this into an actor.passwd
+            this.account.sip_password(value);
+        });
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/sip/account.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/sip/account.module.ts
new file mode 100644 (file)
index 0000000..d97d354
--- /dev/null
@@ -0,0 +1,27 @@
+import {NgModule} from '@angular/core';
+import {AdminCommonModule} from '@eg/staff/admin/common.module';
+import {SipAccountRoutingModule} from './routing.module';
+import {SipAccountListComponent} from './account-list.component';
+import {SipAccountComponent} from './account.component';
+import {DeleteGroupDialogComponent} from './delete-group-dialog.component';
+
+@NgModule({
+  declarations: [
+    SipAccountComponent,
+    SipAccountListComponent,
+    DeleteGroupDialogComponent
+  ],
+  imports: [
+    AdminCommonModule,
+    SipAccountRoutingModule
+  ],
+  exports: [
+  ],
+  providers: [
+  ]
+})
+
+export class SipAccountModule {
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/sip/delete-group-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/sip/delete-group-dialog.component.html
new file mode 100644 (file)
index 0000000..b8ae758
--- /dev/null
@@ -0,0 +1,37 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>Delete Setting Group</h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <div class="row">
+      <div class="col-lg-12" *ngIf="group" i18n>
+        Deleting SIP setting group: {{group.label()}}
+      </div>
+    </div>
+    <div class="row mt-2 pt-2 border-top">
+      <div class="col-lg-12" i18n>
+        Select another group to act as the transfer target group.
+        Any SIP accounts linked to the deleted group will be transferred
+        to this group:
+      </div>
+    </div>
+    <div class="row mt-2 pt-2 border-top">
+      <div class="col-lg-8 offset-lg-2" i18n>
+        <eg-combobox #grpCbox required="true" [selectedId]="targetGroup"
+          [entries]="trimmedSettingGroups" (onChange)="grpChanged($event)">
+        </eg-combobox>
+      </div>
+    </div>
+
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-success" 
+      (click)="doDelete(true)" i18n>Confirm</button>
+    <button type="button" class="btn btn-warning" 
+      (click)="close(false)" i18n>Cancel</button>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/sip/delete-group-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/sip/delete-group-dialog.component.ts
new file mode 100644 (file)
index 0000000..1c43fab
--- /dev/null
@@ -0,0 +1,59 @@
+import {Component, Input, ViewChild, OnInit} from '@angular/core';
+import {Observable, of} from 'rxjs';
+import {map, tap, switchMap, catchError} from 'rxjs/operators';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {NetService} from '@eg/core/net.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {StringService} from '@eg/share/string/string.service';
+import {NgbModal, NgbModalRef, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {ComboboxEntry, ComboboxComponent} from '@eg/share/combobox/combobox.component';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {Pager} from '@eg/share/util/pager';
+
+@Component({
+    templateUrl: './delete-group-dialog.component.html',
+    selector: 'eg-sip-group-delete-dialog'
+})
+export class DeleteGroupDialogComponent extends DialogComponent implements OnInit {
+
+    @Input() group: IdlObject;
+    @Input() settingGroups: ComboboxEntry[];
+    targetGroup = 1;  // Default to the 'Default Settings' group.
+    trimmedSettingGroups: ComboboxEntry[];
+
+    constructor(
+        private modal: NgbModal,
+        private auth: AuthService,
+        private net: NetService
+    ) {
+        super(modal);
+    }
+
+    ngOnInit() {
+        this.onOpen$.subscribe(_ => {
+            this.trimmedSettingGroups = this.settingGroups.filter(
+                entry => entry.id !== this.group.id());
+        });
+    }
+
+    grpChanged(entry: ComboboxEntry) {
+        if (entry) {
+            this.targetGroup = entry.id;
+        }
+    }
+
+    doDelete() {
+        this.net.request('open-ils.sip2',
+            'open-ils.sip2.setting_group.delete',
+            this.auth.token(), this.group.id(), this.targetGroup
+        ).subscribe(ok => this.close((Number(ok) === 1)));
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/sip/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/sip/routing.module.ts
new file mode 100644 (file)
index 0000000..ee6f41d
--- /dev/null
@@ -0,0 +1,20 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {SipAccountListComponent} from './account-list.component';
+import {SipAccountComponent} from './account.component';
+
+const routes: Routes = [{
+    path: '',
+    component: SipAccountListComponent
+}, {
+    path: ':id',
+    component: SipAccountComponent
+}];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule]
+})
+
+export class SipAccountRoutingModule {}
+
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2.pm
new file mode 100644 (file)
index 0000000..887de63
--- /dev/null
@@ -0,0 +1,738 @@
+package OpenILS::Application::SIP2;
+use strict; use warnings;
+use base 'OpenILS::Application';
+use DateTime;
+use DateTime::Format::ISO8601;
+use OpenILS::Application;
+use OpenILS::Event;
+use OpenILS::Utils::Fieldmapper;
+use OpenSRF::Utils::Logger qw(:logger);
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::DateTime qw/:datetime/;
+use OpenILS::Application::SIP2::Common;
+use OpenILS::Application::SIP2::Session;
+use OpenILS::Application::SIP2::Item;
+use OpenILS::Application::SIP2::Hold;
+use OpenILS::Application::SIP2::Patron;
+use OpenILS::Application::SIP2::Checkout;
+use OpenILS::Application::SIP2::Checkin;
+use OpenILS::Application::SIP2::Payment;
+use OpenILS::Application::SIP2::Admin;
+
+my $U = 'OpenILS::Application::AppUtils';
+my $SC = 'OpenILS::Application::SIP2::Common';
+
+__PACKAGE__->register_method(
+    method    => 'dispatch_sip2_request',
+    api_name  => 'open-ils.sip2.request', 
+    api_level => 1,
+    argc      => 2,
+    signature => {
+        desc     => q/
+            Takes a SIP2 JSON message and handles the request/,
+        params   => [{   
+            name => 'seskey',
+            desc => 'The SIP2 session key',
+            type => 'string'
+        }, {
+            name => 'message',
+            desc => 'SIP2 JSON message',
+            type => q/SIP JSON object/
+        }],
+        return => {
+            desc => q/SIP2 JSON message on success, Event on error/,
+            type => 'object'
+        }
+    }
+);
+
+sub dispatch_sip2_request {
+    my ($self, $client, $seskey, $message) = @_;
+
+    OpenSRF::AppSession->ingress('sip2');
+
+    return OpenILS::Event->new('SIP2_SESSION_REQUIRED') unless $seskey;
+    my $msg_code = $message->{code};
+
+    return handle_login($seskey, $message) if $msg_code eq '93';
+    return handle_sc_status($seskey, $message) if $msg_code eq '99';
+
+    # A cached session means we have successfully logged in with
+    # the SIP credentials provided during a login request.  All
+    # message types following require authentication.
+    my $session = OpenILS::Application::SIPSession->find($seskey);
+
+    if (!$session) {
+        return undef if $msg_code eq 'XS'; # end session signal
+        return OpenILS::Event->new('SIP2_SESSION_REQUIRED');
+    }
+
+    my $MESSAGE_MAP = {
+        '09' => \&handle_checkin,
+        '11' => \&handle_checkout,
+        '15' => \&handle_hold,
+        '17' => \&handle_item_info,
+        '23' => \&handle_patron_status,
+        '29' => \&handle_renew,
+        '37' => \&handle_payment,
+        '63' => \&handle_patron_info,
+        '65' => \&handle_renew_all,
+        'XS' => \&handle_end_session
+    };
+
+    return OpenILS::Event->new('SIP2_NOT_IMPLEMENTED', {payload => $message})
+        unless exists $MESSAGE_MAP->{$msg_code};
+
+    return $MESSAGE_MAP->{$msg_code}->($session, $message);
+}
+
+sub handle_end_session {
+    my ($session, $message) = @_;
+    my $e = $session->editor;
+    my $seskey = $session->seskey;
+
+    $SC->cache->delete_cache("sip2_$seskey");
+
+    $U->simplereq('open-ils.auth', 
+        'open-ils.auth.session.delete', $e->authtoken);
+
+    return undef if $U->is_true($session->sip_account->transient);
+
+    $e->xact_begin;
+    my $ses = $e->retrieve_sip_session($seskey);
+    if ($ses) {
+        $e->delete_sip_session($ses);
+        $e->commit;
+    } else {
+        $e->rollback;
+    }
+
+    return undef;
+}
+
+# Login to Evergreen and cache the login data.
+sub handle_login {
+    my ($seskey, $message) = @_;
+    my $e = new_editor();
+
+    # Default to login-failed
+    my $response = {code => '94', fixed_fields => ['0']};
+
+    my $sip_username = $SC->get_field_value($message, 'CN');
+    my $sip_password = $SC->get_field_value($message, 'CO');
+    my $sip_account = $e->search_sip_account([
+        {sip_username => $sip_username, enabled => 't'}, 
+        {flesh => 1, flesh_fields => {sipacc => ['workstation']}}
+    ])->[0];
+
+    if (!$sip_account) {
+        $logger->warn("SIP2: No such SIP account: $sip_username");
+        return $response;
+    }
+
+    if ($U->verify_user_password($e, $sip_account->usr, $sip_password, 'sip2')) {
+    
+        my $session = OpenILS::Application::SIPSession->new(
+            seskey => $seskey,
+            sip_account => $sip_account
+        );
+        $response->{fixed_fields}->[0] = '1' if $session->set_ils_account;
+
+    } else {
+        $logger->info("SIP2: login failed for user=$sip_username")
+    }
+
+    return $response;
+}
+
+sub handle_sc_status {
+    my ($seskey, $message) = @_;
+
+    my $session = OpenILS::Application::SIPSession->find($seskey);
+
+    my $config;
+
+    if ($session) {
+        $config = $session->config;
+
+    } else {
+
+        # Confirm sc-status-before-login is enabled before continuing.
+        
+        my $flag = new_editor()->search_config_global_flag({
+            name => 'sip.sc_status_before_login_institution',
+            value => {'!=' => undef},
+            enabled => 't',
+        })->[0];
+
+        return OpenILS::Event->new(
+            'SC_STATUS_REQUIRES_LOGIN', {payload => $message}) unless $flag;
+
+        $config = {
+            settings => {},
+            id => $flag->value,
+            supports => OpenILS::Application::SIPSession->supports
+        };
+    }
+
+    my $response = {
+        code => '98',
+        fixed_fields => [
+            $SC->sipbool(1),    # online_status
+            $SC->sipbool(1),    # checkin_ok
+            $SC->sipbool(1),    # checkout_ok
+            $SC->sipbool(1),    # acs_renewal_policy
+            $SC->sipbool(0),    # status_update_ok
+            $SC->sipbool(0),    # offline_ok
+            '999',              # timeout_period
+            '999',              # retries_allowed
+            $SC->sipdate,       # transaction date
+            '2.00'              # protocol_version
+        ],
+        fields => [
+            {AO => $config->{institution}},
+            {BX => join('', @{$config->{supports}})}
+        ]
+    }
+}
+
+sub handle_item_info {
+    my ($session, $message) = @_;
+
+    my $barcode = $SC->get_field_value($message, 'AB');
+    my $config = $session->config;
+
+    my $details = OpenILS::Application::SIP2::Item->get_item_details(
+        $session, barcode => $barcode
+    );
+
+    if (!$details) {
+        # No matching item found, return a minimal response.
+        return {
+            code => '18',
+            fixed_fields => [
+                '01', # circ status: other/Unknown
+                '01', # security marker: other/unknown
+                '01', # fee type: other/unknown
+                $SC->sipdate
+            ],
+            fields => [{AB => $barcode, AJ => ''}]
+        };
+    };
+
+    return {
+        code => '18',
+        fixed_fields => [
+            $details->{circ_status},
+            '02', # Security Marker, consistent with ../SIP*
+            $details->{fee_type},
+            $SC->sipdate
+        ],
+        fields => [
+            {AB => $barcode},
+            {AH => $details->{due_date}},
+            {AJ => $details->{title}},
+            {AP => $details->{current_loc}},
+            {AQ => $details->{permanent_loc}},
+            {BG => $details->{owning_loc}},
+            {BH => $config->{settings}->{currency}},
+            {BV => $details->{item}->deposit_amount},
+            {CF => $details->{hold_queue_length}},
+            {CK => $details->{media_type}},
+            {CM => $details->{hold_pickup_date}},
+            {CT => $details->{destination_loc}},
+            {CY => $details->{hold_patron_barcode}}
+        ]
+    };
+}
+
+sub handle_patron_info {
+    my ($session, $message) = @_;
+    my $sip_account = $session->sip_account;
+
+    my $barcode = $SC->get_field_value($message, 'AA');
+    my $password = $SC->get_field_value($message, 'AD');
+
+    my $summary = 
+        ref $message->{fixed_fields} ? $message->{fixed_fields}->[2] : '';
+
+    my $list_items = $SC->patron_summary_list_items($summary);
+
+    my $details = OpenILS::Application::SIP2::Patron->get_patron_details(
+        $session,
+        barcode => $barcode,
+        password => $password,
+        summary_start_item => $SC->get_field_value($message, 'BP'),
+        summary_end_item => $SC->get_field_value($message, 'BQ'),
+        summary_list_items => $list_items
+    );
+
+    my $response = 
+        patron_response_common_data($session, $barcode, $password, $details);
+
+    $response->{code} = '64';
+
+    return $response unless $details;
+    my $patron = $details->{patron};
+
+    push(
+        @{$response->{fixed_fields}}, 
+        $SC->count4($details->{holds_count}),
+        $SC->count4($details->{overdue_count}),
+        $SC->count4($details->{out_count}),
+        $SC->count4($details->{fine_count}),
+        $SC->count4($details->{recall_count}),
+        $SC->count4($details->{unavail_holds_count})
+    );
+
+    push(
+        @{$response->{fields}}, 
+        {BE => $patron->email},
+        {PA => $SC->sipymd($patron->expire_date)},
+        {PB => $SC->sipymd($patron->dob, 1)},
+        {PC => $patron->profile->name},
+        {XI => $patron->id}
+    );
+
+    if ($list_items eq 'hold_items') {
+        for my $hold (@{$details->{hold_items}}) {
+            push(@{$response->{fields}}, {AS => $hold});
+        }
+    } elsif ($list_items eq 'charged_items') {
+        for my $item (@{$details->{items_out}}) {
+            push(@{$response->{fields}}, {AU => $item});
+        }
+    } elsif ($list_items eq 'overdue_items') {
+        for my $item (@{$details->{overdue_items}}) {
+            push(@{$response->{fields}}, {AT => $item});
+        }
+    } elsif ($list_items eq 'fine_items') {
+        for my $item (@{$details->{fine_items}}) {
+            push(@{$response->{fields}}, {AV => $item});
+        }
+    } elsif ($list_items eq 'unavailable_holds') {
+        for my $item (@{$details->{unavailable_holds}}) {
+            push(@{$response->{fields}}, {CD => $item});
+        }
+    }
+
+    # NOTE: Recall Items (BU) is not supported.
+
+    return $response;
+}
+
+sub handle_patron_status {
+    my ($session, $message) = @_;
+    my $sip_account = $session->sip_account;
+
+    my $barcode = $SC->get_field_value($message, 'AA');
+    my $password = $SC->get_field_value($message, 'AD');
+
+    my $details = OpenILS::Application::SIP2::Patron->get_patron_details(
+        $session,
+        barcode => $barcode,
+        password => $password
+    );
+
+    my $response = patron_response_common_data(
+        $session, $barcode, $password, $details);
+
+    $response->{code} = '24';
+
+    return $response;
+}
+
+# Patron Info and Patron Status responses share mostly the same data.
+# This returns the base data which can be augmented as needed.
+# Note we don't call Patron->get_patron_details here since different
+# messages collect different amounts of data.
+sub patron_response_common_data {
+    my ($session, $barcode, $password, $details) = @_;
+
+    if (!$details) {
+        # No such user.  Return a stub response with all things denied.
+
+        return {
+            fixed_fields => [
+                $SC->spacebool(1), # charge denied
+                $SC->spacebool(1), # renew denied
+                $SC->spacebool(1), # recall denied
+                $SC->spacebool(1), # holds denied
+                split('', (' ' x 10)),
+                '000', # language
+                $SC->sipdate
+            ],
+            fields => [
+                {AO => $session->config->{institution}},
+                {AA => $barcode},
+                {BL => $SC->sipbool(0)}, # valid patron
+                {CQ => $SC->sipbool(0)}  # valid patron password
+            ]
+        };
+    }
+
+    my $patron = $details->{patron};
+    return {
+        fixed_fields => [
+            $SC->spacebool($details->{charge_denied}),
+            $SC->spacebool($details->{renew_denied}),
+            $SC->spacebool($details->{recall_denied}),
+            $SC->spacebool($details->{holds_denied}),
+            $SC->spacebool($patron->card->active eq 'f'),
+            $SC->spacebool(0), # too many charged
+            $SC->spacebool($details->{too_may_overdue}),
+            $SC->spacebool(0), # too many renewals
+            $SC->spacebool(0), # too many claims retruned
+            $SC->spacebool(0), # too many lost
+            $SC->spacebool($details->{too_many_fines}),
+            $SC->spacebool($details->{too_many_fines}),
+            $SC->spacebool(0), # recall overdue
+            $SC->spacebool($details->{too_many_fines}),
+            '000', # language
+            $SC->sipdate
+        ],
+        fields => [
+            {AA => $barcode},
+            {AO => $session->config->{institution}},
+            {BH => $session->config->{settings}->{currency}},
+            {BL => $SC->sipbool(1)},          # valid patron
+            {BV => $details->{balance_owed}}, # fee amount
+            {CQ => $SC->sipbool($password)}   # password verified if exists
+        ]
+    };
+}
+
+
+sub handle_checkout {
+    my ($session, $message) = @_;
+    return checkout_renew_common($session, $message);
+}
+
+sub handle_renew {
+    my ($session, $message) = @_;
+    return checkout_renew_common($session, $message, 1);
+}
+
+sub checkout_renew_common {
+    my ($session, $message, $is_renewal) = @_;
+    my $config = $session->config;
+
+    my $patron_barcode = $SC->get_field_value($message, 'AA');
+    my $item_barcode = $SC->get_field_value($message, 'AB');
+    my $fee_ack = $SC->get_field_value($message, 'BO');
+
+    my $code = $is_renewal ? '30' : '12';
+    my $stub = {
+        code => $code,
+        fixed_fields => [
+            0,                  # checkout ok
+            $SC->sipbool(0),    # renewal ok
+            $SC->sipbool(0),    # magnetic media
+            $SC->sipbool(0),    # desensitize
+            $SC->sipdate,       # transaction date
+        ],
+        fields => [
+            {AA => $patron_barcode},
+            {AB => $item_barcode}
+        ]
+    };
+
+    my $item_details = OpenILS::Application::SIP2::Item->get_item_details(
+        $session, barcode => $item_barcode);
+
+    return $stub unless $item_details;
+
+    my $patron_details = OpenILS::Application::SIP2::Patron->get_patron_details(
+        $session, barcode => $patron_barcode);
+    
+    return $stub unless $patron_details;
+
+    my $circ_details = OpenILS::Application::SIP2::Checkout->checkout(
+        $session, 
+        patron_barcode => $patron_barcode, 
+        item_barcode => $item_barcode,
+        fee_ack => $fee_ack,
+        is_renew => $is_renewal
+    );
+
+    my $magnetic = $item_details->{magnetic_media};
+    my $deposit = $item_details->{item}->deposit_amount;
+    my $screen_msg = $circ_details->{screen_msg};
+    my $due_date = $circ_details->{due_date};
+    my $circ = $circ_details->{circ};
+
+    my $can_renew = 0;
+    if ($circ) {
+        $can_renew = !$patron_details->{renew_denied} 
+            && $circ->renewal_remaining > 0;
+    }
+
+    return {
+        code => $code,
+        fixed_fields => [
+            $circ ? 1 : 0,              # checkout ok
+            $SC->sipbool($can_renew),   # renewal ok
+            $SC->sipbool($magnetic),    # magnetic media
+            $SC->sipbool(!$magnetic),   # desensitize
+            $SC->sipdate,               # transaction date
+        ],
+        fields => [
+            {AA => $patron_barcode},
+            {AB => $item_barcode},
+            {AJ => $item_details->{title}},
+            {AO => $config->{institution}},
+            {BT => $item_details->{fee_type}},
+            {CI => 0}, # security inhibit
+            {CK => $item_details->{media_type}},
+            $screen_msg ? {AF => $screen_msg}   : (),
+            $due_date   ? {AH => $due_date}     : (),
+            $circ       ? {BK => $circ->id}     : (),
+            $deposit    ? {BV => $deposit}      : (),
+        ]
+    };
+}
+
+sub handle_renew_all {
+    my ($session, $message) = @_;
+    my $config = $session->config;
+
+    my $patron_barcode = $SC->get_field_value($message, 'AA');
+    my $fee_ack = $SC->get_field_value($message, 'BO');
+
+    my $stub = {
+        code => '66',
+        fixed_fields => [
+            0,              # ok
+            $SC->count4(0), # renewed count
+            $SC->count4(0), # unrenewed count
+            $SC->sipdate,   # transaction date
+        ],
+        fields => [
+            {AA => $patron_barcode},
+            {AO => $config->{institution}},
+        ]
+    };
+
+    my $patron_details = OpenILS::Application::SIP2::Patron->get_patron_details(
+        $session, barcode => $patron_barcode);
+
+    return $stub unless $patron_details;
+
+    my $circ_details = OpenILS::Application::SIP2::Checkout->renew_all(
+        $session, $patron_details, fee_ack => $fee_ack
+    );
+
+    my $screen_msg = $circ_details->{screen_msg};
+    my @renewed = @{$circ_details->{items_renewed}};
+    my @unrenewed = @{$circ_details->{items_unrenewed}};
+
+    return {
+        code => '66',
+        fixed_fields => [
+            1, # ok
+            $SC->count4(scalar(@renewed)),
+            $SC->count4(scalar(@unrenewed)),
+            $SC->sipdate,
+        ],
+        fields => [
+            {AA => $patron_barcode},
+            {AO => $config->{institution}},
+            @{ [ map { {BM => $_} } @renewed ] },
+            @{ [ map { {BN => $_} } @unrenewed ] },
+            $screen_msg ? {AF => $screen_msg} : ()
+        ]
+    };
+}
+
+
+sub handle_checkin {
+    my ($session, $message) = @_;
+    my $config = $session->config;
+
+    my @fixed_fields = @{$message->{fixed_fields} || []};
+
+    my $item_barcode = $SC->get_field_value($message, 'AB');
+    my $current_loc = $SC->get_field_value($message, 'AP');
+    my $return_date = $fixed_fields[2];
+
+    my $stub = {
+        code => '10',
+        fixed_fields => [
+            0,                  # checkin ok
+            $SC->sipbool(0),    # resensitize
+            $SC->sipbool(0),    # magnetic media
+            'N',                # alert
+            $SC->sipdate,       # transaction date
+        ],
+        fields => [
+            {AB => $item_barcode},
+            {AO => $config->{institution}},
+            {CV => '00'} # unkown alert type
+        ]
+    };
+
+    my $item_details = OpenILS::Application::SIP2::Item->get_item_details(
+        $session, barcode => $item_barcode);
+
+    return $stub unless $item_details;
+
+    my $checkin_details = OpenILS::Application::SIP2::Checkin->checkin(
+        $session, 
+        item_barcode => $item_barcode,
+        current_loc => $current_loc,
+        item_details => $item_details,
+        return_date => $return_date
+    );
+
+    my $screen_msg = $checkin_details->{screen_msg};
+    my $magnetic = $item_details->{magnetic_media};
+    my $hold_bc = $checkin_details->{hold_patron_barcode};
+    my $hold_name = $checkin_details->{hold_patron_name};
+    my $dest_loc = $checkin_details->{destination_loc};
+
+    return {
+        code => '10',
+        fixed_fields => [
+            $checkin_details->{ok},                     # checkin ok
+            $SC->sipbool(!$magnetic),                   # resensitize
+            $SC->sipbool($magnetic),                    # magnetic media
+            $SC->sipbool($checkin_details->{alert}),    # alert
+            $SC->sipdate,                               # transaction date
+        ],
+        fields => [
+            {AA => $checkin_details->{patron_barcode}},
+            {AB => $item_barcode},
+            {AJ => $item_details->{title}},
+            {AO => $config->{institution}},
+            {AP => $checkin_details->{current_loc}},
+            {AQ => $checkin_details->{permanent_loc}},
+            {BG => $item_details->{owning_loc}},
+            {BT => $item_details->{fee_type}},
+            {CI => 0}, # security inhibit
+            {CK => $item_details->{media_type}},
+            {CV => $checkin_details->{alert_type}},
+            $screen_msg ? {AF => $screen_msg}   : (),
+            $dest_loc   ? {CT => $dest_loc}     : (),
+            $hold_bc    ? {CY => $hold_bc}      : (),
+            $hold_name  ? {DA => $hold_name}    : ()
+        ]
+    };
+}
+
+sub handle_hold {
+    my ($session, $message) = @_;
+    my $config = $session->config;
+
+    my @fixed_fields = @{$message->{fixed_fields} || []};
+    my $hold_mode = $fixed_fields[0];
+
+    my $patron_barcode = $SC->get_field_value($message, 'AA');
+    my $item_barcode = $SC->get_field_value($message, 'AB');
+
+    my $stub = {
+        code => '16',
+        fixed_fields => [
+            0, # ok
+            0, # available
+            $SC->sipdate
+        ],
+        fields => [
+            {AA => $patron_barcode},
+            {AB => $item_barcode},
+            {AO => $config->{institution}}
+        ]
+    };
+
+    # Hold Cancel is the only supported action.
+    return $stub unless $hold_mode eq '-';
+
+    my $patron_details = 
+        OpenILS::Application::SIP2::Patron->get_patron_details(
+        $session, barcode => $patron_barcode);
+
+    return $stub unless $patron_details;
+
+    my $item_details = OpenILS::Application::SIP2::Item->get_item_details(
+        $session, barcode => $item_barcode);
+
+    return $stub unless $item_details;
+
+    my $hold = OpenILS::Application::SIP2::Hold->hold_from_copy(
+        $session, $patron_details, $item_details);
+
+    return $stub unless $hold;
+
+    my $details = OpenILS::Application::SIP2::Hold->cancel($session, $hold);
+
+    return $stub unless $details->{ok};
+    
+    # report info on the targeted copy if one is set.
+    my $copy = $hold->current_copy || $item_details->{item};
+    my $title_id = $item_details->{item}->call_number->record->id;
+
+    return {
+        code => '16',
+        fixed_fields => [
+            1, # ok
+            0, # available
+            $SC->sipdate
+        ],
+        fields => [
+            {AA => $patron_barcode},
+            {AB => $copy->barcode},
+            {AJ => $title_id},
+            {AO => $config->{institution}}
+        ]
+    };
+}
+
+sub handle_payment {
+    my ($session, $message) = @_;
+    my $config = $session->config;
+
+    my @fixed_fields = @{$message->{fixed_fields} || []};
+
+    my $fee_type = $fixed_fields[1];
+    my $pay_type = $fixed_fields[2];
+    my $pay_amount = $SC->get_field_value($message, 'BV');
+    my $patron_barcode = $SC->get_field_value($message, 'AA');
+    my $fee_id = $SC->get_field_value($message, 'CG');
+    my $terminal_xact = $SC->get_field_value($message, 'BK');
+
+    # Envisionware extensions for relaying information about 
+    # payments made via credit card kiosk or cash register.
+    my $register_login = $SC->get_field_value($message, 'OR');
+    my $check_number = $SC->get_field_value($message, 'RN');
+
+    my $details = OpenILS::Application::SIP2::Payment->apply_payment(
+        $session, 
+        fee_id => $fee_id,
+        fee_type => $fee_type,
+        pay_type => $pay_type,
+        pay_amount => $pay_amount,
+        check_number => $check_number,
+        patron_barcode => $patron_barcode,
+        terminal_xact => $terminal_xact,
+        register_login => $register_login
+    );
+
+    my $screen_msg = $details->{screen_msg};
+
+    return {
+        code => '38',
+        fixed_fields => [
+            $SC->sipbool($details->{ok}),
+            $SC->sipdate,
+        ],
+        fields => [
+            {AA => $patron_barcode},
+            {AO => $config->{institution}},
+            $screen_msg ? {AF => $screen_msg} : (),
+        ]
+    }
+}
+
+1;
+
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Admin.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Admin.pm
new file mode 100644 (file)
index 0000000..b5cb3eb
--- /dev/null
@@ -0,0 +1,125 @@
+package OpenILS::Application::SIP2::Admin;
+use strict; use warnings;
+use base 'OpenILS::Application';
+use OpenILS::Event;
+use OpenILS::Application;
+use OpenILS::Utils::Fieldmapper;
+use OpenSRF::Utils::Logger qw(:logger);
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenILS::Application::AppUtils;
+
+my $U = 'OpenILS::Application::AppUtils';
+
+__PACKAGE__->register_method(
+    method    => 'delete_setting_group',
+    api_name  => 'open-ils.sip2.setting_group.delete',
+    api_level => 1,
+    argc      => 2,
+    signature => {
+        desc     => q/
+            Takes a SIP2 JSON message and handles the request/,
+        params   => [{   
+            name => 'auth',
+            desc => 'Authtoken',
+            type => 'string'
+        }, {
+            name => 'del_grp_id',
+            desc => 'Setting group ID to delete',
+            type => 'number',
+        }, {
+            name => 'xfer_grp_id',
+            desc => q/Setting group ID to use as account transfer destination.
+                    If no destination group is specified, defaults to setting
+                    group ID 1 (Defaults)/,
+            type => 'number',
+        }],
+        return => {
+            desc => q/1 on success, Event on error/,
+            type => 'number | object'
+        }
+    }
+);
+
+sub delete_setting_group {
+    my ($self, $client, $auth, $del_grp_id, $xfer_grp_id) = @_;
+    $xfer_grp_id ||= 1; # Defaults Group
+    
+    my $e = new_editor(authtoken => $auth, xact => 1);
+    return $e->die_event unless $e->checkauth;
+    return $e->die_event unless $e->allowed('SIP_ADMIN');
+
+    return $e->die_event unless
+        my $grp = $e->retrieve_sip_setting_group($del_grp_id);
+
+    my $accounts = $e->search_sip_account({setting_group => $del_grp_id});
+
+    for my $acc (@$accounts) {
+        $acc->setting_group($xfer_grp_id);
+        return $e->die_event unless $e->update_sip_account($acc);
+    }
+
+    # note: sip.setting objects are deleted via cascade
+    return $e->die_event 
+        unless $e->delete_sip_setting_group($grp) && $e->commit;
+
+    return 1;
+}
+
+__PACKAGE__->register_method(
+    method    => 'account_cud',
+    api_name  => 'open-ils.sip2.account.cud',
+    api_level => 1,
+    argc      => 2,
+    signature => {
+        desc     => q/Create, Update, Delete SIP accounts.  If a value is
+            stored in the virtual sip_password field on the account, the
+            value will be used as the new password for the account/,
+        params   => [{   
+            name => 'auth',
+            desc => 'Authtoken',
+            type => 'string'
+        }, {
+            name => 'account',
+            desc => 'SIP account object',
+            type => 'object'
+        }],
+        return => {
+            desc => q/Account object on success, Event on error/,
+            type => 'object'
+        }
+    }
+);
+
+sub account_cud {
+    my ($self, $client, $auth, $account) = @_;
+
+    my $e = new_editor(authtoken => $auth, xact => 1);
+    return $e->die_event unless $e->checkauth;
+    return $e->die_event unless $e->allowed('SIP_ADMIN');
+
+    if ($account->sip_password) {
+        my $pw = $e->json_query({from => ['actor.change_password', 
+            $account->usr, $account->sip_password, 'sip2']});
+
+        return $e->die_event unless $pw;
+    }
+
+    if ($account->isnew) {
+        return undef unless $e->create_sip_account($account);
+        
+    } elsif ($account->ischanged) {
+        return undef unless $e->update_sip_account($account);
+
+    } elsif ($account->isdeleted) {
+        return undef unless $e->delete_sip_account($account);
+    }
+
+    $account = $e->retrieve_sip_account($account->id);
+
+    return $e->die_event unless $e->commit;
+
+    return $account;
+}
+
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Checkin.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Checkin.pm
new file mode 100644 (file)
index 0000000..1df5f80
--- /dev/null
@@ -0,0 +1,156 @@
+package OpenILS::Application::SIP2::Checkin;
+use strict; use warnings;
+use DateTime;
+use DateTime::Format::ISO8601;
+use OpenSRF::System;
+use OpenILS::Utils::CStoreEditor q/:funcs/;
+use OpenSRF::Utils::Logger q/$logger/;
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::DateTime qw/:datetime/;
+use OpenILS::Const qw/:const/;
+use OpenILS::Application::SIP2::Common;
+use OpenILS::Application::SIP2::Session;
+my $U = 'OpenILS::Application::AppUtils';
+my $SC = 'OpenILS::Application::SIP2::Common';
+
+
+sub checkin {
+    my ($class, $session, %params) = @_;
+
+    my $details = {};
+    my $override = 0;
+
+    for (0, 1) { # 2 checkin requests max
+
+        $override = perform_checkin($session, $details, $override, %params);
+
+        last unless $override;
+    }
+    
+    return $details;
+}
+
+
+# Returns 1 if the checkin should be performed again with override.
+# Returns 0 if there's nothing left to do (final success / error)
+# Updates $details along the way.
+sub perform_checkin {
+    my ($session, $details, $override, %params) = @_;
+    my $config = $session->config;
+    my $item_details = $params{item_details};
+
+    my $args = {
+        copy_barcode => $params{item_barcode},
+        hold_as_transit => $config->{checkin_hold_as_transit}
+    };
+
+    if (my $backdate = $params{return_date}) {
+        $backdate =~ s/(\d{4})(\d{2})(\d{2}).*/$1-$2-$3/; # YYYYMMDD => ISO
+        $logger->info("Checking in with backdate $backdate");
+        $args->{backdate} = $backdate;
+    }
+
+    $args->{circ_lib} = 
+        $SC->org_id_from_sn($session, $params{corrent_loc}) 
+        || $session->editor->requestor->ws_ou;
+
+    my $method = 'open-ils.circ.checkin';
+    $method .= '.override' if $override;
+
+    my $resp = $U->simplereq(
+        'open-ils.circ', $method, $session->editor->authtoken, $args);
+
+    # Treat the first response as the main result.
+    my $event = ref $resp eq 'ARRAY' ? $resp->[0] : $resp;
+
+    return unless $U->is_event($event); # should never happen; fail gracefully
+
+    my $textcode = $event->{textcode};
+    my $payload = $event->{payload} || {};
+
+    return 1 if !$override && $config->{"checkin.override.$textcode"};
+
+    my $circ = $payload->{circ};
+    my $copy = $payload->{copy};
+
+    # These may be replaced below
+    $details->{current_loc} = 
+        $params{item_details}->{item}->circ_lib->shortname;
+
+    $details->{permanent_loc} = 
+        $params{item_details}->{item}->circ_lib->shortname;
+
+    $details->{destination_loc} = 
+        $SC->org_sn_from_id($event->{org}) if $event->{org};
+
+   if ($copy && $copy->circ_lib != $item_details->{item}->circ_lib->id) {
+        # Checkin of floating copies changes the circ lib.
+        $details->{current_loc} = 
+            $details->{permanent_loc} = 
+            $SC->org_sn_from_id($session, $copy->circ_lib);
+    }
+
+    if ($circ) {
+        my $usr = $session->editor->retrieve_actor_user([
+            $circ->usr, {flesh => 1, flesh_fields => {au => ['card']}}]);
+
+        $details->{patron_barcode} = 
+            $usr->card->barcode if $usr && $usr->card;
+    }
+
+    handle_hold($session, $details, $payload, %params);
+
+    if ($textcode eq 'NO_CHANGE' || $textcode eq 'SUCCESS') {
+
+        $details->{ok} = 1;
+
+    } elsif ($textcode eq 'ROUTE_ITEM') {
+
+        $details->{ok} = 1;
+        $details->{alert} = 1;
+        $details->{alert_type} = '04' unless $details->{alert_type};
+
+    } else {
+
+        $details->{ok} = 0; # unknown
+        $details->{alert} = 1;
+        $details->{alert_type} = '00' unless $details->{alert_type};
+    }
+
+    return 0;
+}
+
+sub handle_hold {
+    my ($session, $details, $payload, %params) = @_;
+
+    my $hold = $payload->{remote_hold} || $payload->{hold};
+
+    return unless $hold;
+
+    my ($pickup_lib_id, $pickup_lib_sn);
+
+    my $holder = $session->editor->retrieve_actor_user(
+        [$hold->usr, {flesh => 1, flesh_fields => {au => ['card']}}]);
+
+    $details->{hold_patron_name} = $SC->format_user_name($holder);
+
+    if (my $card = $holder->card) { # null-able
+        $details->{hold_patron_barcode} = $card->barcode;
+    }
+
+    if (ref $hold->pickup_lib) {
+        $pickup_lib_id = $hold->pickup_lib->id;
+        $pickup_lib_sn = $hold->pickup_lib->shortname;
+
+    } else {
+        $pickup_lib_id = $hold->pickup_lib;
+        $pickup_lib_sn = $SC->org_sn_from_id($session, $pickup_lib_id);
+    }
+
+    $details->{alert} = 1;
+    $details->{destination_loc} = $pickup_lib_sn;
+    $details->{alert_type} = 
+        ($pickup_lib_id == $session->editor->requestor->ws_ou) ? '01' : '02';
+}
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Checkout.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Checkout.pm
new file mode 100644 (file)
index 0000000..ab3b73f
--- /dev/null
@@ -0,0 +1,150 @@
+package OpenILS::Application::SIP2::Checkout;
+use strict; use warnings;
+use DateTime;
+use DateTime::Format::ISO8601;
+use OpenILS::Utils::DateTime qw/:datetime/;
+use OpenSRF::Utils::Logger q/$logger/;
+use OpenILS::Application::AppUtils;
+use OpenILS::Application::SIP2::Common;
+my $U = 'OpenILS::Application::AppUtils';
+my $SC = 'OpenILS::Application::SIP2::Common';
+
+
+# Returns the 'circ' object on success, undef on error.
+sub checkout {
+    my ($class, $session, %params) = @_;
+
+    my $circ_details = {};
+    my $override = 0;
+
+    for (0, 1) { # 2 checkout requests max
+
+        $override = 
+            perform_checkout($session, $circ_details, $override, %params);
+
+        last unless $override;
+    }
+
+    return $circ_details;
+}
+
+sub renew_all {
+    my ($class, $session, $patron_details, %params) = @_;
+
+    my $circ_details = {};
+
+    my @circ_ids = (
+        @{$patron_details->{items_out_ids}}, 
+        @{$patron_details->{items_overdue_ids}}
+    );
+
+    my @renewed;
+    my @unrenewed;
+    for my $circ_id (@circ_ids) {
+
+        my $circ = $session->editor->retrieve_action_circulation([
+            $circ_id, {flesh => 1, flesh_fields => {circ => ['target_copy']}}]);
+
+        my $item_barcode = $circ->target_copy->barcode;
+
+        my $detail = $class->checkout($session, 
+            item_barcode => $item_barcode,
+            fee_ack => $params{fee_ack},
+            is_renew => 1
+        );
+
+        if ($detail->{ok}) {
+            push(@renewed, $item_barcode);
+        } else {
+            push(@unrenewed, $item_barcode);
+        }
+    }
+
+    $circ_details->{items_renewed} = \@renewed;
+    $circ_details->{items_unrenewed} = \@unrenewed;
+
+    return $circ_details;
+}
+
+# Returns 1 if the checkout should be performed again with override.
+# Returns 0 if there's nothing left to do (final success / error)
+# Updates $circ_details along the way.
+sub perform_checkout {
+    my ($session, $circ_details, $override, %params) = @_;
+    my $config = $session->config;
+
+    my $action = $params{is_renew} ? 'renew' : 'checkout';
+
+    my $args = {
+        copy_barcode => $params{item_barcode},
+        # During renewal, the circ API will confirm the specified
+        # patron has the specified item checked out before renewing.
+        patron_barcode => $params{patron_barcode}
+    };
+
+    my $method = $action eq 'renew' ?
+        'open-ils.circ.renew' : 'open-ils.circ.checkout.full';
+
+    $method .= '.override' if $override;
+
+    my $resp = $U->simplereq(
+        'open-ils.circ', $method, $session->editor->authtoken, $args);
+
+    $resp = [$resp] unless ref $resp eq 'ARRAY';
+
+    for my $event (@$resp) {
+        next unless $U->is_event($event); # this should never happen.
+        my $textcode = $event->{textcode};
+
+        if ($textcode eq 'SUCCESS' && $event->{payload}) {
+            if (my $circ = $event->{payload}->{circ}) {
+                $circ_details->{circ} = $circ;
+
+                my $due_date= 
+                    DateTime::Format::ISO8601->new
+                        ->parse_datetime(clean_ISO8601($circ->due_date));
+
+                $circ_details->{due_date} =
+                    $config->{due_date_use_sip_date_format} ?
+                    $SC->sipdate($due_date) :
+                    $due_date->strftime('%F %T');
+
+                return 0;
+            }
+        }
+
+        if (!$override) {
+            if ($config->{"$action.override.$textcode"}) {
+                # Event type is configured for override;
+                return 1;
+
+            } elsif ($params{fee_ack} &&
+                $textcode =~ /ITEM_(?:DEPOSIT|RENTAL)_FEE_REQUIRED/ ) {
+                # Patron acknowledged the fee.  Redo with override.
+                return 1;
+            }
+        }
+
+        if ($textcode eq 'OPEN_CIRCULATION_EXISTS' ) {
+            my $msg = $session->editor
+                ->retrieve_sip_screen_message('checkout.open_circ_exists');
+
+            $circ_details->{screen_msg} = 
+                $msg ? $msg->message : 'This item is already checked out';
+
+        } else {
+
+            my $msg = 
+                $session->editor
+                    ->retrieve_sip_screen_message('checkout.patron_not_allowed');
+
+            $circ_details->{screen_msg} = $msg ? $msg->message : 
+                'Patron is not allowed to checkout the selected item';
+        }
+    }
+
+    return 0;
+}
+
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Common.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Common.pm
new file mode 100644 (file)
index 0000000..c9c38c6
--- /dev/null
@@ -0,0 +1,137 @@
+package OpenILS::Application::SIP2::Common;
+use strict; use warnings;
+use OpenILS::Utils::DateTime qw/:datetime/;
+use OpenSRF::Utils::Cache;
+
+use constant SIP_DATE_FORMAT => "%Y%m%d    %H%M%S";
+
+my $_cache;
+sub cache {
+    $_cache = OpenSRF::Utils::Cache->new unless $_cache;
+    return $_cache;
+}
+
+sub add_field {
+    my ($class, $message, $field, $value) = @_;
+    $value = '' unless defined $value;
+    push (@{$message->{fields}}, {$field => $value});
+}
+
+sub maybe_add_field {
+    my ($class, $message, $field, $value) = @_;
+    push (@{$message->{fields}}, {$field => $value}) if defined $value;
+}
+
+sub sipdate {
+    my ($class, $date) = @_;
+    $date ||= DateTime->now;
+    return $date->strftime(SIP_DATE_FORMAT);
+}
+
+sub sipymd {
+    my ($class, $date_str, $to_local_tz) = @_;
+    return '' unless $date_str;
+
+    my $dt = DateTime::Format::ISO8601->new
+        ->parse_datetime(clean_ISO8601($date_str));
+
+    # actor.usr.dob stores dates without time/timezone, which causes
+    # DateTime to assume the date is stored as UTC.  Tell DateTime to
+    # use the local time zone, instead.  Other dates will have time
+    # zones and should be parsed as-is.
+    $dt->set_time_zone('local') if $to_local_tz;
+
+    return $dt->strftime('%Y%m%d');
+}
+
+# False == 'N'
+sub sipbool {
+    my ($class, $bool) = @_;
+    return $bool ? 'Y' : 'N';
+}
+
+# False == ' '
+sub spacebool {
+    my ($class, $bool) = @_;
+    return $bool ? 'Y' : ' ';
+}
+
+sub count4 {
+    my ($class, $value) = @_;
+    return '    ' unless defined $value;
+    return sprintf("%04d", $value);
+}
+
+# Returns the value of the first occurrence of the requested SIP code.
+sub get_field_value {
+    my ($class, $message, $code) = @_;
+    for my $field (@{$message->{fields}}) {
+        while (my ($c, $v) = each(%$field)) { # one pair per field
+            return $v if $c eq $code;
+        }
+    }
+
+    return undef;
+}
+
+my %org_sn_cache; # shortname => org
+my %org_id_cache; # id => org
+sub org_id_from_sn {
+    my ($class, $session, $org_sn) = @_;
+
+    return undef unless $org_sn;
+
+    my $org = $org_sn_cache{$org_sn} ||
+        $session->editor->search_actor_org_unit({shortname => $org_sn})->[0];
+
+    return undef unless $org;
+
+    $org_sn_cache{$org_sn} = $org;
+    $org_id_cache{$org->id} = $org;
+
+    return $org->id;
+}
+
+sub org_sn_from_id {
+    my ($class, $session, $org_id) = @_;
+
+    return undef unless $org_id;
+
+    my $org = $org_id_cache{$org_id} ||
+        $session->editor->retrieve_actor_org_unit($org_id);
+
+    return undef unless $org;
+
+    $org_sn_cache{$org->shortname} = $org;
+    $org_id_cache{$org_id} = $org;
+
+    return $org->shortname;
+}
+
+# Determines which class of data the SIP client wants detailed
+# information on in the patron info request.
+sub patron_summary_list_items {
+    my ($class, $summary) = @_;
+
+    my $idx = index($summary, 'Y');
+
+    return 'hold_items'        if $idx == 0;
+    return 'overdue_items'     if $idx == 1;
+    return 'charged_items'     if $idx == 2;
+    return 'fine_items'        if $idx == 3;
+    return 'recall_items'      if $idx == 4;
+    return 'unavailable_holds' if $idx == 5;
+    return '';
+}
+
+sub format_user_name {
+    my ($class, $user) = @_;
+    return sprintf('%s%s%s', 
+        $user->first_given_name  ?       $user->first_given_name : '',
+        $user->second_given_name ? ' ' . $user->second_given_name : '',
+        $user->family_name       ? ' ' . $user->family_name : ''
+    );
+}
+
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Hold.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Hold.pm
new file mode 100644 (file)
index 0000000..3cb3edd
--- /dev/null
@@ -0,0 +1,78 @@
+package OpenILS::Application::SIP2::Hold;
+use strict; use warnings;
+use OpenSRF::Utils::Logger q/$logger/;
+use OpenILS::Application::AppUtils;
+use OpenILS::Application::SIP2::Common;
+my $U = 'OpenILS::Application::AppUtils';
+my $SC = 'OpenILS::Application::SIP2::Common';
+
+
+sub cancel {
+    my ($class, $session, $hold) = @_;
+
+    my $details = {ok => 0};
+    
+    my $resp = $U->simplereq(
+        'open-ils.circ',
+        'open-ils.circ.hold.cancel', 
+        $session->editor->authtoken, $hold->id, 7 # cancel via SIP
+    );
+
+    return $details unless $resp && !$U->event_code($resp);
+
+    $details->{ok} = 1;
+
+    return $details;
+}
+
+# Given a "representative" copy, finds a matching hold
+sub hold_from_copy {
+    my ($class, $session, $patron_details, $item_details) = @_;
+    my $e = $session->editor;
+    my $hold;
+
+    my $copy = $item_details->{item};
+
+    my $run_hold_query = sub {
+        my %filter = @_;
+        return $e->search_action_hold_request([
+            {   usr => $patron_details->{patron}->id,
+                cancel_time => undef,
+                fulfillment_time => undef,
+                %filter
+            }, {
+                flesh => 2,
+                flesh_fields => {
+                    ahr => ['current_copy'],
+                    acp => ['call_number']
+                },
+                order_by => {ahr => 'request_time DESC'},
+                limit => 1
+            }
+        ])->[0];
+    };
+
+    # first see if there is a match on current_copy
+    return $hold if $hold = 
+        $run_hold_query->(current_copy => $copy->id);
+
+    # next, assume bib-level holds are the most common
+    return $hold if $hold = $run_hold_query->(
+        target => $copy->call_number->record->id, hold_type => 'T');
+
+    # next try metarecord holds
+    my $map = $e->search_metabib_metarecord_source_map(
+        {source => $copy->call_number->record->id})->[0];
+
+    return $hold if $hold = $run_hold_query->(
+        target => $map->metarecord, hold_type => 'M');
+
+    # volume holds
+    return $hold if $hold = $run_hold_query->(
+        target => $copy->call_number->id, hold_type => 'V');
+
+    # copy holds
+    return $run_hold_query->(
+        target => $copy->id, hold_type => ['C', 'F', 'R']);
+}
+
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Item.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Item.pm
new file mode 100644 (file)
index 0000000..f697382
--- /dev/null
@@ -0,0 +1,151 @@
+package OpenILS::Application::SIP2::Item;
+use strict; use warnings;
+use DateTime;
+use DateTime::Format::ISO8601;
+use OpenSRF::System;
+use OpenILS::Utils::CStoreEditor q/:funcs/;
+use OpenSRF::Utils::Logger q/$logger/;
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::DateTime qw/:datetime/;
+use OpenILS::Const qw/:const/;
+use OpenILS::Application::SIP2::Common;
+my $U = 'OpenILS::Application::AppUtils';
+my $SC = 'OpenILS::Application::SIP2::Common';
+
+sub get_item_details {
+    my ($class, $session, %params) = @_;
+
+    my $config = $session->config;
+    my $barcode = $params{barcode};
+    my $e = $session->editor;
+
+    my $item = $e->search_asset_copy([{
+        barcode => $barcode,
+        deleted => 'f'
+    }, {
+        flesh => 3,
+        flesh_fields => {
+            acp => [qw/circ_lib call_number
+                status stat_cat_entry_copy_maps circ_modifier/],
+            acn => [qw/owning_lib record/],
+            bre => [qw/flat_display_entries/],
+            ascecm => [qw/stat_cat stat_cat_entry/],
+        }
+    }])->[0];
+
+    return undef unless $item;
+
+    my $details = {
+        item => $item,
+        security_marker => '02', # matches SIP/Item.pm
+        owning_loc => $item->call_number->owning_lib->shortname,
+        current_loc => $item->circ_lib->shortname,
+        permanent_loc => $item->circ_lib->shortname,
+        destination_loc => $item->circ_lib->shortname # maybe replaced below
+    };
+
+    $details->{circ} = $e->search_action_circulation([{
+        target_copy => $item->id,
+        checkin_time => undef,
+        '-or' => [
+            {stop_fines => undef},
+            {stop_fines => [qw/MAXFINES LONGOVERDUE/]},
+        ]
+    }, {
+        flesh => 2,
+        flesh_fields => {circ => ['usr'], au => ['card']}
+    }])->[0];
+
+    if ($details->{circ}) {
+
+        my $due_date = DateTime::Format::ISO8601->new->
+            parse_datetime(clean_ISO8601($details->{circ}->due_date));
+
+        $details->{due_date} =
+            $config->{due_date_use_sip_date_format} ?
+            $SC->sipdate($due_date) :
+            $due_date->strftime('%F %T');
+    }
+
+    if ($item->status->id == OILS_COPY_STATUS_IN_TRANSIT) {
+        $details->{transit} = $e->search_action_transit_copy([{
+            target_copy => $item->id,
+            dest_recv_time => undef,
+            cancel_time => undef
+        },{
+            flesh => 1,
+            flesh_fields => {atc => ['dest']}
+        }])->[0];
+
+        $details->{destination_loc} = $details->{transit}->dest->shortname;
+    }
+
+    if ($item->status->id == OILS_COPY_STATUS_ON_HOLDS_SHELF || (
+        $details->{transit} &&
+        $details->{transit}->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF)) {
+
+        $details->{hold} = $e->search_action_hold_request([{
+            current_copy        => $item->id,
+            capture_time        => {'!=' => undef},
+            cancel_time         => undef,
+            fulfillment_time    => undef
+        }, {
+            limit => 1,
+            flesh => 1,
+            flesh_fields => {ahr => ['pickup_lib']}
+        }])->[0];
+    }
+
+    if (my $hold = $details->{hold}) {
+        my $pickup_date = $hold->shelf_expire_time;
+        $details->{hold_pickup_date} =
+            $pickup_date ? $SC->sipdate($pickup_date) : undef;
+
+        my $card = $e->search_actor_card({usr => $hold->usr})->[0];
+        $details->{hold_patron_barcode} = $card->barcode if $card;
+        $details->{destination_loc} = $hold->pickup_lib->shortname;
+    }
+
+    my ($title_entry) = grep {$_->name eq 'title'}
+        @{$item->call_number->record->flat_display_entries};
+
+    $details->{title} = $title_entry ? $title_entry->value : '';
+
+    # Same as ../SIP*
+    $details->{hold_queue_length} = $details->{hold} ? 1 : 0;
+
+    $details->{circ_status} = circulation_status($item->status->id);
+
+    $details->{fee_type} =
+        ($item->deposit_amount > 0.0 && $item->deposit eq 'f') ?
+        '06' : '01';
+
+    my $cmod = $item->circ_modifier;
+    $details->{magnetic_media} = $cmod && $cmod->magnetic_media eq 't';
+    $details->{media_type} = $cmod ? $cmod->sip2_media_type : '001';
+
+    return $details;
+}
+
+# Maps item status to SIP circulation status constants.
+sub circulation_status {
+    my $stat = shift;
+
+    return '02' if $stat == OILS_COPY_STATUS_ON_ORDER;
+    return '03' if $stat == OILS_COPY_STATUS_AVAILABLE;
+    return '04' if $stat == OILS_COPY_STATUS_CHECKED_OUT;
+    return '06' if $stat == OILS_COPY_STATUS_IN_PROCESS;
+    return '08' if $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF;
+    return '09' if $stat == OILS_COPY_STATUS_RESHELVING;
+    return '10' if $stat == OILS_COPY_STATUS_IN_TRANSIT;
+    return '12' if (
+        $stat == OILS_COPY_STATUS_LOST ||
+        $stat == OILS_COPY_STATUS_LOST_AND_PAID
+    );
+    return '13' if $stat == OILS_COPY_STATUS_MISSING;
+
+    return '01';
+}
+
+1
+
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Patron.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Patron.pm
new file mode 100644 (file)
index 0000000..40a6a2f
--- /dev/null
@@ -0,0 +1,520 @@
+package OpenILS::Application::SIP2::Patron;
+use strict; use warnings;
+use DateTime;
+use DateTime::Format::ISO8601;
+use OpenSRF::System;
+use OpenILS::Utils::CStoreEditor q/:funcs/;
+use OpenSRF::Utils::Logger q/$logger/;
+use OpenILS::Const qw/:const/;
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::DateTime qw/:datetime/;
+use OpenILS::Application::SIP2::Common;
+my $U = 'OpenILS::Application::AppUtils';
+my $SC = 'OpenILS::Application::SIP2::Common';
+
+sub get_patron_details {
+    my ($class, $session, %params) = @_;
+
+    my $barcode = $params{barcode};
+    my $password = $params{password};
+
+    my $e = $session->editor;
+    my $details = {};
+
+    my $card = $e->search_actor_card([{
+        barcode => $barcode
+    }, {
+        flesh => 3,
+        flesh_fields => {
+            ac => [qw/usr/],
+            au => [qw/
+                billing_address
+                mailing_address
+                profile
+                stat_cat_entries
+            /],
+            actscecm => [qw/stat_cat/]
+        }
+    }])->[0];
+
+    return undef unless $card;
+
+    my $patron = $details->{patron} = $card->usr;
+    $patron->card($card);
+
+    # We only attempt to verify the password if one is provided.
+    return undef if defined $password &&
+        !$U->verify_migrated_user_password($e, $patron->id, $password);
+
+    set_patron_privileges($session, $details);
+
+    my $summary = $e->retrieve_money_open_user_summary($patron->id);
+    $details->{balance_owed} = ($summary) ? $summary->balance_owed : 0;
+
+    set_patron_summary_items($session, $details, %params);
+    set_patron_summary_list_items($session, $details, %params);
+    log_activity($session, $patron);
+
+    return $details;
+}
+
+sub log_activity {
+    my ($session, $patron) = @_;
+
+    my $ewho = $session->sip_account->activity_who 
+        || $session->config->{default_activity_who};
+    $U->log_user_activity($patron->id, $ewho, 'verify');
+}
+
+
+# Sets:
+#    holds_count
+#    overdue_count
+#    out_count
+#    fine_count
+#    recall_count
+#    unavail_holds_count
+sub set_patron_summary_items {
+    my ($session, $details, %params) = @_;
+
+    my $patron = $details->{patron};
+    my $e = $session->editor;
+
+    $details->{recall_count} = 0; # not supported
+
+    $details->{hold_ids} = get_hold_ids($session, $patron);
+    $details->{holds_count} = scalar(@{$details->{hold_ids}});
+
+    $details->{unavailable_hold_ids} = get_hold_ids($session, $patron, 1);
+    $details->{unavail_holds_count} = scalar(@{$details->{unavailable_hold_ids}});
+
+    $details->{overdue_count} = 0;
+    $details->{out_count} = 0;
+
+    my $circ_summary = $e->retrieve_action_open_circ_list($patron->id);
+    if ($circ_summary) { # undef if no circs for user
+        my $overdue_ids = [ grep {$_ > 0} split(',', $circ_summary->overdue) ];
+        my $out_ids = [ grep {$_ > 0} split(',', $circ_summary->out) ];
+        $details->{overdue_count} = scalar(@$overdue_ids);
+        $details->{out_count} = scalar(@$out_ids) + scalar(@$overdue_ids);
+        $details->{items_overdue_ids} = $overdue_ids;
+        $details->{items_out_ids} = $out_ids;
+    }
+
+    my $xacts = $U->simplereq(
+        'open-ils.actor',                                
+        'open-ils.actor.user.transactions.history.have_balance',               
+        $session->editor->authtoken,
+        $patron->id
+    );
+
+    $details->{fine_count} = scalar(@$xacts);
+}
+
+sub get_hold_ids {
+    my ($session, $patron, $unavail, $offset, $limit) = @_;
+
+    my $e = $session->editor;
+
+    my $holds_where = {
+        usr => $patron->id,
+        fulfillment_time => undef,
+        cancel_time => undef
+    };
+
+    if ($unavail) {
+        $holds_where->{'-or'} = [
+            {current_shelf_lib => undef},
+            {current_shelf_lib => {'!=' => {'+ahr' => 'pickup_lib'}}}
+        ];
+
+    } else {
+
+        $holds_where->{current_shelf_lib} = {'=' => {'+ahr' => 'pickup_lib'}} 
+            if $session->config->{msg64_hold_items_available};
+    }
+
+    my $query = {
+        select => {ahr => ['id']},
+        from => 'ahr',
+        where => {'+ahr' => $holds_where}
+    };
+
+    $query->{offset} = $offset if $offset;
+    $query->{limit} = $limit if $limit;
+
+    my $id_hashes = $e->json_query($query);
+
+    return [map {$_->{id}} @$id_hashes];
+}
+
+sub set_patron_summary_list_items {
+    my ($session, $details, %params) = @_;
+    my $e = $session->editor;
+
+    my $list_items = $params{summary_list_items};
+
+    return unless $list_items;
+
+    # Start and end are 1-based.  Translate to zero-based for internal use.
+    my $offset = $params{summary_start_item} ? $params{summary_start_item} - 1 : 0;
+    my $end = $params{summary_end_item} ? $params{summary_end_item} - 1 : 10;
+    my $limit = $end - $offset;
+
+    add_hold_items($session, $details, $offset, $limit)
+        if $list_items eq 'hold_items';
+
+    add_hold_items($session, $details, $offset, $limit, 1)
+        if $list_items eq 'unavailable_holds';
+
+    add_items_out($session, $details, $offset, $limit)
+        if $list_items eq 'charged_items';
+
+    add_items_out($session, $details, $offset, $limit)
+        if $list_items eq 'charged_items';
+
+    add_fine_items($session, $details, $offset, $limit)
+        if $list_items eq 'fine_items';
+
+}
+
+sub add_hold_items {
+    my ($session, $details, $offset, $limit, $unavailable) = @_;
+
+    my $patron = $details->{patron};
+    my $format = $session->config->{msg64_hold_datatype} || '';
+    my $hold_ids = $unavailable ? 
+        $details->{unavailable_hold_ids} : $details->{hold_ids};
+
+    my @hold_items;
+    for my $hold_id (@$hold_ids) {
+        my $hold = $session->editor->retrieve_action_hold_request($hold_id);
+
+        if ($format eq 'barcode') {
+            my $copy = find_copy_for_hold($session, $hold);
+            push(@hold_items, $copy->barcode) if $copy;
+        } else {
+            my $title = find_title_for_hold($session, $hold);
+            push(@hold_items, $title) if $title;
+        }
+    }
+
+    $details->{hold_items} = \@hold_items;
+}
+
+sub add_items_out {
+    my ($session, $details, $offset, $limit) = @_;
+    my $patron = $details->{patron};
+
+    my @circ_ids = (@{$details->{items_out_ids}}, @{$details->{items_overdue_ids}});
+
+    @circ_ids = grep { $_ } @circ_ids[$offset .. ($offset + $limit - 1)];
+
+    $details->{items_out} = [];
+    for my $circ_id (@circ_ids) {
+        my $value = circ_id_to_value($session, $circ_id);
+        push(@{$details->{items_out}}, $value);
+    }
+}
+
+sub add_overdue_items {
+    my ($session, $details, $offset, $limit) = @_;
+    my $patron = $details->{patron};
+
+    my @circ_ids = @{$details->{items_overdue_ids}};
+
+    @circ_ids = grep { $_ } @circ_ids[$offset .. ($offset + $limit - 1)];
+
+    $details->{overdue_items} = [];
+    for my $circ_id (@circ_ids) {
+        my $value = circ_id_to_value($session, $circ_id);
+        push(@{$details->{items_out}}, $value);
+    }
+}
+
+sub circ_id_to_value {
+    my ($session, $circ_id) = @_;
+
+    my $value = '';
+    my $format = $session->config->{settings}->{msg64_summary_datatype} || '';
+
+    if ($format eq 'barcode') {
+        my $circ = $session->editor->retrieve_action_circulation([
+            $circ_id, {
+            flesh => 1,
+            flesh_fields => {circ => ['target_copy']}
+        }]);
+
+        $value = $circ->target_copy->barcode;
+        
+    } else { # title
+
+        my $circ = $session->editor->retrieve_action_circulation([
+            $circ_id, {
+            flesh => 4,
+            flesh_fields => {
+                circ => ['target_copy'],
+                acp => ['call_number'],
+                acn => ['record'],
+                bre => ['simple_record']
+            }
+        }]);
+
+        if ($circ->target_copy->call_number == -1) {
+            $value = $circ->target_copy->dummy_title;
+        } else {
+            $value = 
+                $circ->target_copy->call_number->record->simple_record->title;
+        }
+    }
+
+    return $value;
+}
+
+# Hold -> reporter.hold_request_record -> display field for title.
+sub find_title_for_hold {
+    my ($session, $hold) = @_;
+    my $e = $session->editor;
+
+    my $bib_link = $e->retrieve_reporter_hold_request_record($hold->id);
+
+    my $title_field = $e->search_metabib_flat_display_entry({
+        source => $bib_link->bib_record, name => 'title'})->[0];
+
+    return $title_field ? $title_field->value : '';
+}
+
+# Finds a representative copy for the given hold.  If no copy exists at
+# all, undef is returned.  The only limit placed on what constitutes a
+# "representative" copy is that it cannot be deleted.  Otherwise, any
+# copy that allows us to find the hold later is good enough.
+sub find_copy_for_hold {
+    my ($session, $hold) = @_;
+    my $e = $session->editor;
+
+    return $e->retrieve_asset_copy($hold->current_copy)
+        if $hold->current_copy; 
+
+    return $e->retrieve_asset_copy($hold->target)
+        if $hold->hold_type =~ /C|R|F/;
+
+    return $e->search_asset_copy([
+        {call_number => $hold->target, deleted => 'f'}, 
+        {limit => 1}])->[0] if $hold->hold_type eq 'V';
+
+    my $bre_ids = [$hold->target];
+
+    if ($hold->hold_type eq 'M') {
+        # find all of the bibs that link to the target metarecord
+        my $maps = $e->search_metabib_metarecord_source_map(
+            {metarecord => $hold->target});
+        $bre_ids = [map {$_->record} @$maps];
+    }
+
+    my $vol_ids = $e->search_asset_call_number( 
+        {record => $bre_ids, deleted => 'f'}, 
+        {idlist => 1}
+    );
+
+    return $e->search_asset_copy([
+        {call_number => $vol_ids, deleted => 'f'}, 
+        {limit => 1}
+    ])->[0];
+}
+
+
+sub set_patron_privileges {
+    my ($session, $details) = @_;
+    my $patron = $details->{patron};
+
+    my $expire = DateTime::Format::ISO8601->new
+        ->parse_datetime(clean_ISO8601($patron->expire_date));
+
+    if ($expire < DateTime->now) {
+        $logger->info("SIP2 Patron account is expired; all privileges blocked");
+        $details->{charge_denied} = 1;
+        $details->{recall_denied} = 1;
+        $details->{renew_denied} = 1;
+        $details->{holds_denied} = 1;
+        return;
+    }
+
+    # Non-expired patrons are allowed all privileges when 
+    # patron_status_permit_all is true.
+    return if $session->config->{patron_status_permit_all};
+
+    my $penalties = get_patron_penalties($session, $patron);
+
+    $details->{too_many_overdue} = 1 if
+        grep {$_->{id} == OILS_PENALTY_PATRON_EXCEEDS_OVERDUE_COUNT}
+        @$penalties;
+
+    $details->{too_many_fines} = 1 if
+        grep {$_->{id} == OILS_PENALTY_PATRON_EXCEEDS_FINES}
+        @$penalties;
+
+    my $blocked = (
+           $patron->barred eq 't'
+        || $patron->active eq 'f'
+        || $patron->card->active eq 'f'
+    );
+
+    my @block_tags = map {$_->{block_list}} grep {$_->{block_list}} @$penalties;
+
+    return unless $blocked || @block_tags; # no blocks remain
+
+    $details->{holds_denied} = ($blocked || grep {$_ =~ /HOLD/} @block_tags);
+
+    # Ignore loan-related blocks?
+    return if $session->config->{patron_status_permit_loans};
+
+    $details->{charge_denied} = ($blocked || grep {$_ =~ /CIRC/} @block_tags);
+    $details->{renew_denied} = ($blocked || grep {$_ =~ /RENEW/} @block_tags);
+
+    # In evergreen, patrons cannot create Recall holds directly, but that
+    # doesn't mean they would not have said privilege if the functionality
+    # existed.  Base the ability to perform recalls on whether they have
+    # checkout and holds privilege, since both would be needed for recalls.
+    $details->{recall_denied} = 
+        ($details->{charge_denied} || $details->{holds_denied});
+
+}
+
+# Returns an array of penalty hashes with keys "id" and "block_list"
+sub get_patron_penalties {
+    my ($session, $patron) = @_;
+
+    return $session->editor->json_query({
+        select => {csp => ['id', 'block_list']},
+        from => {ausp => 'csp'},
+        where => {
+            '+ausp' => {
+                usr => $patron->id,
+                '-or' => [
+                    {stop_date => undef},
+                    {stop_date => {'>' => 'now'}}
+                ],
+                org_unit => 
+                    $U->get_org_full_path($session->editor->requestor->ws_ou)
+            }
+        }
+    });
+}
+
+sub add_fine_items {
+    my ($session, $details, $offset, $limit) = @_;
+    my $patron = $details->{patron};
+    my $e = $session->editor;
+
+    my @fines;
+    my $AV_format = lc($session->config->{settings}->{av_format} || 'eg_legacy');
+
+    # Do a prescan for validity and default to eg_legacy
+    if ($AV_format ne "swyer_a" &&
+        $AV_format ne "swyer_b" &&
+        $AV_format ne "eg_legacy" &&
+        $AV_format ne "3m") {
+
+        syslog(LOG_WARNING => "SIP2 Unknown value for AV_format: $AV_format");
+        $AV_format = "eg_legacy";
+    }
+
+    my $xacts = $U->simplereq(
+        'open-ils.actor',
+        'open-ils.actor.user.transactions.history.have_balance',
+        $e->authtoken, $patron->id
+    );
+
+    foreach my $xact (@{$xacts}) {
+        my ($title, $author, $line, $fee_type);
+
+        if ($xact->last_billing_type eq 'Lost Materials') {
+            $fee_type = 'LOST';
+        } elsif ($xact->last_billing_type =~ /^Overdue/) {
+            $fee_type = 'FINE';
+        } else {
+            $fee_type = 'FEE';
+        }
+
+        if ($xact->xact_type eq 'circulation') {
+            my $circ = $e->retrieve_action_circulation([
+                $xact->id, {
+                    flesh => 2,
+                    flesh_fields => {
+                        circ => ['target_copy'],
+                        acp => ['call_number']
+                    }
+                }
+            ]);
+
+            if ($circ->target_copy->call_number->id == -1) {
+                $title = $circ->target_copy->dummy_title;
+                $author = $circ->target_copy->dummy_author;
+
+            } else {
+
+                my $displays = $e->search_metabib_flat_display_entry({
+                    source => $circ->target_copy->call_number->record,
+                    name => ['title', 'author']
+                });
+
+                ($title) = map {$_->value} grep {$_->name eq 'title'} @$displays;
+                ($author) = map {$_->value} grep {$_->name eq 'author'} @$displays;
+            }
+
+            # Scrub "/" chars since they are used in some cases 
+            # to delineate title/author.
+            if ($title) {
+                $title =~ s/\///g;
+            } else {
+                $title = '';
+            }
+
+            if ($author) {
+                $author =~ s/\///g;
+            } else {
+                $author = '';
+            }
+        }
+
+        if ($AV_format eq "eg_legacy") {
+
+            $line = $xact->balance_owed . " " . $xact->last_billing_type . " ";
+
+            if ($xact->xact_type eq 'circulation') {
+                $line .= "$title / $author";
+            } else {
+                $line .= $xact->last_billing_type;
+            }
+
+        } elsif ($AV_format eq "3m" or $AV_format eq "swyer_a") {
+
+            $line = $xact->id . ' $' . $xact->balance_owed . " \"$fee_type\" ";
+
+            if ($xact->xact_type eq 'circulation') {
+                $line .= "$title";
+            } else {
+                $line .= $xact->last_billing_type;
+            }
+
+        } elsif ($AV_format eq "swyer_b") {
+
+            $line =   "Charge-Number: " . $xact->id;
+            $line .=  ", Amount-Due: "  . $xact->balance_owed;
+            $line .=  ", Fine-Type: $fee_type";
+
+            if ($xact->xact_type eq 'circulation') {
+                $line .= ", Title: $title";
+            } else {
+                $line .= ", Title: " . $xact->last_billing_type;
+            }
+        }
+
+        push @fines, $line;
+    }
+
+    $details->{fine_items} = \@fines;
+}
+
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Payment.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Payment.pm
new file mode 100644 (file)
index 0000000..bb8b85b
--- /dev/null
@@ -0,0 +1,189 @@
+package OpenILS::Application::SIP2::Payment;
+use strict; use warnings;
+use DateTime;
+use DateTime::Format::ISO8601;
+use OpenSRF::System;
+use OpenILS::Utils::CStoreEditor q/:funcs/;
+use OpenSRF::Utils::Logger q/$logger/;
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::DateTime qw/:datetime/;
+use OpenILS::Const qw/:const/;
+use OpenILS::Application::SIP2::Common;
+use OpenILS::Application::SIP2::Session;
+my $U = 'OpenILS::Application::AppUtils';
+my $SC = 'OpenILS::Application::SIP2::Common';
+
+
+sub apply_payment {
+    my ($class, $session, %params) = @_;
+
+    my $details = {ok => 0};
+
+    my $card = $session->editor->search_actor_card([
+        {barcode => $params{patron_barcode}}, 
+        {flesh => 1, flesh_fields => {ac => [qw/usr/]}}
+    ])->[0];
+
+    return $details unless $card;
+
+    my $user = $card->usr;
+
+    if ($params{fee_id}) {
+        pay_one_transaction($session, $details, $user, %params);
+
+    } else {
+        # No transaction was specified, pay whatever we can.
+        pay_multi_transactions($session, $details, $user, %params);
+    }
+
+    return $details;
+}
+
+sub pay_one_transaction {
+    my ($session, $details, $user, %params) = @_;
+
+    my $fee_id = $params{fee_id};  # action.billable_xact.id
+
+    my $xact = 
+        $session->editor->retrieve_money_billable_transaction_summary($fee_id);
+
+    return unless $xact && $xact->usr == $user->id;
+
+    my $pay_amount = $params{pay_amount};
+
+    return unless $pay_amount > 0;
+
+    if ($pay_amount > $xact->balance_owed) {
+        my $msg = $session->editor
+            ->retrieve_sip_screen_message('payment.overpayment_not_allowed');
+
+        $details->{screen_msg} = $msg ? $msg->message : 'Overpayment not allowed';
+        return;
+    }
+
+    my $payments = [[$xact->id, $pay_amount]];
+
+    send_payments($session, $details, $user, $payments, %params);
+}
+
+sub pay_multi_transactions {
+    my ($session, $details, $user, %params) = @_;
+    my $payments = [];
+
+    # See if we can find some find some transactions to pay.
+    my $xacts = $U->simplereq('open-ils.actor', 
+        'open-ils.actor.user.transactions.history.have_balance', 
+        $session->editor->authtoken, $user->id);
+
+    if (!$xacts || !@$xacts) { # nothing to pay
+        my $msg = $session->editor->
+            retrieve_sip_screen_message('payment.transaction_not_found');
+
+        $details->{screen_msg} = $msg ? $msg->message : 'Bill not found';
+        return;
+    }
+
+    my $pay_amount = $params{pay_amount};
+    my $amount_remaining = $pay_amount;
+
+    for my $xact (@$xacts) {
+        next if $xact->balance_owed <= 0;
+
+        my $payment;
+        my $xact_id = $xact->id;
+        my $balance_owed = $xact->balance_owed;
+
+        if ($balance_owed >= $amount_remaining) {
+
+            # We owe as much as or more than we have money left, 
+            # so pay what we have left.
+            $payment = $amount_remaining;
+            $amount_remaining = 0;
+
+        } else {
+
+            # This bill is for less than the amount we have
+            # left, so pay the full bill amount.
+            $payment = $balance_owed;
+            $amount_remaining = $U->fpdiff($amount_remaining, $balance_owed);
+        }
+
+        push(@$payments, [$xact->id, $payment]);
+
+        $amount_remaining = sprintf("%.2f", $amount_remaining);
+        $balance_owed = sprintf("%.2f", $balance_owed);
+
+        $logger->info("SIP paid $payment on $xact_id with a ".
+            "balance of $balance_owed and $amount_remaining remaining");
+
+        # Leave if we ran out of money.
+        last if $amount_remaining == 0;
+    }
+
+    if ($amount_remaining > 0) {
+        my $msg = $session->editor
+            ->retrieve_sip_screen_message('payment.overpayment_not_allowed');
+
+        $details->{screen_msg} = $msg ? $msg->message : 'Overpayment not allowed';
+        return;
+    }
+
+    send_payments($session, $details, $user, $payments, %params);
+}
+
+# Takes array ref of array ref of [xact_id, payment_amount] to pay in batch.
+sub send_payments {
+    my ($session, $details, $user, $payments, %params) = @_;
+
+    my $pay_type = $params{pay_type};
+    my $register_login = $params{register_login};
+
+    if ($register_login) {
+        $logger->debug("SIP register login sent as '$register_login'");
+
+        if ($register_login =~ /\\.+/) { # Windows domain login DOMAIN\user
+            my @parts = split(/\\/, $register_login);
+            $register_login = $parts[1];
+        }
+    }
+    
+    my $args = {
+        userid => $user->id,
+        note => $register_login ? 
+            "Via SIP2: Register login '$register_login'" : "Via SIP2",
+        payments => $payments,
+        payment_type => 'cash_payment'
+    };
+
+    if ($pay_type eq '01' || $pay_type eq '02') {
+        # '01' is "VISA"
+        # '02' is "credit card"
+
+        $args->{payment_type} = 'credit_card_payment';
+        $args->{cc_args} = {
+            approval_code => 
+                $params{terminal_xact} || 'Not provided by SIP client'
+        };
+
+    } elsif ($pay_type eq '05') {
+
+        $args->{payment_type} = 'check_payment';
+        $args->{check_number} = 
+            $params{check_number} || 'Not Provided by SIP Client';
+    }
+
+    my $resp = $U->simplereq(
+        'open-ils.circ', 'open-ils.circ.money.payment', 
+        $session->editor->authtoken, $args, $user->last_xact_id);
+
+    if ($U->event_code($resp)) {
+        $details->{screen_msg} = $resp->{descr} || $resp->{textcode};
+    } else {
+        $details->{ok} = 1;
+    }
+}
+
+
+
+
+
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Session.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/SIP2/Session.pm
new file mode 100644 (file)
index 0000000..b767ce8
--- /dev/null
@@ -0,0 +1,192 @@
+package OpenILS::Application::SIPSession;
+use strict; use warnings;
+use JSON::XS;
+use OpenSRF::Utils::Logger q/$logger/;
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::CStoreEditor q/:funcs/;
+use OpenILS::Utils::Fieldmapper;
+use OpenILS::Application::SIP2::Common;
+my $json = JSON::XS->new;
+my $U = 'OpenILS::Application::AppUtils';
+my $SC = 'OpenILS::Application::SIP2::Common';
+$json->ascii(1);
+$json->allow_nonref(1);
+
+# Supported Messages (BX)
+# Currently hard-coded, since it's based on availabilty of functionality
+# in the code, but it could be moved into the database to limit access for 
+# specific setting groups.
+use constant INSTITUTION_SUPPORTS => [ 
+    'Y', # patron status request,
+    'Y', # checkout,
+    'Y', # checkin,
+    'N', # block patron,
+    'Y', # acs status,
+    'N', # request sc/acs resend,
+    'Y', # login,
+    'Y', # patron information,
+    'N', # end patron session,
+    'Y', # fee paid,
+    'Y', # item information,
+    'N', # item status update,
+    'N', # patron enable,
+    'N', # hold,
+    'Y', # renew,
+    'N', # renew all,
+];
+
+sub new {
+    my ($class, %args) = @_;
+    return bless(\%args, $class);
+}
+
+sub supports {
+    return INSTITUTION_SUPPORTS;
+}
+
+sub config {
+    my $self = shift;
+    return $self->{config} if $self->{config};
+
+    my $group = $self->editor->retrieve_sip_setting_group([
+        $self->sip_account->setting_group,
+        {flesh => 1, flesh_fields => {sipsetg => ['settings']}}
+    ]);
+
+    my $config = {
+        institution => $group->institution,
+        supports => INSTITUTION_SUPPORTS
+    };
+
+    # Decode and hashify settings for easy access
+    $config->{settings} = 
+        {map {$_->name => $json->decode($_->value)} @{$group->settings}};
+
+    $logger->info("SIP settings " . $json->encode($config->{settings}));
+    return $self->{config} = $config;
+}
+
+# Retrieve an existing SIP session via SIP session token
+sub find {
+    my ($class, $seskey) = @_;
+
+    my $session = $class->new(seskey => $seskey);
+    my $e = $session->editor;
+
+    my $cache_ses = $SC->cache->get_cache("sip2_$seskey");
+
+    if ($cache_ses) {
+        $session->{sip_account} = $cache_ses->{sip_account};
+        $e->authtoken($cache_ses->{ils_token});
+        return $session if $session->set_ils_account;
+    }
+
+    # Nothing in the cache, check the DB.
+
+    my $ses = $e->retrieve_sip_session([
+        $seskey, {flesh => 1, flesh_fields => {sipses => ['account']}}]);
+
+    if ($ses) {
+        $session->{sip_account} = $ses->account;
+        $e->authtoken($ses->ils_token);
+        return $session if $session->set_ils_account($ses);
+    }
+
+    $logger->warn("SIP2: No session found for key $seskey");
+    return undef;
+}
+
+# The editor contains the authtoken and ILS user account (requestor).
+sub editor {
+    my $self = shift;
+    $self->{editor} = new_editor() unless $self->{editor};
+    return $self->{editor};
+}
+
+sub seskey {
+    my $self = shift;
+    return $self->{seskey};
+}
+
+# SIP account
+sub sip_account {
+    my $self = shift;
+    return $self->{sip_account};
+}
+
+# Logs in to Evergreen and stores the auth token/login with the SIP
+# account data.
+# Returns true on success, false on failure to authenticate.
+sub set_ils_account {
+    my $self = shift;
+    my $ses = shift;
+    my $e = $self->editor;
+    my $account = $self->sip_account;
+
+    return 1 if $e->authtoken && $e->checkauth;
+
+    my $args = {
+        user_id => $account->usr,
+        login_type => 'staff'
+    };
+
+    $args->{workstation} = $account->workstation->name
+        if $account->workstation;
+
+    my $auth = $U->simplereq(
+        'open-ils.auth_internal',
+        'open-ils.auth_internal.session.create', $args);
+
+    if ($auth->{textcode} ne 'SUCCESS') {
+        $logger->warn(
+            "SIP2 failed to create an internal login session for ILS user: ".
+            $account->usr);
+        return 0;
+    }
+
+    my $seskey = $self->seskey;
+    my $ils_token = $auth->{payload}->{authtoken};
+    $e->authtoken($ils_token);
+
+    my $cache_ses = {
+        sip_account => $account,
+        ils_token => $ils_token
+    };
+
+    $SC->cache->put_cache("sip2_$seskey", $cache_ses);
+
+    # transient account sessions are not tracked in the database
+    return 1 if $U->is_true($account->transient);
+
+    $e->xact_begin;
+
+    if ($ses) {
+        # ILS token expired on an existing SIP session.
+        # Update the session to use the new token.
+
+        $ses->ils_token($ils_token);
+        unless ($e->udpate_sip_session($ses)) {
+            $e->rollback;
+            return 0;
+        }
+            
+    } else {
+        # New session
+
+        my $ses = Fieldmapper::sip::session->new;
+        $ses->key($seskey);
+        $ses->ils_token($ils_token);
+        $ses->account($account->id);
+
+        unless ($e->create_sip_session($ses)) {
+            $e->rollback;
+            return 0;
+        }
+    }
+
+    $e->xact_commit;
+
+    return 1;
+}
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/SIP2Mediator.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/SIP2Mediator.pm
new file mode 100644 (file)
index 0000000..90eae94
--- /dev/null
@@ -0,0 +1,100 @@
+# ---------------------------------------------------------------
+# Copyright (C) 2020 King County Library System
+# Bill Erickson <berickxx@gmail.com>
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# ---------------------------------------------------------------
+# Code borrows heavily and sometimes copies directly from from
+# ../SIP* and SIPServer*
+# ---------------------------------------------------------------
+package OpenILS::WWW::SIP2Mediator;
+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 JSON::XS;
+use OpenSRF::System;
+use OpenSRF::Utils::Logger q/$logger/;
+use OpenILS::Application::AppUtils;
+my $U = 'OpenILS::Application::AppUtils';
+
+my $json = JSON::XS->new;
+$json->ascii(1);
+$json->allow_nonref(1);
+
+my $osrf_config;
+sub import {
+    $osrf_config = shift;
+}
+
+my $init_complete = 0;
+sub init {
+    return if $init_complete;
+    $init_complete = 1;
+    OpenSRF::System->bootstrap_client(config_file => $osrf_config);
+}
+
+sub handler {
+    my $r = shift;
+    my $cgi = CGI->new;
+    my ($message, $msg_code);
+
+    init();
+
+    my $seskey = $cgi->param('session');
+    my $msg_json = $cgi->param('message');
+
+    # sip2-mediator generates a unique key for each client session.
+    # This key is required even if the client has not yet authenticated.
+    return Apache2::Const::FORBIDDEN unless $seskey;
+
+    if ($msg_json) {
+        eval { $message = $json->decode($msg_json) };
+        if ($message) {
+            $msg_code = $message->{code};
+        } else {
+            $logger->error("SIP2: Error parsing message JSON: $@ : $msg_json");
+        }
+    }
+
+    return Apache2::Const::HTTP_BAD_REQUEST unless $msg_code;
+
+    my $response = $U->simplereq(
+        'open-ils.sip2',
+        'open-ils.sip2.request', $seskey, $message);
+
+    if (!$response) {
+
+        # It's OK not receive a response after an End Session message
+        return Apache2::Const::OK if $msg_code eq 'XS';
+
+        $logger->error("SIP2: API Request returned no value for: $msg_json");
+        return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR;
+
+    } elsif (my $textcode = $response->{textcode}) {
+
+        # SIP API returned a failure event
+        $logger->error("SIP2: API request returned $textcode: $msg_json");
+
+        return Apache2::Const::FORBIDDEN if $textcode eq 'PERM_FAILURE';
+
+        return Apache2::Const::HTTP_BAD_REQUEST;
+    }
+
+    $r->content_type('application/json');
+    $r->print($json->encode($response));
+
+    return Apache2::Const::OK;
+}
+
+1;
diff --git a/Open-ILS/src/sql/Pg/410.schema.sip.sql b/Open-ILS/src/sql/Pg/410.schema.sip.sql
new file mode 100644 (file)
index 0000000..cdc1de5
--- /dev/null
@@ -0,0 +1,53 @@
+BEGIN;
+
+DROP SCHEMA IF EXISTS sip CASCADE;
+
+CREATE SCHEMA sip;
+
+-- Collections of settings that can be linked to one or more SIP accounts.
+CREATE TABLE sip.setting_group (
+    id          SERIAL PRIMARY KEY,
+    label       TEXT UNIQUE NOT NULL,
+    institution TEXT NOT NULL -- Duplicates OK
+);
+
+-- Key/value setting pairs
+CREATE TABLE sip.setting (
+    id SERIAL       PRIMARY KEY,
+    setting_group   INTEGER NOT NULL REFERENCES sip.setting_group (id)
+                    ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
+    name            TEXT NOT NULL,
+    description     TEXT NOT NULL,
+    value           JSON NOT NULL,
+    CONSTRAINT      name_once_per_inst UNIQUE (setting_group, name)
+);
+
+CREATE TABLE sip.account (
+    id              SERIAL PRIMARY KEY,
+    enabled         BOOLEAN NOT NULL DEFAULT TRUE,
+    setting_group   INTEGER NOT NULL REFERENCES sip.setting_group (id)
+                    DEFERRABLE INITIALLY DEFERRED,
+    sip_username    TEXT NOT NULL,
+    usr             BIGINT NOT NULL REFERENCES actor.usr(id)
+                    DEFERRABLE INITIALLY DEFERRED,
+    workstation     INTEGER REFERENCES actor.workstation(id),
+    -- sessions for transient accounts are not tracked in sip.session
+    transient       BOOLEAN NOT NULL DEFAULT FALSE,
+    activity_who    TEXT -- config.usr_activity_type.ewho
+);
+
+CREATE TABLE sip.session (
+    key         TEXT PRIMARY KEY,
+    ils_token   TEXT NOT NULL UNIQUE,
+    account     INTEGER NOT NULL REFERENCES sip.account(id)
+                ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
+    create_time TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE sip.screen_message (
+    key     TEXT PRIMARY KEY,
+    message TEXT NOT NULL
+);
+
+COMMIT;
+
index 85d9ace..2c9fcb5 100644 (file)
@@ -21617,3 +21617,138 @@ VALUES
      'coust', 'description'),
    'integer' );
 
+INSERT INTO actor.passwd_type (code, name, login, crypt_algo, iter_count)
+    VALUES ('sip2', 'SIP2 Client Password', FALSE, 'bf', 5);
+
+-- ID 1 is magic.
+INSERT INTO sip.setting_group (id, label, institution) 
+    VALUES (1, 'Default Settings', 'example');
+
+-- carve space for other canned setting groups
+SELECT SETVAL('sip.setting_group_id_seq'::TEXT, 1000);
+
+-- has to be global since settings are linked to accounts and if
+-- status-before-login is used, no account information will be available.
+INSERT INTO config.global_flag (name, value, enabled, label) VALUES
+(   'sip.sc_status_before_login_institution', NULL, FALSE, 
+    oils_i18n_gettext(
+        'sip.sc_status_before_login_institution',
+        'Activate status-before-login-support and define the institution ' ||
+        'value which should be used in the response',
+        'cgf', 'label')
+);
+
+INSERT INTO sip.setting (setting_group, name, value, description)
+VALUES (
+    1, 'currency', '"USD"',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'currency'),
+        'Monetary amounts are reported in this currency',
+        'sipset', 'description')
+), (
+    1, 'av_format', '"eg_legacy"',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'av_format'),
+        'AV Format. Options: eg_legacy, 3m, swyer_a, swyer_b',
+        'sipset', 'description')
+), (
+    1, 'due_date_use_sip_date_format', 'false',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'due_date_use_sip_date_format'),
+        'Due date uses 18-char date format (YYYYMMDDZZZZHHMMSS).  Otherwise "YYYY-MM-DD HH:MM:SS',
+        'sipset', 'description')
+), (
+    1, 'patron_status_permit_loans', 'false',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'patron_status_permit_loans'),
+        'Checkout and renewal are allowed even when penalties blocking these actions exist',
+        'sipset', 'description')
+), (
+    1, 'patron_status_permit_all', 'false',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'patron_status_permit_all'),
+        'Holds, checkouts, and renewals allowed regardless of blocking penalties',
+        'sipset', 'description')
+), (
+    1, 'default_activity_who', 'null',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'default_activity_who'),
+        'Patron holds data may be returned as either "title" or "barcode"',
+        'sipset', 'description')
+), (
+    1, 'msg64_summary_datatype', '"title"',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'msg64_summary_datatype'),
+        'Patron circulation data may be returned as either "title" or "barcode"',
+        'sipset', 'description')
+), (
+    1, 'msg64_hold_items_available', '"title"',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'msg64_hold_items_available'),
+        'Patron holds data may be returned as either "title" or "barcode"',
+        'sipset', 'description')
+), (
+    1, 'checkout.override.COPY_ALERT_MESSAGE', 'true',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'checkout.override.COPY_ALERT_MESSAGE'),
+        'Checkout override copy alert message',
+        'sipset', 'description')
+), (
+    1, 'checkout.override.COPY_NOT_AVAILABLE', 'true',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'checkout.override.COPY_NOT_AVAILABLE'),
+        'Checkout override copy not available message',
+        'sipset', 'description')
+), (
+    1, 'checkin.override.COPY_ALERT_MESSAGE', 'true',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'checkin.override.COPY_ALERT_MESSAGE'),
+        'Checkin override copy alert message',
+        'sipset', 'description')
+), (
+    1, 'checkin.override.COPY_BAD_STATUS', 'true',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'checkin.override.COPY_BAD_STATUS'),
+        'Checkin override bad copy status',
+        'sipset', 'description')
+), (
+    1, 'checkin.override.COPY_STATUS_MISSING', 'true',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'checkin.override.COPY_STATUS_MISSING'),
+        'Checkin override copy status missing',
+        'sipset', 'description')
+), (
+    1, 'checkin_hold_as_transit', 'false',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'checkin_hold_as_transit'),
+        'Checkin local holds as transits',
+        'sipset', 'description')
+);
+
+INSERT INTO sip.screen_message (key, message) VALUES (
+    'checkout.open_circ_exists', 
+    oils_i18n_gettext(
+        'checkout.open_circ_exists',
+        'This item is already checked out',
+        'sipsm', 'message')
+), (
+    'checkout.patron_not_allowed', 
+    oils_i18n_gettext(
+        'checkout.patron_not_allowed',
+        'Patron is not allowed to checkout the selected item',
+        'sipsm', 'message')
+), (
+    'payment.overpayment_not_allowed',
+    oils_i18n_gettext(
+        'payment.overpayment_not_allowed',
+        'Overpayment not allowed',
+        'sipsm', 'message')
+), (
+    'payment.transaction_not_found',
+    oils_i18n_gettext(
+        'payment.transaction_not_found',
+        'Bill not found',
+        'sipsm', 'message')
+);
+
+
index e4f9152..96e5c65 100644 (file)
@@ -46,6 +46,7 @@ FTS_CONFIG_FILE
 
 300.schema.staged_search.sql
 400.schema.action_trigger.sql
+410.schema.sip.sql
 
 500.view.cross-schema.sql
 
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.sip-config.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.sip-config.sql
new file mode 100644 (file)
index 0000000..d06fea5
--- /dev/null
@@ -0,0 +1,212 @@
+
+BEGIN;
+
+-- SELECT evergreen.upgrade_deps_block_check('TODO', :eg_version);
+
+DROP SCHEMA IF EXISTS sip CASCADE;
+
+CREATE SCHEMA sip;
+
+-- Collections of settings that can be linked to one or more SIP accounts.
+CREATE TABLE sip.setting_group (
+    id          SERIAL PRIMARY KEY,
+    label       TEXT UNIQUE NOT NULL,
+    institution TEXT NOT NULL -- Duplicates OK
+);
+
+-- Key/value setting pairs
+CREATE TABLE sip.setting (
+    id SERIAL       PRIMARY KEY,
+    setting_group   INTEGER NOT NULL REFERENCES sip.setting_group (id)
+                    ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
+    name            TEXT NOT NULL,
+    description     TEXT NOT NULL,
+    value           JSON NOT NULL,
+    CONSTRAINT      name_once_per_inst UNIQUE (setting_group, name)
+);
+
+CREATE TABLE sip.account (
+    id              SERIAL PRIMARY KEY,
+    enabled         BOOLEAN NOT NULL DEFAULT TRUE,
+    setting_group   INTEGER NOT NULL REFERENCES sip.setting_group (id)
+                    DEFERRABLE INITIALLY DEFERRED,
+    sip_username    TEXT NOT NULL,
+    usr             BIGINT NOT NULL REFERENCES actor.usr(id)
+                    DEFERRABLE INITIALLY DEFERRED,
+    workstation     INTEGER REFERENCES actor.workstation(id),
+    -- sessions for transient accounts are not tracked in sip.session
+    transient       BOOLEAN NOT NULL DEFAULT FALSE,
+    activity_who    TEXT -- config.usr_activity_type.ewho
+);
+
+CREATE TABLE sip.session (
+    key         TEXT PRIMARY KEY,
+    ils_token   TEXT NOT NULL UNIQUE,
+    account     INTEGER NOT NULL REFERENCES sip.account(id)
+                ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
+    create_time TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE sip.screen_message (
+    key     TEXT PRIMARY KEY,
+    message TEXT NOT NULL
+);
+
+-- SEED DATA
+
+INSERT INTO actor.passwd_type (code, name, login, crypt_algo, iter_count)
+    VALUES ('sip2', 'SIP2 Client Password', FALSE, 'bf', 5);
+
+-- ID 1 is magic.
+INSERT INTO sip.setting_group (id, label, institution) 
+    VALUES (1, 'Default Settings', 'example');
+
+-- carve space for other canned setting groups
+SELECT SETVAL('sip.setting_group_id_seq'::TEXT, 1000);
+
+-- has to be global since settings are linked to accounts and if
+-- status-before-login is used, no account information will be available.
+INSERT INTO config.global_flag (name, value, enabled, label) VALUES
+(   'sip.sc_status_before_login_institution', NULL, FALSE, 
+    oils_i18n_gettext(
+        'sip.sc_status_before_login_institution',
+        'Activate status-before-login-support and define the institution ' ||
+        'value which should be used in the response',
+        'cgf', 'label')
+);
+
+INSERT INTO sip.setting (setting_group, name, value, description)
+VALUES (
+    1, 'currency', '"USD"',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'currency'),
+        'Monetary amounts are reported in this currency',
+        'sipset', 'description')
+), (
+    1, 'av_format', '"eg_legacy"',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'av_format'),
+        'AV Format. Options: eg_legacy, 3m, swyer_a, swyer_b',
+        'sipset', 'description')
+), (
+    1, 'due_date_use_sip_date_format', 'false',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'due_date_use_sip_date_format'),
+        'Due date uses 18-char date format (YYYYMMDDZZZZHHMMSS).  Otherwise "YYYY-MM-DD HH:MM:SS',
+        'sipset', 'description')
+), (
+    1, 'patron_status_permit_loans', 'false',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'patron_status_permit_loans'),
+        'Checkout and renewal are allowed even when penalties blocking these actions exist',
+        'sipset', 'description')
+), (
+    1, 'patron_status_permit_all', 'false',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'patron_status_permit_all'),
+        'Holds, checkouts, and renewals allowed regardless of blocking penalties',
+        'sipset', 'description')
+), (
+    1, 'default_activity_who', 'null',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'default_activity_who'),
+        'Patron holds data may be returned as either "title" or "barcode"',
+        'sipset', 'description')
+), (
+    1, 'msg64_summary_datatype', '"title"',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'msg64_summary_datatype'),
+        'Patron circulation data may be returned as either "title" or "barcode"',
+        'sipset', 'description')
+), (
+    1, 'msg64_hold_items_available', '"title"',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'msg64_hold_items_available'),
+        'Patron holds data may be returned as either "title" or "barcode"',
+        'sipset', 'description')
+), (
+    1, 'checkout.override.COPY_ALERT_MESSAGE', 'true',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'checkout.override.COPY_ALERT_MESSAGE'),
+        'Checkout override copy alert message',
+        'sipset', 'description')
+), (
+    1, 'checkout.override.COPY_NOT_AVAILABLE', 'true',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'checkout.override.COPY_NOT_AVAILABLE'),
+        'Checkout override copy not available message',
+        'sipset', 'description')
+), (
+    1, 'checkin.override.COPY_ALERT_MESSAGE', 'true',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'checkin.override.COPY_ALERT_MESSAGE'),
+        'Checkin override copy alert message',
+        'sipset', 'description')
+), (
+    1, 'checkin.override.COPY_BAD_STATUS', 'true',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'checkin.override.COPY_BAD_STATUS'),
+        'Checkin override bad copy status',
+        'sipset', 'description')
+), (
+    1, 'checkin.override.COPY_STATUS_MISSING', 'true',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'checkin.override.COPY_STATUS_MISSING'),
+        'Checkin override copy status missing',
+        'sipset', 'description')
+), (
+    1, 'checkin_hold_as_transit', 'false',
+    oils_i18n_gettext(
+        (SELECT id FROM sip.setting WHERE name = 'checkin_hold_as_transit'),
+        'Checkin local holds as transits',
+        'sipset', 'description')
+);
+
+INSERT INTO sip.screen_message (key, message) VALUES (
+    'checkout.open_circ_exists', 
+    oils_i18n_gettext(
+        'checkout.open_circ_exists',
+        'This item is already checked out',
+        'sipsm', 'message')
+), (
+    'checkout.patron_not_allowed', 
+    oils_i18n_gettext(
+        'checkout.patron_not_allowed',
+        'Patron is not allowed to checkout the selected item',
+        'sipsm', 'message')
+), (
+    'payment.overpayment_not_allowed',
+    oils_i18n_gettext(
+        'payment.overpayment_not_allowed',
+        'Overpayment not allowed',
+        'sipsm', 'message')
+), (
+    'payment.transaction_not_found',
+    oils_i18n_gettext(
+        'payment.transaction_not_found',
+        'Bill not found',
+        'sipsm', 'message')
+);
+
+
+/* EXAMPLE SETTINGS
+
+-- Example linking a SIP password to the 'admin' account.
+SELECT actor.set_passwd(1, 'sip2', 'sip_password');
+
+INSERT INTO actor.workstation (name, owning_lib) VALUES ('BR1-SIP2-Gateway', 4);
+
+INSERT INTO sip.account(
+    setting_group, sip_username, sip_password, usr, workstation
+) VALUES (
+    1, 'admin', 
+    (SELECT id FROM actor.passwd WHERE usr = 1 AND passwd_type = 'sip2'),
+    1, 
+    (SELECT id FROM actor.workstation WHERE name = 'BR1-SIP2-Gateway')
+);
+
+*/
+
+COMMIT;
+
+
diff --git a/docs/RELEASE_NOTES_NEXT/SIP/sip2-mediator-support.adoc b/docs/RELEASE_NOTES_NEXT/SIP/sip2-mediator-support.adoc
new file mode 100644 (file)
index 0000000..48c3ec8
--- /dev/null
@@ -0,0 +1,19 @@
+SIP2Mediator Support
+^^^^^^^^^^^^^^^^^^^^
+
+Evergreen now supports back-end functionality to integrate with SIP2Mediator.
+
+For more information, see 
+https://wiki.evergreen-ils.org/doku.php?id=evergreen-admin:sip2mediator[Evergreen Wiki].
+
+
+New Admin Interfaces
+++++++++++++++++++++
+
+* Manage SIP accounts:
+ ** Administration => Server Administration => SIP Accounts
+
+* Manage SIP Screen Messages:
+ ** Administration => Server Administration => SIP Screen Messages
+
+