From a0bf526d430644669d3d7f51414a6257027411da Mon Sep 17 00:00:00 2001 From: Jason Etheridge Date: Thu, 3 Dec 2020 10:08:44 -0500 Subject: [PATCH] lp1863252 toward geosort From the release notes: Sort Holdings by Geographical Proximity ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This functionality integrates 3rd party geographic lookup services to allow patrons to enter an address on the record details page in the OPAC and sort the holdings for that record based on proximity of their circulating libraries to the entered address. To support this, latitude and longitude coordinates may be associated with each org unit. Care is given to not log or leak patron provided addresses or the context in which they are used. squashed commits: * actor.org_address * config.geolocation_service * config.global_flag * opac.holdings_sort_by_geographic_proximity OUS * opac.geographic_proximity_in_miles OUS * opac.geographic_location_service_for_address * permission.perm_list * Geo.pm, opensrf*.xml, ils_events.xml * Org Unit Configuration * OPAC Record Details * release notes * remove base_url * provide an open-ils.actor wrapper for open-ils.geo.retrieve_coordinates * some error trapping This will throw the GEOCODING_LOCATION_NOT_FOUND event for a wider range of problems. With the Google service, you can test by providing a bad API key or sending the copyright symbol as an address to lookup. * distribute permissions to stock perm groups * prerequisite cpan modules * live_t/ tests Signed-off-by: Jason Etheridge --- Open-ILS/examples/fm_IDL.xml | 23 +++ Open-ILS/examples/opensrf.xml.example | 21 +++ Open-ILS/examples/opensrf_core.xml.example | 1 + .../server/admin-server-splash.component.html | 2 + .../app/staff/admin/server/org-addr.component.html | 3 + .../app/staff/admin/server/org-addr.component.ts | 27 ++- Open-ILS/src/extras/ils_events.xml | 9 + Open-ILS/src/extras/install/Makefile.debian-buster | 3 + Open-ILS/src/extras/install/Makefile.debian-jessie | 3 + .../src/extras/install/Makefile.debian-stretch | 3 + Open-ILS/src/extras/install/Makefile.fedora | 3 + Open-ILS/src/extras/install/Makefile.ubuntu-bionic | 3 + Open-ILS/src/extras/install/Makefile.ubuntu-focal | 3 + .../src/perlmods/lib/OpenILS/Application/Actor.pm | 24 +++ .../src/perlmods/lib/OpenILS/Application/Geo.pm | 188 +++++++++++++++++++++ .../src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm | 5 + Open-ILS/src/perlmods/live_t/32-geosort.t | 92 ++++++++++ Open-ILS/src/sql/Pg/002.schema.config.sql | 9 + Open-ILS/src/sql/Pg/005.schema.actors.sql | 4 +- Open-ILS/src/sql/Pg/800.fkeys.sql | 3 + Open-ILS/src/sql/Pg/950.data.seed-values.sql | 69 ++++++++ Open-ILS/src/sql/Pg/upgrade/XXXX.geosort.sql | 93 ++++++++++ .../src/templates/opac/parts/record/copy_table.tt2 | 7 + .../OPAC/geographic_sorting.adoc | 11 ++ 24 files changed, 607 insertions(+), 2 deletions(-) create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/Application/Geo.pm create mode 100644 Open-ILS/src/perlmods/live_t/32-geosort.t create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.geosort.sql create mode 100644 docs/RELEASE_NOTES_NEXT/OPAC/geographic_sorting.adoc diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml index 45ce731535..d6c112fc32 100644 --- a/Open-ILS/examples/fm_IDL.xml +++ b/Open-ILS/examples/fm_IDL.xml @@ -6505,6 +6505,8 @@ SELECT usr, + + @@ -13497,6 +13499,27 @@ SELECT usr, + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/examples/opensrf.xml.example b/Open-ILS/examples/opensrf.xml.example index 36a43961ab..8d26f16aef 100644 --- a/Open-ILS/examples/opensrf.xml.example +++ b/Open-ILS/examples/opensrf.xml.example @@ -736,6 +736,26 @@ vim:et:ts=4:sw=4: + + 5 + 1 + perl + OpenILS::Application::Geo + 199 + + open-ils.geo_unix.sock + open-ils.geo_unix.pid + 1000 + open-ils.geo_unix.log + 1 + 15 + 1 + 5 + + + + + 5 1 @@ -1357,6 +1377,7 @@ vim:et:ts=4:sw=4: open-ils.ebook_api open-ils.courses open-ils.curbside + open-ils.geo diff --git a/Open-ILS/examples/opensrf_core.xml.example b/Open-ILS/examples/opensrf_core.xml.example index db89f5602a..dfa0c3b5d9 100644 --- a/Open-ILS/examples/opensrf_core.xml.example +++ b/Open-ILS/examples/opensrf_core.xml.example @@ -30,6 +30,7 @@ Example OpenSRF bootstrap configuration file for Evergreen open-ils.courses open-ils.curbside open-ils.fielder + open-ils.geo open-ils.pcrud open-ils.permacrud open-ils.reporter diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html index fd65e16f9b..c6a4108031 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html @@ -43,6 +43,8 @@ url="/eg/staff/admin/server/actor/org_unit_custom_tree"> + + + diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.ts index 2092075104..cea8fd1154 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.ts @@ -2,6 +2,8 @@ import {Component, Input, Output, EventEmitter} from '@angular/core'; import {IdlService, IdlObject} from '@eg/core/idl.service'; import {OrgService} from '@eg/core/org.service'; import {PcrudService} from '@eg/core/pcrud.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; import {NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap'; const ADDR_TYPES = @@ -36,7 +38,9 @@ export class OrgAddressComponent { constructor( private idl: IdlService, private org: OrgService, - private pcrud: PcrudService + private pcrud: PcrudService, + private auth: AuthService, + private net: NetService ) { this.addrChange = new EventEmitter(); this.tabName = 'billing_address'; @@ -159,5 +163,26 @@ export class OrgAddressComponent { return org; } + getCoordinates($event: any) { + const addr = $event.record; + + this.net.request( + 'open-ils.actor', + 'open-ils.actor.geo.retrieve_coordinates', + this.auth.token(), + addr.org_unit(), + addr.street1() + ' ' + addr.street2() + ', ' + + addr.city() + ', ' + addr.state() + ' ' + addr.post_code() + + ' ' + addr.country() + ).subscribe( + (res) => { + addr.latitude( res.latitude ); + addr.longitude( res.longitude ); + }, + (err) => { + console.error(err); + } + ); + } } diff --git a/Open-ILS/src/extras/ils_events.xml b/Open-ILS/src/extras/ils_events.xml index 527c61215a..0a5507f657 100644 --- a/Open-ILS/src/extras/ils_events.xml +++ b/Open-ILS/src/extras/ils_events.xml @@ -947,6 +947,15 @@ A scheduled, unfilled curbside request already exists + + Geo-Coding is not enabled for this installation + + + A Geographic Location Service is not configured for this library + + + No location returned by Geographic Location Service + diff --git a/Open-ILS/src/extras/install/Makefile.debian-buster b/Open-ILS/src/extras/install/Makefile.debian-buster index a561a0a92b..fd7b42479c 100644 --- a/Open-ILS/src/extras/install/Makefile.debian-buster +++ b/Open-ILS/src/extras/install/Makefile.debian-buster @@ -94,6 +94,9 @@ export DEB_APACHE_DISCONF = \ serve-cgi-bin export CPAN_MODULES = \ + Geo::Coder::Free \ + Geo::Coder::OSM \ + Geo::Coder::Google \ Business::OnlinePayment::PayPal \ Email::Send diff --git a/Open-ILS/src/extras/install/Makefile.debian-jessie b/Open-ILS/src/extras/install/Makefile.debian-jessie index 21c906b96d..122aeb0e39 100644 --- a/Open-ILS/src/extras/install/Makefile.debian-jessie +++ b/Open-ILS/src/extras/install/Makefile.debian-jessie @@ -95,6 +95,9 @@ export DEB_APACHE_DISCONF = \ serve-cgi-bin export CPAN_MODULES = \ + Geo::Coder::Free \ + Geo::Coder::OSM \ + Geo::Coder::Google \ Business::OnlinePayment::PayPal \ Email::Send diff --git a/Open-ILS/src/extras/install/Makefile.debian-stretch b/Open-ILS/src/extras/install/Makefile.debian-stretch index e5a9ce596f..1c14bc4f49 100644 --- a/Open-ILS/src/extras/install/Makefile.debian-stretch +++ b/Open-ILS/src/extras/install/Makefile.debian-stretch @@ -94,6 +94,9 @@ export DEB_APACHE_DISCONF = \ serve-cgi-bin export CPAN_MODULES = \ + Geo::Coder::Free \ + Geo::Coder::OSM \ + Geo::Coder::Google \ Business::OnlinePayment::PayPal \ Email::Send diff --git a/Open-ILS/src/extras/install/Makefile.fedora b/Open-ILS/src/extras/install/Makefile.fedora index 43ba4844b1..ae6d4b03d0 100644 --- a/Open-ILS/src/extras/install/Makefile.fedora +++ b/Open-ILS/src/extras/install/Makefile.fedora @@ -72,6 +72,9 @@ FEDORA_RPMS = \ yaz export CPAN_MODULES = \ + Geo::Coder::Free \ + Geo::Coder::OSM \ + Geo::Coder::Google \ Excel::Writer::XLSX \ Business::ISSN \ Net::Z3950::ZOOM \ diff --git a/Open-ILS/src/extras/install/Makefile.ubuntu-bionic b/Open-ILS/src/extras/install/Makefile.ubuntu-bionic index 519d063381..815fdad145 100644 --- a/Open-ILS/src/extras/install/Makefile.ubuntu-bionic +++ b/Open-ILS/src/extras/install/Makefile.ubuntu-bionic @@ -90,6 +90,9 @@ export DEB_APACHE_DISCONF = \ serve-cgi-bin export CPAN_MODULES = \ + Geo::Coder::Free \ + Geo::Coder::OSM \ + Geo::Coder::Google \ Business::OnlinePayment::PayPal \ Email::Send \ MARC::Charset \ diff --git a/Open-ILS/src/extras/install/Makefile.ubuntu-focal b/Open-ILS/src/extras/install/Makefile.ubuntu-focal index deb054876e..0617dc79b0 100644 --- a/Open-ILS/src/extras/install/Makefile.ubuntu-focal +++ b/Open-ILS/src/extras/install/Makefile.ubuntu-focal @@ -90,6 +90,9 @@ export DEB_APACHE_DISCONF = \ serve-cgi-bin export CPAN_MODULES = \ + Geo::Coder::Free \ + Geo::Coder::OSM \ + Geo::Coder::Google \ Business::OnlinePayment::PayPal \ Email::Send \ MARC::Charset \ diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm index bbe57e29a2..23021c7063 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm @@ -1429,6 +1429,30 @@ sub get_my_org_path { $org_id ); } +__PACKAGE__->register_method( + method => "retrieve_coordinates", + api_name => "open-ils.actor.geo.retrieve_coordinates", + signature => { + params => [ + {desc => 'Authentication token', type => 'string' }, + {type => 'number', desc => 'Context Organizational Unit'}, + {type => 'string', desc => 'Address to look-up as a text string'} + ], + return => { desc => 'Hash/object containing latitude and longitude for the provided address.'} + } +); + +sub retrieve_coordinates { + my( $self, $client, $auth, $org_id, $addr_string ) = @_; + my $e = new_editor(authtoken=>$auth); + return $e->event unless $e->checkauth; + $org_id = $e->requestor->ws_ou unless defined $org_id; + + return $apputils->simple_scalar_request( + "open-ils.geo", + "open-ils.geo.retrieve_coordinates", + $org_id, $addr_string ); +} __PACKAGE__->register_method( method => "patron_adv_search", diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Geo.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Geo.pm new file mode 100644 index 0000000000..0d2ba8c606 --- /dev/null +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Geo.pm @@ -0,0 +1,188 @@ +package OpenILS::Application::Geo; + +use strict; +use warnings; + +use OpenSRF::AppSession; +use OpenILS::Application; +use base qw/OpenILS::Application/; + +use OpenILS::Utils::CStoreEditor qw/:funcs/; +use OpenILS::Utils::Fieldmapper; +use OpenILS::Application::AppUtils; +my $U = "OpenILS::Application::AppUtils"; + +use OpenSRF::Utils::Logger qw/$logger/; + +use Geo::Coder::Free; +use Geo::Coder::OSM; +use Geo::Coder::Google; + +use Math::Trig qw(great_circle_distance deg2rad); + +sub calculate_distance { + my ($self, $conn, $pointA, $pointB) = @_; + + return new OpenILS::Event("BAD_PARAMS", "desc" => "Missing coordinates") unless $pointA; + return new OpenILS::Event("BAD_PARAMS", "desc" => "Missing coordinates") unless $pointB; + return new OpenILS::Event("BAD_PARAMS", "desc" => "Malformed coordinates") unless scalar(@{ $pointA }) == 2; + return new OpenILS::Event("BAD_PARAMS", "desc" => "Malformed coordinates") unless scalar(@{ $pointB }) == 2; + + sub NESW { deg2rad($_[0]), deg2rad(90 - $_[1]) } + my @A = NESW( $pointA->[0], $pointA->[1] ); + my @B = NESW( $pointB->[0], $pointB->[1] ); + my $km = great_circle_distance(@A, @B, 6378); + + return $km; +} +__PACKAGE__->register_method( + method => "calculate_distance", + api_name => "open-ils.geo.calculate_distance", + signature => { + params => [ + {type => 'array', desc => 'An array containing latitude and longitude for point A'}, + {type => 'array', desc => 'An array containing latitude and longitude for point B'} + ], + return => { desc => '"Great Circle (as the crow flies)" distance between points A and B in kilometers'} + } +); + +sub sort_orgs_by_distance_from_coordinate { + my ($self, $conn, $pointA, $orgs) = @_; + + return new OpenILS::Event("BAD_PARAMS", "desc" => "Missing coordinates") unless $pointA; + return new OpenILS::Event("BAD_PARAMS", "desc" => "Malformed coordinates") unless scalar(@{ $pointA }) == 2; + return new OpenILS::Event("BAD_PARAMS", "desc" => "Missing org list") unless $orgs; + return new OpenILS::Event("BAD_PARAMS", "desc" => "Empty org list") unless scalar(@{ $orgs }) > 0; + + my $e = new_editor(xact => 1); + + my $fleshed_orgs = $e->search_actor_org_unit([ + { + "id" => $orgs + }, { + "flesh" => 1, + "flesh_fields" => {"aou" => ["billing_address"]} + } + ]) or return (undef, $e->die_event); + + my @orgs_with_coordinates = grep { + defined $_->billing_address + && defined $_->billing_address->latitude + && defined $_->billing_address->longitude } @$fleshed_orgs; + my @orgs_without_coordinates = grep { + !defined $_->billing_address + || !defined $_->billing_address->latitude + || !defined $_->billing_address->longitude } @$fleshed_orgs; + + my @org_ids_with_distances = map { + [ $_->id, calculate_distance($self, $conn, $pointA, [ + $_->billing_address->latitude, + $_->billing_address->longitude + ]) ] + } @orgs_with_coordinates; + + my @sorted_orgs = sort { $a->[1] <=> $b->[1] } @org_ids_with_distances; + push @sorted_orgs, map { [ $_->id, -1 ] } sort { $a->name cmp $b->name } @orgs_without_coordinates; + my @sorted_org_ids = map { $_->[0] } @sorted_orgs; + + return $self->api_name =~ /include_distances/ ? \@sorted_orgs : \@sorted_org_ids; +} +__PACKAGE__->register_method( + method => "sort_orgs_by_distance_from_coordinate", + api_name => "open-ils.geo.sort_orgs_by_distance_from_coordinate", + signature => { + params => [ + {type => 'array', desc => 'An array containing latitude and longitude for the reference point'}, + {type => 'array', desc => 'An array of Context Organizational Unit IDs'} + ], + return => { desc => 'An array of Context Organizational Unit IDs sorted by geographic proximity to the reference point (closest first). Units without coordinates are appended to the end of the list in alphabetical order by name relative to each other.'} + } +); +__PACKAGE__->register_method( + method => "sort_orgs_by_distance_from_coordinate", + api_name => "open-ils.geo.sort_orgs_by_distance_from_coordinate.include_distances", + signature => { + params => [ + {type => 'array', desc => 'An array containing latitude and longitude for the reference point'}, + {type => 'array', desc => 'An array of Context Organizational Unit IDs'} + ], + return => { desc => 'An array of Context Organizational Unit IDs and distances (each pair itself an array) sorted by geographic proximity to the reference point (closest first). Units without coordinates are appended to the end of the list in alphabetical order by name relative to each other and given a distance of -1.'} + } +); + + +sub retrieve_coordinates { # invoke 3rd party API for latitude/longitude lookup + my ($self, $conn, $org, $address) = @_; + + my $e = new_editor(xact => 1); + # TODO: if we're not going to require authentication, we may want to consider + # implementing some options for limiting outgoing geo-coding API calls + # return $e->die_event unless $e->checkauth; + + my $use_geo = $e->retrieve_config_global_flag('opac.use_geolocation'); + $use_geo = ($use_geo and $U->is_true($use_geo->enabled)); + return new OpenILS::Event("GEOCODING_NOT_ENABLED") unless ($U->is_true($use_geo)); + + return new OpenILS::Event("BAD_PARAMS", "desc" => "No org ID supplied") unless $org; + my $service_id = $U->ou_ancestor_setting_value($org, 'opac.geographic_location_service_for_address'); + return new OpenILS::Event("GEOCODING_NOT_ALLOWED") unless ($U->is_true($service_id)); + + my $service = $e->retrieve_config_geolocation_service($service_id); + return new OpenILS::Event("GEOCODING_NOT_ALLOWED") unless ($U->is_true($service)); + + return new OpenILS::Event("BAD_PARAMS", "desc" => "No address supplied") unless $address; + my $geo_coder; + eval { + if ($service->service_code eq 'Free') { + $logger->debug("Using Geo::Coder::Free (service id $service_id)"); + $geo_coder = Geo::Coder::Free->new(); + } elsif ($service->service_code eq 'Google') { + $logger->debug("Using Geo::Coder::Google (service id $service_id)"); + $geo_coder = Geo::Coder::Google->new(key => $service->api_key); + } else { + $logger->debug("Using Geo::Coder::OSM (service id $service_id)"); + $geo_coder = Geo::Coder::OSM->new(); + } + }; + if ($@ || !$geo_coder) { + $logger->error("geosort: problem creating Geo::Coder instance : $@"); + return OpenILS::Event->new('GEOCODING_LOCATION_NOT_FOUND'); + } + my $location; + eval { + $location = $geo_coder->geocode(location => $address); + }; + if ($@) { + $logger->error("geosort: problem invoking location lookup : $@"); + return OpenILS::Event->new('GEOCODING_LOCATION_NOT_FOUND'); + } + + my $latitude; my $longitude; + return new OpenILS::Event("GEOCODING_LOCATION_NOT_FOUND") unless ($U->is_true($location)); + if ($service->service_code eq 'Free') { + $latitude = $location->{'latitude'}; + $longitude = $location->{'longitude'}; + } elsif ($service->service_code eq 'Google') { + $latitude = $location->{'geometry'}->{'location'}->{'lat'}; + $longitude = $location->{'geometry'}->{'location'}->{'lng'}; + } else { + $latitude = $location->{lat}; + $longitude = $location->{lon}; + } + + return { latitude => $latitude, longitude => $longitude } +} +__PACKAGE__->register_method( + method => "retrieve_coordinates", + api_name => "open-ils.geo.retrieve_coordinates", + signature => { + params => [ + {type => 'number', desc => 'Context Organizational Unit'}, + {type => 'string', desc => 'Address to look-up as a text string'} + ], + return => { desc => 'Hash/object containing latitude and longitude for the provided address.'} + } +); + +1; diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm index bf927cec48..5763cb8e03 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm @@ -364,6 +364,11 @@ sub load_common { $ctx->{carousel_loc} = $self->get_carousel_loc; $ctx->{physical_loc} = $self->get_physical_loc; + my $geo_sort = $e->retrieve_config_global_flag('opac.use_geolocation'); + $geo_sort = ($geo_sort && $U->is_true($geo_sort->enabled)); + my $geo_org = $ctx->{physical_loc} || $self->cgi->param('loc') || $ctx->{aou_tree}->()->id; + my $geo_sort_for_org = $ctx->{get_org_setting}->($geo_org, 'opac.holdings_sort_by_geographic_proximity'); + $ctx->{geo_sort} = $geo_sort && $U->is_true($geo_sort_for_org); # capture some commonly accessed pages $ctx->{home_page} = $ctx->{proto} . '://' . $ctx->{hostname} . $self->ctx->{opac_root} . "/home"; diff --git a/Open-ILS/src/perlmods/live_t/32-geosort.t b/Open-ILS/src/perlmods/live_t/32-geosort.t new file mode 100644 index 0000000000..2bc610588f --- /dev/null +++ b/Open-ILS/src/perlmods/live_t/32-geosort.t @@ -0,0 +1,92 @@ +#!perl +use strict; use warnings; +use Test::More tests => 8; +use OpenILS::Utils::TestUtils; +use OpenILS::Const qw(:const); +use OpenILS::Utils::CStoreEditor qw/:funcs/; +use OpenILS::Utils::Fieldmapper; + +diag("test geocoding"); + +my $U = 'OpenILS::Application::AppUtils'; +my $script = OpenILS::Utils::TestUtils->new(); +$script->bootstrap; + +my $geo_session = $script->session('open-ils.geo'); + +my $request = $geo_session->request( + 'open-ils.geo.retrieve_coordinates', + 4, + '30016' +); +my $result = $request->recv(); +my $content = $result->content(); +is($content->{textcode},'GEOCODING_NOT_ENABLED','received expected GEOCODING_NOT_ENABLED'); + +my $e = new_editor(xact => 1); +$e->init; + +my $flag = $e->retrieve_config_global_flag('opac.use_geolocation'); +$flag->enabled('t'); +my $stat = $e->update_config_global_flag($flag); +ok($stat, 'opac.use_geolocation enabled'); +$e->xact_commit; + +$request = $geo_session->request( + 'open-ils.geo.retrieve_coordinates', + 4, + '30016' +); +$result = $request->recv(); +$content = $result->content(); +is($content->{textcode},'GEOCODING_NOT_ALLOWED','received expected GEOCODING_NOT_ALLOWED'); + +$e->xact_begin; +my $cgs = Fieldmapper::config::geolocation_service->new; +$cgs->active('t'); +$cgs->owner(1); +$cgs->name('OSM'); +$cgs->service_code('OSM'); +$stat = $e->create_config_geolocation_service($cgs); +ok($stat, 'Geolocation service created successfully'); +$e->xact_commit; + +$script->authenticate({ + username => 'admin', + password => 'demo123', + type => 'staff'}); + +my $authtoken = $script->authtoken; +ok($authtoken, 'Have an authtoken'); + +my $setting_value = $U->simplereq( + 'open-ils.actor', + 'open-ils.actor.org_unit.settings.update', + $authtoken, + 4, + {'opac.geographic_location_service_for_address', 1} +); +ok( + ! ref $setting_value, + 'opac.geographic_location_service_for_address set for BR1' +); + +$request = $geo_session->request( + 'open-ils.geo.retrieve_coordinates', + 4, + '30016' +); +$result = $request->recv(); +$content = $result->content(); +use Data::Dumper; +diag(Dumper($content)); +ok( + $content->{latitude}, + 'Result contains latitude' +); +ok( + $content->{latitude}, + 'Result contains longitude' +); +$request->finish(); + diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql index b657b5d9f3..27fa6bd26a 100644 --- a/Open-ILS/src/sql/Pg/002.schema.config.sql +++ b/Open-ILS/src/sql/Pg/002.schema.config.sql @@ -1373,4 +1373,13 @@ VALUES SELECT SETVAL('config.carousel_type_id_seq'::TEXT, 100); +CREATE TABLE config.geolocation_service ( + id SERIAL PRIMARY KEY, + active BOOLEAN, + owner INT NOT NULL, -- REFERENCES actor.org_unit (id) + name TEXT, + service_code TEXT, + api_key TEXT +); + COMMIT; diff --git a/Open-ILS/src/sql/Pg/005.schema.actors.sql b/Open-ILS/src/sql/Pg/005.schema.actors.sql index 6fb6a49320..b3b2b7a013 100644 --- a/Open-ILS/src/sql/Pg/005.schema.actors.sql +++ b/Open-ILS/src/sql/Pg/005.schema.actors.sql @@ -663,7 +663,9 @@ CREATE TABLE actor.org_address ( state TEXT, country TEXT NOT NULL, post_code TEXT NOT NULL, - san TEXT + san TEXT, + latitude FLOAT, + longitude FLOAT ); CREATE INDEX actor_org_address_org_unit_idx ON actor.org_address (org_unit); diff --git a/Open-ILS/src/sql/Pg/800.fkeys.sql b/Open-ILS/src/sql/Pg/800.fkeys.sql index 9ec6446bb4..bb1389116c 100644 --- a/Open-ILS/src/sql/Pg/800.fkeys.sql +++ b/Open-ILS/src/sql/Pg/800.fkeys.sql @@ -262,4 +262,7 @@ ALTER TABLE config.copy_tag_type ADD CONSTRAINT copy_tag_type_owner_fkey FOREIGN ALTER TABLE config.print_template ADD CONSTRAINT cpt_owner_fkey FOREIGN KEY (owner) REFERENCES actor.org_unit(id) DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE config.geolocation_service ADD CONSTRAINT cgs_owner_fkey + FOREIGN KEY (owner) REFERENCES actor.org_unit(id) DEFERRABLE INITIALLY DEFERRED; + COMMIT; 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 363a8b4d92..9b1c255585 100644 --- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql +++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql @@ -1955,6 +1955,10 @@ INSERT INTO permission.perm_list ( id, code, description ) VALUES 'Manage batch (subscription) hold events', 'ppl', 'description')), ( 629, 'ADMIN_LIBRARY_GROUPS', oils_i18n_gettext(629, 'Administer library groups', 'ppl', 'description')) + ( 630, 'VIEW_GEOLOCATION_SERVICES', oils_i18n_gettext(630, + 'View geographic location services', 'ppl', 'description')), + ( 631, 'ADMIN_GEOLOCATION_SERVICES', oils_i18n_gettext(631, + 'Administer geographic location services', 'ppl', 'description')) ; @@ -21185,6 +21189,71 @@ INSERT INTO action_trigger.validator (module, description) VALUES ( 'Curbside', 'Confirm that curbside pickup is enabled for the hold pickup library' ); +-- geosort + +INSERT INTO config.global_flag (name, value, enabled, label) +VALUES ( + 'opac.use_geolocation', + NULL, + FALSE, + oils_i18n_gettext( + 'opac.use_geolocation', + 'Offer use of geographic location services in the public catalog', + 'cgf', 'label' + ) +); + +INSERT INTO config.org_unit_setting_type (name, label, grp, description, datatype) +VALUES ( + 'opac.holdings_sort_by_geographic_proximity', + oils_i18n_gettext('opac.holdings_sort_by_geographic_proximity', + 'Enable Holdings Sort by Geographic Proximity', + 'coust', 'label'), + 'opac', + oils_i18n_gettext('opac.holdings_sort_by_geographic_proximity', + 'When set to TRUE, will cause the record details page to display the controls for sorting holdings by geographic proximity. This also depends on the global flag opac.use_geolocation being enabled.', + 'coust', 'description'), + 'bool' +); + +INSERT INTO config.org_unit_setting_type (name, label, grp, description, datatype) +VALUES ( + 'opac.geographic_proximity_in_miles', + oils_i18n_gettext('opac.geographic_proximity_in_miles', + 'Show Geographic Proximity in Miles', + 'coust', 'label'), + 'opac', + oils_i18n_gettext('opac.geographic_proximity_in_miles', + 'When set to TRUE, will cause the record details page to show distances for geographic proximity in miles instead of kilometers.', + 'coust', 'description'), + 'bool' +); + +INSERT INTO config.org_unit_setting_type (name, label, grp, description, datatype, fm_class) +VALUES ( + 'opac.geographic_location_service_for_address', + oils_i18n_gettext('opac.geographic_location_service_for_address', + 'Geographic Location Service to use for Addresses', + 'coust', 'label'), + 'opac', + oils_i18n_gettext('opac.geographic_location_service_for_address', + 'Specifies which geographic location service to use for converting address input to geographic coordinates.', + 'coust', 'description'), + 'link', 'cgs' +); + +INSERT INTO permission.grp_perm_map (grp, perm, depth, grantable) + SELECT + pgt.id, perm.id, aout.depth, TRUE + FROM + permission.grp_tree pgt, + permission.perm_list perm, + actor.org_unit_type aout + WHERE + (pgt.name = 'Global Administrator' OR pgt.name = 'System Administrator') AND + aout.name = 'Consortium' AND + (perm.code = 'ADMIN_GEOLOCATION_SERVICES' OR perm.code = 'VIEW_GEOLOCATION_SERVICES'); + ------------------- Disabled example A/T defintions ------------------------------ -- Create a "dummy" slot when applicable, and trigger the "offer curbside" events diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.geosort.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.geosort.sql new file mode 100644 index 0000000000..99664924e4 --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.geosort.sql @@ -0,0 +1,93 @@ +BEGIN; + +-- check whether patch can be applied +SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version); + +-- 005.schema.actors.sql + +-- CREATE TABLE actor.org_address ( +-- ... +-- latitude FLOAT, +-- longitude FLOAT +-- ); + +ALTER TABLE actor.org_address ADD COLUMN latitude FLOAT; +ALTER TABLE actor.org_address ADD COLUMN longitude FLOAT; + +-- 002.schema.config.sql + +CREATE TABLE config.geolocation_service ( + id SERIAL PRIMARY KEY, + active BOOLEAN, + owner INT NOT NULL, -- REFERENCES actor.org_unit (id) + name TEXT, + service_code TEXT, + api_key TEXT +); + +-- 800.fkeys.sql + +ALTER TABLE config.geolocation_service ADD CONSTRAINT cgs_owner_fkey + FOREIGN KEY (owner) REFERENCES actor.org_unit(id) DEFERRABLE INITIALLY DEFERRED; + +-- 950.data.seed-values.sql + +INSERT INTO config.global_flag (name, value, enabled, label) +VALUES ( + 'opac.use_geolocation', + NULL, + FALSE, + oils_i18n_gettext( + 'opac.use_geolocation', + 'Offer use of geographic location services in the public catalog', + 'cgf', 'label' + ) +); + +INSERT INTO config.org_unit_setting_type (name, label, grp, description, datatype) +VALUES ( + 'opac.holdings_sort_by_geographic_proximity', + oils_i18n_gettext('opac.holdings_sort_by_geographic_proximity', + 'Enable Holdings Sort by Geographic Proximity', + 'coust', 'label'), + 'opac', + oils_i18n_gettext('opac.holdings_sort_by_geographic_proximity', + 'When set to TRUE, will cause the record details page to display the controls for sorting holdings by geographic proximity. This also depends on the global flag opac.use_geolocation being enabled.', + 'coust', 'description'), + 'bool' +); + +INSERT INTO config.org_unit_setting_type (name, label, grp, description, datatype) +VALUES ( + 'opac.geographic_proximity_in_miles', + oils_i18n_gettext('opac.geographic_proximity_in_miles', + 'Show Geographic Proximity in Miles', + 'coust', 'label'), + 'opac', + oils_i18n_gettext('opac.geographic_proximity_in_miles', + 'When set to TRUE, will cause the record details page to show distances for geographic proximity in miles instead of kilometers.', + 'coust', 'description'), + 'bool' +); + +INSERT INTO config.org_unit_setting_type (name, label, grp, description, datatype, fm_class) +VALUES ( + 'opac.geographic_location_service_for_address', + oils_i18n_gettext('opac.geographic_location_service_for_address', + 'Geographic Location Service to use for Addresses', + 'coust', 'label'), + 'opac', + oils_i18n_gettext('opac.geographic_location_service_for_address', + 'Specifies which geographic location service to use for converting address input to geographic coordinates.', + 'coust', 'description'), + 'link', 'cgs' +); + +INSERT INTO permission.perm_list ( id, code, description ) VALUES + ( 630, 'VIEW_GEOLOCATION_SERVICES', oils_i18n_gettext(630, + 'View geographic location services', 'ppl', 'description')), + ( 631, 'ADMIN_GEOLOCATION_SERVICES', oils_i18n_gettext(631, + 'Administer geographic location services', 'ppl', 'description')) +; + +COMMIT; diff --git a/Open-ILS/src/templates/opac/parts/record/copy_table.tt2 b/Open-ILS/src/templates/opac/parts/record/copy_table.tt2 index 8d8693d260..15f2dbabc8 100644 --- a/Open-ILS/src/templates/opac/parts/record/copy_table.tt2 +++ b/Open-ILS/src/templates/opac/parts/record/copy_table.tt2 @@ -31,6 +31,13 @@ IF has_copies or ctx.foreign_copies; total_copies = ctx.copy_summary.$depth.count; %] [% use_courses = (ctx.get_org_setting(ctx.aou_tree.id, 'circ.course_materials_opt_in') == 1) ? 1 : 0 %] +[% IF ctx.geo_sort %] + + [% l("Sort by distance from:") %] + + + +[% END %] diff --git a/docs/RELEASE_NOTES_NEXT/OPAC/geographic_sorting.adoc b/docs/RELEASE_NOTES_NEXT/OPAC/geographic_sorting.adoc new file mode 100644 index 0000000000..a3baaa360d --- /dev/null +++ b/docs/RELEASE_NOTES_NEXT/OPAC/geographic_sorting.adoc @@ -0,0 +1,11 @@ +Sort Holdings by Geographical Proximity +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This functionality integrates 3rd party geographic lookup services to allow patrons +to enter an address on the record details page in the OPAC and sort the holdings +for that record based on proximity of their circulating libraries to the entered +address. To support this, latitude and longitude coordinates may be associated with +each org unit. Care is given to not log or leak patron provided addresses or the +context in which they are used. + +Requires the following Perl modules: Geo::Coder::Free, Geo::Coder::Google, and Geo::Coder::OSM -- 2.11.0