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(); 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; 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: