From 0b49a0e4fc32212b7a13fcf5dfb6a983f4869e97 Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Wed, 6 Jul 2022 16:35:28 -0400 Subject: [PATCH 1/3] (from Evergreen) LP1884950 Org service loads all ou types Explicitly load all org unit types at page startup so that we fetch org types that may not have an org unit attached. Signed-off-by: Bill Erickson Signed-off-by: Dawn Dale Signed-off-by: Chris Sharp Signed-off-by: Galen Charlton --- Open-ILS/src/eg2/src/app/core/org.service.ts | 27 ++++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/Open-ILS/src/eg2/src/app/core/org.service.ts b/Open-ILS/src/eg2/src/app/core/org.service.ts index 456f93f6f9..0c98da37fa 100644 --- a/Open-ILS/src/eg2/src/app/core/org.service.ts +++ b/Open-ILS/src/eg2/src/app/core/org.service.ts @@ -175,17 +175,10 @@ export class OrgService { if (!node) { node = this.orgTree; this.orgMap = {}; - this.orgList = []; - this.orgTypeMap = {}; } this.orgMap[node.id()] = node; this.orgList.push(node); - this.orgTypeMap[node.ou_type().id()] = node.ou_type(); - if (!this.orgTypeList.filter(t => t.id() === node.ou_type().id())[0]) { - this.orgTypeList.push(node.ou_type()); - } - node.children().forEach(c => this.absorbTree(c)); } @@ -194,10 +187,22 @@ export class OrgService { * various shapes, then returns an "all done" promise. */ fetchOrgs(): Promise { - return this.pcrud.search('aou', {parent_ou : null}, - {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}}, - {anonymous : true} - ).toPromise().then(tree => { + + // Grab org types separately so we are guaranteed to fetch types + // that are not yet in use by an org unit. + return this.pcrud.retrieveAll( + 'aout', {}, {anonymous: true, atomic: true}).toPromise() + .then(types => { + this.orgTypeList = types; + types.forEach(t => this.orgTypeMap[Number(t.id())] = t); + + return this.pcrud.search('aou', {parent_ou : null}, + {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}}, + {anonymous : true} + ).toPromise() + }) + + .then(tree => { // ingest tree, etc. this.orgTree = tree; this.absorbTree(); From eeb1c53520b68d17b14e3512d7a7df429a7f8e80 Mon Sep 17 00:00:00 2001 From: Galen Charlton Date: Wed, 14 Dec 2022 23:20:53 +0000 Subject: [PATCH 2/3] Updated Koha connector This updates the Koha connector to use various Koha API endpoints where possible. In particular, it uses the legacy /svc endpoint to create bibs and items. It uses the Koha REST API for item retrieval and placing holds, but uses the REST API extensions from https://github.com/NatLibFi/koha-plugin-rest-di to implement patron authentication, as the Koha REST API currently has no endpoint for verifying the validity of the credentials other than the API user itself. It defaults to SIP2 for checkouts and checkins. Resolving Koha bug 23336 would allow the REST API to be used. Assuming that a single main user is used to access the API, the following connector parameters are generally needed: * Connector svc API host (for Koha) - this is new, and should be set to the staff hostname * Connecter svc API user (for Koha) - also new * Connector svc API password (for Koha) - also new * Default Connector Host - should be set to the OPAC username * Default Connector User - this should be set to the OAuth client ID of the chosen staff user accessing the API * Default Connector Password - this should be set to the OAuth secret of the staff user accessing the API * SIP2 Hostname * SIP2 institution * SIP2 port * SIP2 login username * SIP2 login password Installation steps need for the Koha-side: * Install and set up the https://github.com/NatLibFi/koha-plugin-rest-di plugin * Enable the RESTOAuth2ClientCredentials system preference * Create a staff user with the following permissions: - circulate - catalogue - borrowers - reserveforothers - editcatalogue * Create API keys for that staff user The connector requires that the Koha version be at least 21.05. Signed-off-by: Galen Charlton --- .../lib/FulfILLment/LAIConnector/Koha.pm | 358 ++++++++++++++---- 1 file changed, 276 insertions(+), 82 deletions(-) diff --git a/Open-ILS/src/perlmods/lib/FulfILLment/LAIConnector/Koha.pm b/Open-ILS/src/perlmods/lib/FulfILLment/LAIConnector/Koha.pm index 91bfe8b71a..d59d80a6df 100644 --- a/Open-ILS/src/perlmods/lib/FulfILLment/LAIConnector/Koha.pm +++ b/Open-ILS/src/perlmods/lib/FulfILLment/LAIConnector/Koha.pm @@ -3,6 +3,7 @@ use base FulfILLment::LAIConnector; use strict; use warnings; use XML::LibXML; use LWP::UserAgent; +use Data::Dumper; use OpenSRF::Utils::Logger qw/$logger/; # TODO: for holds @@ -10,6 +11,9 @@ use DateTime; my $U = 'OpenILS::Application::AppUtils'; use OpenILS::Utils::CStoreEditor qw/:funcs/; +# We're using both the legacy /svc API endpoint, because +# that's what's available to create bibs and items, and Koha's REST API + # special thanks to Koha => misc/migration_tools/koha-svc.pl sub svc_login { my $self = shift; @@ -19,10 +23,9 @@ sub svc_login { my $password = $self->{extra}->{'svc.password'} || $self->{passwd}; my $url = sprintf( - "%s://%s:%s/cgi-bin/koha/svc", - $self->{extra}->{'svc.proto'} || $self->{proto} || 'https', + "%s://%s/cgi-bin/koha/svc", + 'https', $self->{extra}->{'svc.host'} || $self->{host}, - $self->{extra}->{'svc.port'} || $self->{port} || 443 ); my $ua = LWP::UserAgent->new(); @@ -46,6 +49,85 @@ sub svc_login { return 1; } +sub _base_api_url { + my $self = shift; + + return sprintf( + "%s://%s/api/v1", + 'https', + $self->{host}, + ); +} + +sub _oauth_login { + my $self = shift; + return 1 if $self->{oauth_agent}; + + my $client_id = $self->{'user'}; + my $client_secret = $self->{'passwd'}; + + my $url = $self->_base_api_url; + + my $ua = LWP::UserAgent->new(); + $ua->cookie_jar({}); + + $logger->info("FF Koha logging in via OAuth"); + + my $resp = $ua->post( + "$url/oauth/token", + { + client_id => $client_id, + client_secret => $client_secret, + grant_type => 'client_credentials' + } + ); + + if (!$resp->is_success) { + $logger->error("FF Koha oauth login failed " . $resp->status_line); + return; + } + + my $result = OpenSRF::Utils::JSON->JSON2perl($resp->decoded_content); + $self->{oauth_token} = $result->{access_token}; + $self->{oauth_agent} = $ua; + + return 1; +} + +sub _make_api_request { + my $self = shift; + my $request_type = shift; + my $route = shift; + my $params = shift; + my $format = shift // 'application/json'; + + return unless $self->_oauth_login; + + my $url = $self->_base_api_url . '/' . $route; + my $req = HTTP::Request->new( + $request_type => $url + ); + $req->header('Cache-Control', 'no-cache'); + $req->header('Pragma', 'no-cache'); + $req->header('Authorization', 'Bearer ' . $self->{oauth_token}); + $req->header('Accept', $format); + $req->content(OpenSRF::Utils::JSON->perl2JSON($params)) if defined($params); + + my $resp = $self->{oauth_agent}->request($req); + + if (!$resp->is_success) { + $logger->error( + "FF Koha REST API request error [HTTP ".$resp->code."] for $url\n". Dumper($req) . "\n" . Dumper($resp)); + return undef; + } + + if ($format =~ /json/) { + return OpenSRF::Utils::JSON->JSON2perl($resp->decoded_content); + } else { + return $resp->decoded_content; + } +} + sub escape_xml { my $str = shift; $str =~ s/&/&/sog; @@ -80,14 +162,19 @@ XML my $title = escape_xml($ref_copy->call_number->record->simple_record->title); my $author = escape_xml($ref_copy->call_number->record->simple_record->author); - my $barcode = escape_xml($ref_copy->barcode); # TODO: setting for leading org id + my $barcode = escape_xml('ILL' . $ref_copy->barcode); my $callnumber = escape_xml($ref_copy->call_number->label); $marc =~ s/TITLE/$title/g; $marc =~ s/AUTHOR/$author/g; $marc =~ s/BARCODE/$barcode/g; $marc =~ s/CALLNUMBER/$callnumber/g; - $marc =~ s/LOCATION/$circ_lib_code/g; + + my $svc_user = $self->get_user($self->{extra}->{'svc.user'}); + return unless $svc_user; + + my $library = $svc_user->{home_library}; + $marc =~ s/LOCATION/$library/g; $logger->info("FF Koha borrower rec/copy: $marc"); @@ -125,131 +212,238 @@ XML } sub get_record_by_id { - my ($self, $record_id, $with_items) = @_; - return unless $self->svc_login; - - $with_items = '?items=1' if $with_items; + my ($self, $record_id) = @_; - my $url = $self->{svc_url}."/bib/$record_id$with_items"; - my $resp = $self->{svc_agent}->get($url); + my $resp = $self->_make_api_request( + 'GET', 'biblios/' . $record_id, undef, 'application/marcxml+xml' + ); - if (!$resp->is_success) { - $logger->error("FF Koha record_by_id failed " . $resp->status_line); + if (!$resp) { + $logger->error("FF Koha record_by_id failed"); return; } - return $resp->decoded_content + return { + marc => $resp, + error => 0, + id => $record_id + }; } -# NOTE: unused, but kept for reference -sub get_record_by_id_z3950 { - my ($self, $record_id) = @_; - - my $attr = $self->{args}{extra}{'z3950.search_attr'}; +sub _get_due_date_for_item { + my ($self, $koha_item_id) = @_; - # Koha returns holdings by default, which is useful - # for get_items_by_record (below). - - my $xml = $self->z39_client->get_record_by_id( - $record_id, $attr, undef, 'xml', 1) or return; + my $resp = $self->_make_api_request( + 'GET', 'checkouts', { item_id => $koha_item_id }, 'application/json' + ); - return {marc => $xml, id => $record_id}; + if ($resp && $resp->[0] && $resp->[0]->{due_date}) { + return $resp->[0]->{due_date} + } else { + return; + } } sub get_items_by_record { my ($self, $record_id) = @_; - my $rec = $self->get_record_by_id($record_id, 1) or return []; - - # when calling get_record_by_id_z3950 - # my $doc = XML::LibXML->new->parse_string($rec->{marc}) or return []; - - my $doc = XML::LibXML->new->parse_string($rec) or return []; - - # marc code to copy field map - my %map = ( - o => 'call_number', - p => 'barcode', - a => 'location_code' + my $resp = $self->_make_api_request( + 'GET', 'items', { biblionumber => $record_id }, 'application/json' ); - my @items; - for my $node ($doc->findnodes('//*[@tag="952"]')) { + if (!$resp || !(ref $resp eq 'ARRAY')) { + $logger->error("FF Koha get_items_by_record failed"); + return; + } - my $item = {bib_id => $record_id}; + my @items; - for my $key (keys %map) { - my $val = $node->findnodes("./*[\@code='$key']")->string_value; - next unless $val; - $val =~ s/^\s+|\s+$//g; # cleanup - $item->{$map{$key}} = $val; + foreach my $item (@{ $resp }) { + my $holdable = 't'; + if ($item->{checked_out_date} || + $item->{not_for_loan_status} || + $item->{lost_status} || + $item->{restricted_status}) { + $holdable = 'f'; } - - push (@items, $item); + my $munged_item = { + bib_id => $record_id, + owner => $item->{home_library_id} // '', + barcode => $item->{external_id} // '', + call_number => $item->{callnumber} // '', + holdable => $holdable, + item_id => $item->{item_id}, + }; + if ($item->{checked_out_date}) { + my $due_date = $self->_get_due_date_for_item($item->{item_id}); + if ($due_date) { + $due_date =~ s/T.*$//; + $munged_item->{due_date} = $due_date; + } + } + push @items, $munged_item; } return \@items; } -# NOTE: initial code review suggests Koha only supports bib-level -# holds via SIP, but they are created via copy barcode (not bib id). -# Needs more research +sub get_item { + my ($self, $barcode) = @_; + + my $resp = $self->_make_api_request( + 'GET', 'items', { external_id => $barcode }, 'application/json' + ); + + if (!$resp || !(ref $resp eq 'ARRAY')) { + $logger->error("FF Koha get_item failed"); + return; + } + + return unless scalar(@{ $resp }) > 0; + + my $item = $resp->[0]; + my $holdable = 't'; + if ($item->{checked_out_date} || + $item->{not_for_loan_status} || + $item->{lost_status} || + $item->{restricted_status}) { + $holdable = 'f'; + } + my $munged_item = { + bib_id => $item->{biblio_id}, + owner => $item->{home_library_id} // '', + barcode => $item->{external_id} // '', + call_number => $item->{callnumber} // '', + holdable => $holdable, + item_id => $item->{item_id}, + }; + if ($item->{checked_out_date}) { + my $due_date = $self->_get_due_date_for_item($item->{item_id}); + if ($due_date) { + $due_date =~ s/T.*$//; + $munged_item->{due_date} = $due_date; + } + } + return $munged_item; +} sub place_borrower_hold { my ($self, $item_barcode, $user_barcode, $pickup_lib) = @_; - # NOTE: i believe koha ignores (but requires) the hold type - my $hold = $self->place_hold_via_sip( - undef, $item_barcode, $user_barcode, $pickup_lib, 3) - or return; + my $ill_barcode = 'ILL' . $item_barcode; - $hold->{hold_type} = 'T'; - return $hold; + return $self->place_lender_hold($ill_barcode, $user_barcode); } sub place_lender_hold { - my ($self, $item_barcode, $user_barcode, $pickup_lib) = @_; + my ($self, $item_barcode, $user_barcode) = @_; + + my $lender_user = $self->get_user($user_barcode); + return unless defined $lender_user; + + my $item = $self->get_item($item_barcode); + return unless defined $item; + + my $resp = $self->_make_api_request( + 'POST', 'holds', { + patron_id => $lender_user->{user_id}, + biblio_id => $item->{bib_id}, + item_id => $item->{item_id}, + pickup_library_id => $lender_user->{home_library}, + }, 'application/json' + ); + + if (!$resp || !(ref $resp eq 'HASH')) { + $logger->error("FF Koha place_lender failed"); + return; + } + + return $resp->{hold_id}; +} + +sub _find_last_active_hold { + my ($self, $item_id, $patron_id, $bib_id) = @_; - # NOTE: i believe koha ignores (but requires) the hold type - my $hold = $self->place_hold_via_sip( - undef, $item_barcode, $user_barcode, $pickup_lib, 2) - or return; + my $resp = $self->_make_api_request( + 'GET', 'holds', { + patron_id => $patron_id, + biblio_id => $bib_id, + item_id => $item_id + } + ); - $hold->{hold_type} = 'T'; - return $hold; + return unless $resp and ref($resp) eq 'ARRAY'; + return $resp->[0]->{hold_id}; } sub delete_borrower_hold { my ($self, $item_barcode, $user_barcode) = @_; - # TODO: find the hold in the FF db to determine the pickup_lib - # for now, assume pickup lib matches the user's home lib - my $user = $self->flesh_user($user_barcode); - my $pickup_lib = $user->home_ou->shortname if $user; - - my $resp = $self->sip_client->delete_hold( - $user_barcode, undef, undef, - $pickup_lib, 3, $item_barcode) - or return; + my $ill_barcode = 'ILL' . $item_barcode; - return unless $resp; - return $self->translate_sip_hold($resp); + return $self->delete_lender_hold($ill_barcode, $user_barcode); } sub delete_lender_hold { my ($self, $item_barcode, $user_barcode) = @_; - my $user = $self->flesh_user($user_barcode); - my $pickup_lib = $user->home_ou->shortname if $user; + my $lender_user = $self->get_user($user_barcode); + return unless defined $lender_user; - my $resp = $self->sip_client->delete_hold( - $user_barcode, undef, undef, - $pickup_lib, 2, $item_barcode) - or return; + my $item = $self->get_item($item_barcode); + return unless defined $item; + + my $hold_id = $self->_find_last_active_hold($item->{item_id}, $lender_user->{user_id}, $item->{bib_id}); + return unless $hold_id; + + my $resp => $self->_make_api_request( + 'DELETE', 'holds/' . $hold_id + ); - return unless $resp; - return $self->translate_sip_hold($resp); + return $hold_id; } +sub get_user { + my $self = shift; + my $user_barcode = shift; + my $user_password = shift; + + return unless $self->_oauth_login; + + my $patron; + if (defined($user_password) && $user_password ne '') { + # validate the user credentials first + my $password_check = $self->_make_api_request( + 'POST', 'contrib/kohasuomi/auth/patrons/validation', + { cardnumber => $user_barcode, password => $user_password } + ); + if ($password_check) { + $patron = $password_check; + } else { + $logger->info("Koha: unable to verify credentials for user $user_barcode"); + return OpenILS::Event->new("ACTOR_USER_NOT_FOUND", error => 1); + } + } else { + my $patrons = $self->_make_api_request('GET', 'patrons', { cardnumber => $user_barcode }); + if ($patrons && $patrons->[0]) { + $patron = $patrons->[0]; + } else { + $logger->info("Koha: unable to retrieve user $user_barcode"); + return OpenILS::Event->new("ACTOR_USER_NOT_FOUND", error => 1); + } + } + + my $data = {}; + $data->{surname} = $patron->{surname}; + $data->{initials} = $patron->{initials}; + $data->{given_name} = $patron->{firstname}; + $data->{user_id} = $patron->{patron_id}; + $data->{exp_date} = $patron->{expiry_date}; + $data->{user_barcode} = $user_barcode; + $data->{email} = $patron->{email}; + $data->{home_library} = $patron->{library_id}; + + return $data; +} 1; From fe3c6ddfe961974e173d0add1d6d9046667361c0 Mon Sep 17 00:00:00 2001 From: Galen Charlton Date: Thu, 15 Dec 2022 01:24:05 +0000 Subject: [PATCH 3/3] Koha connector: add three new settings The Koha connector needs to use both the legacy /svc API (to create temporary bibs and items) and its RESTful API. Since the RESTful API uses OAuth-derived tokens and is accessed from the OPAC host but the /svc API uses username/passwords and is accessed from the staff interface host, we need the extra settings. Signed-off-by: Galen Charlton --- Open-ILS/src/sql/Pg/950.data.seed-values.sql | 12 ++++++++++++ .../XXXX.data.koha-svc-connector-settings.sql | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.data.koha-svc-connector-settings.sql diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql index 54ef9eff5b..81c426afd3 100644 --- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql +++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql @@ -16913,6 +16913,18 @@ INSERT INTO config.org_unit_setting_type ( name, label, description, datatype ) ( 'ff.remote.connector.passwd.item', oils_i18n_gettext( 'ff.remote.connector.passwd.item', 'LAI: Connector Item Data Password', 'coust', 'label'), oils_i18n_gettext( 'ff.remote.connector.passwd.item', 'Password to be used with the owing site''s Connector', 'coust', 'description'), + 'string'), +( 'ff.remote.connector.extra.svc.host', + oils_i18n_gettext( 'ff.remote.connector.extra.svc.host', 'LAI: Connector svc API host (for Koha)', 'coust', 'label'), + oils_i18n_gettext( 'ff.remote.connector.extra.svc.host', 'Hostname of the Koha /svc API (usually the staff interface)', 'coust', 'description'), + 'string'), +( 'ff.remote.connector.extra.svc.user', + oils_i18n_gettext( 'ff.remote.connector.extra.svc.user', 'LAI: Connector svc API user (for Koha)', 'coust', 'label'), + oils_i18n_gettext( 'ff.remote.connector.extra.svc.user', 'User to be used to log into the Koha /svc API', 'coust', 'description'), + 'string'), +( 'ff.remote.connector.extra.svc.password', + oils_i18n_gettext( 'ff.remote.connector.extra.svc.password', 'LAI: Connector svc API password (for Koha)', 'coust', 'label'), + oils_i18n_gettext( 'ff.remote.connector.extra.svc.password', 'Password to be used to log into the Koha /svc API', 'coust', 'description'), 'string'); -- item auto-ingest related settings diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.koha-svc-connector-settings.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.koha-svc-connector-settings.sql new file mode 100644 index 0000000000..c9c360501d --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.koha-svc-connector-settings.sql @@ -0,0 +1,17 @@ +BEGIN; + +INSERT INTO config.org_unit_setting_type ( name, label, description, datatype ) VALUES +( 'ff.remote.connector.extra.svc.host', + oils_i18n_gettext( 'ff.remote.connector.extra.svc.host', 'LAI: Connector svc API host (for Koha)', 'coust', 'label'), + oils_i18n_gettext( 'ff.remote.connector.extra.svc.host', 'Hostname of the Koha /svc API (usually the staff interface)', 'coust', 'description'), + 'string'), +( 'ff.remote.connector.extra.svc.user', + oils_i18n_gettext( 'ff.remote.connector.extra.svc.user', 'LAI: Connector svc API user (for Koha)', 'coust', 'label'), + oils_i18n_gettext( 'ff.remote.connector.extra.svc.user', 'User to be used to log into the Koha /svc API', 'coust', 'description'), + 'string'), +( 'ff.remote.connector.extra.svc.password', + oils_i18n_gettext( 'ff.remote.connector.extra.svc.password', 'LAI: Connector svc API password (for Koha)', 'coust', 'label'), + oils_i18n_gettext( 'ff.remote.connector.extra.svc.password', 'Password to be used to log into the Koha /svc API', 'coust', 'description'), + 'string'); + +COMMIT: