From d04e26f376eab39d6b40c3c70554f7c77b473c86 Mon Sep 17 00:00:00 2001
From: Michael Birkner <michael.birkner@akwien.at>
Date: Fri, 13 Jul 2018 07:47:15 -0400
Subject: [PATCH] Completed work on Alma driver. (#955) - Includes
 Alma-specific authentication module and controller for handling webhooks. -
 Resolves VUFIND-1212.

---
 config/vufind/Alma.ini                        |   86 ++
 config/vufind/config.ini                      |   15 +-
 config/vufind/permissions.ini                 |    7 +
 languages/de.ini                              |    1 +
 languages/en.ini                              |    1 +
 module/VuFind/config/module.config.php        |   17 +
 .../VuFind/src/VuFind/Auth/AlmaDatabase.php   |  142 +++
 module/VuFind/src/VuFind/Auth/Factory.php     |   15 +
 .../VuFind/src/VuFind/Auth/PluginManager.php  |    2 +
 .../src/VuFind/Controller/AlmaController.php  |  500 ++++++++
 module/VuFind/src/VuFind/ILS/Driver/Alma.php  | 1059 +++++++++++++++--
 .../src/VuFind/ILS/Driver/AlmaFactory.php     |   83 ++
 .../src/VuFind/ILS/Driver/PluginManager.php   |    2 +
 .../templates/Email/new-user-welcome.phtml    |   10 +
 14 files changed, 1802 insertions(+), 138 deletions(-)
 create mode 100644 module/VuFind/src/VuFind/Auth/AlmaDatabase.php
 create mode 100644 module/VuFind/src/VuFind/Controller/AlmaController.php
 create mode 100644 module/VuFind/src/VuFind/ILS/Driver/AlmaFactory.php
 create mode 100644 themes/root/templates/Email/new-user-welcome.phtml

diff --git a/config/vufind/Alma.ini b/config/vufind/Alma.ini
index 4e09d407615..e6dcfde3a26 100644
--- a/config/vufind/Alma.ini
+++ b/config/vufind/Alma.ini
@@ -4,9 +4,11 @@ apiBaseUrl = "https://api-eu.hosted.exlibrisgroup.com/almaws/v1"
 ; An API key configured to allow access to Alma:
 apiKey = "your-key-here"
 
+
 [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.
+; For activating title level hold request, add "description" and "level".
 HMACKeys = id:item_id:holding_id
 
 ; defaultRequiredDate - A colon-separated list used to set the default "not required
@@ -25,3 +27,87 @@ extraHoldFields = comments:requiredByDate:pickUpLocation
 ; 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 = ""
+
+
+; The "NewUser" section defines some default values that are used when creating an account
+; in Alma via its API. This is only relevant if you use the authentication method "AlmaDatabase"
+; in the "Authentication" section of the "config.ini" file.
+[NewUser]
+; Mandatory. The Alma user record type. Usually "PUBLIC".
+recordType = PUBLIC
+
+; Mandatory. The Alma user account type. Usually this is "INTERNAL" if you use the AlmaDatabase 
+; authentication method.
+accountType = INTERNAL
+
+; Mandatory. The status of the Alma user account. Usually "ACTIVE".
+status = ACTIVE
+
+; Mandatory. The user group to which the new Alma account should belong. Use the code of one of
+; the user groups that are defined in Alma (see "Alma Configuration -> User Management -> User Groups").
+userGroup = 
+
+; Mandatory. The type of ID under which the username should be saved to Alma. Log in to the ExLibris developer
+; network and check the Alma API documentation for possible values on this site:
+; https://developers.exlibrisgroup.com/alma/apis/xsd/rest_user.xsd?tags=POST#user_identifier
+idType = 
+
+; Mandatory. The preferred language of the new Alma account. This should normally be the Alma language
+; code of your local language (see "Alma Configuration -> General -> Institution Languages").
+preferredLanguage = 
+
+; Mandatory. The type of eMail of the users eMail address. Log in to the ExLibris developer network and
+; check the Alma API documentation for possible values on this site:
+; https://developers.exlibrisgroup.com/alma/apis/xsd/rest_user.xsd?tags=POST#email_types
+emailType = 
+
+; Optional. Set the time period when the Alma account should expire. The given period will be added to the
+; point in time of the Alma account creation. Use the DateInterval notation of PHP to express the period. See:
+; https://secure.php.net/manual/en/dateinterval.construct.php#refsect1-dateinterval.construct-parameters
+; If not set, 1 year (P1Y) will be used as default value.
+expiryDate = 
+
+; Optional. Set the time period that should be used for the Alma user account purge date. The given period
+; will be added to the point in time of the Alma account creation. Use the DateInterval notation of PHP to
+; express the period. See:
+; https://secure.php.net/manual/en/dateinterval.construct.php#refsect1-dateinterval.construct-parameters
+; If not set, the purge date of the Alma user account will be empty.
+purgeDate =
+
+
+[FulfillmentUnits]
+; Specify the association of fulfillment units and its locations. Take the codes from:
+; Alma Configuration -> Fulfillment -> Fulfillment Units -> [Choose Fulfillment Unit] -> Fulfillment Unit Locations.
+; Tip: Export the list from Alma as Excel and use the CONCATENATE() formula to generate this list.
+; Format: FULFILLMENT_UNIT_CODE[] = LOCATION_CODE
+; Example:
+;	STACKS[] = stack1
+;	STACKS[] = stack2
+;	STACKS[] = stack3
+;	LIMITED[] = periodicalroom
+;	LIMITED[] = musicrefs
+;	SHORTLOAN[] = office1
+;	SHORTLOAN[] = office2
+
+[Requestable]
+; Specify for which combination of user group and fulfillment unit (see above) the request link
+; should be displayed (who is allowed to request what). Define every combination of fulfillment unit
+; and user group and assign "N" for "No, not requestable for this user group" or "Y" for "Yes, is
+; requestable for this user group". You will find the user group codes here:
+; Alma Configuration -> User Management -> User Groups
+; Format: FULFILLMENT_UNIT_CODE[USER_GROUP_CODE] = N
+; Example:
+;	STACKS[STAFF] = Y
+;	STACKS[STUDENT] = Y
+;	STACKS[GUEST] = Y
+;	LIMITED[STAFF] = Y
+;	LIMITED[STUDENT] = N
+;	LIMITED[GUEST] = N
+;	SHORTLOAN[STAFF] = Y
+;	SHORTLOAN[STUDENT] = Y
+;	SHORTLOAN[GUEST] = N
+
+[Webhook]
+; The webhook secret. This must be the same value that was added to the Alma webhook configuration as a secret.
+secret = YOUR_WEBHOOK_SECRET_FROM_ALMA
+
diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index c5f897538d5..05a335aa1a2 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -328,13 +328,14 @@ title_level_holds_mode = "disabled"
 
 ; This section allows you to determine how the users will authenticate.
 ; You can use an LDAP directory, the local ILS (or multiple ILSes through
-; the MultiILS option), the VuFind database (Database), Shibboleth, SIP2,
-; CAS, Facebook or some combination of these (via the MultiAuth or ChoiceAuth
-; options).
+; the MultiILS option), the VuFind database (Database), AlmaDatabase (combination
+; of VuFind database and Alma account), Shibboleth, SIP2, CAS, Facebook or some
+; combination of these (via the MultiAuth or ChoiceAuth options).
 [Authentication]
 ;method          = LDAP
 ;method         = ILS
 method         = Database
+;method			= AlmaDatabase
 ;method         = Shibboleth
 ;method         = SIP2
 ;method         = CAS
@@ -355,7 +356,7 @@ method         = Database
 hideLogin = false
 
 ; Set this to false if you would like to store local passwords in plain text
-; (only applies when method = Database above).
+; (only applies when method = Database or AlmaDatabase above).
 hash_passwords = false
 
 ; Allow users to recover passwords via email (if supported by Auth method)
@@ -387,7 +388,7 @@ ils_encryption_key = false
 ;ils_encryption_algo = "blowfish"
 
 ; This setting may optionally be uncommented to restrict the email domain(s) from
-; which users are allowed to register when using the Database method.
+; which users are allowed to register when using the Database or AlmaDatabase method.
 ;domain_whitelist[] = "myuniversity.edu"
 ;domain_whitelist[] = "mail.myuniversity.edu"
 
@@ -406,8 +407,8 @@ ils_encryption_key = false
 ; Uncomment this line to switch on "privacy mode" in which no user information
 ; will be stored in the database. Note that this is incompatible with social
 ; features, password resets, and many other features. It is not recommended for
-; use with "Database" authentication, since the user will be forced to create a
-; new account upon every login.
+; use with "Database" or "AlmaDatabase" authentication, since the user will be 
+; forced to create a new account upon every login.
 ;privacy = true
 
 ; Allow a user to delete their account. Default is false.
diff --git a/config/vufind/permissions.ini b/config/vufind/permissions.ini
index e36cf4e944f..e85dab8330f 100644
--- a/config/vufind/permissions.ini
+++ b/config/vufind/permissions.ini
@@ -166,3 +166,10 @@ role = loggedin
 ;require = ANY
 ;ipRange[] = '127.0.0.1'
 ;ipRange[] = '::1'
+
+; Example permission for Alma webbooks
+;[alma.Webhooks]
+;permission[] = "access.alma.webhook.user"
+;permission[] = "access.alma.webhook.challenge"
+;require = ALL
+;ipRange[] = "127.0.0.1"
diff --git a/languages/de.ini b/languages/de.ini
index e93371e08aa..c397cf2320e 100644
--- a/languages/de.ini
+++ b/languages/de.ini
@@ -509,6 +509,7 @@ ill_request_processed = "Bearbeitet"
 ill_request_profile_html = "Um die Fernleihe zu nutzen, richten Sie bitte ihr <a href="%%url%%">Bibliothekskatalog-Profil</a> ein."
 ill_request_submit_text = "Anfrage abschicken"
 Illustrated = "Abbildungen"
+ils_account_create_error = "Es konnte kein Konto in unserem Bibliotheksverwaltungssystem für Sie erstellt werden. Wir entschuldigen uns für die Umstände und stehen für weitere Fragen gerne zur Verfügung."
 ils_action_unavailable = "Diese Funktion ist für das aktuelle Bibliothekskonto nicht verfügbar."
 ils_connection_failed = "Unser Bibliotheksverwaltungssystem ist momentan wegen Wartungsarbeiten nicht verfügbar."
 ils_offline_holdings_message = "Bestandes- und Verfügbarkeitsinformationen können momentan leider nicht angezeigt werden. Wir entschuldigen uns für die Umstände und stehen für weitere Fragen gerne zur Verfügung:"
diff --git a/languages/en.ini b/languages/en.ini
index 3ec6681eb8b..369d4810717 100644
--- a/languages/en.ini
+++ b/languages/en.ini
@@ -510,6 +510,7 @@ ill_request_processed = "Processed"
 ill_request_profile_html = "For interlibrary loan request information, please establish your <a href="%%url%%">Library Catalog Profile</a>."
 ill_request_submit_text = "Place Request"
 Illustrated = "Illustrated"
+ils_account_create_error = "Your account could not be created in our library management system. If the problem persists, please contact your library."
 ils_action_unavailable = "The requested function is not available with the active library card."
 ils_connection_failed = "Connection to the library management system failed. Information related to your library account cannot be displayed. If the problem persists, please contact your library."
 ils_offline_holdings_message = "Holdings and item availability information is currently unavailable. Please accept our apologies for any inconvenience this may cause and contact us for further assistance:"
diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php
index 0bc27f60044..40ea0d9483f 100644
--- a/module/VuFind/config/module.config.php
+++ b/module/VuFind/config/module.config.php
@@ -18,6 +18,20 @@ $config = [
                     ],
                 ],
             ],
