From b191a45d7c3c6ed7556f32def7f218f758b571c1 Mon Sep 17 00:00:00 2001
From: Galen Charlton <gmc@equinoxinitiative.org>
Date: Wed, 12 Jun 2019 17:58:21 -0400
Subject: [PATCH] LP#1832897: business logic for carousels

This patch adds various methods in open-ils.actor and open-ils.storage
to manipulate carousels. It also adds a server-side script,
refresh_carousels.srfsh, and an example crontab entry.

The new methods are:

* open-ils.actor.carousel.retrieve_by_org
* open-ils.actor.carousel.retrieve_manual_by_staff
* open-ils.actor.carousel.refresh
* open-ils.actor.carousel.create.from_bucket
* open-ils.storage.container.refresh_from_carousel
* open-ils.storage.carousel.refresh_all

Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
---
 Open-ILS/examples/crontab.example                  |   3 +
 Open-ILS/src/Makefile.am                           |   2 +
 .../src/perlmods/lib/OpenILS/Application/Actor.pm  |   1 +
 .../lib/OpenILS/Application/Actor/Carousel.pm      | 215 ++++++++++++++++++++
 .../OpenILS/Application/Storage/CDBI/container.pm  |   2 +-
 .../Application/Storage/Publisher/container.pm     | 222 +++++++++++++++++++++
 .../src/support-scripts/refresh_carousels.srfsh    |   2 +
 7 files changed, 446 insertions(+), 1 deletion(-)
 create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Carousel.pm
 create mode 100644 Open-ILS/src/support-scripts/refresh_carousels.srfsh

diff --git a/Open-ILS/examples/crontab.example b/Open-ILS/examples/crontab.example
index 35144cd871..30f62a5916 100644
--- a/Open-ILS/examples/crontab.example
+++ b/Open-ILS/examples/crontab.example
@@ -61,6 +61,9 @@ EG_BIN_DIR = /openils/bin
 # Run the hard due date updater
 2  3  * * *   . ~/.bashrc && $EG_BIN_DIR/update_hard_due_dates.srfsh
 
+# Run the carousel updater
+5  3  * * *   . ~/.bashrc && $EG_BIN_DIR/refresh_carousels.srfsh
+
 # Run the credit card number clearing script
 #5  4  * * *   . ~/.bashrc && $EG_BIN_DIR/clear_cc_number.srfsh
 
diff --git a/Open-ILS/src/Makefile.am b/Open-ILS/src/Makefile.am
index 9420028df4..7f7954e1ca 100644
--- a/Open-ILS/src/Makefile.am
+++ b/Open-ILS/src/Makefile.am
@@ -65,6 +65,7 @@ core_scripts =   $(examples)/oils_ctl.sh \
 		 $(supportscr)/reshelving_complete.srfsh \
 		 $(supportscr)/clear_expired_circ_history.srfsh \
 		 $(supportscr)/update_hard_due_dates.srfsh \
+		 $(supportscr)/refresh_carousels.srfsh \
 		 $(supportscr)/juv_to_adult.srfsh \
 		 $(supportscr)/thaw_expired_frozen_holds.srfsh \
 		 $(supportscr)/long-overdue-status-update.pl \
@@ -276,6 +277,7 @@ ilscore-install:
 	sed -i 's|BINDIR|@bindir@|g' '$(DESTDIR)@bindir@/reshelving_complete.srfsh'
 	sed -i 's|BINDIR|@bindir@|g' '$(DESTDIR)@bindir@/clear_expired_circ_history.srfsh'
 	sed -i 's|BINDIR|@bindir@|g' '$(DESTDIR)@bindir@/update_hard_due_dates.srfsh'
+	sed -i 's|BINDIR|@bindir@|g' '$(DESTDIR)@bindir@/refresh_carousels.srfsh'
 	sed -i 's|BINDIR|@bindir@|g' '$(DESTDIR)@bindir@/juv_to_adult.srfsh'
 	sed -i 's|BINDIR|@bindir@|g' '$(DESTDIR)@bindir@/long-overdue-status-update.pl'
 	sed -i 's|SYSCONFDIR|@sysconfdir@|g' '$(DESTDIR)@bindir@/long-overdue-status-update.pl'
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm
index 219f611b6b..d04160fe73 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm
@@ -26,6 +26,7 @@ use DateTime;
 use DateTime::Format::ISO8601;
 use OpenILS::Const qw/:const/;
 
