--- /dev/null
+# ---------------------------------------------------------------
+# 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::SIPSession;
+use strict; use warnings;
+use OpenSRF::Utils::Cache;
+use OpenSRF::Utils::Logger q/$logger/;
+use OpenILS::Application::AppUtils;
+my $U = 'OpenILS::Application::AppUtils';
+
+# Cache instances cannot be created until opensrf is connected.
+my $_cache;
+sub cache {
+ $_cache = OpenSRF::Utils::Cache->new unless $_cache;
+ return $_cache;
+}
+
+sub new {
+ my ($class, %args) = @_;
+ my $self = bless(\%args, $class);
+}
+
+# Create a new sessesion from cached data.
+sub from_cache {
+ my ($class, $seskey) = @_;
+
+ my $account = cache()->get_cache("sip2_$seskey");
+ return undef unless $account;
+
+ return $class->new(seskey => $seskey, account => $account);
+}
+
+sub seskey {
+ my $self = shift;
+ return $self->{seskey};
+}
+
+# Login account
+sub account {
+ my $self = shift;
+ return $self->{account};
+}
+
+# Logs in to Evergreen and caches the auth token/login with the SIP
+# account data.
+# Returns true on success, false on failure to authenticate.
+sub authenticate {
+ my ($self, $account) = @_;
+
+ my $seskey = $self->seskey;
+
+ my $auth = $U->simplereq(
+ 'open-ils.auth',
+ 'open-ils.auth.login', {
+ username => $account->{ils_username},
+ password => $account->{ils_password},
+ workstation => $account->{ils_workstation},
+ type => 'staff'
+ });
+
+ if ($auth->{textcode} ne 'SUCCESS') {
+ $logger->warn(
+ "SIP2 login failed for ils_username".$account->{ils_username});
+ return 0;
+ }
+
+ $account->{authtoken} = $auth->{payload}->{authtoken};
+
+ # cache the login user account as well
+ $account->{login} = $U->simplereq(
+ 'open-ils.auth',
+ 'open-ils.auth.session.retrieve',
+ $account->{authtoken}
+ );
+
+ cache()->put_cache("sip2_$seskey", $account);
+ return 1;
+}
+
+package OpenILS::WWW::SIP2Gateway;
+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 DateTime;
+use DateTime::Format::ISO8601;
+use JSON::XS;
+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::WWW::SIP2Gateway::Patron;
+
+my $json = JSON::XS->new;
+$json->ascii(1);
+$json->allow_nonref(1);
+
+my $SIP_DATE_FORMAT = "%Y%m%d %H%M%S";
+
+ # TODO: move to config / database
+my $config = {
+ options => {
+ # Allow 99 (sc status) message before successful 93 (login) message
+ allow_sc_status_before_login => 1
+ },
+ accounts => [{
+ sip_username => 'sip',
+ sip_password => 'sip',
+ ils_username => 'admin',
+ ils_password => 'demo123',
+ ils_workstation => 'BR1-gamma'
+ }],
+ institutions => [{
+ id => 'example',
+ currency => 'USD',
+ supports => [ # Supported Messages (BX)
+ '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,
+ ],
+ options => {
+ due_date_use_sip_date_format => 0,
+ patron_status_permit_loans => 0,
+ patron_status_permit_all => 0,
+ msg64_hold_items_available => 0
+ }
+ }]
+};
+
+my $osrf_config;
+sub import {
+ $osrf_config = shift;
+ warn "OSRF CONFIG IS $osrf_config\n";
+}
+
+my $init_complete = 0;
+sub init {
+ return if $init_complete;
+ $init_complete = 1;
+
+ OpenSRF::System->bootstrap_client(config_file => $osrf_config);
+ OpenILS::Utils::CStoreEditor->init;
+
+ return Apache2::Const::OK;
+}
+
+sub sipdate {
+ my $date = shift || DateTime->now;
+ return $date->strftime($SIP_DATE_FORMAT);
+}
+
+sub sipbool {
+ my ($bool, $use_n) = @_;
+ return 'Y' if $bool;
+ return $use_n ? 'N' : ' ';
+}
+
+sub count4 {
+ my $value = shift;
+ return ' ' unless defined $value;
+ return sprintf("%04d", $value);
+}
+
+sub handler {
+ my $r = shift;
+ my $cgi = CGI->new;
+
+ init();
+
+ my $seskey = $cgi->param('session');
+ my $msg_json = $cgi->param('message');
+ my $message = $json->decode($msg_json);
+ my $msg_code = $message->{code};
+ my $response;
+
+ if ($msg_code eq '93') {
+ $response = handle_login($seskey, $message);
+
+ } elsif ($msg_code eq '99') {
+ $response = handle_sc_status($seskey, $message);
+
+ } else {
+
+ # 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::WWW::SIPSession->from_cache($seskey);
+
+ return Apache2::Const::FORBIDDEN unless $session;
+
+ if ($msg_code eq '63') {
+ $response = handle_patron_info($session, $message);
+ } elsif ($msg_code eq '17') {
+ $response = handle_item_info($session, $message);
+ }
+ }
+
+ unless ($response) {
+ $logger->error("SIP2: no response generated for: $msg_code");
+ return Apache2::Const::NOT_FOUND;
+ }
+
+ $r->content_type('application/json');
+ $r->print($json->encode($response));
+
+ return Apache2::Const::OK;
+}
+
+# Returns the value of the first occurrence of the requested SIP code.
+sub get_field_value {
+ my ($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;
+}
+
+# Returns the configuation chunk mapped to the requested institution.
+sub get_inst_config {
+ my $institution = shift;
+ my ($instconf) = grep {$_->{id} eq $institution} @{$config->{institutions}};
+
+ $logger->error(
+ "SIP2 has no configuration for institution: $institution")
+ unless $instconf;
+
+ return $instconf;
+}
+
+# Login to Evergreen and cache the login data.
+sub handle_login {
+ my ($seskey, $message) = @_;
+
+ my $response = {
+ code => '94',
+ fixed_fields => ['0'] # default to login failed.
+ };
+
+ my $sip_username = get_field_value($message, 'CN');
+ my $sip_password = get_field_value($message, 'CO');
+
+ my ($account) = grep {
+ $_->{sip_username} eq $sip_username &&
+ $_->{sip_password} eq $sip_password
+ } @{$config->{accounts}};
+
+ if ($account) {
+ my $session = OpenILS::WWW::SIPSession->new(seskey => $seskey);
+ $response->{fixed_fields}->[0] = '1'
+ if $session->authenticate($account);
+
+ } else {
+ $logger->info("SIP2 login failed for user=$sip_username")
+ }
+
+ return $response;
+}
+
+sub handle_sc_status {
+ my ($seskey, $message) = @_;
+
+ return undef unless (
+ $config->{options}->{allow_sc_status_before_login} ||
+ OpenILS::WWW::SIPSession->from_cache($seskey)
+ );
+
+ # The SC Status message does not include an institution, but expects
+ # one in return. Use the configuration for the first institution.
+ # Maybe the SIP server itself should track which institutoin its
+ # instance is configured to use? That may multiple servers could
+ # run, one per institution.
+ my $instconf = $config->{institutions}->[0];
+ my $instname = $instconf->{id};
+
+ my $response = {
+ code => '98',
+ fixed_fields => [
+ sipbool(1), # online_status
+ sipbool(1), # checkin_ok
+ sipbool(1), # checkout_ok
+ sipbool(1), # acs_renewal_policy
+ sipbool(0, 1), # status_update_ok
+ sipbool(0, 1), # offline_ok
+ '999', # timeout_period
+ '999', # retries_allowed
+ sipdate(), # transaction date
+ '2.00' # protocol_version
+ ],
+ fields => [
+ {AO => $instname},
+ {BX => join('', @{$instconf->{supports}})}
+ ]
+ }
+}
+
+sub handle_item_info {
+ my ($session, $message) = @_;
+
+ my $account = $session->account;
+ my $institution = get_field_value($message, 'AO');
+ my $instconf = get_inst_config($institution) || return undef;
+ my $barcode = get_field_value($message, 'AB');
+ my $idetails = get_item_details($session, $instconf, $barcode);
+
+ if (!$idetails) {
+ # 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
+ sipdate()
+ ],
+ fields => [{AB => $barcode, AJ => ''}]
+ };
+ };
+
+ return {
+ code => '18',
+ fixed_fields => [
+ $idetails->{circ_status},
+ '02', # Security Marker, consistent with ../SIP*
+ $idetails->{fee_type},
+ sipdate()
+ ],
+ fields => [
+ {AB => $barcode},
+ {AJ => $idetails->{title}},
+ {CF => $idetails->{hold_queue_length}},
+ {AH => $idetails->{due_date}},
+ {CM => $idetails->{hold_pickup_date}},
+ {BG => $idetails->{item}->circ_lib->shortname},
+ {BH => $instconf->{currency}},
+ {BV => $idetails->{item}->deposit_amount},
+ {CK => $idetails->{media_type}},
+ {AQ => $idetails->{item}->circ_lib->shortname},
+ {AP => $idetails->{item}->circ_lib->shortname},
+ ]
+ };
+}
+
+sub get_item_details {
+ my ($session, $instconf, $barcode) = @_;
+ my $e = new_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};
+
+ $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 ($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];
+ }
+
+ 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];
+ }
+
+ 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';
+
+ if ($details->{circ}) {
+
+ my $due_date = DateTime::Format::ISO8601->new->
+ parse_datetime(clean_ISO8601($details->{circ}->due_date));
+
+ $details->{due_date} =
+ $instconf->{due_date_use_sip_date_format} ?
+ sipdate($due_date) :
+ $due_date->strftime('%F %T');
+ }
+
+ if ($details->{hold}) {
+ my $pickup_date = $details->{hold}->shelf_expire_time;
+ $details->{hold_pickup_date} =
+ $pickup_date ? sipdate($pickup_date) : undef;
+ }
+
+ 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';
+}
+
+sub handle_patron_info {
+ my ($session, $message) = @_;
+ my $account = $session->account;
+
+ my $institution = get_field_value($message, 'AO');
+ my $barcode = get_field_value($message, 'AA');
+ my $password = get_field_value($message, 'AD');
+ my $instconf = get_inst_config($institution) || return undef;
+ my $summary = $message->{fixed_fields}->[2];
+
+ my $pdetails = OpenILS::WWW::SIP2Gateway::Patron->get_patron_details(
+ session => $session,
+ instconf => $instconf,
+ barcode => $barcode,
+ password => $password,
+ summary_start_item => get_field_value($message, 'BP'),
+ summary_end_item => get_field_value($message, 'BQ'),
+ summary_list_items => patron_summary_list_items($summary)
+ );
+
+ if (!$pdetails) {
+ return {
+ code => '64',
+ fixed_fields => [
+ sipbool(1), # charge denied
+ sipbool(1), # renew denied
+ sipbool(1), # recall denied
+ sipbool(1), # holds denied
+ split('', (' ' x 10)),
+ '000', # language
+ sipdate()
+ ],
+ fields => [
+ {AO => $institution},
+ {AA => $barcode},
+ {BL => sipbool(0, 1)}, # valid patron
+ {CQ => sipbool(0, 1)} # valid patron password
+ ]
+ };
+ }
+
+ return {
+ code => '64',
+ fixed_fields => [
+ sipbool($pdetails->{charge_denied}),
+ sipbool($pdetails->{renew_denied}),
+ sipbool($pdetails->{recall_denied}),
+ sipbool($pdetails->{holds_denied}),
+ sipbool($pdetails->{patron}->card->active eq 'f'),
+ sipbool(0), # too many charged
+ sipbool($pdetails->{too_may_overdue}),
+ sipbool(0), # too many renewals
+ sipbool(0), # too many claims retruned
+ sipbool(0), # too many lost
+ sipbool($pdetails->{too_many_fines}),
+ sipbool($pdetails->{too_many_fines}),
+ sipbool(0), # recall overdue
+ sipbool($pdetails->{too_many_fines}),
+ '000', # language
+ sipdate(),
+ count4($pdetails->{holds_count}),
+ count4($pdetails->{overdue_count}),
+ count4($pdetails->{out_count}),
+ count4($pdetails->{fine_count}),
+ count4($pdetails->{recall_count}),
+ count4($pdetails->{unavail_holds_count}),
+ ],
+ fields => [
+ {AO => $institution},
+ {AA => $barcode},
+ {BL => sipbool(1)}, # valid patron
+ {CQ => sipbool($password, 1)} # password verified if exists
+ ]
+ };
+}
+
+
+# Determines which class of data the SIP client wants detailed
+# information on in the patron info request.
+sub patron_summary_list_items {
+ my $summary = shift;
+
+ 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;
+}
+
+1;
--- /dev/null
+# ---------------------------------------------------------------
+# 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::SIP2Gateway::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::Application::AppUtils;
+use OpenILS::Utils::DateTime qw/:datetime/;
+use OpenILS::Const qw/:const/;
+my $U = 'OpenILS::Application::AppUtils';
+
+sub get_patron_details {
+ my ($class, %params) = @_;
+
+ my $session = $params{session};
+ my $instconf = $params{instconf};
+ my $barcode = $params{barcode};
+ my $password = $params{password};
+
+ my $e = new_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];
+
+ 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);
+
+ my $penalties = get_patron_penalties($session, $patron);
+
+ set_patron_privileges($session, $instconf, $details, $penalties);
+
+ $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;
+
+ set_patron_summary_items($session, $instconf, $details, %params);
+
+ return $details;
+}
+
+
+# Sets:
+# holds_count
+# overdue_count
+# out_count
+# fine_count
+# recall_count
+# unavail_holds_count
+sub set_patron_summary_items {
+ my ($session, $instconf, $details, %params) = @_;
+
+ my $patron = $details->{patron};
+ my $start_item = $params{summary_start_item} || 0;
+ my $end_item = $params{summary_end_item} || 10;
+ my $list_items = $params{summary_list_items};
+
+ my $e = new_editor();
+
+ my $holds_where = {
+ usr => $patron->id,
+ fulfillment_time => undef,
+ cancel_time => undef
+ };
+
+ $holds_where->{current_shelf_lib} = {'=' => {'+ahr' => 'pickup_lib'}}
+ if $instconf->{msg64_hold_items_available};
+
+ my $hold_ids = $e->json_query({
+ select => {ahr => ['id']},
+ from => 'ahr',
+ where => {'+ahr' => $holds_where}
+ });
+
+ $details->{holds_count} = scalar(@$hold_ids);
+
+ my $circ_ids = $e->retrieve_action_open_circ_list($patron->id);
+ my $overdue_ids = [ grep {$_ > 0} split(',', $circ_ids->overdue) ];
+ my $out_ids = [ grep {$_ > 0} split(',', $circ_ids->out) ];
+
+ $details->{overdue_count} = scalar(@$overdue_ids);
+ $details->{out_count} = scalar(@$out_ids) + scalar(@$overdue_ids);
+
+ $details->{recall_count} = undef; # not supported
+
+ my $xacts = $U->simplereq(
+ 'open-ils.actor',
+ 'open-ils.actor.user.transactions.history.have_balance',
+ $session->account->{authtoken},
+ $patron->id
+ );
+
+ $details->{fine_count} = scalar(@$xacts);
+
+ # TODO: unavail holds count; summary details request
+}
+
+sub set_patron_privileges {
+ my ($session, $instconf, $details, $penalties) = @_;
+ 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 $instconf->{patron_status_permit_all};
+
+ 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 $instconf->{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 new_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->account->{login}->ws_ou)
+ }
+ }
+ });
+}
+
+
+
+
+1;
+++ /dev/null
-# ---------------------------------------------------------------
-# 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::SIPSession;
-use strict; use warnings;
-use OpenSRF::Utils::Cache;
-use OpenSRF::Utils::Logger q/$logger/;
-use OpenILS::Application::AppUtils;
-my $U = 'OpenILS::Application::AppUtils';
-
-# Cache instances cannot be created until opensrf is connected.
-my $_cache;
-sub cache {
- $_cache = OpenSRF::Utils::Cache->new unless $_cache;
- return $_cache;
-}
-
-sub new {
- my ($class, %args) = @_;
- my $self = bless(\%args, $class);
-}
-
-# Create a new sessesion from cached data.
-sub from_cache {
- my ($class, $seskey) = @_;
-
- my $account = cache()->get_cache("sip2_$seskey");
- return undef unless $account;
-
- return $class->new(seskey => $seskey, account => $account);
-}
-
-sub seskey {
- my $self = shift;
- return $self->{seskey};
-}
-
-# Login account
-sub account {
- my $self = shift;
- return $self->{account};
-}
-
-# Logs in to Evergreen and caches the auth token/login with the SIP
-# account data.
-# Returns true on success, false on failure to authenticate.
-sub authenticate {
- my ($self, $account) = @_;
-
- my $seskey = $self->seskey;
-
- my $auth = $U->simplereq(
- 'open-ils.auth',
- 'open-ils.auth.login', {
- username => $account->{ils_username},
- password => $account->{ils_password},
- workstation => $account->{ils_workstation},
- type => 'staff'
- });
-
- if ($auth->{textcode} ne 'SUCCESS') {
- $logger->warn(
- "SIP2 login failed for ils_username".$account->{ils_username});
- return 0;
- }
-
- $account->{authtoken} = $auth->{payload}->{authtoken};
-
- # cache the login user account as well
- $account->{login} = $U->simplereq(
- 'open-ils.auth',
- 'open-ils.auth.session.retrieve',
- $account->{authtoken}
- );
-
- cache()->put_cache("sip2_$seskey", $account);
- return 1;
-}
-
-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 DateTime;
-use DateTime::Format::ISO8601;
-use JSON::XS;
-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/;
-
-my $json = JSON::XS->new;
-$json->ascii(1);
-$json->allow_nonref(1);
-
-my $SIP_DATE_FORMAT = "%Y%m%d %H%M%S";
-
-my $config = { # TODO: move to external config / database settings
- options => {
- # Allow 99 (sc status) message before successful 93 (login) message
- allow_sc_status_before_login => 1
- },
- accounts => [{
- sip_username => 'sip',
- sip_password => 'sip',
- ils_username => 'admin',
- ils_password => 'demo123',
- ils_workstation => 'BR1-gamma'
- }],
- institutions => [{
- id => 'example',
- currency => 'USD',
- supports => [ # Supported Messages (BX)
- '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,
- ],
- options => {
- due_date_use_sip_date_format => 0,
- patron_status_permit_loans => 0,
- patron_status_permit_all => 0,
- msg64_hold_items_available => 0
- }
- }]
-};
-
-my $osrf_config;
-sub import {
- $osrf_config = shift;
- warn "OSRF CONFIG IS $osrf_config\n";
-}
-
-my $init_complete = 0;
-sub init {
- return if $init_complete;
- $init_complete = 1;
-
- OpenSRF::System->bootstrap_client(config_file => $osrf_config);
- OpenILS::Utils::CStoreEditor->init;
-
- return Apache2::Const::OK;
-}
-
-sub sipdate {
- my $date = shift || DateTime->now;
- return $date->strftime($SIP_DATE_FORMAT);
-}
-
-sub sipbool {
- my ($bool, $use_n) = @_;
- return 'Y' if $bool;
- return $use_n ? 'N' : ' ';
-}
-
-sub count4 {
- my $value = shift;
- return ' ' unless defined $value;
- return sprintf("%04d", $value);
-}
-
-sub handler {
- my $r = shift;
- my $cgi = CGI->new;
-
- init();
-
- my $seskey = $cgi->param('session');
- my $msg_json = $cgi->param('message');
- my $message = $json->decode($msg_json);
- my $msg_code = $message->{code};
- my $response;
-
- if ($msg_code eq '93') {
- $response = handle_login($seskey, $message);
-
- } elsif ($msg_code eq '99') {
- $response = handle_sc_status($seskey, $message);
-
- } else {
-
- # 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::WWW::SIPSession->from_cache($seskey);
-
- return Apache2::Const::FORBIDDEN unless $session;
-
- if ($msg_code eq '63') {
- $response = handle_patron_info($session, $message);
- } elsif ($msg_code eq '17') {
- $response = handle_item_info($session, $message);
- }
- }
-
- unless ($response) {
- $logger->error("SIP2: no response generated for: $msg_code");
- return Apache2::Const::NOT_FOUND;
- }
-
- $r->content_type('application/json');
- $r->print($json->encode($response));
-
- return Apache2::Const::OK;
-}
-
-# Returns the value of the first occurrence of the requested SIP code.
-sub get_field_value {
- my ($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;
-}
-
-# Returns the configuation chunk mapped to the requested institution.
-sub get_inst_config {
- my $institution = shift;
- my ($instconf) = grep {$_->{id} eq $institution} @{$config->{institutions}};
-
- $logger->error(
- "SIP2 has no configuration for institution: $institution")
- unless $instconf;
-
- return $instconf;
-}
-
-# Login to Evergreen and cache the login data.
-sub handle_login {
- my ($seskey, $message) = @_;
-
- my $response = {
- code => '94',
- fixed_fields => ['0'] # default to login failed.
- };
-
- my $sip_username = get_field_value($message, 'CN');
- my $sip_password = get_field_value($message, 'CO');
-
- my ($account) = grep {
- $_->{sip_username} eq $sip_username &&
- $_->{sip_password} eq $sip_password
- } @{$config->{accounts}};
-
- if ($account) {
- my $session = OpenILS::WWW::SIPSession->new(seskey => $seskey);
- $response->{fixed_fields}->[0] = '1'
- if $session->authenticate($account);
-
- } else {
- $logger->info("SIP2 login failed for user=$sip_username")
- }
-
- return $response;
-}
-
-sub handle_sc_status {
- my ($seskey, $message) = @_;
-
- return undef unless (
- $config->{options}->{allow_sc_status_before_login} ||
- OpenILS::WWW::SIPSession->from_cache($seskey)
- );
-
- # The SC Status message does not include an institution, but expects
- # one in return. Use the configuration for the first institution.
- # Maybe the SIP server itself should track which institutoin its
- # instance is configured to use? That may multiple servers could
- # run, one per institution.
- my $instconf = $config->{institutions}->[0];
- my $instname = $instconf->{id};
-
- my $response = {
- code => '98',
- fixed_fields => [
- sipbool(1), # online_status
- sipbool(1), # checkin_ok
- sipbool(1), # checkout_ok
- sipbool(1), # acs_renewal_policy
- sipbool(0, 1), # status_update_ok
- sipbool(0, 1), # offline_ok
- '999', # timeout_period
- '999', # retries_allowed
- sipdate(), # transaction date
- '2.00' # protocol_version
- ],
- fields => [
- {AO => $instname},
- {BX => join('', @{$instconf->{supports}})}
- ]
- }
-}
-
-sub handle_item_info {
- my ($session, $message) = @_;
-
- my $account = $session->account;
- my $institution = get_field_value($message, 'AO');
- my $instconf = get_inst_config($institution) || return undef;
- my $barcode = get_field_value($message, 'AB');
- my $idetails = get_item_details($session, $instconf, $barcode);
-
- if (!$idetails) {
- # 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
- sipdate()
- ],
- fields => [{AB => $barcode, AJ => ''}]
- };
- };
-
- return {
- code => '18',
- fixed_fields => [
- $idetails->{circ_status},
- '02', # Security Marker, consistent with ../SIP*
- $idetails->{fee_type},
- sipdate()
- ],
- fields => [
- {AB => $barcode},
- {AJ => $idetails->{title}},
- {CF => $idetails->{hold_queue_length}},
- {AH => $idetails->{due_date}},
- {CM => $idetails->{hold_pickup_date}},
- {BG => $idetails->{item}->circ_lib->shortname},
- {BH => $instconf->{currency}},
- {BV => $idetails->{item}->deposit_amount},
- {CK => $idetails->{media_type}},
- {AQ => $idetails->{item}->circ_lib->shortname},
- {AP => $idetails->{item}->circ_lib->shortname},
- ]
- };
-}
-
-sub get_item_details {
- my ($session, $instconf, $barcode) = @_;
- my $e = new_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};
-
- $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 ($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];
- }
-
- 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];
- }
-
- 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';
-
- if ($details->{circ}) {
-
- my $due_date = DateTime::Format::ISO8601->new->
- parse_datetime(clean_ISO8601($details->{circ}->due_date));
-
- $details->{due_date} =
- $instconf->{due_date_use_sip_date_format} ?
- sipdate($due_date) :
- $due_date->strftime('%F %T');
- }
-
- if ($details->{hold}) {
- my $pickup_date = $details->{hold}->shelf_expire_time;
- $details->{hold_pickup_date} =
- $pickup_date ? sipdate($pickup_date) : undef;
- }
-
- 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';
-}
-
-sub handle_patron_info {
- my ($session, $message) = @_;
- my $account = $session->account;
-
- my $institution = get_field_value($message, 'AO');
- my $instconf = get_inst_config($institution) || return undef;
- my $barcode = get_field_value($message, 'AA');
- my $password = get_field_value($message, 'AD');
-
- my $pdetails =
- get_patron_details($session, $instconf, $message, $barcode, $password);
-
- if (!$pdetails) {
- return {
- code => '64',
- fixed_fields => [
- sipbool(1), # charge denied
- sipbool(1), # renew denied
- sipbool(1), # recall denied
- sipbool(1), # holds denied
- split('', (' ' x 10)),
- '000', # language
- sipdate()
- ],
- fields => [
- {AO => $institution},
- {AA => $barcode},
- {BL => sipbool(0, 1)}, # valid patron
- {CQ => sipbool(0, 1)} # valid patron password
- ]
- };
- }
-
- return {
- code => '64',
- fixed_fields => [
- sipbool($pdetails->{charge_denied}),
- sipbool($pdetails->{renew_denied}),
- sipbool($pdetails->{recall_denied}),
- sipbool($pdetails->{holds_denied}),
- sipbool($pdetails->{patron}->card->active eq 'f'),
- sipbool(0), # too many charged
- sipbool($pdetails->{too_may_overdue}),
- sipbool(0), # too many renewals
- sipbool(0), # too many claims retruned
- sipbool(0), # too many lost
- sipbool($pdetails->{too_many_fines}),
- sipbool($pdetails->{too_many_fines}),
- sipbool(0), # recall overdue
- sipbool($pdetails->{too_many_fines}),
- '000', # language
- sipdate(),
- count4($pdetails->{holds_count}),
- count4($pdetails->{overdue_count}),
- count4($pdetails->{out_count}),
- count4($pdetails->{fine_count}),
- count4($pdetails->{recall_count}),
- count4($pdetails->{unavail_holds_count}),
- ],
- fields => [
- {AO => $institution},
- {AA => $barcode},
- {BL => sipbool(1)}, # valid patron
- {CQ => sipbool($password, 1)} # password verified if exists
- ]
- };
-}
-
-sub get_patron_details {
- my ($session, $instconf, $message, $barcode, $password) = @_;
-
- my $e = new_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];
-
- 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);
-
- my $penalties = get_patron_penalties($session, $patron);
-
- set_patron_privileges($session, $instconf, $details, $penalties);
-
- $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;
-
- set_patron_summary_items($session, $instconf, $message, $details);
-
- return $details;
-}
-
-
-# Sets:
-# holds_count
-# overdue_count
-# out_count
-# fine_count
-# recall_count
-# unavail_holds_count
-sub set_patron_summary_items {
- my ($session, $instconf, $message, $details) = @_;
-
- my $patron = $details->{patron};
- my $start_item = get_field_value($message, 'BP');
- my $end_item = get_field_value($message, 'BQ');
- my @summary = @{$message->{fixed_fields}}[21 .. 30];
-
- my $e = new_editor();
-
- my $holds_where = {
- usr => $patron->id,
- fulfillment_time => undef,
- cancel_time => undef
- };
-
- $holds_where->{current_shelf_lib} = {'=' => {'+ahr' => 'pickup_lib'}}
- if $instconf->{msg64_hold_items_available};
-
- my $hold_ids = $e->json_query({
- select => {ahr => ['id']},
- from => 'ahr',
- where => {'+ahr' => $holds_where}
- });
-
- $details->{holds_count} = scalar(@$hold_ids);
-
- my $circ_ids = $e->retrieve_action_open_circ_list($patron->id);
- my $overdue_ids = [ grep {$_ > 0} split(',', $circ_ids->overdue) ];
- my $out_ids = [ grep {$_ > 0} split(',', $circ_ids->out) ];
-
- $details->{overdue_count} = scalar(@$overdue_ids);
- $details->{out_count} = scalar(@$out_ids) + scalar(@$overdue_ids);
-
- $details->{recall_count} = undef; # not supported
-
- my $xacts = $U->simplereq(
- 'open-ils.actor',
- 'open-ils.actor.user.transactions.history.have_balance',
- $session->account->{authtoken},
- $patron->id
- );
-
- $details->{fine_count} = scalar(@$xacts);
-
- # TODO: unavail holds count; summary details request
-}
-
-sub set_patron_privileges {
- my ($session, $instconf, $details, $penalties) = @_;
- 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 $instconf->{patron_status_permit_all};
-
- 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 $instconf->{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 new_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->account->{login}->ws_ou)
- }
- }
- });
-}
-
-
-
-
-1;