From 74b0cd945db41ec97a75adb72d73536991a1a364 Mon Sep 17 00:00:00 2001
From: Ere Maijala <ere.maijala@helsinki.fi>
Date: Tue, 9 Jun 2020 22:25:34 +0300
Subject: [PATCH] KohaRest driver implementation (#1016)

---
 config/vufind/KohaRest.ini                    |   88 +
 config/vufind/config.ini                      |    3 +
 languages/en.ini                              |   12 +
 languages/fi.ini                              |   12 +
 languages/sv.ini                              |   12 +
 .../VuFind/src/VuFind/ILS/Driver/KohaRest.php | 2347 +++++++++++++++++
 .../src/VuFind/ILS/Driver/KohaRestFactory.php |   69 +
 .../src/VuFind/ILS/Driver/PluginManager.php   |    2 +
 8 files changed, 2545 insertions(+)
 create mode 100644 config/vufind/KohaRest.ini
 create mode 100644 module/VuFind/src/VuFind/ILS/Driver/KohaRest.php
 create mode 100644 module/VuFind/src/VuFind/ILS/Driver/KohaRestFactory.php

diff --git a/config/vufind/KohaRest.ini b/config/vufind/KohaRest.ini
new file mode 100644
index 00000000000..650a2ffc932
--- /dev/null
+++ b/config/vufind/KohaRest.ini
@@ -0,0 +1,88 @@
+[Catalog]
+; The API address without any version such as v1
+host = "http://koha-server/api"
+; You should create a Koha user for the API with at least the following privileges:
+; - circulate_remaining_permissions
+; - catalogue
+;- borrowers
+;  - edit_borrowers
+;  - view_borrower_infos_from_any_libraries
+;- reserveforothers
+;- modify_holds_priority
+;- place_holds
+;- updatecharges
+;  - payout
+;  - remaining_permissions
+; Add an API key to the user and copy the values below:
+; OAuth2 client ID
+clientId = ""
+; OAuth2 client secret
+clientSecret = ""
+
+[Holds]
+; HMACKeys - A list of hold form element names that will be analyzed for consistency
+; during hold form processing. Most users should not need to change this setting.
+HMACKeys = item_id:holdtype:level
+
+; defaultRequiredDate - A colon-separated list used to set the default "not required
+; after" date for holds in the format days:months:years
+; e.g. 0:1:0 will set a "not required after" date of 1 month from the current date
+defaultRequiredDate = 0:0:2
+
+; extraHoldFields - A colon-separated list used to display extra visible fields in the
+; place holds form. Supported values are "requiredByDate" and "pickUpLocation"
+extraHoldFields = requiredByDate:pickUpLocation
+
+; A Pick Up Location Code used to pre-select the pick up location drop down list and
+; provide a default option if others are not available. Must be one of the following:
+; 1) empty string to indicate that the first location is default (default setting)
+; 2) "user-selected" to indicate that the user always has to choose the location
+; 3) a value within the Location IDs returned by getPickUpLocations()
+defaultPickUpLocation = ""
+
+; By default the pick up location list is sorted alphabetically. This setting can be
+; used to manually set the order by entering location IDs as a colon-separated list.
+; You can also disable sorting by setting this to false.
+;pickUpLocationOrder = 158:155
+
+; This setting can be used to exclude locations from the pickup location list
+;excludePickupLocations = 1:6:10:15
+
+; This section controls article request behavior. To enable, uncomment (at minimum)
+; the HMACKeys and extraFields settings below.
+[StorageRetrievalRequests]
+; Whether to allow article requests on checked out items. Default is false.
+;allow_checked_out = true
+
+; HMACKeys - A list of form element names that will be analyzed for consistency
+; during form processing. Most users should not need to change this setting.
+HMACKeys = item_id:holdings_id:StorageRetrievalRequest
+
+; extraFields - A colon-separated list used to display extra visible fields in the
+; place request form. Supported values are "comments", "pickUpLocation",
+; "item-issue" and "acceptTerms"
+extraFields = item-issue:acceptTerms:pickUpLocation
+
+; Optional help texts that can be displayed on the request form
+helpText = "Help text for all languages."
+;helpText[en-gb] = "Help text for English language."
+
+; Optional label for the "acceptTerms" extra field
+acceptTermsText = "I accept the terms in any language."
+;acceptTermsText[en-gb] = "I accept the terms in English."
+
+; This section allows modification of the default mappings from item status codes to
+; VuFind item statuses
+[ItemStatusMappings]
+;Item::Held = "Held"
+
+; Uncomment the following lines to enable password (PIN) change
+;[changePassword]
+; PIN change parameters. The default limits are taken from the interface documentation.
+;minLength = 4
+;maxLength = 4
+; See the password_pattern/password_hint settings in the [Authentication] section
+; of config.ini for notes on these settings. When set here, these will override the
+; config.ini defaults when Voyager is used for authentication.
+;pattern = "numeric"
+;hint = "Your optional custom hint can go here."
diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index efaec85e904..94b801297bb 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -220,6 +220,9 @@ session_name = VUFIND_SESSION
 ;   - Innovative (for INNOPAC; see also Sierra/SierraRest)
 ;   - Koha (basic database access only)
 ;   - KohaILSDI (more features via ILS-DI API)
+;   - KohaRest (the most feature-complete Koha driver using Koha's REST API. Requires
+;     at least Koha 20.05 and the koha-plugin-rest-di extension found at:
+;     https://github.com/natlibfi/koha-plugin-rest-di)
 ;   - LBS4
 ;   - MultiBackend (to chain together multiple drivers in a consortial setting)
 ;   - NewGenLib
diff --git a/languages/en.ini b/languages/en.ini
index 0dc7f8ffe9f..075b9e1bf2a 100644
--- a/languages/en.ini
+++ b/languages/en.ini
@@ -500,8 +500,13 @@ hold_cancel_success_items = "%%count%% request(s) were successfully canceled"
 hold_date_invalid = "Please enter a valid date"
 hold_date_past = "Please enter a date in the future"
 hold_empty_selection = "No holds were selected"
+hold_error_age_restricted = "A hold cannot be placed due to age restriction on the material."
 hold_error_blocked = "You do not have sufficient privileges to place a hold on this item"
 hold_error_fail = "Your request failed. Please contact the circulation desk for further assistance"
+hold_error_item_not_holdable = "This item cannot be requested."
+hold_error_not_holdable = "This material cannot be requested."
+hold_error_on_shelf_blocked = "Shelf holds are not available."
+hold_error_too_many_holds = "A hold cannot be placed because the maximum number of holds has been reached."
 hold_invalid_pickup = "An invalid pick up location was entered. Please try again"
 hold_invalid_request_group = "An invalid hold request group was entered. Please try again"
 hold_items_available = "Cannot place a hold because items are available."
@@ -867,6 +872,13 @@ password_only_alphanumeric = "Numbers and letters A-Z only"
 password_only_numeric = "Numbers only"
 Passwords do not match = "Passwords do not match"
 past_days = "Past %%range%% Days"
+patron_status_address_missing = "Your address is missing."
+patron_status_card_blocked = "This library card is blocked from borrowing."
+patron_status_card_expired = "Your library card has expired."
+patron_status_debarred_overdue = "You have non-returned items overdue."
+patron_status_debt_limit_reached = "You have %%blockCount%% in fines and fees. Borrowing block limit is %%blockLimit%%."
+patron_status_guarantees_debt_limit_reached = "Your guarantees have %%blockCount%% in fines and fees. Borrowing block limit is %%blockLimit%%."
+patron_status_maximum_requests = "You have reached the maximum number (%%blockCount%%) of active requests."
 PDF Full Text = "PDF Full Text"
 peer_reviewed = "Peer Reviewed"
 peer_reviewed_limit = "Limit to articles from peer-reviewed journals"
diff --git a/languages/fi.ini b/languages/fi.ini
index 799e150a19a..f160b1396c2 100644
--- a/languages/fi.ini
+++ b/languages/fi.ini
@@ -500,8 +500,13 @@ hold_cancel_success_items = "%%count%% varaus(ta) peruttu"
 hold_date_invalid = "Syötä kelvollinen päivämäärä"
 hold_date_past = "Syötä tätä päivää myöhempi päivämäärä"
 hold_empty_selection = "Yhtään varausta ei valittu"
+hold_error_age_restricted = "Varausta ei voi tehdä aineiston ikärajan takia."
 hold_error_blocked = "Varaaminen ei ole mahdollista, koska olet lainauskiellossa."
 hold_error_fail = "Pyyntö epäonnistui. Ota yhteyttä kirjaston asiakaspalveluun."
+hold_error_item_not_holdable = "Nide ei ole varattavissa."
+hold_error_not_holdable = "Tätä aineistoa ei voi varata."
+hold_error_on_shelf_blocked = "Hyllyssä olevan aineiston varaaminen ei ole mahdollista."
+hold_error_too_many_holds = "Varausta ei voi tehdä, koska varausten maksimimäärä on saavutettu."
 hold_invalid_pickup = "Valittu noutopaikka on virheellinen. Yritä uudestaan."
 hold_invalid_request_group = "Valittu varausryhmä on virheellinen. Yritä uudestaan."
 hold_items_available = "Varaaminen ei ole mahdollista, koska niteitä on saatavissa."
@@ -864,6 +869,13 @@ password_only_alphanumeric = "Vain numerot ja kirjaimet a-z"
 password_only_numeric = "Vain numerot"
 Passwords do not match = "Salasanat eivät täsmää"
 past_days = "Viimeiset %%range%% päivää"
+patron_status_address_missing = "Osoitetietosi ovat puutteelliset."
+patron_status_card_blocked = "Kirjastokorttisi on lainauskiellossa."
+patron_status_card_expired = "Kirjastokorttisi on vanhentunut."
+patron_status_debarred_overdue = "Sinulla on aineistoa palauttamatta."
+patron_status_debt_limit_reached = "Sinulla on kertyneitä maksuja %%blockCount%%. Lainauskiellon raja on %%blockLimit%%."
+patron_status_guarantees_debt_limit_reached = "Huollettavallasi on kertyneitä maksuja %%blockCount%%. Lainauskiellon raja on %%blockLimit%%."
+patron_status_maximum_requests = "Sinulla on maksimimäärä (%%blockCount%%) varauksia."
 PDF Full Text = "PDF-kokoteksti"
 peer_reviewed = "Vertaisarvioitu"
 peer_reviewed_limit = "Rajaa artikkeleihin vertaisarvioiduista lehdistä"
diff --git a/languages/sv.ini b/languages/sv.ini
index f6685f147f8..a404aa83b9a 100644
--- a/languages/sv.ini
+++ b/languages/sv.ini
@@ -495,8 +495,13 @@ hold_cancel_success_items = "%%count%% reservering(ar) har annullerats"
 hold_date_invalid = "Ange ett giltigt datum"
 hold_date_past = "Ange ett datum som infaller senare än idag"
 hold_empty_selection = "Inga reserveringar valda"
+hold_error_age_restricted = "Reservering är inte möjlig eftersom materialet har en åldersgräns."
 hold_error_blocked = "Reservering är inte möjlig, eftersom du har låneförbud eller redan har en likvärdig reservering."
 hold_error_fail = "Begäran misslyckades. Kontakta kundtjänst."
+hold_error_item_not_holdable = "Exempeln kan inte reserveras."
+hold_error_not_holdable = "Denna material kan inte reserveras."
+hold_error_on_shelf_blocked = "Material som finns i hyllan kan inte reserveras."
+hold_error_too_many_holds = "Reservering är inte möjlig eftersom det maximala antalet reserveringar har uppnåtts."
 hold_invalid_pickup = "Det valda avhämtningsstället är felaktigt. Försök igen."
 hold_invalid_request_group = "Det valda gruppet är felaktigt. Försök igen."
 hold_items_available = "Reservering kan inte göras eftersom det finns exemplar tillgängliga för utlåning i biblioteket."
@@ -859,6 +864,13 @@ password_only_alphanumeric = "Bara siffror och bokstäver a-z"
 password_only_numeric = "Bara siffror"
 Passwords do not match = "Lösenorden stämmer inte"
 past_days = "Senaste %%range%% Dagar"
+patron_status_address_missing = "Din adress saknas."
+patron_status_card_blocked = "Du har låneförbud."
+patron_status_card_expired = "Ditt bibliotekskort har expirerad."
+patron_status_debarred_overdue = "Du har oåterlämnade lån."
+patron_status_debt_limit_reached = "Du har %%blockCount%% i avgifter. Gränsen för låneförbud är %%blockLimit%%."
+patron_status_guarantees_debt_limit_reached = "Någon under din vårdnad har %%blockCount%% i avgifter. Gränsen för låneförbud är %%blockLimit%%."
+patron_status_maximum_requests = "Du har nått det maximala antalet (%%blockCount%%) reserveringar."
 PDF Full Text = "PDF-fulltext"
 peer_reviewed = "Referentgranskad"
 peer_reviewed_limit = "Begränsa till artiklar i referentgranskade tidskrifter"
diff --git a/module/VuFind/src/VuFind/ILS/Driver/KohaRest.php b/module/VuFind/src/VuFind/ILS/Driver/KohaRest.php
new file mode 100644
index 00000000000..dc20b8317b5
--- /dev/null
+++ b/module/VuFind/src/VuFind/ILS/Driver/KohaRest.php
@@ -0,0 +1,2347 @@
+<?php
+/**
+ * VuFind Driver for Koha, using REST API
+ *
+ * PHP version 7
+ *
+ * Copyright (C) The National Library of Finland 2016-2020.
+ * Copyright (C) Moravian Library 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  ILS_Drivers
+ * @author   Bohdan Inhliziian <bohdan.inhliziian@gmail.com.cz>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @author   Josef Moravec <josef.moravec@mzk.cz>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
+ */
+namespace VuFind\ILS\Driver;
+
+use VuFind\Date\DateException;
+use VuFind\Exception\ILS as ILSException;
+
+/**
+ * VuFind Driver for Koha, using REST API
+ *
+ * Minimum Koha Version: 20.05 + koha-plugin-rest-di REST API plugin from
+ * https://github.com/natlibfi/koha-plugin-rest-di
+ *
+ * @category VuFind
+ * @package  ILS_Drivers
+ * @author   Bohdan Inhliziian <bohdan.inhliziian@gmail.com.cz>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @author   Josef Moravec <josef.moravec@mzk.cz>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
+ */
+class KohaRest extends \VuFind\ILS\Driver\AbstractBase implements
+    \VuFindHttp\HttpServiceAwareInterface,
+    \VuFind\I18n\Translator\TranslatorAwareInterface,
+    \Laminas\Log\LoggerAwareInterface
+{
+    use \VuFindHttp\HttpServiceAwareTrait;
+    use \VuFind\I18n\Translator\TranslatorAwareTrait;
+    use \VuFind\Log\LoggerAwareTrait {
+        logError as error;
+    }
+    use \VuFind\ILS\Driver\CacheTrait;
+
+    /**
+     * Library prefix
+     *
+     * @var string
+     */
+    protected $source = '';
+
+    /**
+     * Date converter object
+     *
+     * @var \VuFind\Date\Converter
+     */
+    protected $dateConverter;
+
+    /**
+     * Factory function for constructing the SessionContainer.
+     *
+     * @var Callable
+     */
+    protected $sessionFactory;
+
+    /**
+     * Session cache
+     *
+     * @var \Laminas\Session\Container
+     */
+    protected $sessionCache;
+
+    /**
+     * Default pickup location
+     *
+     * @var string
+     */
+    protected $defaultPickUpLocation;
+
+    /**
+     * Item status rankings. The lower the value, the more important the status.
+     *
+     * @var array
+     */
+    protected $statusRankings = [
+        'Charged' => 1,
+        'On Hold' => 2
+    ];
+
+    /**
+     * Mappings from fee (account line) types
+     *
+     * @var array
+     */
+    protected $feeTypeMappings = [
+        'A' => 'Account',
+        'C' => 'Credit',
+        'Copie' => 'Copier Fee',
+        'F' => 'Overdue',
+        'FU' => 'Accrued Fine',
+        'L' => 'Lost Item Replacement',
+        'M' => 'Sundry',
+        'N' => 'New Card',
+        'ODUE' => 'Overdue',
+        'Res' => 'Hold Fee',
+        'HE' => 'Hold Expired',
+        'RENT' => 'Rental'
+    ];
+
+    /**
+     * Mappings from renewal block reasons
+     *
+     * @var array
+     */
+    protected $renewalBlockMappings = [
+        'too_soon' => 'Cannot renew yet',
+        'onsite_checkout' => 'Copy has special circulation',
+        'on_reserve' => 'renew_item_requested',
+        'too_many' => 'renew_item_limit',
+        'restriction' => 'Borrowing Block Message',
+        'overdue' => 'renew_item_overdue',
+        'cardlost' => 'renew_card_lost',
+        'gonenoaddress' => 'patron_status_address_missing',
+        'debarred' => 'patron_status_card_blocked',
+        'debt' => 'renew_debt'
+    ];
+
+    /**
+     * Permanent renewal blocks
+     */
+    protected $permanentRenewalBlocks = [
+        'onsite_checkout',
+        'on_reserve',
+        'too_many'
+    ];
+
+    /**
+     * Patron status mappings
+     */
+    protected $patronStatusMappings = [
+        'Hold::MaximumHoldsReached' => 'patron_status_maximum_requests',
+        'Patron::CardExpired' => 'patron_status_card_expired',
+        'Patron::DebarredOverdue' => 'patron_status_debarred_overdue',
+        'Patron::Debt' => 'patron_status_debt_limit_reached',
+        'Patron::DebtGuarantees' => 'patron_status_guarantees_debt_limit_reached',
+        'Patron::GoneNoAddress' => 'patron_status_address_missing',
+    ];
+
+    /**
+     * Whether to display home library instead of holding library
+     *
+     * @var bool
+     */
+    protected $useHomeLibrary = false;
+
+    /**
+     * Whether to sort items by serial issue. Default is true.
+     *
+     * @var bool
+     */
+    protected $sortItemsBySerialIssue;
+
+    /**
+     * Constructor
+     *
+     * @param \VuFind\Date\Converter $dateConverter  Date converter object
+     * @param Callable               $sessionFactory Factory function returning
+     * SessionContainer object
+     */
+    public function __construct(\VuFind\Date\Converter $dateConverter,
+        $sessionFactory
+    ) {
+        $this->dateConverter = $dateConverter;
+        $this->sessionFactory = $sessionFactory;
+    }
+
+    /**
+     * Initialize the driver.
+     *
+     * Validate configuration and perform all resource-intensive tasks needed to
+     * make the driver active.
+     *
+     * @throws ILSException
+     * @return void
+     */
+    public function init()
+    {
+        // Validate config
+        $required = ['host'];
+        foreach ($required as $current) {
+            if (!isset($this->config['Catalog'][$current])) {
+                throw new ILSException("Missing Catalog/{$current} config setting.");
+            }
+        }
+
+        $this->defaultPickUpLocation
+            = isset($this->config['Holds']['defaultPickUpLocation'])
+            ? $this->config['Holds']['defaultPickUpLocation']
+            : '';
+        if ($this->defaultPickUpLocation === 'user-selected') {
+            $this->defaultPickUpLocation = false;
+        }
+
+        if (!empty($this->config['StatusRankings'])) {
+            $this->statusRankings = array_merge(
+                $this->statusRankings, $this->config['StatusRankings']
+            );
+        }
+
+        if (!empty($this->config['FeeTypeMappings'])) {
+            $this->feeTypeMappings = array_merge(
+                $this->feeTypeMappings, $this->config['FeeTypeMappings']
+            );
+        }
+
+        if (!empty($this->config['PatronStatusMappings'])) {
+            $this->patronStatusMappings = array_merge(
+                $this->patronStatusMappings, $this->config['PatronStatusMappings']
+            );
+        }
+
+        $this->useHomeLibrary = !empty($this->config['Holdings']['useHomeLibrary']);
+
+        $this->sortItemsBySerialIssue
+            = $this->config['Holdings']['sortBySerialIssue'] ?? true;
+
+        // Init session cache for session-specific data
+        $namespace = md5($this->config['Catalog']['host']);
+        $factory = $this->sessionFactory;
+        $this->sessionCache = $factory($namespace);
+    }
+
+    /**
+     * Method to ensure uniform cache keys for cached VuFind objects.
+     *
+     * @param string|null $suffix Optional suffix that will get appended to the
+     * object class name calling getCacheKey()
+     *
+     * @return string
+     */
+    protected function getCacheKey($suffix = null)
+    {
+        return 'KohaRest' . '-' . md5($this->config['Catalog']['host'] . $suffix);
+    }
+
+    /**
+     * Get Status
+     *
+     * This is responsible for retrieving the status information of a certain
+     * record.
+     *
+     * @param string $id The record id to retrieve the holdings for
+     *
+     * @return array An associative array with the following keys:
+     * id, availability (boolean), status, location, reserve, callnumber.
+     */
+    public function getStatus($id)
+    {
+        return $this->getItemStatusesForBiblio($id);
+    }
+
+    /**
+     * Get Statuses
+     *
+     * This is responsible for retrieving the status information for a
+     * collection of records.
+     *
+     * @param array $ids The array of record ids to retrieve the status for
+     *
+     * @return mixed     An array of getStatus() return values on success.
+     */
+    public function getStatuses($ids)
+    {
+        $items = [];
+        foreach ($ids as $id) {
+            $items[] = $this->getItemStatusesForBiblio($id);
+        }
+        return $items;
+    }
+
+    /**
+     * Get Holding
+     *
+     * This is responsible for retrieving the holding information of a certain
+     * record.
+     *
+     * @param string $id      The record id to retrieve the holdings for
+     * @param array  $patron  Patron data
+     * @param array  $options Extra options
+     *
+     * @throws \VuFind\Exception\ILS
+     * @return array         On success, an associative array with the following
+     * keys: id, availability (boolean), status, location, reserve, callnumber,
+     * duedate, number, barcode.
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getHolding($id, array $patron = null, array $options = [])
+    {
+        return $this->getItemStatusesForBiblio($id, $patron);
+    }
+
+    /**
+     * Get Purchase History
+     *
+     * This is responsible for retrieving the acquisitions history data for the
+     * specific record (usually recently received issues of a serial).
+     *
+     * @param string $id The record id to retrieve the info for
+     *
+     * @return mixed     An array with the acquisitions data on success.
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getPurchaseHistory($id)
+    {
+        return [];
+    }
+
+    /**
+     * Get New Items
+     *
+     * Retrieve the IDs of items recently added to the catalog.
+     *
+     * @param int $page    Page number of results to retrieve (counting starts at 1)
+     * @param int $limit   The size of each page of results to retrieve
+     * @param int $daysOld The maximum age of records to retrieve in days (max. 30)
+     * @param int $fundId  optional fund ID to use for limiting results (use a value
+     * returned by getFunds, or exclude for no limit); note that "fund" may be a
+     * misnomer - if funds are not an appropriate way to limit your new item
+     * results, you can return a different set of values from getFunds. The
+     * important thing is that this parameter supports an ID returned by getFunds,
+     * whatever that may mean.
+     *
+     * @return array       Associative array with 'count' and 'results' keys
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getNewItems($page, $limit, $daysOld, $fundId = null)
+    {
+        return ['count' => 0, 'results' => []];
+    }
+
+    /**
+     * Find Reserves
+     *
+     * Obtain information on course reserves.
+     *
+     * @param string $course ID from getCourses (empty string to match all)
+     * @param string $inst   ID from getInstructors (empty string to match all)
+     * @param string $dept   ID from getDepartments (empty string to match all)
+     *
+     * @return mixed An array of associative arrays representing reserve items.
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function findReserves($course, $inst, $dept)
+    {
+        return [];
+    }
+
+    /**
+     * Patron Login
+     *
+     * This is responsible for authenticating a patron against the catalog.
+     *
+     * @param string $username The patron username
+     * @param string $password The patron password
+     *
+     * @return mixed           Associative array of patron info on successful login,
+     * null on unsuccessful login.
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function patronLogin($username, $password)
+    {
+        if (empty($username) || empty($password)) {
+            return null;
+        }
+
+        $result = $this->makeRequest(
+            [
+                'path' => 'v1/contrib/kohasuomi/patrons/validation',
+                'json' => ['userid' => $username, 'password' => $password],
+                'method' => 'POST',
+                'errors' => true,
+            ]
+        );
+
+        if (401 === $result['code'] || 403 === $result['code']) {
+            return null;
+        }
+        if (200 !== $result['code']) {
+            throw new ILSException('Problem with Koha REST API.');
+        }
+
+        $result = $result['data'];
+        return [
+            'id' => $result['patron_id'],
+            'firstname' => $result['firstname'],
+            'lastname' => $result['surname'],
+            'cat_username' => $username,
+            'cat_password' => $password,
+            'email' => $result['email'],
+            'major' => null,
+            'college' => null,
+            'home_library' => $result['library_id']
+        ];
+    }
+
+    /**
+     * Check whether the patron is blocked from placing requests (holds/ILL/SRR).
+     *
+     * @param array $patron Patron data from patronLogin().
+     *
+     * @return mixed A boolean false if no blocks are in place and an array
+     * of block reasons if blocks are in place
+     */
+    public function getRequestBlocks($patron)
+    {
+        return $this->getPatronBlocks($patron);
+    }
+
+    /**
+     * Check whether the patron has any blocks on their account.
+     *
+     * @param array $patron Patron data from patronLogin().
+     *
+     * @return mixed A boolean false if no blocks are in place and an array
+     * of block reasons if blocks are in place
+     */
+    public function getAccountBlocks($patron)
+    {
+        return $this->getPatronBlocks($patron);
+    }
+
+    /**
+     * Get Patron Profile
+     *
+     * This is responsible for retrieving the profile for a specific patron.
+     *
+     * @param array $patron The patron array
+     *
+     * @throws ILSException
+     * @return array        Array of the patron's profile data on success.
+     */
+    public function getMyProfile($patron)
+    {
+        $result = $this->makeRequest(['v1', 'patrons', $patron['id']]);
+
+        if (200 !== $result['code']) {
+            throw new ILSException('Problem with Koha REST API.');
+        }
+
+        $result = $result['data'];
+        return [
+            'firstname' => $result['firstname'],
+            'lastname' => $result['surname'],
+            'phone' => $result['phone'],
+            'mobile_phone' => $result['mobile'],
+            'email' => $result['email'],
+            'address1' => $result['address'],
+            'address2' => $result['address2'],
+            'zip' => $result['postal_code'],
+            'city' => $result['city'],
+            'country' => $result['country'],
+            'expiration_date' => $this->convertDate($result['expiry_date'] ?? null)
+        ];
+    }
+
+    /**
+     * Get Patron Transactions
+     *
+     * This is responsible for retrieving all transactions (i.e. checked out items)
+     * by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     * @param array $params Parameters
+     *
+     * @throws DateException
+     * @throws ILSException
+     * @return array        Array of the patron's transactions on success.
+     */
+    public function getMyTransactions($patron, $params = [])
+    {
+        return $this->getTransactions($patron, $params, false);
+    }
+
+    /**
+     * Get Renew Details
+     *
+     * @param array $checkOutDetails An array of item data
+     *
+     * @return string Data for use in a form field
+     */
+    public function getRenewDetails($checkOutDetails)
+    {
+        return $checkOutDetails['checkout_id'] . '|' . $checkOutDetails['item_id'];
+    }
+
+    /**
+     * Renew My Items
+     *
+     * Function for attempting to renew a patron's items.  The data in
+     * $renewDetails['details'] is determined by getRenewDetails().
+     *
+     * @param array $renewDetails An array of data required for renewing items
+     * including the Patron ID and an array of renewal IDS
+     *
+     * @return array              An array of renewal information keyed by item ID
+     */
+    public function renewMyItems($renewDetails)
+    {
+        $finalResult = ['details' => []];
+
+        foreach ($renewDetails['details'] as $details) {
+            list($checkoutId, $itemId) = explode('|', $details);
+            $result = $this->makeRequest(
+                [
+                    'path' => ['v1', 'checkouts', $checkoutId, 'renewal'],
+                    'method' => 'POST'
+                ]
+            );
+            if (201 === $result['code']) {
+                $newDate
+                    = $this->convertDate($result['data']['due_date'] ?? null, true);
+                $finalResult['details'][$itemId] = [
+                    'item_id' => $itemId,
+                    'success' => true,
+                    'new_date' => $newDate
+                ];
+            } else {
+                $finalResult['details'][$itemId] = [
+                    'item_id' => $itemId,
+                    'success' => false
+                ];
+            }
+        }
+        return $finalResult;
+    }
+
+    /**
+     * Get Patron Transaction History
+     *
+     * This is responsible for retrieving all historical transactions
+     * (i.e. checked out items)
+     * by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     * @param array $params Parameters
+     *
+     * @throws DateException
+     * @throws ILSException
+     * @return array        Array of the patron's transactions on success.
+     */
+    public function getMyTransactionHistory($patron, $params)
+    {
+        return $this->getTransactions($patron, $params, true);
+    }
+
+    /**
+     * Get Patron Holds
+     *
+     * This is responsible for retrieving all holds by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     *
+     * @throws DateException
+     * @throws ILSException
+     * @return array        Array of the patron's holds on success.
+     */
+    public function getMyHolds($patron)
+    {
+        $result = $this->makeRequest(
+            [
+                'path' => 'v1/holds',
+                'query' => [
+                    'patron_id' => $patron['id'],
+                    '_match' => 'exact'
+                ]
+            ]
+        );
+
+        $holds = [];
+        foreach ($result['data'] as $entry) {
+            $biblio = $this->getBiblio($entry['biblio_id']);
+            $frozen = false;
+            if (!empty($entry['suspended'])) {
+                $frozen = !empty($entry['suspend_until']) ? $entry['suspend_until']
+                    : true;
+            }
+            $volume = '';
+            if ($entry['item_id'] ?? null) {
+                $item = $this->getItem($entry['item_id']);
+                $volume = $item['serial_issue_number'];
+            }
+            $holds[] = [
+                'id' => $entry['biblio_id'],
+                'item_id' => $entry['item_id'] ?? null,
+                'requestId' => $entry['hold_id'],
+                'location' => $this->getLibraryName(
+                    $entry['pickup_library_id'] ?? null
+                ),
+                'create' => $this->convertDate($entry['hold_date'] ?? null),
+                'expire' => $this->convertDate($entry['expiration_date'] ?? null),
+                'position' => $entry['priority'],
+                'available' => !empty($entry['waiting_date']),
+                'frozen' => $frozen,
+                'in_transit' => !empty($entry['status']) && $entry['status'] == 'T',
+                'title' => $this->getBiblioTitle($biblio),
+                'isbn' => $biblio['isbn'] ?? '',
+                'issn' => $biblio['issn'] ?? '',
+                'publication_year' => $biblio['copyright_date']
+                    ?? $biblio['publication_year'] ?? '',
+                'volume' => $volume,
+            ];
+        }
+
+        return $holds;
+    }
+
+    /**
+     * Get Cancel Hold Details
+     *
+     * Get required data for canceling a hold. This value is used by relayed to the
+     * cancelHolds function when the user attempts to cancel a hold.
+     *
+     * @param array $holdDetails An array of hold data
+     *
+     * @return string Data for use in a form field
+     */
+    public function getCancelHoldDetails($holdDetails)
+    {
+        return $holdDetails['available'] || $holdDetails['in_transit'] ? ''
+            : $holdDetails['requestId'] . '|' . $holdDetails['item_id'];
+    }
+
+    /**
+     * Cancel Holds
+     *
+     * Attempts to Cancel a hold. The data in $cancelDetails['details'] is determined
+     * by getCancelHoldDetails().
+     *
+     * @param array $cancelDetails An array of item and patron data
+     *
+     * @return array               An array of data on each request including
+     * whether or not it was successful and a system message (if available)
+     */
+    public function cancelHolds($cancelDetails)
+    {
+        $details = $cancelDetails['details'];
+        $count = 0;
+        $response = [];
+
+        foreach ($details as $detail) {
+            list($holdId, $itemId) = explode('|', $detail, 2);
+            $result = $this->makeRequest(
+                [
+                    'path' => ['v1', 'holds', $holdId],
+                    'method' => 'DELETE',
+                    'errors' => true
+                ]
+            );
+
+            if (200 === $result['code']) {
+                $response[$itemId] = [
+                    'success' => true,
+                    'status' => 'hold_cancel_success'
+                ];
+                ++$count;
+            } else {
+                $response[$itemId] = [
+                    'success' => false,
+                    'status' => 'hold_cancel_fail',
+                    'sysMessage' => false
+                ];
+            }
+        }
+        return ['count' => $count, 'items' => $response];
+    }
+
+    /**
+     * Get Pick Up Locations
+     *
+     * This is responsible for gettting a list of valid library locations for
+     * holds / recall retrieval
+     *
+     * @param array $patron      Patron information returned by the patronLogin
+     * method.
+     * @param array $holdDetails Optional array, only passed in when getting a list
+     * in the context of placing a hold; contains most of the same values passed to
+     * placeHold, minus the patron data.  May be used to limit the pickup options
+     * or may be ignored.  The driver must not add new options to the return array
+     * based on this data or other areas of VuFind may behave incorrectly.
+     *
+     * @throws ILSException
+     * @return array        An array of associative arrays with locationID and
+     * locationDisplay keys
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getPickUpLocations($patron = false, $holdDetails = null)
+    {
+        $bibId = $holdDetails['id'];
+        $itemId = $holdDetails['item_id'] ?? false;
+        $level = isset($holdDetails['level']) && !empty($holdDetails['level'])
+            ? $holdDetails['level'] : 'copy';
+        if ('copy' === $level && false === $itemId) {
+            return [];
+        }
+        $requestType
+            = array_key_exists('StorageRetrievalRequest', $holdDetails ?? [])
+                ? 'StorageRetrievalRequests' : 'Holds';
+        $included = null;
+        if ('Holds' === $requestType) {
+            // Collect library codes that are to be included
+            if ('copy' === $level) {
+                $result = $this->makeRequest(
+                    [
+                        'path' => [
+                            'v1', 'contrib', 'kohasuomi', 'availability', 'items',
+                            $itemId, 'hold'
+                        ],
+                        'query' => [
+                            'patron_id' => (int)$patron['id'],
+                            'query_pickup_locations' => 1
+                        ]
+                    ]
+                );
+                if (empty($result['data'])) {
+                    return [];
+                }
+                $notes = $result['data']['availability']['notes'];
+                $included = $notes['Item::PickupLocations']['to_libraries'];
+            } else {
+                $result = $this->makeRequest(
+                    [
+                        'path' => [
+                            'v1', 'contrib', 'kohasuomi', 'availability', 'biblios',
+                            $bibId, 'hold'
+                        ],
+                        'query' => [
+                            'patron_id' => (int)$patron['id'],
+                            'query_pickup_locations' => 1
+                        ]
+                    ]
+                );
+                if (empty($result['data'])) {
+                    return [];
+                }
+                $notes = $result['data']['availability']['notes'];
+                $included = $notes['Biblio::PickupLocations']['to_libraries'];
+            }
+        }
+
+        $excluded = isset($this->config['Holds']['excludePickupLocations'])
+            ? explode(':', $this->config['Holds']['excludePickupLocations']) : [];
+        $locations = [];
+        foreach ($this->getLibraries() as $library) {
+            $code = $library['library_id'];
+            if ((null === $included && !$library['pickup_location'])
+                || in_array($code, $excluded)
+                || (null !== $included && !in_array($code, $included))
+            ) {
+                continue;
+            }
+            $locations[] = [
+                'locationID' => $code,
+                'locationDisplay' => $library['name']
+            ];
+        }
+
+        // Do we need to sort pickup locations? If the setting is false, don't
+        // bother doing any more work. If it's not set at all, default to
+        // alphabetical order.
+        $orderSetting = isset($this->config['Holds']['pickUpLocationOrder'])
+            ? $this->config['Holds']['pickUpLocationOrder'] : 'default';
+        if (count($locations) > 1 && !empty($orderSetting)) {
+            $locationOrder = $orderSetting === 'default'
+                ? [] : array_flip(explode(':', $orderSetting));
+            $sortFunction = function ($a, $b) use ($locationOrder) {
+                $aLoc = $a['locationID'];
+                $bLoc = $b['locationID'];
+                if (isset($locationOrder[$aLoc])) {
+                    if (isset($locationOrder[$bLoc])) {
+                        return $locationOrder[$aLoc] - $locationOrder[$bLoc];
+                    }
+                    return -1;
+                }
+                if (isset($locationOrder[$bLoc])) {
+                    return 1;
+                }
+                return strcasecmp($a['locationDisplay'], $b['locationDisplay']);
+            };
+            usort($locations, $sortFunction);
+        }
+
+        return $locations;
+    }
+
+    /**
+     * Get Default Pick Up Location
+     *
+     * Returns the default pick up location
+     *
+     * @param array $patron      Patron information returned by the patronLogin
+     * method.
+     * @param array $holdDetails Optional array, only passed in when getting a list
+     * in the context of placing a hold; contains most of the same values passed to
+     * placeHold, minus the patron data.  May be used to limit the pickup options
+     * or may be ignored.
+     *
+     * @return false|string      The default pickup location for the patron or false
+     * if the user has to choose.
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getDefaultPickUpLocation($patron = false, $holdDetails = null)
+    {
+        return $this->defaultPickUpLocation;
+    }
+
+    /**
+     * Check if request is valid
+     *
+     * This is responsible for determining if an item is requestable
+     *
+     * @param string $id     The Bib ID
+     * @param array  $data   An Array of item data
+     * @param patron $patron An array of patron data
+     *
+     * @return mixed An array of data on the request including
+     * whether or not it is valid and a status message. Alternatively a boolean
+     * true if request is valid, false if not.
+     */
+    public function checkRequestIsValid($id, $data, $patron)
+    {
+        if ($this->getPatronBlocks($patron)) {
+            return false;
+        }
+        $level = $data['level'] ?? 'copy';
+        if ('title' === $level) {
+            $result = $this->makeRequest(
+                [
+                    'path' => [
+                        'v1', 'contrib', 'kohasuomi', 'availability', 'biblios', $id,
+                        'hold'
+                    ],
+                    'query' => ['patron_id' => $patron['id']]
+                ]
+            );
+            if (!empty($result['data']['availability']['available'])) {
+                return [
+                    'valid' => true,
+                    'status' => 'title_hold_place'
+                ];
+            }
+            return [
+                'valid' => false,
+                'status' => $this->getHoldBlockReason($result['data'])
+            ];
+        }
+
+        $result = $this->makeRequest(
+            [
+                'path' => [
+                    'v1', 'contrib', 'kohasuomi', 'availability', 'items',
+                    $data['item_id'], 'hold'
+                ],
+                'query' => ['patron_id' => $patron['id']]
+            ]
+        );
+        if (!empty($result['data']['availability']['available'])) {
+            return [
+                'valid' => true,
+                'status' => 'hold_place'
+            ];
+        }
+        return [
+            'valid' => false,
+            'status' => $this->getHoldBlockReason($result['data'])
+        ];
+    }
+
+    /**
+     * Place Hold
+     *
+     * Attempts to place a hold or recall on a particular item and returns
+     * an array with result details or throws an exception on failure of support
+     * classes
+     *
+     * @param array $holdDetails An array of item and patron data
+     *
+     * @throws ILSException
+     * @return mixed An array of data on the request including
+     * whether or not it was successful and a system message (if available)
+     */
+    public function placeHold($holdDetails)
+    {
+        $patron = $holdDetails['patron'];
+        $level = isset($holdDetails['level']) && !empty($holdDetails['level'])
+            ? $holdDetails['level'] : 'copy';
+        $pickUpLocation = !empty($holdDetails['pickUpLocation'])
+            ? $holdDetails['pickUpLocation'] : $this->defaultPickUpLocation;
+        $itemId = $holdDetails['item_id'] ?? false;
+        $comment = $holdDetails['comment'] ?? '';
+        $bibId = $holdDetails['id'];
+
+        if ($level == 'copy' && empty($itemId)) {
+            throw new ILSException("Hold level is 'copy', but item ID is empty");
+        }
+
+        // Convert last interest date from Display Format to Koha's required format
+        try {
+            $lastInterestDate = $this->dateConverter->convertFromDisplayDate(
+                'Y-m-d', $holdDetails['requiredBy']
+            );
+        } catch (DateException $e) {
+            // Hold Date is invalid
+            return $this->holdError('hold_date_invalid');
+        }
+
+        try {
+            $checkTime = $this->dateConverter->convertFromDisplayDate(
+                'U', $holdDetails['requiredBy']
+            );
+            if (!is_numeric($checkTime)) {
+                throw new DateException('Result should be numeric');
+            }
+        } catch (DateException $e) {
+            throw new ILSException('Problem parsing required by date.');
+        }
+
+        if (time() > $checkTime) {
+            // Hold Date is in the past
+            return $this->holdError('hold_date_past');
+        }
+
+        // Make sure pickup location is valid
+        if (!$this->pickUpLocationIsValid($pickUpLocation, $patron, $holdDetails)) {
+            return $this->holdError('hold_invalid_pickup');
+        }
+
+        $request = [
+            'biblio_id' => (int)$bibId,
+            'patron_id' => (int)$patron['id'],
+            'pickup_library_id' => $pickUpLocation,
+            'notes' => $comment,
+            'expiration_date' => $lastInterestDate,
+        ];
+        if ($level == 'copy') {
+            $request['item_id'] = (int)$itemId;
+        }
+
+        $result = $this->makeRequest(
+            [
+                'path' => 'v1/holds',
+                'json' => $request,
+                'method' => 'POST',
+                'errors' => true
+            ]
+        );
+
+        if ($result['code'] >= 300) {
+            return $this->holdError($result['data']['error'] ?? 'hold_error_fail');
+        }
+        return ['success' => true];
+    }
+
+    /**
+     * Get Patron Storage Retrieval Requests
+     *
+     * This is responsible for retrieving all article requests by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     *
+     * @return array        Array of the patron's storage retrieval requests.
+     */
+    public function getMyStorageRetrievalRequests($patron)
+    {
+        $result = $this->makeRequest(
+            [
+                'v1', 'contrib', 'kohasuomi', 'patrons', $patron['id'],
+                'articlerequests'
+            ]
+        );
+        if (empty($result)) {
+            return [];
+        }
+        $requests = [];
+        foreach ($result['data'] as $entry) {
+            // Article requests don't yet have a unified API mapping in Koha.
+            // Try to take into account existing and predicted field names.
+            $bibId = $entry['biblio_id'] ?? $entry['biblionumber'] ?? null;
+            $itemId = $entry['item_id'] ?? $entry['itemnumber'] ?? null;
+            $location = $entry['library_id'] ?? $entry['branchcode'] ?? null;
+            $title = '';
+            $volume = '';
+            if ($itemId) {
+                $item = $this->getItem($itemId);
+                $bibId = $item['biblio_id'];
+                $volume = $item['serial_issue_number'];
+            }
+            if (!empty($bibId)) {
+                $bib = $this->getBiblio($bibId);
+                $title = $this->getBiblioTitle($bib);
+            }
+            $requests[] = [
+                'id' => $bibId,
+                'item_id' => $entry['id'],
+                'location' => $location,
+                'create' => $this->convertDate($entry['created_on']),
+                'available' => $entry['status'] === 'COMPLETED',
+                'title' => $title,
+                'volume' => $volume,
+            ];
+        }
+        return $requests;
+    }
+
+    /**
+     * Get Cancel Storage Retrieval Request (article request) Details
+     *
+     * @param array $details An array of item data
+     *
+     * @return string Data for use in a form field
+     */
+    public function getCancelStorageRetrievalRequestDetails($details)
+    {
+        return $details['item_id'];
+    }
+
+    /**
+     * Cancel Storage Retrieval Requests (article requests)
+     *
+     * Attempts to Cancel an article request on a particular item. The
+     * data in $cancelDetails['details'] is determined by
+     * getCancelStorageRetrievalRequestDetails().
+     *
+     * @param array $cancelDetails An array of item and patron data
+     *
+     * @return array               An array of data on each request including
+     * whether or not it was successful and a system message (if available)
+     */
+    public function cancelStorageRetrievalRequests($cancelDetails)
+    {
+        $details = $cancelDetails['details'];
+        $patron = $cancelDetails['patron'];
+        $count = 0;
+        $response = [];
+
+        foreach ($details as $id) {
+            $result = $this->makeRequest(
+                [
+                    'path' => [
+                        'v1', 'contrib', 'kohasuomi', 'patrons', $patron['id'],
+                        'articlerequests', $id
+                    ],
+                    'method' => 'DELETE',
+                    'errors' => true
+                ]
+            );
+
+            if (200 !== $result['code']) {
+                $response[$id] = [
+                    'success' => false,
+                    'status' => 'storage_retrieval_request_cancel_fail',
+                    'sysMessage' => false
+                ];
+            } else {
+                $response[$id] = [
+                    'success' => true,
+                    'status' => 'storage_retrieval_request_cancel_success'
+                ];
+                ++$count;
+            }
+        }
+        return ['count' => $count, 'items' => $response];
+    }
+
+    /**
+     * Check if storage retrieval request is valid
+     *
+     * This is responsible for determining if an item is requestable
+     *
+     * @param string $id     The Bib ID
+     * @param array  $data   An Array of item data
+     * @param patron $patron An array of patron data
+     *
+     * @return bool True if request is valid, false if not
+     */
+    public function checkStorageRetrievalRequestIsValid($id, $data, $patron)
+    {
+        if (!isset($this->config['StorageRetrievalRequests'])
+            || $this->getPatronBlocks($patron)
+        ) {
+            return false;
+        }
+
+        $level = $data['level'] ?? 'copy';
+
+        if ('title' === $level) {
+            $result = $this->makeRequest(
+                [
+                    'path' => [
+                        'v1', 'contrib', 'kohasuomi', 'availability', 'biblios', $id,
+                        'articlerequest'
+                    ],
+                    'query' => ['patron_id' => $patron['id']]
+                ]
+            );
+        } else {
+            $result = $this->makeRequest(
+                [
+                    'path' => [
+                        'v1', 'contrib', 'kohasuomi', 'availability', 'items',
+                        $data['item_id'], 'articlerequest'
+                    ],
+                    'query' => ['patron_id' => $patron['id']]
+                ]
+            );
+        }
+        return !empty($result['data']['availability']['available']);
+    }
+
+    /**
+     * Place Storage Retrieval Request (Call Slip)
+     *
+     * Attempts to place a call slip request on a particular item and returns
+     * an array with result details
+     *
+     * @param array $details An array of item and patron data
+     *
+     * @return mixed An array of data on the request including
+     * whether or not it was successful and a system message (if available)
+     */
+    public function placeStorageRetrievalRequest($details)
+    {
+        $patron = $details['patron'];
+        $level = $details['level'] ?? 'copy';
+        $pickUpLocation = $details['pickUpLocation'] ?? null;
+        $itemId = $details['item_id'] ?? false;
+        $comment = $details['comment'] ?? '';
+        $bibId = $details['id'];
+
+        if ('copy' === $level && empty($itemId)) {
+            throw new ILSException("Request level is 'copy', but item ID is empty");
+        }
+
+        // Make sure pickup location is valid
+        if (null !== $pickUpLocation
+            && !$this->pickUpLocationIsValid($pickUpLocation, $patron, $details)
+        ) {
+            return [
+                'success' => false,
+                'sysMessage' => 'storage_retrieval_request_invalid_pickup'
+            ];
+        }
+
+        $request = [
+            'biblio_id' => (int)$bibId,
+            'pickup_library_id' => $pickUpLocation,
+            'notes' => $comment,
+            'volume' => $details['volume'] ?? '',
+            'issue' => $details['issue'] ?? '',
+            'date' => $details['year'] ?? '',
+        ];
+        if ($level == 'copy') {
+            $request['item_id'] = (int)$itemId;
+        }
+
+        $result = $this->makeRequest(
+            [
+                'path' => [
+                    'v1', 'contrib', 'kohasuomi', 'patrons', $patron['id'],
+                    'articlerequests'
+                ],
+                'json' => $request,
+                'method' => 'POST',
+                'errors' => true
+            ]
+        );
+
+        if ($result['code'] >= 300) {
+            $message = $result['data']['error']
+                ?? 'storage_retrieval_request_error_fail';
+            return [
+                'success' => false,
+                'sysMessage' => $message
+            ];
+        }
+        return [
+            'success' => true,
+            'status' => 'storage_retrieval_request_place_success'
+        ];
+    }
+
+    /**
+     * Get Patron Fines
+     *
+     * This is responsible for retrieving all fines by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     *
+     * @throws DateException
+     * @throws ILSException
+     * @return array        Array of the patron's fines on success.
+     */
+    public function getMyFines($patron)
+    {
+        // TODO: Make this use X-Koha-Embed when the endpoint allows
+        $result = $this->makeRequest(['v1', 'patrons', $patron['id'], 'account']);
+
+        $fines = [];
+        foreach ($result['data']['outstanding_debits']['lines'] ?? [] as $entry) {
+            $bibId = null;
+            if (!empty($entry['item_id'])) {
+                $item = $this->getItem($entry['item_id']);
+                if (!empty($item['biblio_id'])) {
+                    $bibId = $item['biblio_id'];
+                }
+            }
+            $type = $entry['debit_type'];
+            $type = $this->translate($this->feeTypeMappings[$type] ?? $type);
+            if ($entry['description'] !== $type) {
+                $type .= ' - ' . $entry['description'];
+            }
+            $fine = [
+                'amount' => $entry['amount'] * 100,
+                'balance' => $entry['amount_outstanding'] * 100,
+                'fine' => $type,
+                'createdate' => $this->convertDate($entry['date'] ?? null),
+                'checkout' => '',
+            ];
+            if (null !== $bibId) {
+                $fine['id'] = $bibId;
+            }
+            $fines[] = $fine;
+        }
+        return $fines;
+    }
+
+    /**
+     * Change Password
+     *
+     * Attempts to change patron password (PIN code)
+     *
+     * @param array $details An array of patron id and old and new password:
+     *
+     * 'patron'      The patron array from patronLogin
+     * 'oldPassword' Old password
+     * 'newPassword' New password
+     *
+     * @return array An array of data on the request including
+     * whether or not it was successful and a system message (if available)
+     */
+    public function changePassword($details)
+    {
+        $patron = $details['patron'];
+        $request = [
+            'password' => $details['newPassword'],
+            'password_2' => $details['newPassword']
+        ];
+
+        $result = $this->makeRequest(
+            [
+                'path' => ['v1', 'patrons', $patron['id'], 'password'],
+                'json' => $request,
+                'method' => 'POST',
+                'errors' => true
+            ]
+        );
+
+        if (200 !== $result['code']) {
+            if (400 === $result['code']) {
+                $message = 'password_error_invalid';
+            } else {
+                $message = 'An error has occurred';
+            }
+            return [
+                'success' => false, 'status' => $message
+            ];
+        }
+        return ['success' => true, 'status' => 'change_password_ok'];
+    }
+
+    /**
+     * Public Function which retrieves renew, hold and cancel settings from the
+     * driver ini file.
+     *
+     * @param string $function The name of the feature to be checked
+     * @param array  $params   Optional feature-specific parameters (array)
+     *
+     * @return array An array with key-value pairs.
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getConfig($function, $params = null)
+    {
+        if ('getMyTransactionHistory' === $function) {
+            if (empty($this->config['TransactionHistory']['enabled'])) {
+                return false;
+            }
+            $limit = $this->config['TransactionHistory']['max_page_size'] ?? 100;
+            return [
+                'max_results' => $limit,
+                'sort' => [
+                    '-checkout_date' => 'sort_checkout_date_desc',
+                    '+checkout_date' => 'sort_checkout_date_asc',
+                    '-checkin_date' => 'sort_return_date_desc',
+                    '+checkin_date' => 'sort_return_date_asc',
+                    '-due_date' => 'sort_due_date_desc',
+                    '+due_date' => 'sort_due_date_asc',
+                    '+title' => 'sort_title'
+                ],
+                'default_sort' => '-checkout_date'
+            ];
+        } elseif ('getMyTransactions' === $function) {
+            $limit = $this->config['Loans']['max_page_size'] ?? 100;
+            return [
+                'max_results' => $limit,
+                'sort' => [
+                    '-checkout_date' => 'sort_checkout_date_desc',
+                    '+checkout_date' => 'sort_checkout_date_asc',
+                    '-due_date' => 'sort_due_date_desc',
+                    '+due_date' => 'sort_due_date_asc',
+                    '+title' => 'sort_title'
+                ],
+                'default_sort' => '+due_date'
+            ];
+        }
+
+        return isset($this->config[$function])
+            ? $this->config[$function] : false;
+    }
+
+    /**
+     * Helper method to determine whether or not a certain method can be
+     * called on this driver.  Required method for any smart drivers.
+     *
+     * @param string $method The name of the called method.
+     * @param array  $params Array of passed parameters
+     *
+     * @return bool True if the method can be called with the given parameters,
+     * false otherwise.
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function supportsMethod($method, $params)
+    {
+        // Special case: change password is only available if properly configured.
+        if ($method == 'changePassword') {
+            return isset($this->config['changePassword']);
+        }
+        return is_callable([$this, $method]);
+    }
+
+    /**
+     * Create a HTTP client
+     *
+     * @param string $url Request URL
+     *
+     * @return \Laminas\Http\Client
+     */
+    protected function createHttpClient($url)
+    {
+        $client = $this->httpService->createClient($url);
+
+        if (isset($this->config['Http']['ssl_verify_peer_name'])
+            && !$this->config['Http']['ssl_verify_peer_name']
+        ) {
+            $adapter = $client->getAdapter();
+            if ($adapter instanceof \Laminas\Http\Client\Adapter\Socket) {
+                $context = $adapter->getStreamContext();
+                $res = stream_context_set_option(
+                    $context, 'ssl', 'verify_peer_name', false
+                );
+                if (!$res) {
+                    throw new \Exception('Unable to set sslverifypeername option');
+                }
+            } elseif ($adapter instanceof \Laminas\Http\Client\Adapter\Curl) {
+                $adapter->setCurlOption(CURLOPT_SSL_VERIFYHOST, false);
+            }
+        }
+
+        // Set timeout value
+        $timeout = isset($this->config['Catalog']['http_timeout'])
+            ? $this->config['Catalog']['http_timeout'] : 30;
+        $client->setOptions(
+            ['timeout' => $timeout, 'useragent' => 'VuFind', 'keepalive' => true]
+        );
+
+        // Set Accept header
+        $client->getRequest()->getHeaders()->addHeaderLine(
+            'Accept', 'application/json'
+        );
+
+        return $client;
+    }
+
+    /**
+     * Make Request
+     *
+     * Makes a request to the Koha REST API
+     *
+     * @param array $request Either a path as string or non-keyed array of path
+     *                       elements, or a keyed array of request parameters:
+     *
+     * path     String or array of values to embed in the URL path. String is taken
+     *          as is, array elements are url-encoded.
+     * query    URL parameters (optional)
+     * method   HTTP method (default is GET)
+     * form     Form request params (optional)
+     * json     JSON request as a PHP array (optional, only when form is not
+     *          specified)
+     * headers  Headers
+     * errors   If true, return errors instead of raising an exception
+     *
+     * @return array
+     * @throws ILSException
+     */
+    protected function makeRequest($request)
+    {
+        // Set up the request
+        $apiUrl = $this->config['Catalog']['host'] . '/';
+
+        // Handle the simple case of just a path in $request
+        if (is_string($request) || !isset($request['path'])) {
+            $request = [
+                'path' => $request
+            ];
+        }
+
+        if (is_array($request['path'])) {
+            $apiUrl .= implode('/', array_map('urlencode', $request['path']));
+        } else {
+            $apiUrl .= $request['path'];
+        }
+
+        $client = $this->createHttpClient($apiUrl);
+        $client->getRequest()->getHeaders()
+            ->addHeaderLine('Authorization', $this->getOAuth2Token());
+
+        // Add params
+        if (!empty($request['query'])) {
+            $client->setParameterGet($request['query']);
+        }
+        if (!empty($request['form'])) {
+            $client->setParameterPost($request['form']);
+        } elseif (!empty($request['json'])) {
+            $client->getRequest()->setContent(json_encode($request['json']));
+            $client->getRequest()->getHeaders()->addHeaderLine(
+                'Content-Type', 'application/json'
+            );
+        }
+
+        if (!empty($request['headers'])) {
+            $requestHeaders = $client->getRequest()->getHeaders();
+            foreach ($request['headers'] as $name => $value) {
+                $requestHeaders->addHeaderLine($name, [$value]);
+            }
+        }
+
+        // Send request and retrieve response
+        $method = $request['method'] ?? 'GET';
+        $startTime = microtime(true);
+        $client->setMethod($method);
+
+        try {
+            $response = $client->send();
+        } catch (\Exception $e) {
+            $this->logError(
+                "$method request for '$apiUrl' failed: " . $e->getMessage()
+            );
+            throw new ILSException('Problem with Koha REST API.');
+        }
+
+        // If we get a 401, we need to renew the access token and try again
+        if ($response->getStatusCode() == 401) {
+            $client->getRequest()->getHeaders()
+                ->addHeaderLine('Authorization', $this->getOAuth2Token(true));
+
+            try {
+                $response = $client->send();
+            } catch (\Exception $e) {
+                $this->logError(
+                    "$method request for '$apiUrl' failed: " . $e->getMessage()
+                );
+                throw new ILSException('Problem with Koha REST API.');
+            }
+        }
+
+        $result = $response->getBody();
+
+        $fullUrl = $apiUrl;
+        if ($method == 'GET') {
+            $fullUrl .= '?' . $client->getRequest()->getQuery()->toString();
+        }
+        $this->debug(
+            '[' . round(microtime(true) - $startTime, 4) . 's]'
+            . " $method request $fullUrl" . PHP_EOL . 'response: ' . PHP_EOL
+            . $result
+        );
+
+        // Handle errors as complete failures only if the API call didn't return
+        // valid JSON that the caller can handle
+        $decodedResult = json_decode($result, true);
+        if (empty($request['errors']) && !$response->isSuccess()
+            && (null === $decodedResult || !empty($decodedResult['error']))
+        ) {
+            $params = $method == 'GET'
+                ? $client->getRequest()->getQuery()->toString()
+                : $client->getRequest()->getPost()->toString();
+            $this->logError(
+                "$method request for '$apiUrl' with params '$params' and contents '"
+                . $client->getRequest()->getContent() . "' failed: "
+                . $response->getStatusCode() . ': ' . $response->getReasonPhrase()
+                . ', response content: ' . $response->getBody()
+            );
+            throw new ILSException('Problem with Koha REST API.');
+        }
+
+        return [
+            'data' => $decodedResult,
+            'code' => (int)$response->getStatusCode(),
+            'headers' => $response->getHeaders()->toArray(),
+        ];
+    }
+
+    /**
+     * Get a new or cached OAuth2 token (type + token)
+     *
+     * @param bool $renew Force renewal of token
+     *
+     * @return string
+     */
+    protected function getOAuth2Token($renew = false)
+    {
+        $cacheKey = 'oauth';
+
+        if (!$renew) {
+            $token = $this->getCachedData($cacheKey);
+            if ($token) {
+                return $token;
+            }
+        }
+
+        $url = $this->config['Catalog']['host'] . '/v1/oauth/token';
+        $client = $this->createHttpClient($url);
+        $client->setMethod('POST');
+        $client->getRequest()->getHeaders()->addHeaderLine(
+            'Content-Type', 'application/x-www-form-urlencoded'
+        );
+
+        $client->setParameterPost(
+            [
+                'client_id' => $this->config['Catalog']['clientId'],
+                'client_secret' => $this->config['Catalog']['clientSecret'],
+                'grant_type' => $this->config['Catalog']['grantType']
+                    ?? 'client_credentials'
+            ]
+        );
+
+        try {
+            $response = $client->send();
+        } catch (\Exception $e) {
+            $this->logError(
+                "POST request for '$url' failed: " . $e->getMessage()
+            );
+            throw new ILSException('Problem with Koha REST API.');
+        }
+
+        if ($response->getStatusCode() != 200) {
+            $errorMessage = 'Error while getting OAuth2 access token (status code '
+                . $response->getStatusCode() . '): ' . $response->getContent();
+            $this->logError($errorMessage);
+            throw new ILSException('Problem with Koha REST API.');
+        }
+        $responseData = json_decode($response->getContent(), true);
+
+        if (empty($responseData['token_type'])
+            || empty($responseData['access_token'])
+        ) {
+            $this->logError(
+                'Did not receive OAuth2 token, response: '
+                . $response->getContent()
+            );
+            throw new ILSException('Problem with Koha REST API.');
+        }
+
+        $token = $responseData['token_type'] . ' '
+            . $responseData['access_token'];
+
+        $this->putCachedData($cacheKey, $token, $responseData['expires_in'] ?? null);
+
+        return $token;
+    }
+
+    /**
+     * Get Item Statuses
+     *
+     * This is responsible for retrieving the status information of a certain
+     * record.
+     *
+     * @param string $id     The record id to retrieve the holdings for
+     * @param array  $patron Patron information, if available
+     *
+     * @return array An associative array with the following keys:
+     * id, availability (boolean), status, location, reserve, callnumber.
+     */
+    protected function getItemStatusesForBiblio($id, $patron = null)
+    {
+        $result = $this->makeRequest(
+            [
+                'path' => [
+                    'v1', 'contrib', 'kohasuomi', 'availability', 'biblios', $id,
+                    'search'
+                ],
+                'errors' => true
+            ]
+        );
+        if (404 == $result['code']) {
+            return [];
+        }
+        if (200 != $result['code']) {
+            throw new ILSException('Problem with Koha REST API.');
+        }
+
+        if (empty($result['data']['item_availabilities'])) {
+            return [];
+        }
+
+        $statuses = [];
+        foreach ($result['data']['item_availabilities'] as $i => $item) {
+            $avail = $item['availability'];
+            $available = $avail['available'];
+            $statusCodes = $this->getItemStatusCodes($item);
+            $status = $this->pickStatus($statusCodes);
+            if (isset($avail['unavailabilities']['Item::CheckedOut']['due_date'])) {
+                $duedate = $this->convertDate(
+                    $avail['unavailabilities']['Item::CheckedOut']['due_date'],
+                    true
+                );
+            } else {
+                $duedate = null;
+            }
+
+            $entry = [
+                'id' => $id,
+                'item_id' => $item['item_id'],
+                'location' => $this->getItemLocationName($item),
+                'availability' => $available,
+                'status' => $status,
+                'status_array' => $statusCodes,
+                'reserve' => 'N',
+                'callnumber' => $this->getItemCallNumber($item),
+                'duedate' => $duedate,
+                'number' => $item['serial_issue_number'],
+                'barcode' => $item['external_id'],
+                'sort' => $i,
+                'requests_placed' => max(
+                    [$item['hold_queue_length'],
+                    $result['data']['hold_queue_length']]
+                )
+            ];
+            if (!empty($item['itemnotes'])) {
+                $entry['item_notes'] = [$item['itemnotes']];
+            }
+
+            if ($patron && $this->itemHoldAllowed($item)) {
+                $entry['is_holdable'] = true;
+                $entry['level'] = 'copy';
+                $entry['addLink'] = 'check';
+            } else {
+                $entry['is_holdable'] = false;
+            }
+
+            if ($patron && $this->itemArticleRequestAllowed($item)) {
+                $entry['storageRetrievalRequest'] = 'auto';
+                $entry['addStorageRetrievalRequestLink'] = 'check';
+            }
+
+            $statuses[] = $entry;
+        }
+
+        usort($statuses, [$this, 'statusSortFunction']);
+        return $statuses;
+    }
+
+    /**
+     * Get statuses for an item
+     *
+     * @param array $item Item from Koha
+     *
+     * @return array Status array and possible due date
+     */
+    protected function getItemStatusCodes($item)
+    {
+        $statuses = [];
+        if ($item['availability']['available']) {
+            $statuses[] = 'On Shelf';
+        } elseif (isset($item['availability']['unavailabilities'])) {
+            foreach ($item['availability']['unavailabilities'] as $key => $reason) {
+                if (isset($this->config['ItemStatusMappings'][$key])) {
+                    $statuses[] = $this->config['ItemStatusMappings'][$key];
+                } elseif (strncmp($key, 'Item::', 6) == 0) {
+                    $status = substr($key, 6);
+                    switch ($status) {
+                    case 'CheckedOut':
+                        $overdue = false;
+                        if (!empty($reason['due_date'])) {
+                            $duedate = $this->dateConverter->convert(
+                                'Y-m-d',
+                                'U',
+                                $reason['due_date']
+                            );
+                            $overdue = $duedate < time();
+                        }
+                        $statuses[] = $overdue ? 'Overdue' : 'Charged';
+                        break;
+                    case 'Lost':
+                        $statuses[] = 'Lost--Library Applied';
+                        break;
+                    case 'NotForLoan':
+                    case 'NotForLoanForcing':
+                        if (isset($reason['code'])) {
+                            switch ($reason['code']) {
+                            case 'Not For Loan':
+                                $statuses[] = 'On Reference Desk';
+                                break;
+                            default:
+                                $statuses[] = $reason['code'];
+                                break;
+                            }
+                        } else {
+                            $statuses[] = 'On Reference Desk';
+                        }
+                        break;
+                    case 'Transfer':
+                        $onHold = false;
+                        if (!empty($item['availability']['notes'])) {
+                            foreach ($item['availability']['notes'] as $noteKey
+                                => $note
+                            ) {
+                                if ('Item::Held' === $noteKey) {
+                                    $onHold = true;
+                                    break;
+                                }
+                            }
+                        }
+                        $statuses[] = $onHold ? 'In Transit On Hold' : 'In Transit';
+                        break;
+                    case 'Held':
+                        $statuses[] = 'On Hold';
+                        break;
+                    case 'Waiting':
+                        $statuses[] = 'On Holdshelf';
+                        break;
+                    default:
+                        $statuses[] = !empty($reason['code'])
+                            ? $reason['code'] : $status;
+                    }
+                } elseif (strncmp($key, 'ItemType::', 10) == 0) {
+                    $status = substr($key, 10);
+                    switch ($status) {
+                    case 'NotForLoan':
+                        $statuses[] = 'On Reference Desk';
+                        break;
+                    }
+                }
+            }
+            if (empty($statuses)) {
+                $statuses[] = 'Not Available';
+            }
+        } else {
+            $this->error(
+                "Unable to determine status for item: " . print_r($item, true)
+            );
+        }
+
+        if (empty($statuses)) {
+            $statuses[] = 'No information available';
+        }
+        return array_unique($statuses);
+    }
+
+    /**
+     * Status item sort function
+     *
+     * @param array $a First status record to compare
+     * @param array $b Second status record to compare
+     *
+     * @return int
+     */
+    protected function statusSortFunction($a, $b)
+    {
+        $result = strcmp($a['location'], $b['location']);
+
+        if (0 === $result && $this->sortItemsBySerialIssue) {
+            $result = strnatcmp($a['number'], $b['number']);
+        }
+
+        if (0 === $result) {
+            $result = $a['sort'] - $b['sort'];
+        }
+        return $result;
+    }
+
+    /**
+     * Check if an item is holdable
+     *
+     * @param array $item Item from Koha
+     *
+     * @return bool
+     */
+    protected function itemHoldAllowed($item)
+    {
+        $unavail = $item['availability']['unavailabilities'] ?? [];
+        if (!isset($unavail['Hold::NotHoldable'])) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Check if an article request can be placed on the item
+     *
+     * @param array $item Item from Koha
+     *
+     * @return bool
+     */
+    protected function itemArticleRequestAllowed($item)
+    {
+        $unavail = $item['availability']['unavailabilities'] ?? [];
+        if (isset($unavail['ArticleRequest::NotAllowed'])) {
+            return false;
+        }
+        if (empty($this->config['StorageRetrievalRequests']['allow_checked_out'])
+            && isset($unavail['Item::CheckedOut'])
+        ) {
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Protected support method to pick which status message to display when multiple
+     * options are present.
+     *
+     * @param array $statusArray Array of status messages to choose from.
+     *
+     * @throws ILSException
+     * @return string            The best status message to display.
+     */
+    protected function pickStatus($statusArray)
+    {
+        // Pick the first entry by default, then see if we can find a better match:
+        $status = $statusArray[0];
+        $rank = $this->getStatusRanking($status);
+        for ($x = 1; $x < count($statusArray); $x++) {
+            if ($this->getStatusRanking($statusArray[$x]) < $rank) {
+                $status = $statusArray[$x];
+            }
+        }
+
+        return $status;
+    }
+
+    /**
+     * Support method for pickStatus() -- get the ranking value of the specified
+     * status message.
+     *
+     * @param string $status Status message to look up
+     *
+     * @return int
+     */
+    protected function getStatusRanking($status)
+    {
+        return isset($this->statusRankings[$status])
+            ? $this->statusRankings[$status] : 32000;
+    }
+
+    /**
+     * Get libraries from cache or from the API
+     *
+     * @return array
+     */
+    protected function getLibraries()
+    {
+        $cacheKey = 'libraries';
+        $libraries = $this->getCachedData($cacheKey);
+        if (null === $libraries) {
+            $result = $this->makeRequest('v1/libraries');
+            $libraries = [];
+            foreach ($result['data'] as $library) {
+                $libraries[$library['library_id']] = $library;
+            }
+            $this->putCachedData($cacheKey, $libraries, 3600);
+        }
+        return $libraries;
+    }
+
+    /**
+     * Get library name
+     *
+     * @param string $library Library ID
+     *
+     * @return string
+     */
+    protected function getLibraryName($library)
+    {
+        $libraries = $this->getLibraries();
+        return $libraries[$library]['name'] ?? '';
+    }
+
+    /**
+     * Get patron's blocks, if any
+     *
+     * @param array $patron Patron
+     *
+     * @return mixed        A boolean false if no blocks are in place and an array
+     * of block reasons if blocks are in place
+     */
+    protected function getPatronBlocks($patron)
+    {
+        $patronId = $patron['id'];
+        $cacheId = "blocks|$patronId";
+        $blockReason = $this->getCachedData($cacheId);
+        if (null === $blockReason) {
+            $result = $this->makeRequest(
+                [
+                    'path' => [
+                        'v1', 'contrib', 'kohasuomi', 'patrons', $patron['id']
+                    ],
+                    'query' => ['query_blocks' => 1]
+                ]
+            );
+            $blockReason = [];
+            if (!empty($result['data']['blocks'])) {
+                $nonHoldBlock = false;
+                foreach ($result['data']['blocks'] as $reason => $details) {
+                    $params = [];
+                    if ($reason === 'Hold::MaximumHoldsReached') {
+                        $params = [
+                            '%%blockCount%%' => $details['current_hold_count'],
+                            '%%blockLimit%%' => $details['max_holds_allowed']
+                        ];
+                    } else {
+                        $nonHoldBlock = true;
+                    }
+                    if (($reason == 'Patron::Debt'
+                        || $reason == 'Patron::DebtGuarantees')
+                        && !empty($details['current_outstanding'])
+                        && !empty($details['max_outstanding'])
+                    ) {
+                        $params = [
+                            '%%blockCount%%' => $details['current_outstanding'],
+                            '%%blockLimit%%' => $details['max_outstanding']
+                        ];
+                    }
+                    if (isset($this->patronStatusMappings[$reason])) {
+                        $blockReason[] = $this->translate(
+                            $this->patronStatusMappings[$reason], $params
+                        );
+                    }
+                }
+                // Add the generic block message to the beginning if we have blocks
+                // other than hold block
+                if ($nonHoldBlock) {
+                    array_unshift(
+                        $blockReason, $this->translate('patron_status_card_blocked')
+                    );
+                }
+            }
+            $this->putCachedData($cacheId, $blockReason);
+        }
+        return empty($blockReason) ? false : $blockReason;
+    }
+
+    /**
+     * Fetch an item record from Koha
+     *
+     * @param int $id Item id
+     *
+     * @return array|null
+     */
+    protected function getItem($id)
+    {
+        static $cachedRecords = [];
+        if (!isset($cachedRecords[$id])) {
+            $result = $this->makeRequest(['v1', 'items', $id]);
+            $cachedRecords[$id] = $result['data'] ?? false;
+        }
+        return $cachedRecords[$id];
+    }
+
+    /**
+     * Fetch a biblio record from Koha
+     *
+     * @param int $id Bib record id
+     *
+     * @return array|null
+     */
+    protected function getBiblio($id)
+    {
+        static $cachedRecords = [];
+        if (!isset($cachedRecords[$id])) {
+            $result = $this->makeRequest(['v1', 'biblios', $id]);
+            $cachedRecords[$id] = $result['data'] ?? false;
+        }
+        return $cachedRecords[$id];
+    }
+
+    /**
+     * Is the selected pickup location valid for the hold?
+     *
+     * @param string $pickUpLocation Selected pickup location
+     * @param array  $patron         Patron information returned by the patronLogin
+     * method.
+     * @param array  $holdDetails    Details of hold being placed
+     *
+     * @return bool
+     */
+    protected function pickUpLocationIsValid($pickUpLocation, $patron, $holdDetails)
+    {
+        $pickUpLibs = $this->getPickUpLocations($patron, $holdDetails);
+        foreach ($pickUpLibs as $location) {
+            if ($location['locationID'] == $pickUpLocation) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Return a hold error message
+     *
+     * @param string $error Error message
+     *
+     * @return array
+     */
+    protected function holdError($error)
+    {
+        switch ($error) {
+        case 'Hold cannot be placed. Reason: tooManyReserves':
+        case 'Hold cannot be placed. Reason: tooManyHoldsForThisRecord':
+            $error = 'hold_error_too_many_holds';
+            break;
+        case 'Hold cannot be placed. Reason: ageRestricted':
+            $error = 'hold_error_age_restricted';
+            break;
+        }
+        return [
+            'success' => false,
+            'sysMessage' => $error
+        ];
+    }
+
+    /**
+     * Map a Koha renewal block reason code to a VuFind translation string
+     *
+     * @param string $reason Koha block code
+     *
+     * @return string
+     */
+    protected function mapRenewalBlockReason($reason)
+    {
+        return isset($this->renewalBlockMappings[$reason])
+            ? $this->renewalBlockMappings[$reason] : 'renew_item_no';
+    }
+
+    /**
+     * Return a location for a Koha item
+     *
+     * @param array $item Item
+     *
+     * @return string
+     */
+    protected function getItemLocationName($item)
+    {
+        $libraryId = (!$this->useHomeLibrary && null !== $item['holding_library_id'])
+            ? $item['holding_library_id'] : $item['home_library_id'];
+        $name = $this->translateLocation($libraryId);
+        if ($name === $libraryId) {
+            $libraries = $this->getLibraries();
+            $name = isset($libraries[$libraryId])
+                ? $libraries[$libraryId]['name'] : $libraryId;
+        }
+        return $name;
+    }
+
+    /**
+     * Translate location name
+     *
+     * @param string $location Location code
+     * @param string $default  Default value if translation is not available
+     *
+     * @return string
+     */
+    protected function translateLocation($location, $default = null)
+    {
+        if (empty($location)) {
+            return null !== $default ? $default : '';
+        }
+        $prefix = 'location_';
+        return $this->translate(
+            "$prefix$location",
+            null,
+            null !== $default ? $default : $location
+        );
+    }
+
+    /**
+     * Return a call number for a Koha item
+     *
+     * @param array $item Item
+     *
+     * @return string
+     */
+    protected function getItemCallNumber($item)
+    {
+        return $item['callnumber'];
+    }
+
+    /**
+     * Get a reason for why a hold cannot be placed
+     *
+     * @param array $result Hold check result
+     *
+     * @return string
+     */
+    protected function getHoldBlockReason($result)
+    {
+        if (!empty($result['availability']['unavailabilities'])) {
+            foreach ($result['availability']['unavailabilities']
+                as $key => $reason
+            ) {
+                switch ($key) {
+                case 'Biblio::NoAvailableItems':
+                    return 'hold_error_not_holdable';
+                case 'Item::NotForLoan':
+                case 'Hold::NotAllowedInOPAC':
+                case 'Hold::ZeroHoldsAllowed':
+                case 'Hold::NotAllowedByLibrary':
+                case 'Hold::NotAllowedFromOtherLibraries':
+                case 'Item::Restricted':
+                case 'Hold::ItemLevelHoldNotAllowed':
+                    return 'hold_error_item_not_holdable';
+                case 'Hold::MaximumHoldsForRecordReached':
+                case 'Hold::MaximumHoldsReached':
+                    return 'hold_error_too_many_holds';
+                case 'Item::AlreadyHeldForThisPatron':
+                    return 'hold_error_already_held';
+                case 'Hold::OnShelfNotAllowed':
+                    return 'hold_error_on_shelf_blocked';
+                }
+            }
+        }
+        return 'hold_error_blocked';
+    }
+
+    /**
+     * Converts given key to corresponding parameter
+     *
+     * @param string $key     to convert
+     * @param string $default value to return
+     *
+     * @return string
+     */
+    protected function getSortParamValue($key, $default = '')
+    {
+        $params = [
+            'checkout' => 'issuedate',
+            'return' => 'returndate',
+            'lastrenewed' => 'lastreneweddate',
+            'title' => 'title'
+        ];
+
+        return $params[$key] ?? $default;
+    }
+
+    /**
+     * Get a complete title from all the title-related fields
+     *
+     * @param array $biblio Biblio record (or something with the correct fields)
+     *
+     * @return string
+     */
+    protected function getBiblioTitle($biblio)
+    {
+        $title = [];
+        foreach (['title', 'subtitle', 'part_number', 'part_name'] as $field) {
+            $content = $biblio[$field] ?? '';
+            if ($content) {
+                $title[] = $content;
+            }
+        }
+        return implode(' ', $title);
+    }
+
+    /**
+     * Convert a date to display format
+     *
+     * @param string $date     Date
+     * @param bool   $withTime Whether the date includes time
+     *
+     * @return string
+     */
+    protected function convertDate($date, $withTime = false)
+    {
+        if (!$date) {
+            return '';
+        }
+        $createFormat = $withTime ? 'Y-m-d\TH:i:sP' : 'Y-m-d';
+        return $this->dateConverter->convertToDisplayDate($createFormat, $date);
+    }
+
+    /**
+     * Get Patron Transactions
+     *
+     * This is responsible for retrieving all transactions (i.e. checked-out items
+     * or checked-in items) by a specific patron.
+     *
+     * @param array $patron    The patron array from patronLogin
+     * @param array $params    Parameters
+     * @param bool  $checkedIn Whether to list checked-in items
+     *
+     * @throws DateException
+     * @throws ILSException
+     * @return array        Array of the patron's transactions on success.
+     */
+    protected function getTransactions($patron, $params, $checkedIn)
+    {
+        $pageSize = $params['limit'] ?? 50;
+        $sort = $params['sort'] ?? '+due_date';
+        if ('+title' === $sort) {
+            $sort = '+title|+subtitle';
+        } elseif ('-title' === $sort) {
+            $sort = '-title|-subtitle';
+        }
+        $queryParams = [
+            '_order_by' => $sort,
+            '_page' => $params['page'] ?? 1,
+            '_per_page' => $pageSize
+        ];
+        if ($checkedIn) {
+            $queryParams['checked_in'] = '1';
+            $arrayKey = 'transactions';
+        } else {
+            $arrayKey = 'records';
+        }
+        $result = $this->makeRequest(
+            [
+                'path' => [
+                    'v1', 'contrib', 'kohasuomi', 'patrons', $patron['id'],
+                    'checkouts'
+                ],
+                'query' => $queryParams
+            ]
+        );
+
+        if (200 !== $result['code']) {
+            throw new ILSException('Problem with Koha REST API.');
+        }
+
+        if (empty($result['data'])) {
+            return [
+                'count' => 0,
+                $arrayKey => []
+            ];
+        }
+        $transactions = [];
+        foreach ($result['data'] as $entry) {
+            $dueStatus = false;
+            $now = time();
+            $dueTimeStamp = strtotime($entry['due_date']);
+            if (is_numeric($dueTimeStamp)) {
+                if ($now > $dueTimeStamp) {
+                    $dueStatus = 'overdue';
+                } elseif ($now > $dueTimeStamp - (1 * 24 * 60 * 60)) {
+                    $dueStatus = 'due';
+                }
+            }
+
+            $renewable = $entry['renewable'];
+            $renewals = $entry['renewals'];
+            $renewLimit = $entry['max_renewals'];
+            $message = '';
+            if (!$renewable && !$checkedIn) {
+                $message = $this->mapRenewalBlockReason(
+                    $entry['renewability_blocks']
+                );
+                $permanent = in_array(
+                    $entry['renewability_blocks'], $this->permanentRenewalBlocks
+                );
+                if ($permanent) {
+                    $renewals = null;
+                    $renewLimit = null;
+                }
+            }
+
+            $transaction = [
+                'id' => $entry['biblio_id'],
+                'checkout_id' => $entry['checkout_id'],
+                'item_id' => $entry['item_id'],
+                'barcode' => $entry['external_id'] ?? null,
+                'title' => $this->getBiblioTitle($entry),
+                'volume' => $entry['serial_issue_number'] ?? '',
+                'publication_year' => $entry['copyright_date']
+                    ?? $entry['publication_year'] ?? '',
+                'borrowingLocation' => $this->getLibraryName($entry['library_id']),
+                'checkoutDate' => $this->convertDate($entry['checkout_date']),
+                'duedate' => $this->convertDate($entry['due_date'], true),
+                'returnDate' => $this->convertDate($entry['checkin_date']),
+                'dueStatus' => $dueStatus,
+                'renew' => $renewals,
+                'renewLimit' => $renewLimit,
+                'renewable' => $renewable,
+                'message' => $message
+            ];
+
+            $transactions[] = $transaction;
+        }
+
+        return [
+            'count' => $result['headers']['X-Total-Count'] ?? count($transactions),
+            $arrayKey => $transactions
+        ];
+    }
+}
diff --git a/module/VuFind/src/VuFind/ILS/Driver/KohaRestFactory.php b/module/VuFind/src/VuFind/ILS/Driver/KohaRestFactory.php
new file mode 100644
index 00000000000..40204ba1526
--- /dev/null
+++ b/module/VuFind/src/VuFind/ILS/Driver/KohaRestFactory.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * Factory for KohaRest ILS driver.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  ILS_Drivers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\ILS\Driver;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for KohaRest ILS driver.
+ *
+ * @category VuFind
+ * @package  ILS_Drivers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class KohaRestFactory extends \VuFind\ILS\Driver\DriverWithDateConverterFactory
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        $sessionFactory = function ($namespace) use ($container) {
+            $manager = $container->get(\Laminas\Session\SessionManager::class);
+            return new \Laminas\Session\Container("KohaRest_$namespace", $manager);
+        };
+        return parent::__invoke($container, $requestedName, [$sessionFactory]);
+    }
+}
diff --git a/module/VuFind/src/VuFind/ILS/Driver/PluginManager.php b/module/VuFind/src/VuFind/ILS/Driver/PluginManager.php
index 6118243fa51..2a79aa5cbad 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/PluginManager.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/PluginManager.php
@@ -58,6 +58,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
         'innovative' => Innovative::class,
         'koha' => Koha::class,
         'kohailsdi' => KohaILSDI::class,
+        'koharest' => KohaRest::class,
         'lbs4' => LBS4::class,
         'multibackend' => MultiBackend::class,
         'newgenlib' => NewGenLib::class,
@@ -93,6 +94,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
         Innovative::class => InvokableFactory::class,
         Koha::class => DriverWithDateConverterFactory::class,
         KohaILSDI::class => DriverWithDateConverterFactory::class,
+        KohaRest::class => KohaRestFactory::class,
         LBS4::class => DriverWithDateConverterFactory::class,
         MultiBackend::class => MultiBackendFactory::class,
         NewGenLib::class => InvokableFactory::class,
-- 
GitLab