+use OpenILS::Application::Actor::Carousel;
 use OpenILS::Application::Actor::Container;
 use OpenILS::Application::Actor::ClosedDates;
 use OpenILS::Application::Actor::UserGroups;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Carousel.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Carousel.pm
new file mode 100644
index 0000000000..0b64841d4d
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Carousel.pm
@@ -0,0 +1,215 @@
+package OpenILS::Application::Actor::Carousel;
+use base 'OpenILS::Application';
+use strict; use warnings;
+use OpenILS::Application::AppUtils;
+use OpenILS::Perm;
+use Data::Dumper;
+use OpenSRF::EX qw(:try);
+use OpenILS::Utils::Fieldmapper;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Cache;
+use Digest::MD5 qw(md5_hex);
+use OpenSRF::Utils::JSON;
+
+my $apputils = "OpenILS::Application::AppUtils";
+my $U = $apputils;
+my $logger = "OpenSRF::Utils::Logger";
+
+sub initialize { return 1; }
+
+__PACKAGE__->register_method(
+    method  => "retrieve_carousels_at_org",
+    api_name    => "open-ils.actor.carousel.retrieve_by_org",
+    authoritative => 1,
+    notes        => <<"    NOTES");
+        Retrieves the IDs and override names of all carousels visible
+        at the specified org unit sorted by their sequence number at
+        that library
+        PARAMS(OrgId)
+    NOTES
+
+sub retrieve_carousels_at_org {
+    my($self, $client, $org_id) = @_;
+    my $e = new_editor();
+
+    my $carousels = $e->json_query({
+        select => { ccou => ['carousel','override_name','seq'] },
+        distinct => 'true',
+        from => { ccou => 'cc' } ,
+        where => {
+            '+ccou' => { org_unit => $org_id },
+            '+cc'   => { active => 't' }
+        },
+        order_by => {
+            'ccou' => ['seq']
+        }
+    });
+
+    return $carousels;
+}
+
+__PACKAGE__->register_method(
+    method  => "retrieve_manual_carousels_for_staff",
+    api_name    => "open-ils.actor.carousel.retrieve_manual_by_staff",
+    authoritative => 1,
+    notes        => <<"    NOTES");
+        Retrieves the IDs, buckets, and names of all manually-maintained
+        carousels visible at any of the staff members working
+        locations.
+        PARAMS(authtoken)
+    NOTES
+
+sub retrieve_manual_carousels_for_staff {
+    my($self, $client, $auth) = @_;
+    my $e = new_editor(authtoken => $auth);
+    return $e->die_event unless $e->checkauth;
+
+    my $orgs = [];
+    if ($e->requestor->super_user eq 't') {
+        # super users can act/see at all OUs
+        my $ous = $e->json_query({
+            select => { aou => ['id'] },
+            from => 'aou'
+        });
+        $orgs = [ map { $_->{id} } @$ous ];
+    } else {
+        my $ous = $e->json_query({
+            select => { puwoum => ['work_ou'] },
+            from => 'puwoum',
+            where => {
+                '+puwoum' => { usr => $e->requestor->id }
+            }
+        });
+        $orgs = [ map { $_->{work_ou} } @$ous ];
+    }
+
+    my $carousels = $e->json_query({
+        select => { cc => ['id','name','bucket'] },
+        distinct => 'true',
+        from => { cc => 'ccou' },
+        where => {
+            '+ccou' => { org_unit => $orgs },
+            '+cc'   => { type => 1, active => 't' }, # FIXME
+        },
+        order_by => {
+            'cc' => ['name']
+        }
+    });
+
+    return $carousels;
+}
+
+__PACKAGE__->register_method(
+    method  => "refresh_carousel",
+    api_name    => "open-ils.actor.carousel.refresh",
+    authoritative => 1,
+    notes        => <<"    NOTES");
+        Refreshes the specified carousel
+        PARAMS(authtoken, carousel_id)
+    NOTES
+
+sub refresh_carousel {
+    my ($self, $client, $auth, $carousel_id) = @_;
+
+    my $e = new_editor(authtoken => $auth);
+    return $e->event unless $e->checkauth;
+    return $e->event unless $e->allowed('REFRESH_CAROUSEL');
+
+    my $carousel;
+    $carousel = $e->retrieve_container_carousel($carousel_id) or return $e->event;
+
+    return $e->event unless $e->allowed('REFRESH_CAROUSEL', $carousel->owner, $carousel);
+
+    my $ctype;
+    $ctype = $e->retrieve_config_carousel_type($carousel->type) or return $e->event;
+    return new OpenILS::Event('CANNOT_REFRESH_MANUAL_CAROUSEL') unless $ctype->automatic eq 't';
+
+    my $orgs = [];
+    my $locs = [];
+    if (defined($carousel->owning_lib_filter)) {
+        my $ou_filter = $carousel->owning_lib_filter;
+        $ou_filter =~ s/[{}]//g;
+        @$orgs = split /,/, $ou_filter;
+    }
+    if (defined($carousel->copy_location_filter)) {
+        my $loc_filter = $carousel->copy_location_filter;
+        $loc_filter =~ s/[{}]//g;
+        @$locs = split /,/, $loc_filter;
+    }
+
+    my $num_updated = $U->simplereq(
+        'open-ils.storage',
+        'open-ils.storage.container.refresh_from_carousel',
+        $carousel->bucket,
+        $carousel->type,
+        $carousel->age_filter,
+        $orgs,
+        $locs,
+        $carousel->max_items,
+    );
+
+    $carousel->last_refresh_time('now');
+    $e->xact_begin;
+    $e->update_container_carousel($carousel) or return $e->event;
+    $e->xact_commit or return $e->event;
+
+    return $num_updated;
+}
+
+__PACKAGE__->register_method(
+    method  => "add_carousel_from_bucket",
+    api_name    => "open-ils.actor.carousel.create.from_bucket",
+    authoritative => 1,
+    notes        => <<"    NOTES");
+        Creates new carousel and its container by copying the
+        contents of an existing bucket.
+        PARAMS(authtoken, carousel_name, bucket_id)
+    NOTES
+
+sub add_carousel_from_bucket {
+    my ($self, $client, $auth, $carousel_name, $bucket_id) = @_;
+
+    my $e = new_editor(authtoken => $auth);
+    return $e->event unless $e->checkauth;
+    return $e->event unless $e->allowed('ADMIN_CAROUSEL');
+
+    $e->xact_begin;
+
+    my $carousel = Fieldmapper::container::carousel->new;
+    $carousel->name($carousel_name);
+    $carousel->type(1); # manual
+    $carousel->owner($e->requestor->ws_ou);
+    $carousel->creator($e->requestor->id);
+    $carousel->editor($e->requestor->id);
+    $e->create_container_carousel($carousel) or return $e->event;
+
+    # and the bucket
+    my $bucket = Fieldmapper::container::biblio_record_entry_bucket->new;
+    $bucket->owner($e->requestor->id);
+    $bucket->name('System-created bucket for carousel ' . $carousel->id . ' copied from bucket ' . $bucket_id);
+    $bucket->btype('carousel');
+    $bucket->pub('t');
+    $bucket->owning_lib($e->requestor->ws_ou);
+    $e->create_container_biblio_record_entry_bucket($bucket) or return $e->event;
+
+    # link it to the container;
+    $carousel = $e->retrieve_container_carousel($carousel->id) or return $e->event;
+    $carousel->bucket($bucket->id);
+    $e->update_container_carousel($carousel) or return $e->event;
+
+    # and fill it
+    my $entries = $e->search_container_biblio_record_entry_bucket_item({ bucket => $bucket_id });
+    foreach my $entry (@$entries) {
+        $entry->clear_id;
+        $entry->bucket($bucket->id);
+        $entry->create_time('now');
+        $e->create_container_biblio_record_entry_bucket_item($entry) or return $e->event;
+    }
+
+    $e->xact_commit or return $e->event;
+
+    return $carousel->id;
+}
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/CDBI/container.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/CDBI/container.pm
index c26d7ae5f0..46d20720e1 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/CDBI/container.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/CDBI/container.pm
@@ -50,7 +50,7 @@ use base qw/container/;
 
 container::biblio_record_entry_bucket_item->table( 'container_biblio_record_entry_bucket_item' );
 container::biblio_record_entry_bucket_item->columns( Primary => qw/id/ );
