lp1863252 toward geosort
authorJason Etheridge <jason@EquinoxInitiative.org>
Thu, 3 Dec 2020 15:08:44 +0000 (10:08 -0500)
committerBill Erickson <berickxx@gmail.com>
Thu, 11 Mar 2021 21:00:52 +0000 (16:00 -0500)
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 <jason@EquinoxInitiative.org>

24 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/examples/opensrf.xml.example
Open-ILS/examples/opensrf_core.xml.example
Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html
Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.html
Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.ts
Open-ILS/src/extras/ils_events.xml
Open-ILS/src/extras/install/Makefile.debian-buster
Open-ILS/src/extras/install/Makefile.debian-jessie
Open-ILS/src/extras/install/Makefile.debian-stretch
Open-ILS/src/extras/install/Makefile.fedora
Open-ILS/src/extras/install/Makefile.ubuntu-bionic
Open-ILS/src/extras/install/Makefile.ubuntu-focal
Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Geo.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
Open-ILS/src/perlmods/live_t/32-geosort.t [new file with mode: 0644]
Open-ILS/src/sql/Pg/002.schema.config.sql
Open-ILS/src/sql/Pg/005.schema.actors.sql
Open-ILS/src/sql/Pg/800.fkeys.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.geosort.sql [new file with mode: 0644]
Open-ILS/src/templates/opac/parts/record/copy_table.tt2
docs/RELEASE_NOTES_NEXT/OPAC/geographic_sorting.adoc [new file with mode: 0644]

index 45ce731..d6c112f 100644 (file)
@@ -6505,6 +6505,8 @@ SELECT  usr,
                        <field name="street2" reporter:label="Street2" reporter:datatype="text"/>
                        <field name="valid" reporter:label="Is Valid?" reporter:datatype="bool" oils_obj:required="true"/>
                        <field name="san" reporter:label="SAN" reporter:datatype="text"/>
+                       <field name="latitude" reporter:label="Latitude" reporter:datatype="float"/>
+                       <field name="longitude" reporter:label="Longitude" reporter:datatype="float"/>
                </fields>
                <links>
                        <link field="org_unit" reltype="has_a" key="id" map="" class="aou"/>
@@ -13497,6 +13499,27 @@ SELECT  usr,
                        </actions>
                </permacrud>
        </class>
+       <class id="cgs" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::geolocation_service" oils_persist:tablename="config.geolocation_service" reporter:label="Geographic Location Service">
+               <fields oils_persist:primary="id" oils_persist:sequence="config.geolocation_service_id_seq">
+                       <field name="id" reporter:selector="name" reporter:datatype="id" reporter:label="ID"/>
+                       <field reporter:label="Active" name="active" reporter:datatype="bool" oils_obj:required="true"/>
+                       <field reporter:label="Owner" name="owner" reporter:datatype="link" oils_obj:required="true"/>
+                       <field name="name"  reporter:datatype="text" oils_persist:i18n="true" reporter:label="Name"/>
+                       <field reporter:label="Service Code" name="service_code" reporter:datatype="text"/>
+                       <field reporter:label="API Key" name="api_key" reporter:datatype="text"/>
+               </fields>
+               <links>
+                       <link field="owner" reltype="has_a" key="id" class="aou"/>
+               </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="ADMIN_GEOLOCATION_SERVICES" global_required="true"/>
+                               <retrieve permission="VIEW_GEOLOCATION_SERVICES" global_required="true"/>
+                               <update permission="ADMIN_GEOLOCATION_SERVICES" global_required="true"/>
+                               <delete permission="ADMIN_GEOLOCATION_SERVICES" global_required="true"/>
+                       </actions>
+               </permacrud>
+       </class>
 
        <!-- ********************************************************************************************************************* -->
 </IDL>
index 36a4396..8d26f16 100644 (file)
@@ -736,6 +736,26 @@ vim:et:ts=4:sw=4:
                 </app_settings>
             </open-ils.booking>
 
+            <open-ils.geo>
+                <keepalive>5</keepalive>
+                <stateless>1</stateless>
+                <language>perl</language>
+                <implementation>OpenILS::Application::Geo</implementation>
+                <max_requests>199</max_requests>
+                <unix_config>
+                    <unix_sock>open-ils.geo_unix.sock</unix_sock>
+                    <unix_pid>open-ils.geo_unix.pid</unix_pid>
+                    <max_requests>1000</max_requests>
+                    <unix_log>open-ils.geo_unix.log</unix_log>
+                    <min_children>1</min_children>
+                    <max_children>15</max_children>
+                    <min_spare_children>1</min_spare_children>
+                    <max_spare_children>5</max_spare_children>
+                </unix_config>
+                <app_settings>
+                </app_settings>
+            </open-ils.geo>
+
             <open-ils.cat>
                 <keepalive>5</keepalive>
                 <stateless>1</stateless>