+            'alma-webhook' => [
+                'type'    => 'Zend\Router\Http\Segment',
+                'options' => [
+                    'route'    => '/Alma/Webhook/[:almaWebhookAction]',
+                    'constraints' => [
+                        'controller' => '[a-zA-Z][a-zA-Z0-9_-]*',
+                        'action'     => '[a-zA-Z][a-zA-Z0-9_-]*',
+                    ],
+                    'defaults' => [
+                        'controller' => 'Alma',
+                        'action' => 'Webhook',
+                    ],
+                ],
+            ],
             'content-page' => [
                 'type'    => 'Zend\Router\Http\Segment',
                 'options' => [
@@ -106,6 +120,7 @@ $config = [
     'controllers' => [
         'factories' => [
             'VuFind\Controller\AjaxController' => 'VuFind\Controller\AjaxControllerFactory',
+            'VuFind\Controller\AlmaController' => 'VuFind\Controller\AbstractBaseFactory',
             'VuFind\Controller\AlphabrowseController' => 'VuFind\Controller\AbstractBaseFactory',
             'VuFind\Controller\AuthorController' => 'VuFind\Controller\AbstractBaseFactory',
             'VuFind\Controller\AuthorityController' => 'VuFind\Controller\AbstractBaseFactory',
@@ -157,6 +172,8 @@ $config = [
         'aliases' => [
             'AJAX' => 'VuFind\Controller\AjaxController',
             'ajax' => 'VuFind\Controller\AjaxController',
+            'Alma' => 'VuFind\Controller\AlmaController',
+            'alma' => 'VuFind\Controller\AlmaController',
             'Alphabrowse' => 'VuFind\Controller\AlphabrowseController',
             'alphabrowse' => 'VuFind\Controller\AlphabrowseController',
             'Author' => 'VuFind\Controller\AuthorController',
diff --git a/module/VuFind/src/VuFind/Auth/AlmaDatabase.php b/module/VuFind/src/VuFind/Auth/AlmaDatabase.php
new file mode 100644
index 00000000000..d4e17a253b0
--- /dev/null
+++ b/module/VuFind/src/VuFind/Auth/AlmaDatabase.php
@@ -0,0 +1,142 @@
+<?php
+/**
+ * Alma Database authentication class
+ *
+ * PHP version 5
+ *
+ * Copyright (C) AK Bibliothek Wien für Sozialwissenschaften 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  Authentication
+ * @author   Michael Birkner <michael.birkner@akwien.at>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:authentication_handlers Wiki
+ */
+namespace VuFind\Auth;
+
+use VuFind\Exception\Auth as AuthException;
+
+/**
+ * Authentication class for Alma. The VuFind database and the Alma API are
+ * combined for authentication by this classe.
+ *
+ * @category VuFind
+ * @package  Authentication
+ * @author   Michael Birkner <michael.birkner@akwien.at>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:authentication_handlers Wiki
+ */
+class AlmaDatabase extends Database
+{
+    /**
+     * ILS Authenticator
+     *
+     * @var \VuFind\Auth\ILSAuthenticator
+     */
+    protected $authenticator;
+
+    /**
+     * Catalog connection
+     *
+     * @var \VuFind\ILS\Connection
+     */
+    protected $catalog = null;
+
+    /**
+     * Alma driver
+     *
+     * @var \VuFind\ILS\Driver\Alma
+     */
+    protected $almaDriver = null;
+
+    /**
+     * Alma config
+     *
+     * @var array
+     */
+    protected $almaConfig = null;
+
+    /**
+     * Constructor
+     *
+     * @param \VuFind\ILS\Connection        $connection    The ILS connection
+     * @param \VuFind\Auth\ILSAuthenticator $authenticator The ILS authenticator
+     */
+    public function __construct(
+        \VuFind\ILS\Connection $connection,
+        \VuFind\Auth\ILSAuthenticator $authenticator
+    ) {
+        $this->catalog = $connection;
+        $this->authenticator = $authenticator;
+        $this->almaDriver = $connection->getDriver();
+        $this->almaConfig = $connection->getDriverConfig();
+    }
+
+    /**
+     * Create a new user account in Alma AND in the VuFind Database.
+     *
+     * @param \Zend\Http\PhpEnvironment\Request $request Request object containing
+     *                                                   new account details.
+     *
+     * @return NULL|\VuFind\Db\Row\User New user row.
+     */
+    public function create($request)
+    {
+        // When in privacy mode, don't create an Alma account and delegate
+        // further code execution to the parent.
+        if ($this->getConfig()->Authentication->privacy) {
+            return parent::create($request);
+        }
+
+        // User variable
+        $user = null;
+
+        // Collect POST parameters from request
+        $params = $this->collectParamsFromRequest($request);
+
+        // Validate username and password
+        $this->validateUsernameAndPassword($params);
+
+        // Get the user table
+        $userTable = $this->getUserTable();
+
+        // Make sure parameters are correct
+        $this->validateParams($params, $userTable);
+
+        // Create user account in Alma
+        $almaAnswer = $this->almaDriver->createAlmaUser($params);
+
+        // Create user account in VuFind user table if Alma gave us an answer
+        if ($almaAnswer !== null) {
+            // If we got this far, we're ready to create the account:
+            $user = $this->createUserFromParams($params, $userTable);
+
+            // Add the Alma primary ID as cat_id to the VuFind user table
+            $user->cat_id = $almaAnswer->primary_id ?? null;
+
+            // Save the new user to the user table
+            $user->save();
+
+            // Save the credentials to cat_username and cat_password to bypass
+            // the ILS login screen from VuFind
+            $user->saveCredentials($params['username'], $params['password']);
+        } else {
+            throw new AuthException($this->translate('ils_account_create_error'));
+        }
+
+        return $user;
+    }
+}
diff --git a/module/VuFind/src/VuFind/Auth/Factory.php b/module/VuFind/src/VuFind/Auth/Factory.php
index 8cb3f837239..e7c773b7ca7 100644
--- a/module/VuFind/src/VuFind/Auth/Factory.php
+++ b/module/VuFind/src/VuFind/Auth/Factory.php
@@ -131,4 +131,19 @@ class Factory
             $sm->get('Zend\Session\SessionManager')
         );
     }
+
+    /**
+     * Construct the AlmaDatabase plugin.
+     *
+     * @param ServiceManager $sm Service manager.
+     *
+     * @return AlmaDatabase
+     */
+    public static function getAlmaDatabase(ServiceManager $sm)
+    {
+        return new AlmaDatabase(
+            $sm->get('VuFind\ILS\Connection'),
+            $sm->get('VuFind\Auth\ILSAuthenticator')
+        );
+    }
 }
diff --git a/module/VuFind/src/VuFind/Auth/PluginManager.php b/module/VuFind/src/VuFind/Auth/PluginManager.php
index 878e214c8f9..f2ba6ecc9eb 100644
--- a/module/VuFind/src/VuFind/Auth/PluginManager.php
+++ b/module/VuFind/src/VuFind/Auth/PluginManager.php
@@ -44,6 +44,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
      * @var array
      */
     protected $aliases = [
+        'almadatabase' => 'VuFind\Auth\AlmaDatabase',
         'cas' => 'VuFind\Auth\CAS',
         'choiceauth' => 'VuFind\Auth\ChoiceAuth',
         'database' => 'VuFind\Auth\Database',
@@ -65,6 +66,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
      * @var array
      */
     protected $factories = [
+        'VuFind\Auth\AlmaDatabase' => 'VuFind\Auth\Factory::getAlmaDatabase',
         'VuFind\Auth\CAS' => 'Zend\ServiceManager\Factory\InvokableFactory',
         'VuFind\Auth\ChoiceAuth' => 'VuFind\Auth\Factory::getChoiceAuth',
         'VuFind\Auth\Database' => 'Zend\ServiceManager\Factory\InvokableFactory',
diff --git a/module/VuFind/src/VuFind/Controller/AlmaController.php b/module/VuFind/src/VuFind/Controller/AlmaController.php
new file mode 100644
index 00000000000..d2ef5671e71
--- /dev/null
+++ b/module/VuFind/src/VuFind/Controller/AlmaController.php
@@ -0,0 +1,500 @@
+<?php
+/**
+ * Alma controller
+ *
+ * PHP version 5
+ *
+ * Copyright (C) AK Bibliothek Wien für Sozialwissenschaften 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  Controller
+ * @author   Michael Birkner <michael.birkner@akwien.at>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+namespace VuFind\Controller;
+
+use Zend\ServiceManager\ServiceLocatorInterface;
+
+/**
+ * Alma controller, mainly for webhooks.
+ *
+ * @category VuFind
+ * @package  Controller
+ * @author   Michael Birkner <michael.birkner@akwien.at>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+class AlmaController extends AbstractBase
+{
+    /**
+     * Http service
+     *
+     * @var \VuFindHttp\HttpService
+     */
+    protected $httpService;
+
+    /**
+     * Http response
+     *
+     * @var \Zend\Http\PhpEnvironment\Response
+     */
+    protected $httpResponse;
+
+    /**
+     * Http headers
+     *
+     * @var \Zend\Http\Headers
+     */
+    protected $httpHeaders;
+
+    /**
+     * Configuration from config.ini
+     *
+     * @var \Zend\Config\Config
+     */
+    protected $config;
+
+    /**
+     * Alma.ini config
+     *
+     * @var \Zend\Config\Config
+     */
+    protected $configAlma;
+
+    /**
+     * User table
+     *
+     * @var \VuFind\Db\Table\User
+     */
+    protected $userTable;
+
+    /**
+     * Alma Controler constructor.
+     *
+     * @param ServiceLocatorInterface $sm The ServiceLocatorInterface
+     */
+    public function __construct(ServiceLocatorInterface $sm)
+    {
+        parent::__construct($sm);
+        $this->httpResponse = $this->getResponse();
+        $this->httpHeaders = $this->httpResponse->getHeaders();
+        $this->config = $this->getConfig('config');
+        $this->configAlma = $this->getConfig('Alma');
+        $this->userTable = $this->getTable('user');
+    }
+
+    /**
+     * Action that is executed when the webhook page is called.
+     *
+     * @return \Zend\Http\Response|NULL
+     */
+    public function webhookAction()
+    {
+        // Request from external
+        $request = $this->getRequest();
+
+        // Get request method (GET, POST, ...)
+        $requestMethod = $request->getMethod();
+
+        // Get request body if method is POST and is not empty
+        $requestBodyJson = null;
+        if ($request->getContent() != null
+            && !empty($request->getContent())
+            && $requestMethod == 'POST'
+        ) {
+            try {
+                $this->checkMessageSignature($request);
+            } catch (\VuFind\Exception\Forbidden $ex) {
+                return $this->createJsonResponse(
+                    'Access to Alma Webhook is forbidden. ' .
+                    'The message signature is not correct.', 403
+                );
+            }
+            $requestBodyJson = json_decode($request->getContent());
+        }
+
+        // Get webhook action
+        $webhookAction = $requestBodyJson->action ?? null;
+
+        // Perform webhook action
+        switch ($webhookAction) {
+
+        case 'USER':
+            $accessPermission = 'access.alma.webhook.user';
+            try {
+                $this->checkPermission($accessPermission);
+            } catch (\VuFind\Exception\Forbidden $ex) {
+                return $this->createJsonResponse(
+                    'Access to Alma Webhook \'' . $webhookAction . '\' forbidden. ' .
+                    'Set permission \'' . $accessPermission .
+                    '\' in \'permissions.ini\'.', 403
+                );
+            }
+
+            return $this->webhookUser($requestBodyJson);
+                break;
+        case 'JOB_END':
+        case 'NOTIFICATION':
+        case 'LOAN':
+        case 'REQUEST':
+        case 'BIB':
+        case 'ITEM':
+            return $this->webhookNotImplemented($webhookAction);
+                break;
+        default:
+            $accessPermission = 'access.alma.webhook.challenge';
+            try {
+                $this->checkPermission($accessPermission);
+            } catch (\VuFind\Exception\Forbidden $ex) {
+                return $this->createJsonResponse(
+                    'Access to Alma Webhook challenge forbidden. Set permission \'' .
+                    $accessPermission . '\' in \'permissions.ini\'.', 403
+                );
+            }
+            return $this->webhookChallenge();
+                break;
+        }
+    }
+
+    /**
+     * Webhook actions related to a newly created, updated or deleted user in Alma.
+     *
+     * @param mixed $requestBodyJson A JSON string decode with json_decode()
+     *
+     * @return NULL|\Zend\Http\Response
+     */
+    protected function webhookUser($requestBodyJson)
+    {
+
+        // Initialize user variable that should hold the user table row
+        $user = null;
+
+        // Initialize response variable
+        $jsonResponse = null;
+
+        // Get method from webhook (e. g. "create" for "new user")
+        $method = $requestBodyJson->webhook_user->method ?? null;
+
+        // Get primary ID
+        $primaryId = $requestBodyJson->webhook_user->user->primary_id ?? null;
+
+        if ($method == 'CREATE' || $method == 'UPDATE') {
+            // Get username (could e. g. be the barcode)
+            $username = null;
+            $userIdentifiers = $requestBodyJson->webhook_user->user->user_identifier
+                               ?? null;
+            $idTypeConfig = $this->configAlma->NewUser->idType ?? null;
+            foreach ($userIdentifiers as $userIdentifier) {
+                $idTypeHook = $userIdentifier->id_type->value ?? null;
+                if ($idTypeHook != null
+                    && $idTypeHook == $idTypeConfig
+                    && $username == null
+                ) {
+                    $username = $userIdentifier->value ?? null;
+                }
+            }
+
+            // Use primary ID as username as a fallback if no other
+            // username ID is available
+            $username = ($username == null) ? $primaryId : $username;
+
+            // Get user details from Alma Webhook message
+            $firstname = $requestBodyJson->webhook_user->user->first_name ?? null;
+            $lastname = $requestBodyJson->webhook_user->user->last_name ?? null;
+
+            $allEmails = $requestBodyJson->webhook_user->user->contact_info->email
+                         ?? null;
+            $email = null;
+            foreach ($allEmails as $currentEmail) {
+                $preferred = $currentEmail->preferred ?? false;
+                if ($preferred && $email == null) {
+                    $email = $currentEmail->email_address ?? null;
+                }
+            }
+
+            if ($method == 'CREATE') {
+                $user = $this->userTable->getByUsername($username, true);
+            }
+
+            if ($method == 'UPDATE') {
+                $user = $this->userTable->getByCatalogId($primaryId);
+            }
+
+            if ($user) {
+                $user->username = $username;
+                $user->firstname = $firstname;
+                $user->lastname = $lastname;
+                $user->email = $email;
+                $user->cat_id = $primaryId;
+                $user->cat_username = $username;
+
+                try {
+                    $user->save();
+                    if ($method == 'CREATE') {
+                        $this->sendSetPasswordEmail($user, $this->config);
+                    }
+                    $jsonResponse = $this->createJsonResponse(
+                        'Successfully ' . strtolower($method) .
+                        'd user with primary ID \'' . $primaryId .
+                        '\' | username \'' . $username . '\'.', 200
+                    );
+                } catch (\Exception $ex) {
+                    $jsonResponse = $this->createJsonResponse(
+                        'Error when saving new user with primary ID \'' .
+                        $primaryId . '\' | username \'' . $username .
+                        '\' to VuFind database and sending the welcome email: ' .
+                        $ex->getMessage() . '. ',
+                        400
+                    );
+                }
+            } else {
+                $jsonResponse = $this->createJsonResponse(
+                    'User with primary ID \'' . $primaryId . '\' | username \'' .
+                    $username . '\' was not found in VuFind database and ' .
+                    'therefore could not be ' . strtolower($method) . 'd.',
+                    404
+                );
+            }
+        } elseif ($method == 'DELETE') {
+            $user = $this->userTable->getByCatalogId($primaryId);
+            if ($user) {
+                $rowsAffected = $user->delete();
+                if ($rowsAffected == 1) {
+                    $jsonResponse = $this->createJsonResponse(
+                        'Successfully deleted use with primary ID \'' . $primaryId .
+                        '\' in VuFind.', 200
+                    );
+                } else {
+                    $jsonResponse = $this->createJsonResponse(
+                        'Problem when deleting user with \'' . $primaryId .
+                        '\' in VuFind. It is expected that only 1 row of the ' .
+                        'VuFind user table is affected by the deletion. But ' .
+                        $rowsAffected . ' were affected. Please check the status ' .
+                        'of the user in the VuFind database.', 400
+                    );
+                }
+            } else {
+                $jsonResponse = $this->createJsonResponse(
+                    'User with primary ID \'' . $primaryId . '\' was not found in ' .
+                    'VuFind database and therefore could not be deleted.', 404
+                );
+            }
+        }
+
+        return $jsonResponse;
+    }
+
+    /**
+     * The webhook challenge. This is used to activate the webhook in Alma. Without
+     * activating it, Alma will not send its webhook messages to VuFind.
+     *
+     * @return \Zend\Http\Response
+     */
+    protected function webhookChallenge()
+    {
+        // Get challenge string from the get parameter that Alma sends us. We need to
+        // return this string in the return message.
+        $secret = $this->params()->fromQuery('challenge');
+
+        // Create the return array
+        $returnArray = [];
+
+        if (isset($secret) && !empty(trim($secret))) {
+            $returnArray['challenge'] = $secret;
+            $this->httpResponse->setStatusCode(200);
+        } else {
+            $returnArray['error'] = 'GET parameter \'challenge\' is empty, not ' .
+            'set or not available when receiving webhook challenge from Alma.';
+            $this->httpResponse->setStatusCode(500);
+        }
+
+        // Remove null from array
+        $returnArray = array_filter($returnArray);
+
+        // Create return JSON value and set it to the response
+        $returnJson = json_encode($returnArray, JSON_PRETTY_PRINT);
+        $this->httpHeaders->addHeaderLine('Content-type', 'application/json');
+        $this->httpResponse->setContent($returnJson);
+
+        return $this->httpResponse;
+    }
+
+    /**
+     * Send the "set password email" to a new user that was created in Alma and sent
+     * to VuFind via webhook.
+     *
+     * @param \VuFind\Db\Row\User $user   A user row object from the VuFind
+     *                                    user table.
+     * @param \Zend\Config\Config $config A config object of config.ini
+     *
+     * @return void
+     */
+    protected function sendSetPasswordEmail($user, $config)
+    {
+        // If we can't find a user
+        if (null == $user) {
+            error_log(
+                'Could not send the email to new user for setting the ' .
+                'password because the user object was not found.'
+            );
+        } else {
+            // Attempt to send the email
+            try {
+                // Create a fresh hash
+                $user->updateHash();
+                $config = $this->getConfig();
+                $renderer = $this->getViewRenderer();
+                $method = $this->getAuthManager()->getAuthMethod();
+
+                // Custom template for emails (text-only)
+                $message = $renderer->render(
+                    'Email/new-user-welcome.phtml', [
+                    'library' => $config->Site->title,
+                    'firstname' => $user->firstname,
+                    'lastname' => $user->lastname,
+                    'username' => $user->username,
+                    'url' => $this->getServerUrl('myresearch-verify') . '?hash=' .
+                        $user->verify_hash . '&auth_method=' . $method
+                    ]
+                );
+                // Send the email
+                $this->serviceLocator->get('VuFind\Mailer\Mailer')->send(
+                    $user->email, $config->Site->email,
+                    $this->translate(
+                        'new_user_welcome_subject',
+                        ['%%library%%' => $config->Site->title]
+                    ),
+                    $message
+                );
+            } catch (\VuFind\Exception\Mail $e) {
+                error_log(
+                    'Could not send the \'set-password-email\' to user with ' .
+                    'primary ID \'' . $user->cat_id . '\' | username \'' .
+                    $user->username . '\': ' . $e->getMessage()
+                );
+            }
+        }
+    }
+
+    /**
+     * Create a HTTP response with JSON content and HTTP status codes that Alma takes
+     * as "answer" to its webhook calls.
+     *
+     * @param string $text           The text that should be sent back to Alma
+     * @param int    $httpStatusCode The HTTP status code that should be sent back
+     *                               to Alma
+     *
+     * @return \Zend\Http\Response
+     */
+    protected function createJsonResponse($text, $httpStatusCode)
+    {
+        $returnArray = [];
+        $returnArray[] = $text;
+        $returnJson = json_encode($returnArray, JSON_PRETTY_PRINT);
+        $this->httpHeaders->addHeaderLine('Content-type', 'application/json');
+        $this->httpResponse->setStatusCode($httpStatusCode);
+        $this->httpResponse->setContent($returnJson);
+        return $this->httpResponse;
+    }
+
+    /**
+     * A default message to be sent back to Alma if an action for a certain webhook
+     * type is not implemented (yet).
+     *
+     * @param string $webhookType The type of the webhook
+     *
+     * @return \Zend\Http\Response
+     */
+    protected function webhookNotImplemented($webhookType)
+    {
+        return $this->createJsonResponse(
+            $webhookType . ' Alma Webhook is not (yet) implemented in VuFind.', 400
+        );
+    }
+
+    /**
+     * Helper function to check access permissions defined in permissions.ini.
+     * The function validateAccessPermission() will throw an exception that can be
+     * catched when the permission is denied.
+     *
+     * @param string $accessPermission The permission name from permissions.ini that
+     *                                 should be checked.
+     *
+     * @return void
+     */
+    protected function checkPermission($accessPermission)
+    {
+        $this->accessPermission = $accessPermission;
+        $this->accessDeniedBehavior = 'exception';
+        $this->validateAccessPermission($this->getEvent());
+    }
+
+    /**
+     * Signing and hashing the body content of the Alma POST request with the
+     * webhook secret in Alma.ini. The calculated hash value must be the same as
+     * the 'X-Exl-Signature' in the request header. This is a security measure to
+     * be sure that the request comes from Alma.
+     *
+     * @param \Zend\Stdlib\RequestInterface $request The request from Alma.
+     *
+     * @throws \VuFind\Exception\Forbidden                Throws forbidden exception
+     *                                                     if hash values are not the
+     *                                                     same.
+     *
+     * @return void
+     */
+    protected function checkMessageSignature(\Zend\Stdlib\RequestInterface $request)
+    {
+        // Get request content
+        $requestBodyString = $request->getContent();
+
+        // Get hashed message signature from request header of Alma webhook request
+        $almaSignature = ($request->getHeaders()->get('X-Exl-Signature'))
+        ? $request->getHeaders()->get('X-Exl-Signature')->getFieldValue()
+        : null;
+
+        // Get the webhook secret defined in Alma.ini
+        $secretConfig = $this->configAlma->Webhook->secret ?? null;
+
+        // Calculate hmac-sha256 hash from request body we get from Alma webhook and
+        // sign it with the Alma webhook secret from Alma.ini
+        $calculatedHash = base64_encode(
+            hash_hmac(
+                'sha256',
+                $requestBodyString,
+                $secretConfig,
+                true
+            )
+        );
+
+        // Check for correct signature
+        if ($almaSignature != $calculatedHash) {
+            error_log(
+                '[Alma] Unauthorized: Signature value not correct! ' .
+                'Hash from Alma: "' . $almaSignature . '". ' .
+                'Calculated hash: "' . $calculatedHash . '". ' .
+                'Body content for calculating the hash was: ' .
+                '"' . json_encode(
+                    json_decode($requestBodyString),
+                    JSON_UNESCAPED_UNICODE |
+                        JSON_UNESCAPED_SLASHES
+                ) . '"'
+            );
+            throw new \VuFind\Exception\Forbidden;
+        }
+    }
+}
diff --git a/module/VuFind/src/VuFind/ILS/Driver/Alma.php b/module/VuFind/src/VuFind/ILS/Driver/Alma.php
index f826980a074..ca16b340779 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/Alma.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/Alma.php
@@ -27,6 +27,10 @@
  */
 namespace VuFind\ILS\Driver;
 
+use SimpleXMLElement;
+use VuFind\Exception\ILS as ILSException;
+use Zend\Http\Headers;
+
 /**
  * Alma ILS Driver
  *
@@ -39,6 +43,7 @@ namespace VuFind\ILS\Driver;
 class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
 {
     use \VuFindHttp\HttpServiceAwareTrait;
+    use CacheTrait;
 
     /**
      * Alma API base URL.
@@ -54,16 +59,32 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
      */
     protected $apiKey;
 
+    /**
+     * Date converter
+     *
+     * @var \VuFind\Date\Converter
+     */
     protected $dateConverter;
 
+    /**
+     * Configuration loader
+     *
+     * @var \VuFind\Config\PluginManager
+     */
+    protected $configLoader;
+
     /**
      * Constructor
      *
-     * @param \VuFind\Date\Converter $dateConverter Date converter object
+     * @param \VuFind\Date\Converter       $dateConverter Date converter object
+     * @param \VuFind\Config\PluginManager $configLoader  Plugin manager
      */
-    public function __construct(\VuFind\Date\Converter $dateConverter)
-    {
+    public function __construct(
+        \VuFind\Date\Converter $dateConverter,
+        \VuFind\Config\PluginManager $configLoader
+    ) {
         $this->dateConverter = $dateConverter;
+        $this->configLoader = $configLoader;
     }
 
     /**
@@ -87,30 +108,109 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
     /**
      * Make an HTTP request against Alma
      *
-     * @param string $path   Path to retrieve from API (excluding base URL/API key)
-     * @param string $params Additional GET params
+     * @param string        $path       Path to retrieve from API (excluding base
+     *                                  URL/API key)
+     * @param array         $paramsGet  Additional GET params
+     * @param array         $paramsPost Additional POST params
+     * @param string        $method     GET or POST. Default is GET.
+     * @param string        $rawBody    Request body.
+     * @param Headers|array $headers    Add headers to the call.
      *
-     * @return \SimpleXMLElement
+     * @throws ILSException
+     * @return NULL|SimpleXMLElement
      */
-    protected function makeRequest($path, $params = [])
-    {
-        // TODO: Support requests of different methods
-        if (!isset($params['apiKey'])) {
-            $params['apiKey'] = $this->apiKey;
-        }
-        $url = strpos($path, '://') === false ? $this->baseUrl . $path : $path;
-        $client = $this->httpService->createClient($url);
-        $client->setParameterGet($params);
-        $result = $client->send();
+    protected function makeRequest(
+        $path,
+        $paramsGet = [],
+        $paramsPost = [],
+        $method = 'GET',
+        $rawBody = null,
+        $headers = null
+    ) {
+        // Set some variables
+        $result = null;
+        $statusCode = null;
+        $returnValue = null;
+
+        try {
+            // Set API key if it is not already available in the GET params
+            if (!isset($paramsGet['apiKey'])) {
+                $paramsGet['apiKey'] = $this->apiKey;
+            }
+
+            // Create the API URL
+            $url = strpos($path, '://') === false ? $this->baseUrl . $path : $path;
+
+            // Create client with API URL
+            $client = $this->httpService->createClient($url);
+
+            // Set method
+            $client->setMethod($method);
+
+            // Set other GET parameters
+            if ($method == 'GET') {
+                $client->setParameterGet($paramsGet);
+            } else {
+                // Always set API key as GET parameter
+                $client->setParameterGet(['apiKey' => $paramsGet['apiKey']]);
+
+                // Set POST parameters
+                if ($method == 'POST') {
+                    $client->setParameterPost($paramsPost);
+                }
+            }
+
+            // Set body if applicable
+            if (isset($rawBody)) {
+                $client->setRawBody($rawBody);
+            }
+
+            // Set headers if applicable
+            if (isset($headers)) {
+                $client->setHeaders($headers);
+            }
+
+            // Execute HTTP call
+            $result = $client->send();
+        } catch (\Exception $e) {
+            throw new ILSException($e->getMessage());
+        }
+
+        // Get the HTTP status code
+        $statusCode = $result->getStatusCode();
+
+        // Check for error
+        if ($result->isServerError()) {
+            throw new ILSException('HTTP error code: ' . $statusCode, $statusCode);
+        }
+
+        $answer = $result->getBody();
+        $answer = str_replace('xmlns=', 'ns=', $answer);
+        $xml = simplexml_load_string($answer);
+
         if ($result->isSuccess()) {
-            return simplexml_load_string($result->getBody());
+            if (!$xml && $result->isServerError()) {
+                throw new ILSException(
+                    'XML is not valid or HTTP error, URL: ' . $url .
+                    ', HTTP status code: ' . $statusCode, $statusCode
+                );
+            }
+            $returnValue = $xml;
         } else {
-            // TODO: Throw an error
-            error_log($client->getUri());
-            error_log(print_r($params, true));
-            error_log($result->getBody());
+            $almaErrorMsg = $xml->errorList->error[0]->errorMessage;
+            error_log(
+                '[ALMA] ' . $almaErrorMsg . ' | Call to: ' . $client->getUri() .
+                '. GET params: ' . var_export($paramsGet, true) . '. POST params: ' .
+                var_export($paramsPost, true) . '. Result body: ' .
+                $result->getBody() . '. HTTP status code: ' . $statusCode
+            );
+            throw new ILSException(
+                'Alma error message: ' . $almaErrorMsg . ' | HTTP error code: ' .
+                $statusCode, $statusCode
+            );
         }
-        return null;
+
+        return $returnValue;
     }
 
     /**
@@ -134,75 +234,403 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
      * @param string $id     The record id to retrieve the holdings for
      * @param array  $patron Patron data
      *
-     * @return array         On success, an associative array with the following
-     * keys: id, availability (boolean), status, location, reserve, callnumber,
-     * duedate, number, barcode.
+     * @return array         On success an associative array with the following keys:
+     *                       id, source, availability (boolean), status, location,
+     *                       reserve, callnumber, duedate, returnDate, number,
+     *                       barcode, item_notes, item_id, holding_id, addLink.
      */
     public function getHolding($id, array $patron = null)
     {
+        // Get config data:
+        $fulfillementUnits = $this->config['FulfillmentUnits'] ?? null;
+        $requestableConfig = $this->config['Requestable'] ?? null;
+
         $results = [];
         $copyCount = 0;
         $bibPath = '/bibs/' . urlencode($id) . '/holdings';
         if ($holdings = $this->makeRequest($bibPath)) {
             foreach ($holdings->holding as $holding) {
                 $holdingId = (string)$holding->holding_id;
+                $locationCode = (string)$holding->location;
+                $addLink = false;
+                if ($fulfillementUnits != null && $requestableConfig != null) {
+                    $addLink = $this->requestsAllowed(
+                        $fulfillementUnits,
+                        $locationCode,
+                        $requestableConfig,
+                        $patron
+                    );
+                }
+
                 $itemPath = $bibPath . '/' . urlencode($holdingId) . '/items';
                 if ($currentItems = $this->makeRequest($itemPath)) {
                     foreach ($currentItems->item as $item) {
+                        $itemId = (string)$item->item_data->pid;
                         $barcode = (string)$item->item_data->barcode;
+                        $processType = (string)$item->item_data->process_type;
+                        $itemNotes = null;
+                        if ($item->item_data->public_note != null
+                            && !empty($item->item_data->public_note)
+                        ) {
+                            $itemNotes = [(string)$item->item_data->public_note];
+                        }
+                        $requested = ((string)$item->item_data->requested == 'false')
+                            ? false
+                            : true;
+
+                        $number = ++$copyCount;
+                        $description = null;
+                        if ($item->item_data->description != null
+                            && !empty($item->item_data->description)
+                        ) {
+                            $number = (string)$item->item_data->description;
+                            $description = (string)$item->item_data->description;
+                        }
+
+                        // For some data we need to do additional API calls
+                        // due to the Alma API architecture
+                        $duedate = ($requested) ? 'requested' : null;
+                        if ($processType == 'LOAN' && !$requested) {
+                            $loanDataPath = '/bibs/' . urlencode($id) . '/holdings/'
+                                . urlencode($holdingId) . '/items/'
+                                . urlencode($itemId) . '/loans';
+                            $loanData = $this->makeRequest($loanDataPath);
+                            $loan = $loanData->item_loan;
+                            $duedate = $this->parseDate((string)$loan->due_date);
+                        }
+
                         $results[] = [
                             'id' => $id,
                             'source' => 'Solr',
                             'availability' => $this->getAvailabilityFromItem($item),
-                            'status' => (string)$item->item_data->base_status[0]
-                                ->attributes()['desc'],
-                            'location' => (string)$holding->library[0]
+                            'status' => (string)$item
+                                ->item_data
+                                ->base_status[0]
                                 ->attributes()['desc'],
+                            'location' => $locationCode,
                             'reserve' => 'N',   // TODO: support reserve status
                             'callnumber' => (string)$item->holding_data->call_number,
-                            'duedate' => null, // TODO: support due dates
+                            'duedate' => $duedate,
                             'returnDate' => false, // TODO: support recent returns
-                            'number' => ++$copyCount,
+                            'number' => $number,//++$copyCount,
                             'barcode' => empty($barcode) ? 'n/a' : $barcode,
-                            'item_id' => (string)$item->item_data->pid,
+                            'item_notes' => $itemNotes,
+                            'item_id' => $itemId,
                             'holding_id' => $holdingId,
-                            'addLink' => 'check'
+                            'addLink' => $addLink,
+                               // For Alma title-level hold requests
+                            'description' => $description
                         ];
                     }
                 }
             }
         }
+
         return $results;
     }
 
+    /**
+     * Check if the user is allowed to place requests for an Alma fulfillment
+     * unit in general. We check for blocks on the patron account that could
+     * block a request in getRequestBlocks().
+     *
+     * @param array  $fulfillementUnits An array of fulfillment units and associated
+     *                                  locations from Alma.ini (see section
+     *                                  [FulfillmentUnits])
+     * @param string $locationCode      The location code of the holding to be
+     *                                  checked
+     * @param array  $requestableConfig An array of fulfillment units and associated
+     *                                  patron groups and their request policy from
+     *                                  Alma.ini (see section [Requestable])
+     * @param array  $patron            An array with the patron details (username
+     *                                  and password)
+     *
+     * @return boolean                  true if the the patron is allowed to place
+     *                                  requests on holdings of this fulfillment
+     *                                  unit, false otherwise.
+     * @author Michael Birkner
+     */
+    protected function requestsAllowed(
+        $fulfillementUnits,
+        $locationCode,
+        $requestableConfig,
+        $patron
+    ) {
+        $requestsAllowed = false;
+
+        // Get user group code
+        $cacheId = 'alma|user|' . $patron['cat_username'] . '|group_code';
+        $userGroupCode = $this->getCachedData($cacheId);
+        if ($userGroupCode === null) {
+            $profile = $this->getMyProfile($patron);
+            $userGroupCode = (string)$profile['group_code'];
+        }
+
+        // Get the fulfillment unit of the location.
+        $locationFulfillmentUnit = $this->getFulfillmentUnitByLocation(
+            $locationCode,
+            $fulfillementUnits
+        );
+
+        // Check if the group of the currently logged in user is allowed to place
+        // requests on items belonging to current fulfillment unit
+        if (($locationFulfillmentUnit != null && !empty($locationFulfillmentUnit))
+            && ($userGroupCode != null && !empty($userGroupCode))
+        ) {
+            $requestsAllowed = false;
+            if ($requestableConfig[$locationFulfillmentUnit][$userGroupCode] == 'Y'
+            ) {
+                $requestsAllowed = true;
+            }
+        }
+
+        return $requestsAllowed;
+    }
+
+    /**
+     * Check for request blocks.
+     *
+     * @param array $patron The patron array with username and password
+     *
+     * @return array|boolean    An array of block messages or false if there are no
+     *                          blocks
+     * @author Michael Birkner
+     */
+    public function getRequestBlocks($patron)
+    {
+        return $this->getAccountBlocks($patron);
+    }
+
+    /**
+     * Check for account blocks in Alma and cache them.
+     *
+     * @param array $patron The patron array with username and password
+     *
+     * @return array|boolean    An array of block messages or false if there are no
+     *                          blocks
+     * @author Michael Birkner
+     */
+    public function getAccountBlocks($patron)
+    {
+        $patronId = $patron['cat_username'];
+        $cacheId = 'alma|user|' . $patronId . '|blocks';
+        $cachedBlocks = $this->getCachedData($cacheId);
+        if ($cachedBlocks !== null) {
+            return $cachedBlocks;
+        }
+
+        $xml = $this->makeRequest('/users/' . $patron['cat_username']);
+        if ($xml == null || empty($xml)) {
+            return false;
+        }
+
+        $userBlocks = $xml->user_blocks->user_block;
+        if ($userBlocks == null || empty($userBlocks)) {
+            return false;
+        }
+
+        $blocks = [];
+        foreach ($userBlocks as $block) {
+            $blockStatus = (string)$block->block_status;
+            if ($blockStatus === 'ACTIVE') {
+                $blockNote = (isset($block->block_note))
+                             ? (string)$block->block_note
+                             : null;
+                $blockDesc = (string)$block->block_description->attributes()->desc;
+                $blockDesc = ($blockNote != null)
+                             ? $blockDesc . '. ' . $blockNote
+                             : $blockDesc;
+                $blocks[] = $blockDesc;
+            }
+        }
+
+        if (!empty($blocks)) {
+            $this->putCachedData($cacheId, $blocks);
+            return $blocks;
+        } else {
+            $this->putCachedData($cacheId, false);
+            return false;
+        }
+    }
+
+    /**
+     * Get an Alma fulfillment unit by an Alma location.
+     *
+     * @param string $locationCode     A location code, e. g. "SCI"
+     * @param array  $fulfillmentUnits An array of fulfillment units with all its
+     *                                 locations.
+     *
+     * @return string|NULL              Null if the location was not found or a
+     *                                  string specifying the fulfillment unit of
+     *                                  the location that was found.
+     * @author Michael Birkner
+     */
+    protected function getFulfillmentUnitByLocation($locationCode, $fulfillmentUnits)
+    {
+        foreach ($fulfillmentUnits as $key => $val) {
+            if (array_search($locationCode, $val) !== false) {
+                return $key;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Create a user in Alma via API call
+     *
+     * @param array $formParams The data from the "create new account" form
+     *
+     * @throws \VuFind\Exception\Auth
+     *
+     * @return NULL|SimpleXMLElement
+     * @author Michael Birkner
+     */
+    public function createAlmaUser($formParams)
+    {
+
+        // Get config for creating new Alma users from Alma.ini
+        $newUserConfig = $this->config['NewUser'];
+
+        // Check if config params are all set
+        $configParams = [
+            'recordType', 'userGroup', 'preferredLanguage',
+            'accountType', 'status', 'emailType', 'idType'
+        ];
+        foreach ($configParams as $configParam) {
+            if (!isset($newUserConfig[$configParam])
+                || empty(trim($newUserConfig[$configParam]))
+            ) {
+                $errorMessage = 'Configuration "' . $configParam . '" is not set ' .
+                                'in Alma.ini in the [NewUser] section!';
+                error_log('[ALMA]: ' . $errorMessage);
+                throw new \VuFind\Exception\Auth($errorMessage);
+            }
+        }
+
+        // Calculate expiry date based on config in Alma.ini
+        $dateNow = new \DateTime('now');
+        $expiryDate = null;
+        if (isset($newUserConfig['expiryDate'])
+            && !empty(trim($newUserConfig['expiryDate']))
+        ) {
+            try {
+                $expiryDate = $dateNow->add(
+                    new \DateInterval($newUserConfig['expiryDate'])
+                );
+            } catch (\Exception $exception) {
+                $errorMessage = 'Configuration "expiryDate" in Alma.ini (see ' .
+                                '[NewUser] section) has the wrong format!';
+                error_log('[ALMA]: ' . $errorMessage);
+                throw new \VuFind\Exception\Auth($errorMessage);
+            }
+        } else {
+            $expiryDate = $dateNow->add(new \DateInterval('P1Y'));
+        }
+        $expiryDateXml = ($expiryDate != null)
+                 ? '<expiry_date>' . $expiryDate->format('Y-m-d') . 'Z</expiry_date>'
+                 : '';
+
+        // Calculate purge date based on config in Alma.ini
+        $purgeDate = null;
+        if (isset($newUserConfig['purgeDate'])
+            && !empty(trim($newUserConfig['purgeDate']))
+        ) {
+            try {
+                $purgeDate = $dateNow->add(
+                    new \DateInterval($newUserConfig['purgeDate'])
+                );
+            } catch (\Exception $exception) {
+                $errorMessage = 'Configuration "purgeDate" in Alma.ini (see ' .
+                                '[NewUser] section) has the wrong format!';
+                error_log('[ALMA]: ' . $errorMessage);
+                throw new \VuFind\Exception\Auth($errorMessage);
+            }
+        }
+        $purgeDateXml = ($purgeDate != null)
+                    ? '<purge_date>' . $purgeDate->format('Y-m-d') . 'Z</purge_date>'
+                    : '';
+
+        // Create user XML for Alma API
+        $userXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+        . '<user>'
+        . '<record_type>' . $this->config['NewUser']['recordType'] . '</record_type>'
+        . '<first_name>' . $formParams['firstname'] . '</first_name>'
+        . '<last_name>' . $formParams['lastname'] . '</last_name>'
+        . '<user_group>' . $this->config['NewUser']['userGroup'] . '</user_group>'
+        . '<preferred_language>' . $this->config['NewUser']['preferredLanguage'] .
+          '</preferred_language>'
+        . $expiryDateXml
+        . $purgeDateXml
+        . '<account_type>' . $this->config['NewUser']['accountType'] .
+          '</account_type>'
+        . '<status>' . $this->config['NewUser']['status'] . '</status>'
+        . '<contact_info>'
+        . '<emails>'
+        . '<email preferred="true">'
+        . '<email_address>' . $formParams['email'] . '</email_address>'
+        . '<email_types>'
+        . '<email_type>' . $this->config['NewUser']['emailType'] . '</email_type>'
+        . '</email_types>'
+        . '</email>'
+        . '</emails>'
+        . '</contact_info>'
+        . '<user_identifiers>'
+        . '<user_identifier>'
+        . '<id_type>' . $this->config['NewUser']['idType'] . '</id_type>'
+        . '<value>' . $formParams['username'] . '</value>'
+        . '</user_identifier>'
+        . '</user_identifiers>'
+        . '</user>';
+
+        // Remove whitespaces from XML
+        $userXml = preg_replace("/\n/i", "", $userXml);
+        $userXml = preg_replace("/>\s*</i", "><", $userXml);
+
+        // Create user in Alma
+        $almaAnswer = $this->makeRequest(
+            '/users',
+            [],
+            [],
+            'POST',
+            $userXml,
+            ['Content-Type' => 'application/xml']
+        );
+
+        // Return the XML from Alma on success. On error, an exception is thrown
+        // in makeRequest
+        return $almaAnswer;
+    }
+
     /**
      * Patron Login
      *
      * This is responsible for authenticating a patron against the catalog.
      *
-     * @param string $barcode  The patron barcode
-     * @param string $password The patron password
+     * @param string $barcode  The patrons barcode.
+     * @param string $password The patrons password.
      *
-     * @throws ILSException
-     * @return mixed           Associative array of patron info on successful login,
-     * null on unsuccessful login.
+     * @return string[]|NULL
      */
     public function patronLogin($barcode, $password)
     {
-        $client = $this->httpService->createClient(
-            $this->baseUrl . '/users/' . $barcode
-            . '?apiKey=' . urlencode($this->apiKey)
-            . '&op=auth&password=' . urlencode(trim($password))
-        );
-        $client->setMethod(\Zend\Http\Request::METHOD_POST);
-        $response = $client->send();
-        // Test once we have POST access
-        if ($response->isSuccess()) {
+        // Create array of get parameters for API call
+        $getParams = [
+            'user_id_type' => 'all_unique',
+            'view' => 'brief',
+            'expand' => 'none'
+        ];
+
+        // Check for patron in Alma
+        $response = $this->makeRequest('/users/' . urlencode($barcode), $getParams);
+
+        // Test once we have access
+        if ($response != null) {
             return [
                 'cat_username' => trim($barcode),
                 'cat_password' => trim($password)
             ];
         }
+
         return null;
     }
 
@@ -217,30 +645,59 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
      */
     public function getMyProfile($patron)
     {
-        $xml = $this->makeRequest('/users/' . $patron['cat_username']);
+        $patronId = $patron['cat_username'];
+        $xml = $this->makeRequest('/users/' . $patronId);
         if (empty($xml)) {
             return [];
         }
         $profile = [
-            'firstname' => $xml->first_name,
-            'lastname'  => $xml->last_name,
-            'group'     => $xml->user_group['desc']
+            'firstname'  => (isset($xml->first_name))
+                                ? (string)$xml->first_name
+                                : null,
+            'lastname'   => (isset($xml->last_name))
+                                ? (string)$xml->last_name
+                                : null,
+            'group'      => (isset($xml->user_group['desc']))
+                                ? (string)$xml->user_group['desc']
+                                : null,
+            'group_code' => (isset($xml->user_group))
+                                ? (string)$xml->user_group
+                                : null
         ];
         $contact = $xml->contact_info;
         if ($contact) {
             if ($contact->addresses) {
                 $address = $contact->addresses[0]->address;
-                $profile['address1'] = $address->line1;
-                $profile['address2'] = $address->line2;
-                $profile['address3'] = $address->line3;
-                $profile['zip']      = $address->postal_code;
-                $profile['city']     = $address->city;
-                $profile['country']  = $address->country;
+                $profile['address1'] =  (isset($address->line1))
+                                            ? (string)$address->line1
+                                            : null;
+                $profile['address2'] =  (isset($address->line2))
+                                            ? (string)$address->line2
+                                            : null;
+                $profile['address3'] =  (isset($address->line3))
+                                            ? (string)$address->line3
+                                            : null;
+                $profile['zip']      =  (isset($address->postal_code))
+                                            ? (string)$address->postal_code
+                                            : null;
+                $profile['city']     =  (isset($address->city))
+                                            ? (string)$address->city
+                                            : null;
+                $profile['country']  =  (isset($address->country))
+                                            ? (string)$address->country
+                                            : null;
             }
             if ($contact->phones) {
-                $profile['phone'] = $contact->phones[0]->phone->phone_number;
+                $profile['phone'] = (isset($contact->phones[0]->phone->phone_number))
+                                   ? (string)$contact->phones[0]->phone->phone_number
+                                   : null;
             }
         }
+
+        // Cache the user group code
+        $cacheId = 'alma|user|' . $patronId . '|group_code';
+        $this->putCachedData($cacheId, $profile['group_code'] ?? null);
+
         return $profile;
     }
 
@@ -260,15 +717,17 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
         );
         $fineList = [];
         foreach ($xml as $fee) {
-            $checkout = (string) $fee->status_time;
+            $checkout = (string)$fee->status_time;
             $fineList[] = [
-                "title"   => (string) $fee->type,
+                "title"   => (string)$fee->type,
                 "amount"   => $fee->original_amount * 100,
                 "balance"  => $fee->balance * 100,
                 "checkout" => $this->dateConverter->convert(
-                    'Y-m-d H:i', 'm-d-Y', $checkout
+                    'Y-m-d H:i',
+                    'm-d-Y',
+                    $checkout
                 ),
-                "fine"     => (string) $fee->type['desc']
+                "fine"     => (string)$fee->type['desc']
             ];
         }
         return $fineList;
@@ -294,15 +753,15 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
         $holdList = [];
         foreach ($xml as $request) {
             $holdList[] = [
-                'create' => (string) $request->request_date,
-                'expire' => (string) $request->last_interest_date,
-                'id' => (string) $request->request_id,
-                'in_transit' => $request->request_status !== 'IN_PROCESS',
-                'item_id' => (string) $request->mms_id,
-                'location' => (string) $request->pickup_location,
+                'create' => (string)$request->request_date,
+                'expire' => (string)$request->last_interest_date,
+                'id' => (string)$request->request_id,
+                'in_transit' => (string)$request->request_status !== 'On Hold Shelf',
+                'item_id' => (string)$request->mms_id,
+                'location' => (string)$request->pickup_location,
                 'processed' => $request->item_policy === 'InterlibraryLoan'
-                    && $request->request_status !== 'NOT_STARTED',
-                'title' => (string) $request->title,
+                    && (string)$request->request_status !== 'Not Started',
+                'title' => (string)$request->title,
                 /*
                 // VuFind keys
                 'available'         => $request->,
@@ -336,6 +795,92 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
         return $holdList;
     }
 
+    /**
+     * Cancel hold requests.
+     *
+     * @param array $cancelDetails An associative array with two keys: patron
+     *                             (array returned by the driver's
+     *                             patronLogin method) and details (an array
+     *                             of strings eturned by the driver's
+     *                             getCancelHoldDetails method)
+     *
+     * @return array                Associative array containing with keys 'count'
+     *                                 (number of items successfully cancelled) and
+     *                                 'items' (array of successfull cancellations).
+     */
+    public function cancelHolds($cancelDetails)
+    {
+        $returnArray = [];
+        $patronId = $cancelDetails['patron']['cat_username'];
+        $count = 0;
+
+        foreach ($cancelDetails['details'] as $requestId) {
+            $item = [];
+            try {
+                // Get some details of the requested items as we need them below.
+                // We only can get them from an API request.
+                $requestDetails = $this->makeRequest(
+                    $this->baseUrl .
+                        '/users/' . urlencode($patronId) .
+                        '/requests/' . urlencode($requestId)
+                );
+
+                $mmsId = (isset($requestDetails->mms_id))
+                          ? (string)$requestDetails->mms_id
+                          : (string)$requestDetails->mms_id;
+
+                // Delete the request in Alma
+                $apiResult = $this->makeRequest(
+                    $this->baseUrl .
+                    '/users/' . urlencode($patronId) .
+                    '/requests/' . urlencode($requestId),
+                    ['reason' => 'CancelledAtPatronRequest'],
+                    [],
+                    'DELETE'
+                );
+
+                // Adding to "count" variable and setting values to return array
+                $count++;
+                $item[$mmsId]['success'] = true;
+                $item[$mmsId]['status'] = 'hold_cancel_success';
+            } catch (ILSException $e) {
+                if (isset($apiResult['xml'])) {
+                    $almaErrorCode = $apiResult['xml']->errorList->error->errorCode;
+                    $sysMessage = $apiResult['xml']->errorList->error->errorMessage;
+                } else {
+                    $almaErrorCode = 'No error code available';
+                    $sysMessage = 'HTTP status code: ' .
+                         ($e->getCode() ?? 'Code not available');
+                }
+                $item[$mmsId]['success'] = false;
+                $item[$mmsId]['status'] = 'hold_cancel_fail';
+                $item[$mmsId]['sysMessage'] = $sysMessage . '. ' .
+                         'Alma MMS ID: ' . $mmsId . '. ' .
+                         'Alma request ID: ' . $requestId . '. ' .
+                         'Alma error code: ' . $almaErrorCode;
+            }
+
+            $returnArray['items'] = $item;
+        }
+
+        $returnArray['count'] = $count;
+
+        return $returnArray;
+    }
+
+    /**
+     * Get details of a single hold request.
+     *
+     * @param array $holdDetails One of the item arrays returned by the
+     *                           getMyHolds method
+     *
+     * @return string            The Alma request ID
+     */
+    public function getCancelHoldDetails($holdDetails)
+    {
+        return $holdDetails['id'];
+    }
+
     /**
      * Get Patron Storage Retrieval Requests
      *
@@ -416,6 +961,164 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
         return $holdList;
     }
 
+    /**
+     * Get transactions of the current patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     *
+     * @return string[]    Transaction information as array or empty array if the
+     *                  patron has no transactions.
+     *
+     * @author Michael Birkner
+     */
+    public function getMyTransactions($patron)
+    {
+        // Defining the return value
+        $returnArray = [];
+
+        // Get the patrons user name
+        $patronUserName = $patron['cat_username'];
+
+        // Create a timestamp for calculating the due / overdue status
+        $nowTS = mktime();
+
+        // Create parameters for the API call
+        // INFO: "order_by" does not seem to work as expected!
+        //       This is an Alma API problem.
+        $params = [
+            'limit' => '100',
+            'order_by' => 'due_date',
+            'direction' => 'DESC',
+            'expand' => 'renewable'
+        ];
+
+        // Get user loans from Alma API
+        $apiResult = $this->makeRequest(
+            '/users/' . $patronUserName . '/loans/',
+            $params
+        );
+
+        // If there is an API result, process it
+        if ($apiResult) {
+            // Iterate over all item loans
+            foreach ($apiResult->item_loan as $itemLoan) {
+                $loan['duedate'] = $this->parseDate(
+                    (string)$itemLoan->due_date,
+                    true
+                );
+                //$loan['dueTime'] = ;
+                $loan['dueStatus'] = null; // Calculated below
+                $loan['id'] = (string)$itemLoan->mms_id;
+                //$loan['source'] = 'Solr';
+                $loan['barcode'] = (string)$itemLoan->item_barcode;
+                //$loan['renew'] = ;
+                //$loan['renewLimit'] = ;
+                //$loan['request'] = ;
+                //$loan['volume'] = ;
+                $loan['publication_year'] = (string)$itemLoan->publication_year;
+                $loan['renewable']
+                    = (strtolower((string)$itemLoan->renewable) == 'true')
+                    ? true
+                    : false;
+                //$loan['message'] = ;
+                $loan['title'] = (string)$itemLoan->title;
+                $loan['item_id'] = (string)$itemLoan->loan_id;
+                $loan['institution_name'] = (string)$itemLoan->library;
+                //$loan['isbn'] = ;
+                //$loan['issn'] = ;
+                //$loan['oclc'] = ;
+                //$loan['upc'] = ;
+                $loan['borrowingLocation'] = (string)$itemLoan->circ_desk;
+
+                // Calculate due status
+                $dueDateTS = strtotime($loan['duedate']);
+                if ($nowTS > $dueDateTS) {
+                    // Loan is overdue
+                    $loan['dueStatus'] = 'overdue';
+                } elseif (($dueDateTS - $nowTS) < 86400) {
+                    // Due date within one day
+                    $loan['dueStatus'] = 'due';
+                }
+
+                $returnArray[] = $loan;
+            }
+        }
+
+        return $returnArray;
+    }
+
+    /**
+     * Get Alma loan IDs for use in renewMyItems.
+     *
+     * @param array $checkOutDetails An array from getMyTransactions
+     *
+     * @return string The Alma loan ID for this loan
+     *
+     * @author Michael Birkner
+     */
+    public function getRenewDetails($checkOutDetails)
+    {
+        $loanId = $checkOutDetails['item_id'];
+        return $loanId;
+    }
+
+    /**
+     * Renew loans via Alma API.
+     *
+     * @param array $renewDetails An array with the IDs of the loans returned by
+     *                            getRenewDetails and the patron information
+     *                            returned by patronLogin.
+     *
+     * @return array[] An array with the renewal details and a success or error
+     *                 message.
+     *
+     * @author Michael Birkner
+     */
+    public function renewMyItems($renewDetails)
+    {
+        $returnArray = [];
+        $patronUserName = $renewDetails['patron']['cat_username'];
+
+        foreach ($renewDetails['details'] as $loanId) {
+            // Create an empty array that holds the information for a renewal
+            $renewal = [];
+
+            try {
+                // POST the renewals to Alma
+                $apiResult = $this->makeRequest(
+                    '/users/' . $patronUserName . '/loans/' . $loanId . '/?op=renew',
+                    [],
+                    [],
+                    'POST'
+                );
+
+                // Add information to the renewal array
+                $blocks = false;
+                $renewal[$loanId]['success'] = true;
+                $renewal[$loanId]['new_date'] = $this->parseDate(
+                    (string)$apiResult->due_date,
+                    true
+                );
+                //$renewal[$loanId]['new_time'] = ;
+                $renewal[$loanId]['item_id'] = (string)$apiResult->loan_id;
+                $renewal[$loanId]['sysMessage'] = 'renew_success';
+
+                // Add the renewal to the return array
+                $returnArray['details'] = $renewal;
+            } catch (ILSException $ilsEx) {
+                // Add the empty renewal array to the return array
+                $returnArray['details'] = $renewal;
+
+                // Add a message that can be translated
+                $blocks[] = 'renew_fail';
+            }
+        }
+
+        $returnArray['blocks'] = $blocks;
+
+        return $returnArray;
+    }
+
     /**
      * Get Status
      *
@@ -458,10 +1161,10 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
                 );
                 $status = [];
                 $tmpl = [
-                    'id' => (string) $bib->mms_id,
+                    'id' => (string)$bib->mms_id,
                     'source' => 'Solr',
                     'callnumber' => isset($bib->isbn)
-                        ? (string) $bib->isbn
+                        ? (string)$bib->isbn
                         : ''
                 ];
                 if ($record = $marc->next()) {
@@ -471,7 +1174,7 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
                         $avail = $field->getSubfield('e')->getData();
                         $item = $tmpl;
                         $item['availability'] = strtolower($avail) === 'available';
-                        $item['location'] = (string) $field->getSubfield('c')
+                        $item['location'] = (string)$field->getSubfield('c')
                             ->getData();
                         $status[] = $item;
                     }
@@ -540,60 +1243,106 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
     }
 
     /**
-     * Ref: https://developers.exlibrisgroup.com/alma/apis/users
-     * POST /almaws/v1/users/{user_id}/requests
+     * Place a hold request via Alma API. This could be a title level request or
+     * an item level request.
      *
      * @param array $holdDetails An associative array w/ atleast patron and item_id
      *
      * @return array success: bool, sysMessage: string
+     *
+     * @link https://developers.exlibrisgroup.com/alma/apis/bibs
      */
     public function placeHold($holdDetails)
     {
-        $client = $this->httpService->createClient(
-            $this->baseUrl . '/bibs/' . $holdDetails['id']
-            . '/holdings/' . urlencode($holdDetails['holding_id'])
-            . '/items/' . urlencode($holdDetails['item_id'])
-            . '/requests?apiKey=' . urlencode($this->apiKey)
-            . '&user_id=' . urlencode($holdDetails['patron']['cat_username'])
-            . '&format=json'
-        );
+        // Check for title or item level request
+        $level = $holdDetails['level'] ?? 'item';
+
+        // Get information that is valid for both, item level requests and title
+        // level requests.
+        $mmsId = $holdDetails['id'];
+        $holId = $holdDetails['holding_id'];
+        $itmId = $holdDetails['item_id'];
+        $patronCatUsername = $holdDetails['patron']['cat_username'];
+        $pickupLocation = $holdDetails['pickUpLocation'] ?? null;
+        $comment = $holdDetails['comment'] ?? null;
+        $requiredBy = (isset($holdDetails['requiredBy']))
+        ? $this->dateConverter->convertFromDisplayDate(
+            'Y-m-d',
+            $holdDetails['requiredBy']
+        ) . 'Z'
+        : null;
+
+        // Create body for API request
+        $body = [];
+        $body['request_type'] = 'HOLD';
+        $body['pickup_location_type'] = 'LIBRARY';
+        $body['pickup_location_library'] = $pickupLocation;
+        $body['comment'] = $comment;
+        $body['last_interest_date'] = $requiredBy;
+
+        // Remove "null" values from body array
+        $body = array_filter($body);
+
+        // Check if we have a title level request or an item level request
+        if ($level === 'title') {
+            // Add description if we have one for title level requests as Alma
+            // needs it under certain circumstances. See: https://developers.
+            // exlibrisgroup.com/alma/apis/xsd/rest_user_request.xsd?tags=POST
+            $description = isset($holdDetails['description']) ?? null;
+            if ($description) {
+                $body['description'] = $description;
+            }
+
+            // Create HTTP client with Alma API URL for title level requests
+            $client = $this->httpService->createClient(
+                $this->baseUrl . '/bibs/' . urlencode($mmsId)
+                . '/requests?apiKey=' . urlencode($this->apiKey)
+                . '&user_id=' . urlencode($patronCatUsername)
+                . '&format=json'
+            );
+        } else {
+            // Create HTTP client with Alma API URL for item level requests
+            $client = $this->httpService->createClient(
+                $this->baseUrl . '/bibs/' . urlencode($mmsId)
+                . '/holdings/' . urlencode($holId)
+                . '/items/' . urlencode($itmId)
+                . '/requests?apiKey=' . urlencode($this->apiKey)
+                . '&user_id=' . urlencode($patronCatUsername)
+                . '&format=json'
+            );
+        }
+
+        // Set headers
         $client->setHeaders(
             [
-                'Content-type: application/json',
-                'Accept: application/json'
+            'Content-type: application/json',
+            'Accept: application/json'
             ]
         );
+
+        // Set HTTP method
         $client->setMethod(\Zend\Http\Request::METHOD_POST);
-        $body = ['request_type' => 'HOLD'];
-        if (isset($holdDetails['comment']) && !empty($holdDetails['comment'])) {
-            $body['comment'] = $holdDetails['comment'];
-        }
-        if (isset($holdDetails['requiredBy'])) {
-            $date = $this->dateConverter->convertFromDisplayDate(
-                'Y-m-d', $holdDetails['requiredBy']
-            );
-            $body['last_interest_date'] = $date;
-        }
-        if (isset($holdDetails['pickUpLocation'])) {
-            $body['pickup_location_type'] = 'LIBRARY';
-            $body['pickup_location_library'] = $holdDetails['pickUpLocation'];
-        }
+
+        // Set body
         $client->setRawBody(json_encode($body));
+
+        // Send API call and get response
         $response = $client->send();
 
+        // Check for success
         if ($response->isSuccess()) {
-            return [
-                'success' => true,
-                'status' => 'hold_request_success'
-            ];
+            return ['success' => true];
         } else {
             // TODO: Throw an error
             error_log($response->getBody());
         }
+
+        // Get error message
         $error = json_decode($response->getBody());
         if (!$error) {
             $error = simplexml_load_string($response->getBody());
         }
+
         return [
             'success' => false,
             'sysMessage' => $error->errorList->error[0]->errorMessage
@@ -607,7 +1356,7 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
      * retrieval
      *
      * @param array $patron Patron information returned by the patronLogin
-     * method.
+     *                      method.
      *
      * @return array An array of associative arrays with locationID and
      * locationDisplay keys
@@ -626,11 +1375,14 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
     }
 
     /**
+     * Request from /courses.
+     *
      * @return array with key = course ID, value = course name
      */
-    public function getCourses() {
+    public function getCourses()
+    {
         // https://developers.exlibrisgroup.com/alma/apis/courses
-        // GET /​almaws/​v1/​courses
+        // GET /almaws/v1/courses
         $xml = $this->makeRequest('/courses');
         $courses = [];
         foreach ($xml as $course) {
@@ -640,18 +1392,21 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
     }
 
     /**
+     * Get reserves by course
+     *
      * @param string $courseID     Value from getCourses
-     * @param string $instructorID Value from getInstructors
-     * @param string $departmentID Value from getDepartments
+     * @param string $instructorID Value from getInstructors (not used yet)
+     * @param string $departmentID Value from getDepartments (not used yet)
      *
      * @return array With key BIB_ID - The record ID of the current reserve item.
      *               Not currently used:
      *               DISPLAY_CALL_NO, AUTHOR, TITLE, PUBLISHER, PUBLISHER_DATE
      */
-    public function findReserves($courseID, $instructorID, $departmentID) {
+    public function findReserves($courseID, $instructorID, $departmentID)
+    {
         // https://developers.exlibrisgroup.com/alma/apis/courses
-        // GET /​almaws/​v1/​courses/​{course_id}/​reading-lists
-        $xml = $this->makeRequest('/courses/​' . $courseID . '/​reading-lists');
+        // GET /almaws/v1/courses/{course_id}/reading-lists
+        $xml = $this->makeRequest('/courses/' . $courseID . '/reading-lists');
         $reserves = [];
         foreach ($xml as $list) {
             $listXML = $this->makeRequest(
@@ -664,27 +1419,69 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
         return $reserves;
     }
 
-    // @codingStandardsIgnoreStart
-
     /**
-     * @return array with key = course ID, value = course name
-     * /
-    public function getFunds() {
-        // https://developers.exlibrisgroup.com/alma/apis/acq
-        // GET /​almaws/​v1/​acq/​funds
+     * Parse a date.
+     *
+     * @param string  $date     Date to parse
+     * @param boolean $withTime Add time to return if available?
+     *
+     * @return string
+     */
+    public function parseDate($date, $withTime = false)
+    {
+        // Remove trailing Z from end of date
+        // e.g. from Alma we get dates like 2012-07-13Z without time, which is wrong)
+        if (strpos($date, 'Z', (strlen($date) - 1))) {
+            $date = preg_replace('/Z{1}$/', '', $date);
+        }
+
+        $compactDate = "/^[0-9]{8}$/"; // e. g. 20120725
+        $euroName = "/^[0-9]+\/[A-Za-z]{3}\/[0-9]{4}$/"; // e. g. 13/jan/2012
+        $euro = "/^[0-9]+\/[0-9]+\/[0-9]{4}$/"; // e. g. 13/7/2012
+        $euroPad = "/^[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{2,4}$/"; // e. g. 13/07/2012
+        $datestamp = "/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/"; // e. g. 2012-07-13
+        $timestamp = "/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$/";
+        // e. g. 2017-07-09T18:00:00
+
+        if ($date == null || $date == '') {
+            return '';
+        } elseif (preg_match($compactDate, $date) === 1) {
+            return $this->dateConverter->convertToDisplayDate('Ynd', $date);
+        } elseif (preg_match($euroName, $date) === 1) {
+            return $this->dateConverter->convertToDisplayDate('d/M/Y', $date);
+        } elseif (preg_match($euro, $date) === 1) {
+            return $this->dateConverter->convertToDisplayDate('d/m/Y', $date);
+        } elseif (preg_match($euroPad, $date) === 1) {
+            return $this->dateConverter->convertToDisplayDate('d/m/y', $date);
+        } elseif (preg_match($datestamp, $date) === 1) {
+            return $this->dateConverter->convertToDisplayDate('Y-m-d', $date);
+        } elseif (preg_match($timestamp, substr($date, 0, 19)) === 1) {
+            if ($withTime) {
+                return $this->dateConverter->convertToDisplayDateAndTime(
+                    'Y-m-d\TH:i:s',
+                    substr($date, 0, 19)
+                );
+            } else {
+                return $this->dateConverter->convertToDisplayDate(
+                    'Y-m-d',
+                    substr($date, 0, 10)
+                );
+            }
+        } else {
+            throw new \Exception("Invalid date: $date");
+        }
     }
-    */
+
+    // @codingStandardsIgnoreStart
 
     /**
-     * @param string $bibID Bibligraphic ID
-     *
-     * @return boolean
+     * @return array with key = course ID, value = course name
      * /
-    public function hasHoldings($bibID) {
-        // https://developers.exlibrisgroup.com/alma/apis/bibs
-        // GET /almaws/v1/bibs/{mms_id}/holdings
-    }
-    */
+     * public function getFunds() {
+     * // https://developers.exlibrisgroup.com/alma/apis/acq
+     * // GET /almaws/v1/acq/funds
+     * }
+     */
 
     /* ================= METHODS INACCESSIBLE OUTSIDE OF GET ================== */
 
@@ -699,10 +1496,10 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
      *                    status – A status message from the language file (required)
      *                    sysMessage - A system supplied failure message (optional)
      * /
-    public function cancelHolds($cancelDetails) {
-        // https://developers.exlibrisgroup.com/alma/apis/users
-        // DELETE /almaws/v1/users/{user_id}/requests/{request_id}
-    }
-    */
+     * public function cancelHolds($cancelDetails) {
+     * // https://developers.exlibrisgroup.com/alma/apis/users
+     * // DELETE /almaws/v1/users/{user_id}/requests/{request_id}
+     * }
+     */
     // @codingStandardsIgnoreEnd
 }
diff --git a/module/VuFind/src/VuFind/ILS/Driver/AlmaFactory.php b/module/VuFind/src/VuFind/ILS/Driver/AlmaFactory.php
new file mode 100644
index 00000000000..fad09dc0c16
--- /dev/null
+++ b/module/VuFind/src/VuFind/ILS/Driver/AlmaFactory.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * Factory for Alma ILS driver.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) AK Bibliothek Wien für Sozialwissenschaften 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   Michael Birkner <michael.birkner@akwien.at>
+ * @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;
+use Interop\Container\Exception\ContainerException;
+use Zend\ServiceManager\Exception\ServiceNotCreatedException;
+use Zend\ServiceManager\Exception\ServiceNotFoundException;
+use Zend\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Alma ILS driver factory.
+ *
+ * @category VuFind
+ * @package  ILS_Drivers
+ * @author   Michael Birkner <michael.birkner@akwien.at>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class AlmaFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Container interface
+     * @param string             $requestedName Driver name
+     * @param null|array         $options       Options
+     *
+     * @return object             Driver 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
+    ) {
+        // Set up the driver with the date converter (and any extra parameters
+        // passed in as options):
+        $driver = new $requestedName(
+            $container->get('VuFind\Date\Converter'),
+            $container->get('VuFind\Config\PluginManager'),
+            ...($options ?: [])
+        );
+
+        // Populate cache storage if a setCacheStorage method is present:
+        if (method_exists($driver, 'setCacheStorage')) {
+            $driver->setCacheStorage(
+                $container->get('VuFind\Cache\Manager')->getCache('object')
+            );
+        }
+
+        return $driver;
+    }
+}
diff --git a/module/VuFind/src/VuFind/ILS/Driver/PluginManager.php b/module/VuFind/src/VuFind/ILS/Driver/PluginManager.php
index d0f185ac4e3..020df9446d6 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/PluginManager.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/PluginManager.php
@@ -45,6 +45,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
      */
     protected $aliases = [
         'aleph' => 'VuFind\ILS\Driver\Aleph',
+        'alma' => 'VuFind\ILS\Driver\Alma',
         'amicus' => 'VuFind\ILS\Driver\Amicus',
         'daia' => 'VuFind\ILS\Driver\DAIA',
         'demo' => 'VuFind\ILS\Driver\Demo',
@@ -78,6 +79,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
      */
     protected $factories = [
         'VuFind\ILS\Driver\Aleph' => 'VuFind\ILS\Driver\AlephFactory',
+        'VuFind\ILS\Driver\Alma' => 'VuFind\ILS\Driver\AlmaFactory',
         'VuFind\ILS\Driver\Amicus' => 'Zend\ServiceManager\Factory\InvokableFactory',
         'VuFind\ILS\Driver\DAIA' =>
             'VuFind\ILS\Driver\DriverWithDateConverterFactory',
diff --git a/themes/root/templates/Email/new-user-welcome.phtml b/themes/root/templates/Email/new-user-welcome.phtml
new file mode 100644
index 00000000000..fc5ff95d7da
--- /dev/null
+++ b/themes/root/templates/Email/new-user-welcome.phtml
@@ -0,0 +1,10 @@
+<?=$this->translate(
+    'new_user_welcome_text',
+    [
+        '%%library%%' => $this->library,
+        '%%firstname%%' => $this->firstname,
+        '%%lastname%%' => $this->lastname,
+        '%%username%%' => $this->username,
+        '%%url%%' => $this->url
+    ]);
+?>
\ No newline at end of file
-- 
GitLab