-container::biblio_record_entry_bucket_item->columns( Essential => qw/bucket target_biblio_record_entry/ );
+container::biblio_record_entry_bucket_item->columns( Essential => qw/bucket target_biblio_record_entry pos/ );
 
 #-------------------------------------------------------------------------------
 package container::call_number_bucket;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/container.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/container.pm
index fe60ee6336..a06fd1dcd3 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/container.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/container.pm
@@ -1,6 +1,228 @@
 package OpenILS::Application::Storage::Publisher::container;
 use base qw/OpenILS::Application::Storage/;
+use vars qw/$VERSION/;
+use OpenSRF::EX qw/:try/;
+use OpenSRF::Utils::Logger qw/:level :logger/;
+use OpenILS::Utils::CStoreEditor;
 #use OpenILS::Application::Storage::CDBI::config;
 
+my $new_items_query = q(
+    WITH c_attr AS (SELECT c_attrs::query_int AS vis_test FROM asset.patron_default_visibility_mask() x)
+    SELECT acn.record AS bib
+    FROM asset.call_number acn
+    JOIN asset.copy acp ON (acp.call_number = acn.id)
+    JOIN asset.copy_location acpl ON (acp.location = acpl.id)
+    JOIN config.copy_status ccs ON (acp.status = ccs.id)
+    , c_attr
+    WHERE acn.owning_lib IN (ORG_LIST)
+    AND acp.circ_lib IN (ORG_LIST)
+    AND acp.holdable
+    AND acp.circulate
+    AND ccs.holdable
+    AND acpl.holdable
+    AND acpl.circulate
+    AND acp.active_date > NOW() - ?::INTERVAL
+    AND (EXISTS (SELECT 1 FROM asset.copy_vis_attr_cache WHERE record = acn.record AND vis_attr_vector @@ c_attr.vis_test))
+    AND (NOT EXISTS (SELECT 1 FROM metabib.record_attr_vector_list WHERE source = acn.record AND vlist @@ metabib.compile_composite_attr(' {"1":[{"_val":"s","_attr":"bib_level"}]}')::query_int))
+    GROUP BY acn.record
+    ORDER BY MIN(AGE(acp.active_date))
+    LIMIT ? 
+);
+my $recently_returned_query = q(
+WITH c_attr AS (SELECT c_attrs::query_int AS vis_test FROM asset.patron_default_visibility_mask() x)
+    SELECT acn.record AS bib
+    FROM asset.call_number acn
+    JOIN asset.copy acp ON (acp.call_number = acn.id)
+    JOIN asset.copy_location acpl ON (acp.location = acpl.id)
+    JOIN config.copy_status ccs ON (acp.status = ccs.id)
+    JOIN action.circulation circ ON (circ.target_copy = acp.id)
+    , c_attr
+    WHERE acn.owning_lib IN (ORG_LIST)
+    AND acp.circ_lib IN (ORG_LIST)
+    AND acp.holdable
+    AND acp.circulate
+    AND ccs.holdable
+    AND acpl.holdable
+    AND acpl.circulate
+    AND circ.checkin_time > NOW() - ?::INTERVAL
+    AND circ.checkin_time IS NOT NULL
+    AND (EXISTS (SELECT 1 FROM asset.copy_vis_attr_cache WHERE record = acn.record AND vis_attr_vector @@ c_attr.vis_test))
+    AND (NOT EXISTS (SELECT 1 FROM metabib.record_attr_vector_list WHERE source = acn.record AND vlist @@ metabib.compile_composite_attr(' {"1":[{"_val":"s","_attr":"bib_level"}]}')::query_int))
+    GROUP BY acn.record
+    ORDER BY MIN(AGE(circ.checkin_time))
+    LIMIT ?
+);
+my $top_circs_query = q(
+    WITH c_attr AS (SELECT c_attrs::query_int AS vis_test FROM asset.patron_default_visibility_mask() x)
+    SELECT acn.record AS bib
+    FROM asset.call_number acn
+    JOIN asset.copy acp ON (acp.call_number = acn.id)
+    JOIN asset.copy_location acpl ON (acp.location = acpl.id)
+    JOIN config.copy_status ccs ON (acp.status = ccs.id)
+    JOIN action.circulation circ ON (circ.target_copy = acp.id)
+    , c_attr
+    WHERE acn.owning_lib IN (ORG_LIST)
+    AND acp.circ_lib IN (ORG_LIST)
+    AND acp.holdable
+    AND acp.circulate
+    AND ccs.holdable
+    AND acpl.holdable
+    AND acpl.circulate
+    AND circ.xact_start > NOW() - ?::INTERVAL
+    AND (EXISTS (SELECT 1 FROM asset.copy_vis_attr_cache WHERE record = acn.record AND vis_attr_vector @@ c_attr.vis_test))
+    AND (NOT EXISTS (SELECT 1 FROM metabib.record_attr_vector_list WHERE source = acn.record AND vlist @@ metabib.compile_composite_attr(' {"1":[{"_val":"s","_attr":"bib_level"}]}')::query_int))
+    GROUP BY acn.record
+    ORDER BY COUNT(circ.id) DESC
+    LIMIT ?
+);
+my $new_by_loc_query = q(
+    WITH c_attr AS (SELECT c_attrs::query_int AS vis_test FROM asset.patron_default_visibility_mask() x)
+    SELECT acn.record AS bib
+    FROM asset.call_number acn
+    JOIN asset.copy acp ON (acp.call_number = acn.id)
+    JOIN asset.copy_location acpl ON (acp.location = acpl.id)
+    JOIN config.copy_status ccs ON (acp.status = ccs.id)
+    , c_attr
+    WHERE acn.owning_lib IN (ORG_LIST)
+    AND acp.circ_lib IN (ORG_LIST)
+    AND acp.active_date > NOW() - ?::INTERVAL
+    AND acp.location IN (LOC_LIST)
+    AND acp.holdable
+    AND acp.circulate
+    AND ccs.holdable
+    AND acpl.holdable
+    AND acpl.circulate
+    AND (EXISTS (SELECT 1 FROM asset.copy_vis_attr_cache WHERE record = acn.record AND vis_attr_vector @@ c_attr.vis_test))
+    AND (NOT EXISTS (SELECT 1 FROM metabib.record_attr_vector_list WHERE source = acn.record AND vlist @@ metabib.compile_composite_attr(' {"1":[{"_val":"s","_attr":"bib_level"}]}')::query_int))
+    GROUP BY acn.record
+    ORDER BY MIN(AGE(acp.active_date))
+    LIMIT ?
+);
+
+my %TYPE_QUERY_MAP = (
+    2 => $new_items_query,
+    3 => $recently_returned_query,
+    4 => $top_circs_query,
+    5 => $new_by_loc_query,
+);
+
+sub refresh_container_from_carousel_definition {
+    my $self = shift;
+    my $client = shift;
+    my $bucket = shift;
+    my $carousel_type = shift;
+    my $age = shift // '15 days';
+    my $libs = shift // [];
+    my $locs = shift // [];
+    my $limit = shift // 50;
+
+    my $e = OpenILS::Utils::CStoreEditor->new;
+    my $ctype = $e->retrieve_config_carousel_type($carousel_type) or return $e->die_event;
+    $e->disconnect;
+
+    unless (exists($TYPE_QUERY_MAP{$carousel_type})) {
+        $logger->error("Carousel for bucket $bucket is misconfigured; type $carousel_type is not recognized");
+        return 0;
+    }
+
+    my $query = $TYPE_QUERY_MAP{$carousel_type};
+
+    if ($ctype->filter_by_copy_owning_lib eq 't') {
+        if (scalar(@$libs) < 1) {
+            $logger->error("Carousel for bucket $bucket is misconfigured; owning library filter expected but none specified");
+            return 0;
+        }
+        my $org_placeholders = join(',', map { '?' } @$libs);
+        $query =~ s/ORG_LIST/$org_placeholders/g;
+    } else {
+        $libs = []; # we'll ignore any superflous supplied values
+    }
+
+    if ($ctype->filter_by_copy_location eq 't') {
+        if (scalar(@$locs) < 1) {
+            $logger->error("Carousel for bucket $bucket is misconfigured; copy location filter expected but none specified");
+            return 0;
+        }
+        my $loc_placeholders = join(',', map { '?' } @$locs);
+        $query =~ s/LOC_LIST/$loc_placeholders/g;
+    } else {
+        $locs = []; # we'll ignore any superflous supplied values
+    }
+
+    my $sth = container::biblio_record_entry_bucket_item->db_Main->prepare_cached($query);
+
+    $sth->execute(@$libs, @$libs, $age, @$locs, $limit);
+    my @bibs = ();
+    while (my $row = $sth->fetchrow_hashref ) {
+        push @bibs, $row->{bib};
+    }
+    container::biblio_record_entry_bucket_item->search( bucket => $bucket )->delete_all;
+    my $i = 0;
+    foreach my $bib (@bibs) {
+        container::biblio_record_entry_bucket_item->create({ bucket => $bucket, target_biblio_record_entry => $bib, pos => $i++ });
+    }
+    return scalar(@bibs);
+}
+
+__PACKAGE__->register_method(
+    api_name    => 'open-ils.storage.container.refresh_from_carousel',
+    method      => 'refresh_container_from_carousel_definition',
+    api_level   => 1,
+    cachable    => 1,
+);
+
+sub refresh_all_carousels {
+    my $self = shift;
+    my $client = shift;
+
+    my $e = OpenILS::Utils::CStoreEditor->new;
+
+    my $automatic_types = $e->search_config_carousel_type({ automatic => 't' });
+    my $carousels = $e->search_container_carousel({ type => [ map { $_->id } @$automatic_types ], active => 't' });
+
+    my $meth = $self->method_lookup('open-ils.storage.container.refresh_from_carousel');
+
+    foreach my $carousel (@$carousels) {
+
+        my $orgs = [];
+        my $locs = [];
+        if (defined($carousel->owning_lib_filter)) {
+            my $ou_filter = $carousel->owning_lib_filter;
+            $ou_filter =~ s/[{}]//g;
+            @$orgs = split /,/, $ou_filter;
+        }
+        if (defined($carousel->copy_location_filter)) {
+            my $loc_filter = $carousel->copy_location_filter;
+            $loc_filter =~ s/[{}]//g;
+            @$locs = split /,/, $loc_filter;
+        }
+
+        my @res = $meth->run($carousel->bucket, $carousel->type, $carousel->age_filter, $orgs, $locs, $carousel->max_items);
+        my $ct = scalar(@res) ? $res[0] : 0;
+
+        $e->xact_begin;
+        $carousel->last_refresh_time('now');
+        $e->update_container_carousel($carousel);
+        $e->xact_commit;
+
+        $client->respond({
+            carousel => $carousel->id,
+            bucket   => $carousel->bucket,
+            updated  => $ct
+        });
+
+    }
+    $e->disconnect;
+    return undef;
+}
+
+__PACKAGE__->register_method(
+    api_name    => 'open-ils.storage.carousel.refresh_all',
+    method      => 'refresh_all_carousels',
+    api_level   => 1,
+    stream      => 1,
+    cachable    => 1,
+);
+
 
 1;
diff --git a/Open-ILS/src/support-scripts/refresh_carousels.srfsh b/Open-ILS/src/support-scripts/refresh_carousels.srfsh
new file mode 100644
index 0000000000..8abc1f2fe7
--- /dev/null
+++ b/Open-ILS/src/support-scripts/refresh_carousels.srfsh
@@ -0,0 +1,2 @@
+#!BINDIR/srfsh
+request open-ils.storage open-ils.storage.carousel.refresh_all
-- 
2.11.0