@@ -1357,6 +1377,7 @@ vim:et:ts=4:sw=4:
                 <appname>open-ils.ebook_api</appname>
                 <appname>open-ils.courses</appname>
                 <appname>open-ils.curbside</appname>
+                <appname>open-ils.geo</appname>
             </activeapps>
         </localhost>
     </hosts>
index db89f56..dfa0c3b 100644 (file)
@@ -30,6 +30,7 @@ Example OpenSRF bootstrap configuration file for Evergreen
           <service>open-ils.courses</service>
           <service>open-ils.curbside</service>
           <service>open-ils.fielder</service>
+          <service>open-ils.geo</service>
           <service>open-ils.pcrud</service>
           <service>open-ils.permacrud</service>
           <service>open-ils.reporter</service>
index fd65e16..c6a4108 100644 (file)
@@ -43,6 +43,8 @@
       url="/eg/staff/admin/server/actor/org_unit_custom_tree"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Floating Groups"  
       routerLink="/staff/admin/server/config/floating_group"></eg-link-table-link>
+    <eg-link-table-link i18n-label label="Geographic Location Service"
+      routerLink="/staff/admin/server/config/geolocation_service"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Global Flags"  
       routerLink="/staff/admin/server/config/global_flag"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Hard Due Date Changes"  
index b3a787b..ab8b534 100644 (file)
@@ -17,6 +17,9 @@
           [record]="addr(type)"
           fieldOrder="address_type,street1,street2,city,county,state,country,post_code,san,valid"
           >
+          <eg-fm-record-editor-action i18n-label label="Get Coordinates"
+            (actionClick)="getCoordinates($event)">
+          </eg-fm-record-editor-action>
           <eg-fm-record-editor-action i18n-label label="Delete" *ngIf="!addr(type).isnew()"
             (actionClick)="deleteAddress($event)" buttonCss="btn-warning">
           </eg-fm-record-editor-action>
index 2092075..cea8fd1 100644 (file)
@@ -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<IdlObject>();
         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);
+            }
+        );
+    }
 }
 
index 527c612..0a5507f 100644 (file)
        <event code='7032' textcode='CURBSIDE_EXISTS'>
                <desc xml:lang="en-US">A scheduled, unfilled curbside request already exists</desc>
        </event>
+       <event code='7033' textcode='GEOCODING_NOT_ENABLED'>
+               <desc xml:lang="en-US">Geo-Coding is not enabled for this installation</desc>
+       </event>
+       <event code='7034' textcode='GEOCODING_NOT_ALLOWED'>
+               <desc xml:lang="en-US">A Geographic Location Service is not configured for this library</desc>
+       </event>
+       <event code='7035' textcode='GEOCODING_LOCATION_NOT_FOUND'>
+               <desc xml:lang="en-US">No location returned by Geographic Location Service</desc>
+       </event>
 
 
        <!-- ================================================================ -->
index a561a0a..fd7b424 100644 (file)
@@ -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
 
index 21c906b..122aeb0 100644 (file)
@@ -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
 
index e5a9ce5..1c14bc4 100644 (file)
@@ -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
 
index 43ba484..ae6d4b0 100644 (file)
@@ -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 \
index 519d063..815fdad 100644 (file)
@@ -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 \
index deb0548..0617dc7 100644 (file)
@@ -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 \
index bbe57e2..23021c7 100644 (file)
@@ -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 (file)
index 0000000..0d2ba8c
--- /dev/null
@@ -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;
index bf927ce..5763cb8 100644 (file)
@@ -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 (file)
index 0000000..2bc6105
--- /dev/null
@@ -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();
+
index b657b5d..27fa6bd 100644 (file)
@@ -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;
index 6fb6a49..b3b2b7a 100644 (file)
@@ -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);
index 9ec6446..bb13891 100644 (file)
@@ -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;
index 363a8b4..9b1c255 100644 (file)
@@ -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 (file)
index 0000000..9966492
--- /dev/null
@@ -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;
index 8d8693d..15f2dba 100644 (file)
@@ -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 %]
+<th colspan="6">
+    [% l("Sort by distance from:") %]
+    <input type="text" id="geographic-location-box" name="geographic-location" aria-label="[% l('Enter address or postal code') %]" placeholder="[% l('Enter address/postal code') %]" class="search-box" x-webkit-speech=""></input>
+    <input id="geographic-location-submit-go" type="submit" value="[% l('Go') %]" class="opac-button" onclick=""></input>
+</th>
+[% END %]
 <table class="table_no_border_space table_no_cell_pad table_no_border" width="100%" id="rdetails_status">
     <thead>
         <tr>
diff --git a/docs/RELEASE_NOTES_NEXT/OPAC/geographic_sorting.adoc b/docs/RELEASE_NOTES_NEXT/OPAC/geographic_sorting.adoc
new file mode 100644 (file)
index 0000000..a3baaa3
--- /dev/null
@@ -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