Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
45.21% covered (danger)
45.21%
302 / 668
34.92% covered (danger)
34.92%
22 / 63
CRAP
0.00% covered (danger)
0.00%
0 / 1
PAIA
45.21% covered (danger)
45.21%
302 / 668
34.92% covered (danger)
34.92%
22 / 63
7254.82
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCacheKey
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getSession
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getScope
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 init
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
5.31
 cancelHolds
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
72
 changePassword
43.48% covered (danger)
43.48%
20 / 46
0.00% covered (danger)
0.00%
0 / 1
15.85
 getCancelHoldDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultPickUpLocation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFunds
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 cancelStorageRetrievalRequests
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCancelStorageRetrievalRequestDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMyILLRequests
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 checkILLRequestIsValid
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 placeILLRequest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getILLPickupLibraries
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getILLPickupLocations
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 cancelILLRequests
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCancelILLRequestDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMyFines
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
7
 getAdditionalFeeData
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getMyHolds
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getMyProfile
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
4.01
 getReadableGroupType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMyTransactions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getMyStorageRetrievalRequests
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getNewItems
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPickUpLocations
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRenewDetails
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCallNumber
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 patronLogin
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
72
 paiaHandleErrors
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
182
 enrichUserDetails
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getConfirmations
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 placeHold
54.55% covered (warning)
54.55%
24 / 44
0.00% covered (danger)
0.00%
0 / 1
16.61
 placeStorageRetrievalRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renewMyItems
52.83% covered (warning)
52.83%
28 / 53
0.00% covered (danger)
0.00%
0 / 1
27.11
 paiaStatusString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 paiaGetItems
74.07% covered (warning)
74.07%
20 / 27
0.00% covered (danger)
0.00%
0 / 1
15.95
 getAlternativeItemId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 paiaParseUserDetails
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 mapPaiaItems
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
 getBasicDetails
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 myHoldsMapping
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 myStorageRetrievalRequestsMapping
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 myTransactionsMapping
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
10
 paiaPostRequest
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
3.23
 paiaGetRequest
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
2.06
 paiaParseJsonAsArray
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 paiaGetAsArray
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 paiaPostAsArray
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 paiaLogin
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 1
132
 paiaGetUserDetails
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 paiaCheckScope
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 checkStorageRetrievalRequestIsValid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkRequestIsValid
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
 paiaGetSystemMessages
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 enrichNotifications
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 paiaRemoveSystemMessage
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 paiaRemoveSystemMessages
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getPaiaNotificationsId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 paiaDeleteRequest
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 getAccountBlocks
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3/**
4 * PAIA ILS Driver for VuFind to get patron information
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Oliver Goldschmidt, Magda Roos, Till Kinstler, André Lahmann 2013,
9 * 2014, 2015.
10 *
11 * This program is free software; you can redistribute it and/or modify
12 * it under the terms of the GNU General Public License version 2,
13 * as published by the Free Software Foundation.
14 *
15 * This program is distributed in the hope that it will be useful,
16 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 * GNU General Public License for more details.
19 *
20 * You should have received a copy of the GNU General Public License
21 * along with this program; if not, write to the Free Software
22 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
23 *
24 * @category VuFind
25 * @package  ILS_Drivers
26 * @author   Oliver Goldschmidt <o.goldschmidt@tuhh.de>
27 * @author   Magdalena Roos <roos@gbv.de>
28 * @author   Till Kinstler <kinstler@gbv.de>
29 * @author   André Lahmann <lahmann@ub.uni-leipzig.de>
30 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
31 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
32 */
33
34namespace VuFind\ILS\Driver;
35
36use VuFind\Exception\Auth as AuthException;
37use VuFind\Exception\Forbidden as ForbiddenException;
38use VuFind\Exception\ILS as ILSException;
39
40use function count;
41use function in_array;
42use function is_array;
43use function is_callable;
44
45/**
46 * PAIA ILS Driver for VuFind to get patron information
47 *
48 * Holding information is obtained by DAIA, so it's not necessary to implement those
49 * functions here; we just need to extend the DAIA driver.
50 *
51 * @category VuFind
52 * @package  ILS_Drivers
53 * @author   Oliver Goldschmidt <o.goldschmidt@tuhh.de>
54 * @author   Magdalena Roos <roos@gbv.de>
55 * @author   Till Kinstler <kinstler@gbv.de>
56 * @author   André Lahmann <lahmann@ub.uni-leipzig.de>
57 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
58 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
59 */
60class PAIA extends DAIA
61{
62    /**
63     * URL of PAIA service
64     *
65     * @var string
66     */
67    protected $paiaURL;
68
69    /**
70     * Accepted grant_type for authorization
71     *
72     * @var string
73     */
74    protected $grantType = 'password';
75
76    /**
77     * Timeout in seconds to be used for PAIA http requests
78     *
79     * @var int
80     */
81    protected $paiaTimeout = null;
82
83    /**
84     * Flag to switch on/off caching for PAIA items
85     *
86     * @var bool
87     */
88    protected $paiaCacheEnabled = false;
89
90    /**
91     * Session containing PAIA login information
92     *
93     * @var \Laminas\Session\Container
94     */
95    protected $session;
96
97    /**
98     * SessionManager
99     *
100     * @var \Laminas\Session\SessionManager
101     */
102    protected $sessionManager;
103
104    /**
105     * PAIA status strings
106     *
107     * @var array
108     */
109    protected static $statusStrings = [
110        '0' => 'no relation',
111        '1' => 'reserved',
112        '2' => 'ordered',
113        '3' => 'held',
114        '4' => 'provided',
115        '5' => 'rejected',
116    ];
117
118    /**
119     * Account blocks that should be reported to the user.
120     *
121     * @see method `getAccountBlocks`
122     * @var array
123     */
124    protected $accountBlockNotificationsForMissingScopes;
125
126    /**
127     * PAIA scopes as defined in
128     * http://gbv.github.io/paia/paia.html#access-tokens-and-scopes
129     *
130     * Notice: logged in users should ALWAYS have scope read_patron as the PAIA
131     * driver performs paiaGetUserDetails() upon each call of VuFind's patronLogin().
132     * That means if paiaGetUserDetails() fails (which is the case if the patron has
133     * NOT the scope read_patron) the patronLogin() will fail as well even though
134     * paiaLogin() might have succeeded. Any other scope not being available for the
135     * patron will be handled more or less gracefully through exception handling.
136     */
137    public const SCOPE_READ_PATRON = 'read_patron';
138    public const SCOPE_UPDATE_PATRON = 'update_patron';
139    public const SCOPE_UPDATE_PATRON_NAME = 'update_patron_name';
140    public const SCOPE_UPDATE_PATRON_EMAIL = 'update_patron_email';
141    public const SCOPE_UPDATE_PATRON_ADDRESS = 'update_patron_address';
142    public const SCOPE_READ_FEES = 'read_fees';
143    public const SCOPE_READ_ITEMS = 'read_items';
144    public const SCOPE_WRITE_ITEMS = 'write_items';
145    public const SCOPE_CHANGE_PASSWORD = 'change_password';
146    public const SCOPE_READ_NOTIFICATIONS = 'read_notifications';
147    public const SCOPE_DELETE_NOTIFICATIONS = 'delete_notifications';
148
149    /**
150     * Constructor
151     *
152     * @param \VuFind\Date\Converter          $converter      Date converter
153     * @param \Laminas\Session\SessionManager $sessionManager Session Manager
154     */
155    public function __construct(
156        \VuFind\Date\Converter $converter,
157        \Laminas\Session\SessionManager $sessionManager
158    ) {
159        parent::__construct($converter);
160        $this->sessionManager = $sessionManager;
161    }
162
163    /**
164     * PAIA specific override of method to ensure uniform cache keys for cached
165     * VuFind objects.
166     *
167     * @param string|null $suffix Optional suffix that will get appended to the
168     * object class name calling getCacheKey()
169     *
170     * @return string
171     */
172    protected function getCacheKey($suffix = null)
173    {
174        return $this->getBaseCacheKey(
175            md5($this->baseUrl . $this->paiaURL) . $suffix
176        );
177    }
178
179    /**
180     * Get the session container (constructing it on demand if not already present)
181     *
182     * @return SessionContainer
183     */
184    protected function getSession()
185    {
186        // SessionContainer not defined yet? Build it now:
187        if (null === $this->session) {
188            $this->session = new \Laminas\Session\Container(
189                'PAIA',
190                $this->sessionManager
191            );
192        }
193        return $this->session;
194    }
195
196    /**
197     * Get the session scope
198     *
199     * @return array Array of the Session scope
200     */
201    protected function getScope()
202    {
203        return $this->getSession()->scope;
204    }
205
206    /**
207     * Initialize the driver.
208     *
209     * Validate configuration and perform all resource-intensive tasks needed to
210     * make the driver active.
211     *
212     * @throws ILSException
213     * @return void
214     */
215    public function init()
216    {
217        parent::init();
218
219        if (!(isset($this->config['PAIA']['baseUrl']))) {
220            throw new ILSException('PAIA/baseUrl configuration needs to be set.');
221        }
222        $this->paiaURL = $this->config['PAIA']['baseUrl'];
223
224        // read configured grantType
225        if (isset($this->config['PAIA']['grantType'])) {
226            $this->grantType = $this->config['PAIA']['grantType'];
227        }
228
229        // use PAIA specific timeout setting for http requests if configured
230        if ((isset($this->config['PAIA']['timeout']))) {
231            $this->paiaTimeout = $this->config['PAIA']['timeout'];
232        }
233
234        // do we have caching enabled for PAIA
235        if (isset($this->config['PAIA']['paiaCache'])) {
236            $this->paiaCacheEnabled = $this->config['PAIA']['paiaCache'];
237        } else {
238            $this->debug('Caching not enabled, disabling it by default.');
239        }
240
241        $this->accountBlockNotificationsForMissingScopes =
242            $this->config['PAIA']['accountBlockNotificationsForMissingScopes'] ?? [];
243    }
244
245    // public functions implemented to satisfy Driver Interface
246
247    /*
248    These methods are not implemented in the PAIA driver as they are probably
249    not necessary in PAIA context:
250    - findReserves
251    - getCancelHoldLink
252    - getConsortialHoldings
253    - getCourses
254    - getDepartments
255    - getHoldDefaultRequiredDate
256    - getInstructors
257    - getOfflineMode
258    - getSuppressedAuthorityRecords
259    - getSuppressedRecords
260    - hasHoldings
261    - loginIsHidden
262    - renewMyItemsLink
263    - supportsMethod
264    */
265
266    /**
267     * This method cancels a list of holds for a specific patron.
268     *
269     * @param array $cancelDetails An associative array with two keys:
270     *      patron   array returned by the driver's patronLogin method
271     *      details  an array of strings returned by the driver's
272     *               getCancelHoldDetails method
273     *
274     * @return array Associative array containing:
275     *      count   The number of items successfully cancelled
276     *      items   Associative array where key matches one of the item_id
277     *              values returned by getMyHolds and the value is an
278     *              associative array with these keys:
279     *                success    Boolean true or false
280     *                status     A status message from the language file
281     *                           (required â€“ VuFind-specific message,
282     *                           subject to translation)
283     *                sysMessage A system supplied failure message
284     */
285    public function cancelHolds($cancelDetails)
286    {
287        // check if user has appropriate scope (refer to scope declaration above for
288        // further details)
289        if (!$this->paiaCheckScope(self::SCOPE_WRITE_ITEMS)) {
290            throw new ForbiddenException(
291                'Exception::access_denied_write_items'
292            );
293        }
294
295        $it = $cancelDetails['details'];
296        $items = [];
297        foreach ($it as $item) {
298            $items[] = ['item' => stripslashes($item)];
299        }
300        $patron = $cancelDetails['patron'];
301        $post_data = ['doc' => $items];
302
303        try {
304            $array_response = $this->paiaPostAsArray(
305                'core/' . $patron['cat_username'] . '/cancel',
306                $post_data
307            );
308        } catch (\Exception $e) {
309            $this->debug($e->getMessage());
310            return [
311                'success' => false,
312                'status' => $e->getMessage(),
313            ];
314        }
315
316        $details = [];
317        $count = 0;
318        if (isset($array_response['error'])) {
319            $details[] = [
320                'success' => false,
321                'status' => $array_response['error_description'],
322                'sysMessage' => $array_response['error'],
323            ];
324        } else {
325            $elements = $array_response['doc'];
326            foreach ($elements as $element) {
327                $item_id = $element['item'];
328                if ($element['error'] ?? false) {
329                    $details[$item_id] = [
330                        'success' => false,
331                        'status' => $element['error'],
332                        'sysMessage' => 'Cancel request rejected',
333                    ];
334                } else {
335                    $details[$item_id] = [
336                        'success' => true,
337                        'status' => 'Success',
338                        'sysMessage' => 'Successfully cancelled',
339                    ];
340                    $count++;
341
342                    // DAIA cache cannot be cleared for particular item as PAIA only
343                    // operates with specific item URIs and the DAIA cache is setup
344                    // by doc URIs (containing items with URIs)
345                }
346            }
347
348            // If caching is enabled for PAIA clear the cache as at least for one
349            // item cancel was successful and therefore the status changed.
350            // Otherwise the changed status will not be shown before the cache
351            // expires.
352            if ($this->paiaCacheEnabled) {
353                $this->removeCachedData($patron['cat_username']);
354            }
355        }
356        $returnArray = ['count' => $count, 'items' => $details];
357
358        return $returnArray;
359    }
360
361    /**
362     * Public Function which changes the password in the library system
363     * (not supported prior to VuFind 2.4)
364     *
365     * @param array $details Array with patron information, newPassword and
366     *                       oldPassword.
367     *
368     * @return array An array with patron information.
369     */
370    public function changePassword($details)
371    {
372        // check if user has appropriate scope (refer to scope declaration above for
373        // further details)
374        if (!$this->paiaCheckScope(self::SCOPE_CHANGE_PASSWORD)) {
375            throw new ForbiddenException(
376                'Exception::access_denied_change_password'
377            );
378        }
379
380        $post_data = [
381            'patron'       => $details['patron']['cat_username'],
382            'username'     => $details['patron']['cat_username'],
383            'old_password' => $details['oldPassword'],
384            'new_password' => $details['newPassword'],
385        ];
386
387        try {
388            $array_response = $this->paiaPostAsArray(
389                'auth/change',
390                $post_data
391            );
392        } catch (AuthException $e) {
393            return [
394                'success' => false,
395                'status' => 'password_error_auth_old',
396            ];
397        } catch (\Exception $e) {
398            $this->debug($e->getMessage());
399            return [
400                'success' => false,
401                'status' => $e->getMessage(),
402            ];
403        }
404
405        $details = [];
406
407        if (isset($array_response['error'])) {
408            // on error
409            $details = [
410                'success'    => false,
411                'status'     => $array_response['error'],
412                'sysMessage' =>
413                    $array_response['error'] ?? ' ' .
414                    $array_response['error_description'] ?? ' ',
415            ];
416        } elseif (
417            isset($array_response['patron'])
418            && $array_response['patron'] === $post_data['patron']
419        ) {
420            // on success patron_id is returned
421            $details = [
422                'success' => true,
423                'status' => 'Successfully changed',
424            ];
425        } else {
426            $details = [
427                'success' => false,
428                'status' => 'Failure changing password',
429                'sysMessage' => serialize($array_response),
430            ];
431        }
432        return $details;
433    }
434
435    /**
436     * This method returns a string to use as the input form value for
437     * cancelling each hold item. (optional, but required if you
438     * implement cancelHolds). Not supported prior to VuFind 1.2
439     *
440     * @param array $hold   A single hold array from getMyHolds
441     * @param array $patron Patron information from patronLogin
442     *
443     * @return string  A string to use as the input form value for cancelling
444     *                 each hold item; you can pass any data that is needed
445     *                 by your ILS to identify the hold â€“ the output of this
446     *                 method will be used as part of the input to the
447     *                 cancelHolds method.
448     *
449     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
450     */
451    public function getCancelHoldDetails($hold, $patron = [])
452    {
453        return $hold['cancel_details'];
454    }
455
456    /**
457     * Get Default Pick Up Location
458     *
459     * @param array $patron      Patron information returned by the patronLogin
460     * method.
461     * @param array $holdDetails Optional array, only passed in when getting a list
462     * in the context of placing a hold; contains most of the same values passed to
463     * placeHold, minus the patron data. May be used to limit the pickup options
464     * or may be ignored.
465     *
466     * @return string       The default pickup location for the patron.
467     */
468    public function getDefaultPickUpLocation($patron = null, $holdDetails = null)
469    {
470        return false;
471    }
472
473    /**
474     * Get Funds
475     *
476     * Return a list of funds which may be used to limit the getNewItems list.
477     *
478     * @return array An associative array with key = fund ID, value = fund name.
479     */
480    public function getFunds()
481    {
482        // If you do not want or support such limits, just return an empty
483        // array here and the limit control on the new item search screen
484        // will disappear.
485        return [];
486    }
487
488    /**
489     * Cancel Storage Retrieval Request
490     *
491     * Attempts to Cancel a Storage Retrieval Request on a particular item. The
492     * data in $cancelDetails['details'] is determined by
493     * getCancelStorageRetrievalRequestDetails().
494     *
495     * @param array $cancelDetails An array of item and patron data
496     *
497     * @return array               An array of data on each request including
498     * whether or not it was successful and a system message (if available)
499     */
500    public function cancelStorageRetrievalRequests($cancelDetails)
501    {
502        // Not yet implemented
503        return [];
504    }
505
506    /**
507     * Get Cancel Storage Retrieval Request Details
508     *
509     * In order to cancel a hold, Voyager requires the patron details an item ID
510     * and a recall ID. This function returns the item id and recall id as a string
511     * separated by a pipe, which is then submitted as form data in Hold.php. This
512     * value is then extracted by the CancelHolds function.
513     *
514     * @param array $details An array of item data
515     * @param array $patron  Patron information from patronLogin
516     *
517     * @return string Data for use in a form field
518     *
519     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
520     */
521    public function getCancelStorageRetrievalRequestDetails($details, $patron)
522    {
523        // Not yet implemented
524        return '';
525    }
526
527    /**
528     * Get Patron ILL Requests
529     *
530     * This is responsible for retrieving all ILL requests by a specific patron.
531     *
532     * @param array $patron The patron array from patronLogin
533     *
534     * @return mixed        Array of the patron's ILL requests
535     *
536     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
537     */
538    public function getMyILLRequests($patron)
539    {
540        // Not yet implemented
541        return [];
542    }
543
544    /**
545     * Check if ILL request available
546     *
547     * This is responsible for determining if an item is requestable
548     *
549     * @param string $id     The Bib ID
550     * @param array  $data   An Array of item data
551     * @param array  $patron An array of patron data
552     *
553     * @return bool True if request is valid, false if not
554     *
555     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
556     */
557    public function checkILLRequestIsValid($id, $data, $patron)
558    {
559        // Not yet implemented
560        return false;
561    }
562
563    /**
564     * Place ILL Request
565     *
566     * Attempts to place an ILL request on a particular item and returns
567     * an array with result details
568     *
569     * @param array $details An array of item and patron data
570     *
571     * @return mixed An array of data on the request including
572     * whether or not it was successful and a system message (if available)
573     */
574    public function placeILLRequest($details)
575    {
576        // Not yet implemented
577        return [];
578    }
579
580    /**
581     * Get ILL Pickup Libraries
582     *
583     * This is responsible for getting information on the possible pickup libraries
584     *
585     * @param string $id     Record ID
586     * @param array  $patron Patron
587     *
588     * @return bool|array False if request not allowed, or an array of associative
589     * arrays with libraries.
590     *
591     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
592     */
593    public function getILLPickupLibraries($id, $patron)
594    {
595        // Not yet implemented
596        return false;
597    }
598
599    /**
600     * Get ILL Pickup Locations
601     *
602     * This is responsible for getting a list of possible pickup locations for a
603     * library
604     *
605     * @param string $id        Record ID
606     * @param string $pickupLib Pickup library ID
607     * @param array  $patron    Patron
608     *
609     * @return bool|array False if request not allowed, or an array of locations.
610     *
611     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
612     */
613    public function getILLPickupLocations($id, $pickupLib, $patron)
614    {
615        // Not yet implemented
616        return false;
617    }
618
619    /**
620     * Cancel ILL Request
621     *
622     * Attempts to Cancel an ILL request on a particular item. The
623     * data in $cancelDetails['details'] is determined by
624     * getCancelILLRequestDetails().
625     *
626     * @param array $cancelDetails An array of item and patron data
627     *
628     * @return array               An array of data on each request including
629     * whether or not it was successful and a system message (if available)
630     */
631    public function cancelILLRequests($cancelDetails)
632    {
633        // Not yet implemented
634        return [];
635    }
636
637    /**
638     * Get Cancel ILL Request Details
639     *
640     * @param array $details An array of item data
641     * @param array $patron  Patron information from patronLogin
642     *
643     * @return string Data for use in a form field
644     *
645     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
646     */
647    public function getCancelILLRequestDetails($details, $patron)
648    {
649        // Not yet implemented
650        return '';
651    }
652
653    /**
654     * Get Patron Fines
655     *
656     * This is responsible for retrieving all fines by a specific patron.
657     *
658     * @param array $patron The patron array from patronLogin
659     *
660     * @return mixed Array of the patron's fines on success
661     */
662    public function getMyFines($patron)
663    {
664        // check if user has appropriate scope (refer to scope declaration above for
665        // further details)
666        if (!$this->paiaCheckScope(self::SCOPE_READ_FEES)) {
667            throw new ForbiddenException('Exception::access_denied_read_fines');
668        }
669
670        $fees = $this->paiaGetAsArray(
671            'core/' . $patron['cat_username'] . '/fees'
672        );
673        // PAIA simple data type money: a monetary value with currency (format
674        // [0-9]+\.[0-9][0-9] [A-Z][A-Z][A-Z]), for instance 0.80 USD.
675        $feeConverter = function ($fee) {
676            $paiaCurrencyPattern = "/^([0-9]+\.[0-9][0-9]) ([A-Z][A-Z][A-Z])$/";
677            if (preg_match($paiaCurrencyPattern, $fee, $feeMatches)) {
678                // VuFind expects fees in PENNIES
679                return $feeMatches[1] * 100;
680            }
681            return $fee;
682        };
683
684        $results = [];
685        if (isset($fees['fee'])) {
686            foreach ($fees['fee'] as $fee) {
687                $result = [
688                    // fee.amount    1..1   money    amount of a single fee
689                    'amount'      => $feeConverter($fee['amount']),
690                    'checkout'    => '',
691                    // fee.feetype   0..1   string   textual description of the type
692                    // of service that caused the fee
693                    'fine'    => ($fee['feetype'] ?? null),
694                    'balance' => $feeConverter($fee['amount']),
695                    // fee.date      0..1   date     date when the fee was claimed
696                    'createdate'  => (isset($fee['date'])
697                        ? $this->convertDate($fee['date']) : null),
698                    'duedate' => '',
699                    // fee.edition   0..1   URI      edition that caused the fee
700                    'id' => (isset($fee['edition'])
701                        ? $this->getAlternativeItemId($fee['edition']) : ''),
702                ];
703                // custom PAIA fields can get added in getAdditionalFeeData
704                $results[] = $result + $this->getAdditionalFeeData($fee, $patron);
705            }
706        }
707        return $results;
708    }
709
710    /**
711     * Gets additional array fields for the item.
712     * Override this method in your custom PAIA driver if necessary.
713     *
714     * @param array $fee    The fee array from PAIA
715     * @param array $patron The patron array from patronLogin
716     *
717     * @return array Additional fee data for the item
718     */
719    protected function getAdditionalFeeData($fee, $patron = null)
720    {
721        $additionalData = [];
722        // The title is always displayed to the user in fines view if no record can
723        // be found for current fee. So always populate the title with content of
724        // about field.
725        if (isset($fee['about'])) {
726            $additionalData['title'] = $fee['about'];
727        }
728
729        // custom PAIA fields
730        // fee.about     0..1     string    textual information about the fee
731        // fee.item      0..1     URI       item that caused the fee
732        // fee.feeid     0..1     URI       URI of the type of service that
733        // caused the fee
734        $additionalData['feeid']      = ($fee['feeid'] ?? null);
735        $additionalData['about']      = ($fee['about'] ?? null);
736        $additionalData['item']       = ($fee['item'] ?? null);
737
738        return $additionalData;
739    }
740
741    /**
742     * Get Patron Holds
743     *
744     * This is responsible for retrieving all holds by a specific patron.
745     *
746     * @param array $patron The patron array from patronLogin
747     *
748     * @return mixed Array of the patron's holds on success.
749     */
750    public function getMyHolds($patron)
751    {
752        // filters for getMyHolds are by default configuration:
753        // status = 1 - reserved (the document is not accessible for the patron yet,
754        //              but it will be)
755        //          4 - provided (the document is ready to be used by the patron)
756        $status = $this->config['Holds']['status'] ?? '1:4';
757        $filter = ['status' => explode(':', $status)];
758        // get items-docs for given filters
759        $items = $this->paiaGetItems($patron, $filter);
760        return $this->mapPaiaItems($items, 'myHoldsMapping');
761    }
762
763    /**
764     * Get Patron Profile
765     *
766     * This is responsible for retrieving the profile for a specific patron.
767     *
768     * @param array $patron The patron array
769     *
770     * @return array Array of the patron's profile data on success,
771     */
772    public function getMyProfile($patron)
773    {
774        if (is_array($patron)) {
775            $type = isset($patron['type'])
776                ? implode(
777                    ', ',
778                    array_map(
779                        [$this, 'getReadableGroupType'],
780                        (array)$patron['type']
781                    )
782                )
783                : null;
784            return [
785                'firstname'  => $patron['firstname'],
786                'lastname'   => $patron['lastname'],
787                'address1'   => $patron['address'],
788                'address2'   => null,
789                'city'       => null,
790                'country'    => null,
791                'zip'        => null,
792                'phone'      => null,
793                'mobile_phone' => null,
794                'group'      => $type,
795                // PAIA specific custom values
796                'expires'    => isset($patron['expires'])
797                    ? $this->convertDate($patron['expires']) : null,
798                'statuscode' => $patron['status'] ?? null,
799                'canWrite'   => in_array(self::SCOPE_WRITE_ITEMS, $this->getScope()),
800            ];
801        }
802        return [];
803    }
804
805    /**
806     * Get Readable Group Type
807     *
808     * Due to PAIA specifications type returns an URI. This method offers a
809     * possibility to translate the URI in a readable value by inheritance
810     * and implementing a personal proceeding.
811     *
812     * @param string $type URI of usertype
813     *
814     * @return string URI of usertype
815     */
816    protected function getReadableGroupType($type)
817    {
818        return $type;
819    }
820
821    /**
822     * Get Patron Transactions
823     *
824     * This is responsible for retrieving all transactions (i.e. checked out items)
825     * by a specific patron.
826     *
827     * @param array $patron The patron array from patronLogin
828     *
829     * @return array Array of the patron's transactions on success,
830     */
831    public function getMyTransactions($patron)
832    {
833        // filters for getMyTransactions are by default configuration:
834        // status = 3 - held (the document is on loan by the patron)
835        $status = $this->config['Transactions']['status'] ?? '3';
836        $filter = ['status' => explode(':', $status)];
837        // get items-docs for given filters
838        $items = $this->paiaGetItems($patron, $filter);
839        return $this->mapPaiaItems($items, 'myTransactionsMapping');
840    }
841
842    /**
843     * Get Patron StorageRetrievalRequests
844     *
845     * This is responsible for retrieving all storage retrieval requests
846     * by a specific patron.
847     *
848     * @param array $patron The patron array from patronLogin
849     *
850     * @return array Array of the patron's storage retrieval requests on success,
851     */
852    public function getMyStorageRetrievalRequests($patron)
853    {
854        // filters for getMyStorageRetrievalRequests are by default configuration:
855        // status = 2 - ordered (the document is ordered by the patron)
856        $status = $this->config['StorageRetrievalRequests']['status'] ?? '2';
857        $filter = ['status' => explode(':', $status)];
858        // get items-docs for given filters
859        $items = $this->paiaGetItems($patron, $filter);
860        return $this->mapPaiaItems($items, 'myStorageRetrievalRequestsMapping');
861    }
862
863    /**
864     * This method queries the ILS for new items
865     *
866     * @param string $page    page number of results to retrieve (counting starts @1)
867     * @param string $limit   the size of each page of results to retrieve
868     * @param string $daysOld the maximum age of records to retrieve in days (max 30)
869     * @param string $fundID  optional fund ID to use for limiting results
870     *
871     * @return array An associative array with two keys: 'count' (the number of items
872     * in the 'results' array) and 'results' (an array of associative arrays, each
873     * with a single key: 'id', a record ID).
874     */
875    public function getNewItems($page, $limit, $daysOld, $fundID)
876    {
877        return [];
878    }
879
880    /**
881     * Get Pick Up Locations
882     *
883     * This is responsible for gettting a list of valid library locations for
884     * holds / recall retrieval
885     *
886     * @param array $patron      Patron information returned by the patronLogin
887     * method.
888     * @param array $holdDetails Optional array, only passed in when getting a list
889     * in the context of placing or editing a hold. When placing a hold, it contains
890     * most of the same values passed to placeHold, minus the patron data. When
891     * editing a hold it contains all the hold information returned by getMyHolds.
892     * May be used to limit the pickup options or may be ignored. The driver must
893     * not add new options to the return array based on this data or other areas of
894     * VuFind may behave incorrectly.
895     *
896     * @return array        An array of associative arrays with locationID and
897     * locationDisplay keys
898     */
899    public function getPickUpLocations($patron = null, $holdDetails = null)
900    {
901        // How to get valid PickupLocations for a PICA LBS?
902        return [];
903    }
904
905    /**
906     * This method returns a string to use as the input form value for renewing
907     * each hold item. (optional, but required if you implement the
908     * renewMyItems method) Not supported prior to VuFind 1.2
909     *
910     * @param array $checkOutDetails One of the individual item arrays returned by
911     *                               the getMyTransactions method
912     *
913     * @return string A string to use as the input form value for renewing
914     *                each item; you can pass any data that is needed by your
915     *                ILS to identify the transaction to renew â€“ the output
916     *                of this method will be used as part of the input to the
917     *                renewMyItems method.
918     */
919    public function getRenewDetails($checkOutDetails)
920    {
921        return $checkOutDetails['renew_details'];
922    }
923
924    /**
925     * Get the callnumber of this item
926     *
927     * @param array $doc Array of PAIA item.
928     *
929     * @return String
930     */
931    protected function getCallNumber($doc)
932    {
933        return $doc['label'] ?? null;
934    }
935
936    /**
937     * Patron Login
938     *
939     * This is responsible for authenticating a patron against the catalog.
940     *
941     * @param string $username The patron's username
942     * @param string $password The patron's login password
943     *
944     * @return mixed          Associative array of patron info on successful login,
945     * null on unsuccessful login.
946     *
947     * @throws ILSException
948     */
949    public function patronLogin($username, $password)
950    {
951        // check also for grantType as patron's password is never required when
952        // grantType = client_credentials is configured
953        if (
954            $username == ''
955            || ($password == '' && $this->grantType != 'client_credentials')
956        ) {
957            throw new ILSException('Invalid Login, Please try again.');
958        }
959
960        $session = $this->getSession();
961
962        // if we already have a session with access_token and patron id, try to get
963        // patron info with session data
964        if (isset($session->expires) && $session->expires > time()) {
965            return $this->enrichUserDetails(
966                $this->paiaGetUserDetails($session->patron),
967                $password
968            );
969        }
970        try {
971            if ($this->paiaLogin($username, $password)) {
972                return $this->enrichUserDetails(
973                    $this->paiaGetUserDetails($session->patron),
974                    $password
975                );
976            }
977        } catch (AuthException $e) {
978            // swallow auth exceptions and return null compliant to spec at:
979            // https://vufind.org/wiki/development:plugins:ils_drivers#patronlogin
980            return null;
981        }
982    }
983
984    /**
985     * Handle PAIA request errors and throw appropriate exception.
986     *
987     * @param array $array Array containing error messages
988     *
989     * @return void
990     *
991     * @throws AuthException
992     * @throws ILSException
993     */
994    protected function paiaHandleErrors($array)
995    {
996        // TODO: also have exception contain content of 'error' as for at least
997        //       error code 403 two differing errors are possible
998        //       (cf. http://gbv.github.io/paia/paia.html#request-errors)
999        if (isset($array['error'])) {
1000            switch ($array['error']) {
1001                case 'access_denied':
1002                    // error        code    error_description
1003                    // access_denied     403     Wrong or missing credentials to get
1004                    //                           an access token
1005                    throw new AuthException(
1006                        $array['error_description'] ?? $array['error'],
1007                        (int)($array['code'] ?? 0)
1008                    );
1009
1010                case 'invalid_grant':
1011                    // invalid_grant     401     The access token was missing,
1012                    //                           invalid or expired
1013
1014                case 'insufficient_scope':
1015                    // insufficient_scope   403   The access token was accepted but
1016                    //                            it lacks permission for the request
1017                    throw new ForbiddenException(
1018                        $array['error_description'] ?? $array['error'],
1019                        (int)($array['code'] ?? 0)
1020                    );
1021
1022                case 'not_found':
1023                    // not_found     404     Unknown request URL or unknown patron.
1024                    //                       Implementations SHOULD first check
1025                    //                       authentication and prefer error
1026                    //                       invalid_grant or access_denied to
1027                    //                       prevent leaking patron identifiers.
1028
1029                case 'not_implemented':
1030                    // not_implemented     501     Known but unsupported request URL
1031                    //                             (for instance a PAIA auth server
1032                    //                             server may not implement
1033                    //                             http://example.org/core/change)
1034
1035                case 'invalid_request':
1036                    // invalid_request     405     Unexpected HTTP verb
1037                    // invalid_request     400     Malformed request (for instance
1038                    //                             error parsing JSON, unsupported
1039                    //                             request content type, etc.)
1040                    // invalid_request     422     The request parameters could be
1041                    //                             parsed but they don’t match the
1042                    //                             request method (for instance
1043                    //                             missing fields, invalid values,
1044                    //                             etc.)
1045
1046                case 'internal_error':
1047                    // internal_error     500     An unexpected error occurred. This
1048                    //                            error corresponds to a bug in the
1049                    //                            implementation of a PAIA auth/core
1050                    //                            server
1051
1052                case 'service_unavailable':
1053                    // service_unavailable    503    The request couldn’t be serviced
1054                    //                               because of a temporary failure
1055
1056                case 'bad_gateway':
1057                    // bad_gateway    502    The request couldn’t be serviced because
1058                    //                       of a backend failure (for instance the
1059                    //                       library system’s database)
1060
1061                case 'gateway_timeout':
1062                    // gateway_timeout     504     The request couldn’t be serviced
1063                    //                             because of a backend failure
1064
1065                default:
1066                    throw new ILSException(
1067                        $array['error_description'] ?? $array['error'],
1068                        (int)($array['code'] ?? 0)
1069                    );
1070            }
1071        }
1072    }
1073
1074    /**
1075     * PAIA helper function to map session data to return value of patronLogin()
1076     *
1077     * @param array  $details  Patron details returned by patronLogin
1078     * @param string $password Patron catalogue password
1079     *
1080     * @return mixed
1081     */
1082    protected function enrichUserDetails($details, $password)
1083    {
1084        $session = $this->getSession();
1085
1086        $details['cat_username'] = $session->patron;
1087        $details['cat_password'] = $password;
1088        return $details;
1089    }
1090
1091    /**
1092     * Returns an array with PAIA confirmations based on the given holdDetails which
1093     * will be used for a request.
1094     * Currently two condition types are supported:
1095     *  - http://purl.org/ontology/paia#StorageCondition to select a document
1096     *    location -- mapped to pickUpLocation
1097     *  - http://purl.org/ontology/paia#FeeCondition to confirm or select a document
1098     *    service causing a fee -- not mapped yet
1099     *
1100     * @param array $holdDetails An array of item and patron data
1101     *
1102     * @return array
1103     */
1104    protected function getConfirmations($holdDetails)
1105    {
1106        $confirmations = [];
1107        if (isset($holdDetails['pickUpLocation'])) {
1108            $confirmations['http://purl.org/ontology/paia#StorageCondition']
1109                = [$holdDetails['pickUpLocation']];
1110        }
1111        return $confirmations;
1112    }
1113
1114    /**
1115     * Place Hold
1116     *
1117     * Attempts to place a hold or recall on a particular item and returns
1118     * an array with result details
1119     *
1120     * Make a request on a specific record
1121     *
1122     * @param array $holdDetails An array of item and patron data
1123     *
1124     * @return mixed An array of data on the request including
1125     * whether or not it was successful and a system message (if available)
1126     */
1127    public function placeHold($holdDetails)
1128    {
1129        // check if user has appropriate scope (refer to scope declaration above for
1130        // further details)
1131        if (!$this->paiaCheckScope(self::SCOPE_WRITE_ITEMS)) {
1132            throw new ForbiddenException(
1133                'Exception::access_denied_write_items'
1134            );
1135        }
1136
1137        $item = $holdDetails['item_id'];
1138        $patron = $holdDetails['patron'];
1139
1140        $doc = [];
1141        $doc['item'] = stripslashes($item);
1142        if ($confirm = $this->getConfirmations($holdDetails)) {
1143            $doc['confirm'] = $confirm;
1144        }
1145        $post_data = [];
1146        $post_data['doc'][] = $doc;
1147
1148        try {
1149            $array_response = $this->paiaPostAsArray(
1150                'core/' . $patron['cat_username'] . '/request',
1151                $post_data
1152            );
1153        } catch (\Exception $e) {
1154            $this->debug($e->getMessage());
1155            return [
1156                'success' => false,
1157                'sysMessage' => $e->getMessage(),
1158            ];
1159        }
1160
1161        $details = [];
1162        if (isset($array_response['error'])) {
1163            $details = [
1164                'success' => false,
1165                'sysMessage' => $array_response['error_description'],
1166            ];
1167        } else {
1168            $elements = $array_response['doc'];
1169            foreach ($elements as $element) {
1170                if (isset($element['error'])) {
1171                    $details = [
1172                        'success' => false,
1173                        'sysMessage' => $element['error'],
1174                    ];
1175                } else {
1176                    $details = [
1177                        'success' => true,
1178                        'sysMessage' => 'Successfully requested',
1179                    ];
1180                    // if caching is enabled for DAIA remove the cached data for the
1181                    // current item otherwise the changed status will not be shown
1182                    // before the cache expires
1183                    if ($this->daiaCacheEnabled) {
1184                        $this->removeCachedData($holdDetails['doc_id']);
1185                    }
1186
1187                    if ($this->paiaCacheEnabled) {
1188                        $this->removeCachedData($patron['cat_username']);
1189                    }
1190                }
1191            }
1192        }
1193        return $details;
1194    }
1195
1196    /**
1197     * Place a Storage Retrieval Request
1198     *
1199     * Attempts to place a request on a particular item and returns
1200     * an array with result details.
1201     *
1202     * @param array $details An array of item and patron data
1203     *
1204     * @return mixed An array of data on the request including
1205     * whether or not it was successful and a system message (if available)
1206     */
1207    public function placeStorageRetrievalRequest($details)
1208    {
1209        // Making a storage retrieval request is the same in PAIA as placing a Hold
1210        return $this->placeHold($details);
1211    }
1212
1213    /**
1214     * This method renews a list of items for a specific patron.
1215     *
1216     * @param array $details - An associative array with two keys:
1217     *      patron - array returned by patronLogin method
1218     *      details - array of values returned by the getRenewDetails method
1219     *                identifying which items to renew
1220     *
1221     * @return array - An associative array with two keys:
1222     *     blocks - An array of strings specifying why a user is blocked from
1223     *              renewing (false if no blocks)
1224     *     details - Not set when blocks exist; otherwise, an array of
1225     *               associative arrays (keyed by item ID) with each subarray
1226     *               containing these keys:
1227     *                  success â€“ Boolean true or false
1228     *                  new_date â€“ string â€“ A new due date
1229     *                  new_time â€“ string â€“ A new due time
1230     *                  item_id â€“ The item id of the renewed item
1231     *                  sysMessage â€“ A system supplied renewal message (optional)
1232     */
1233    public function renewMyItems($details)
1234    {
1235        // check if user has appropriate scope (refer to scope declaration above for
1236        // further details)
1237        if (!$this->paiaCheckScope(self::SCOPE_WRITE_ITEMS)) {
1238            throw new ForbiddenException(
1239                'Exception::access_denied_write_items'
1240            );
1241        }
1242
1243        $it = $details['details'];
1244        $items = [];
1245        foreach ($it as $item) {
1246            $items[] = ['item' => stripslashes($item)];
1247        }
1248        $patron = $details['patron'];
1249        $post_data = ['doc' => $items];
1250
1251        try {
1252            $array_response = $this->paiaPostAsArray(
1253                'core/' . $patron['cat_username'] . '/renew',
1254                $post_data
1255            );
1256        } catch (\Exception $e) {
1257            $this->debug($e->getMessage());
1258            return [
1259                'success' => false,
1260                'sysMessage' => $e->getMessage(),
1261            ];
1262        }
1263
1264        $details = [];
1265
1266        if (isset($array_response['error'])) {
1267            $details[] = [
1268                'success' => false,
1269                'sysMessage' => $array_response['error_description'],
1270            ];
1271        } else {
1272            $elements = $array_response['doc'];
1273            foreach ($elements as $element) {
1274                // VuFind can only assign the response to an id - if none is given
1275                // (which is possible) simply skip this response element
1276                if (isset($element['item'])) {
1277                    if (isset($element['error'])) {
1278                        $details[$element['item']] = [
1279                            'success' => false,
1280                            'sysMessage' => $element['error'],
1281                        ];
1282                    } elseif ($element['status'] == '3') {
1283                        $details[$element['item']] = [
1284                            'success'  => true,
1285                            'new_date' => isset($element['endtime'])
1286                                ? $this->convertDatetime($element['endtime']) : '',
1287                            'item_id'  => 0,
1288                            'sysMessage' => 'Successfully renewed',
1289                        ];
1290                    } else {
1291                        $details[$element['item']] = [
1292                            'success'  => false,
1293                            'item_id'  => 0,
1294                            'new_date' => isset($element['endtime'])
1295                                ? $this->convertDatetime($element['endtime']) : '',
1296                            'sysMessage' => 'Request rejected',
1297                        ];
1298                    }
1299                }
1300
1301                // DAIA cache cannot be cleared for particular item as PAIA only
1302                // operates with specific item URIs and the DAIA cache is setup
1303                // by doc URIs (containing items with URIs)
1304            }
1305
1306            // If caching is enabled for PAIA clear the cache as at least for one
1307            // item renew was successful and therefore the status changed. Otherwise
1308            // the changed status will not be shown before the cache expires.
1309            if ($this->paiaCacheEnabled) {
1310                $this->removeCachedData($patron['cat_username']);
1311            }
1312        }
1313        $returnArray = ['blocks' => false, 'details' => $details];
1314        return $returnArray;
1315    }
1316
1317    /*
1318     * PAIA functions
1319     */
1320
1321    /**
1322     * PAIA support method to return strings for PAIA service status values
1323     *
1324     * @param string $status PAIA service status
1325     *
1326     * @return string Describing PAIA service status
1327     */
1328    protected function paiaStatusString($status)
1329    {
1330        return self::$statusStrings[$status] ?? '';
1331    }
1332
1333    /**
1334     * PAIA support method for PAIA core method 'items' returning only those
1335     * documents containing the given service status.
1336     *
1337     * @param array $patron Array with patron information
1338     * @param array $filter Array of properties identifying the wanted items
1339     *
1340     * @return array|mixed Array of documents containing the given filter properties
1341     */
1342    protected function paiaGetItems($patron, $filter = [])
1343    {
1344        // check if user has appropriate scope (refer to scope declaration above for
1345        // further details)
1346        if (!$this->paiaCheckScope(self::SCOPE_READ_ITEMS)) {
1347            throw new ForbiddenException('Exception::access_denied_read_items');
1348        }
1349
1350        // check for existing data in cache
1351        $itemsResponse = $this->paiaCacheEnabled
1352            ? $this->getCachedData($patron['cat_username']) : null;
1353
1354        if (!isset($itemsResponse) || $itemsResponse == null) {
1355            $itemsResponse = $this->paiaGetAsArray(
1356                'core/' . $patron['cat_username'] . '/items'
1357            );
1358            if ($this->paiaCacheEnabled) {
1359                $this->putCachedData($patron['cat_username'], $itemsResponse);
1360            }
1361        }
1362
1363        if (isset($itemsResponse['doc'])) {
1364            if (count($filter)) {
1365                $filteredItems = [];
1366                foreach ($itemsResponse['doc'] as $doc) {
1367                    $filterCounter = 0;
1368                    foreach ($filter as $filterKey => $filterValue) {
1369                        if (
1370                            isset($doc[$filterKey])
1371                            && in_array($doc[$filterKey], (array)$filterValue)
1372                        ) {
1373                            $filterCounter++;
1374                        }
1375                    }
1376                    if ($filterCounter == count($filter)) {
1377                        $filteredItems[] = $doc;
1378                    }
1379                }
1380                return $filteredItems;
1381            } else {
1382                return $itemsResponse;
1383            }
1384        } else {
1385            $this->debug(
1386                'No documents found in PAIA response. Returning empty array.'
1387            );
1388        }
1389        return [];
1390    }
1391
1392    /**
1393     * PAIA support method to retrieve needed ItemId in case PAIA-response does not
1394     * contain it
1395     *
1396     * @param string $id itemId
1397     *
1398     * @return string $id
1399     */
1400    protected function getAlternativeItemId($id)
1401    {
1402        return $id;
1403    }
1404
1405    /**
1406     * PAIA support function to implement ILS specific parsing of user_details
1407     *
1408     * @param string $patron        User id
1409     * @param array  $user_response Array with PAIA response data
1410     *
1411     * @return array
1412     */
1413    protected function paiaParseUserDetails($patron, $user_response)
1414    {
1415        $username = trim($user_response['name']);
1416        if (count(explode(',', $username)) == 2) {
1417            $nameArr = explode(',', $username);
1418            $firstname = $nameArr[1];
1419            $lastname = $nameArr[0];
1420        } else {
1421            $nameArr = explode(' ', $username);
1422            $lastname = array_pop($nameArr);
1423            $firstname = trim(implode(' ', $nameArr));
1424        }
1425
1426        // TODO: implement parsing of user details according to types set
1427        // (cf. https://github.com/gbv/paia/issues/29)
1428
1429        $user = [];
1430        $user['id']        = $patron;
1431        $user['firstname'] = $firstname;
1432        $user['lastname']  = $lastname;
1433        $user['email']     = ($user_response['email'] ?? '');
1434        $user['major']     = null;
1435        $user['college']   = null;
1436        // add other information from PAIA - we don't want anything to get lost
1437        // while parsing
1438        foreach ($user_response as $key => $value) {
1439            if (!isset($user[$key])) {
1440                $user[$key] = $value;
1441            }
1442        }
1443        return $user;
1444    }
1445
1446    /**
1447     * PAIA helper function to allow customization of mapping from PAIA response to
1448     * VuFind ILS-method return values.
1449     *
1450     * @param array  $items   Array of PAIA items to be mapped
1451     * @param string $mapping String identifying a custom mapping-method
1452     *
1453     * @return array
1454     */
1455    protected function mapPaiaItems($items, $mapping)
1456    {
1457        if (is_callable([$this, $mapping])) {
1458            return $this->$mapping($items);
1459        }
1460
1461        $this->debug('Could not call method: ' . $mapping . '() .');
1462        return [];
1463    }
1464
1465    /**
1466     * Map a PAIA document to an array for use in generating a VuFind request
1467     * (holds, storage retrieval, etc).
1468     *
1469     * @param array $doc Array of PAIA document to be mapped.
1470     *
1471     * @return array
1472     */
1473    protected function getBasicDetails($doc)
1474    {
1475        $result = [];
1476
1477        // item (0..1) URI of a particular copy
1478        $result['item_id'] = ($doc['item'] ?? '');
1479
1480        $result['cancel_details']
1481            = (isset($doc['cancancel']) && $doc['cancancel']
1482            && $this->paiaCheckScope(self::SCOPE_WRITE_ITEMS))
1483            ? $result['item_id'] : '';
1484
1485        // edition (0..1) URI of a the document (no particular copy)
1486        // hook for retrieving alternative ItemId in case PAIA does not
1487        // the needed id
1488        $result['id'] = (isset($doc['edition'])
1489            ? $this->getAlternativeItemId($doc['edition']) : '');
1490
1491        $result['type'] = $this->paiaStatusString($doc['status']);
1492
1493        // storage (0..1) textual description of location of the document
1494        $result['location'] = ($doc['storage'] ?? null);
1495
1496        // queue (0..1) number of waiting requests for the document or item
1497        $result['position'] = ($doc['queue'] ?? null);
1498
1499        // only true if status == 4
1500        $result['available'] = false;
1501
1502        // about (0..1) textual description of the document
1503        $result['title'] = ($doc['about'] ?? null);
1504
1505        // PAIA custom field
1506        // label (0..1) call number, shelf mark or similar item label
1507        $result['callnumber'] = $this->getCallNumber($doc);
1508
1509        /*
1510         * meaning of starttime and endtime depends on status:
1511         *
1512         * status | starttime
1513         *        | endtime
1514         * -------+--------------------------------
1515         * 0      | -
1516         *        | -
1517         * 1      | when the document was reserved
1518         *        | when the reserved document is expected to be available
1519         * 2      | when the document was ordered
1520         *        | when the ordered document is expected to be available
1521         * 3      | when the document was lend
1522         *        | when the loan period ends or ended (due)
1523         * 4      | when the document is provided
1524         *        | when the provision will expire
1525         * 5      | when the request was rejected
1526         *        | -
1527         */
1528
1529        $result['create'] = (isset($doc['starttime'])
1530            ? $this->convertDatetime($doc['starttime']) : '');
1531
1532        // Optional VuFind fields
1533        /*
1534        $result['reqnum'] = null;
1535        $result['volume'] = null;
1536        $result['publication_year'] = null;
1537        $result['isbn'] = null;
1538        $result['issn'] = null;
1539        $result['oclc'] = null;
1540        $result['upc'] = null;
1541        */
1542
1543        return $result;
1544    }
1545
1546    /**
1547     * This PAIA helper function allows custom overrides for mapping of PAIA response
1548     * to getMyHolds data structure.
1549     *
1550     * @param array $items Array of PAIA items to be mapped.
1551     *
1552     * @return array
1553     */
1554    protected function myHoldsMapping($items)
1555    {
1556        $results = [];
1557
1558        foreach ($items as $doc) {
1559            $result = $this->getBasicDetails($doc);
1560
1561            if ($doc['status'] == '4') {
1562                $result['expire'] = (isset($doc['endtime'])
1563                    ? $this->convertDatetime($doc['endtime']) : '');
1564            } else {
1565                $result['duedate'] = (isset($doc['endtime'])
1566                    ? $this->convertDatetime($doc['endtime']) : '');
1567            }
1568
1569            // status: provided (the document is ready to be used by the patron)
1570            $result['available'] = $doc['status'] == 4 ? true : false;
1571
1572            $results[] = $result;
1573        }
1574        return $results;
1575    }
1576
1577    /**
1578     * This PAIA helper function allows custom overrides for mapping of PAIA response
1579     * to getMyStorageRetrievalRequests data structure.
1580     *
1581     * @param array $items Array of PAIA items to be mapped.
1582     *
1583     * @return array
1584     */
1585    protected function myStorageRetrievalRequestsMapping($items)
1586    {
1587        $results = [];
1588
1589        foreach ($items as $doc) {
1590            $result = $this->getBasicDetails($doc);
1591
1592            $results[] = $result;
1593        }
1594        return $results;
1595    }
1596
1597    /**
1598     * This PAIA helper function allows custom overrides for mapping of PAIA response
1599     * to getMyTransactions data structure.
1600     *
1601     * @param array $items Array of PAIA items to be mapped.
1602     *
1603     * @return array
1604     */
1605    protected function myTransactionsMapping($items)
1606    {
1607        $results = [];
1608
1609        foreach ($items as $doc) {
1610            $result = $this->getBasicDetails($doc);
1611
1612            // canrenew (0..1) whether a document can be renewed (bool)
1613            $result['renewable'] = (isset($doc['canrenew'])
1614                && $this->paiaCheckScope(self::SCOPE_WRITE_ITEMS))
1615                ? $doc['canrenew'] : false;
1616
1617            $result['renew_details']
1618                = (isset($doc['canrenew']) && $doc['canrenew']
1619                && $this->paiaCheckScope(self::SCOPE_WRITE_ITEMS))
1620                ? $result['item_id'] : '';
1621
1622            // queue (0..1) number of waiting requests for the document or item
1623            $result['request'] = ($doc['queue'] ?? null);
1624
1625            // renewals (0..1) number of times the document has been renewed
1626            $result['renew'] = ($doc['renewals'] ?? null);
1627
1628            // reminder (0..1) number of times the patron has been reminded
1629            $result['reminder'] = (
1630                $doc['reminder'] ?? null
1631            );
1632
1633            // custom PAIA field
1634            // starttime (0..1) date and time when the status began
1635            $result['startTime'] = (isset($doc['starttime'])
1636                ? $this->convertDatetime($doc['starttime']) : '');
1637
1638            // endtime (0..1) date and time when the status will expire
1639            $result['dueTime'] = (isset($doc['endtime'])
1640                ? $this->convertDatetime($doc['endtime']) : '');
1641
1642            // duedate (0..1) date when the current status will expire (deprecated)
1643            $result['duedate'] = (isset($doc['duedate'])
1644                ? $this->convertDate($doc['duedate']) : '');
1645
1646            // cancancel (0..1) whether an ordered or provided document can be
1647            // canceled
1648
1649            // error (0..1) error message, for instance if a request was rejected
1650            $result['message'] = ($doc['error'] ?? '');
1651
1652            // storage (0..1) textual description of location of the document
1653            $result['borrowingLocation'] = ($doc['storage'] ?? '');
1654
1655            // storageid (0..1) location URI
1656
1657            // Optional VuFind fields
1658            /*
1659            $result['barcode'] = null;
1660            $result['dueStatus'] = null;
1661            $result['renewLimit'] = "1";
1662            $result['volume'] = null;
1663            $result['publication_year'] = null;
1664            $result['isbn'] = null;
1665            $result['issn'] = null;
1666            $result['oclc'] = null;
1667            $result['upc'] = null;
1668            $result['institution_name'] = null;
1669            */
1670
1671            $results[] = $result;
1672        }
1673
1674        return $results;
1675    }
1676
1677    /**
1678     * Post something to a foreign host
1679     *
1680     * @param string $file         POST target URL
1681     * @param string $data_to_send POST data
1682     * @param string $access_token PAIA access token for current session
1683     *
1684     * @return string POST response
1685     * @throws ILSException
1686     */
1687    protected function paiaPostRequest($file, $data_to_send, $access_token = null)
1688    {
1689        // json-encoding
1690        $postData = json_encode($data_to_send);
1691
1692        $http_headers = [];
1693        if (isset($access_token)) {
1694            $http_headers['Authorization'] = 'Bearer ' . $access_token;
1695        }
1696
1697        $result = $this->httpService->post(
1698            $this->paiaURL . $file,
1699            $postData,
1700            'application/json; charset=UTF-8',
1701            $this->paiaTimeout,
1702            $http_headers
1703        );
1704
1705        if (!$result->isSuccess()) {
1706            // log error for debugging
1707            $this->debug(
1708                'HTTP status ' . $result->getStatusCode() .
1709                ' received'
1710            );
1711        }
1712        // return any result as error-handling is done elsewhere
1713        return $result->getBody();
1714    }
1715
1716    /**
1717     * GET data from foreign host
1718     *
1719     * @param string $file         GET target URL
1720     * @param string $access_token PAIA access token for current session
1721     *
1722     * @return bool|string
1723     * @throws ILSException
1724     */
1725    protected function paiaGetRequest($file, $access_token)
1726    {
1727        $http_headers = [
1728            'Authorization' => 'Bearer ' . $access_token,
1729            'Content-type' => 'application/json; charset=UTF-8',
1730        ];
1731        $result = $this->httpService->get(
1732            $this->paiaURL . $file,
1733            [],
1734            $this->paiaTimeout,
1735            $http_headers
1736        );
1737        if (!$result->isSuccess()) {
1738            // log error for debugging
1739            $this->debug(
1740                'HTTP status ' . $result->getStatusCode() .
1741                ' received'
1742            );
1743        }
1744        // return any result as error-handling is done elsewhere
1745        return $result->getBody();
1746    }
1747
1748    /**
1749     * Helper function for PAIA to uniformely parse JSON
1750     *
1751     * @param string $file JSON data
1752     *
1753     * @return mixed
1754     * @throws \Exception
1755     */
1756    protected function paiaParseJsonAsArray($file)
1757    {
1758        $responseArray = json_decode($file, true);
1759
1760        // if we have an error response handle it accordingly (any will throw an
1761        // exception at the moment) and pass on the resulting exception
1762        if (isset($responseArray['error'])) {
1763            $this->paiaHandleErrors($responseArray);
1764        }
1765
1766        return $responseArray;
1767    }
1768
1769    /**
1770     * Retrieve file at given URL and return it as json_decoded array
1771     *
1772     * @param string $file GET target URL
1773     *
1774     * @return array|mixed
1775     * @throws ILSException
1776     */
1777    protected function paiaGetAsArray($file)
1778    {
1779        $responseJson = $this->paiaGetRequest(
1780            $file,
1781            $this->getSession()->access_token
1782        );
1783        $responseArray = $this->paiaParseJsonAsArray($responseJson);
1784        return $responseArray;
1785    }
1786
1787    /**
1788     * Post something at given URL and return it as json_decoded array
1789     *
1790     * @param string $file POST target URL
1791     * @param array  $data POST data
1792     *
1793     * @return array|mixed
1794     * @throws ILSException
1795     */
1796    protected function paiaPostAsArray($file, $data)
1797    {
1798        $responseJson = $this->paiaPostRequest(
1799            $file,
1800            $data,
1801            $this->getSession()->access_token
1802        );
1803        $responseArray = $this->paiaParseJsonAsArray($responseJson);
1804        return $responseArray;
1805    }
1806
1807    /**
1808     * PAIA authentication function
1809     *
1810     * @param string $username Username
1811     * @param string $password Password
1812     *
1813     * @return mixed Associative array of patron info on successful login,
1814     * null on unsuccessful login, PEAR_Error on error.
1815     * @throws ILSException
1816     */
1817    protected function paiaLogin($username, $password)
1818    {
1819        // as PAIA supports two authentication methods (defined as grant_type:
1820        // password or client_credentials), check which one is configured
1821        if (!in_array($this->grantType, ['password', 'client_credentials'])) {
1822            throw new ILSException(
1823                'Unsupported PAIA grant_type configured: ' . $this->grantType
1824            );
1825        }
1826
1827        // prepare http header
1828        $header_data = [];
1829
1830        // prepare post data depending on configured grant type
1831        $post_data = [];
1832        switch ($this->grantType) {
1833            case 'password':
1834                $post_data['username'] = $username;
1835                $post_data['password'] = $password;
1836                break;
1837            case 'client_credentials':
1838                // client_credentials only works if we have client_credentials
1839                // username and password (see PAIA.ini for further explanation)
1840                if (
1841                    isset($this->config['PAIA']['clientUsername'])
1842                    && isset($this->config['PAIA']['clientPassword'])
1843                ) {
1844                    $header_data['Authorization'] = 'Basic ' .
1845                        base64_encode(
1846                            $this->config['PAIA']['clientUsername'] . ':' .
1847                            $this->config['PAIA']['clientPassword']
1848                        );
1849                    $post_data['patron'] = $username; // actual patron identifier
1850                } else {
1851                    throw new ILSException(
1852                        'Missing username and/or password for PAIA grant_type' .
1853                        ' client_credentials in PAIA configuration.'
1854                    );
1855                }
1856                break;
1857        }
1858
1859        // finalize post data
1860        $post_data['grant_type'] = $this->grantType;
1861        $post_data['scope'] = self::SCOPE_READ_PATRON . ' ' .
1862                self::SCOPE_READ_FEES . ' ' .
1863                self::SCOPE_READ_ITEMS . ' ' .
1864                self::SCOPE_WRITE_ITEMS . ' ' .
1865                self::SCOPE_CHANGE_PASSWORD;
1866
1867        // perform full PAIA auth and get patron info
1868        $result = $this->httpService->post(
1869            $this->paiaURL . 'auth/login',
1870            json_encode($post_data),
1871            'application/json; charset=UTF-8',
1872            $this->paiaTimeout,
1873            $header_data
1874        );
1875
1876        if (!$result->isSuccess()) {
1877            // log error for debugging
1878            $this->debug(
1879                'HTTP status ' . $result->getStatusCode() .
1880                ' received'
1881            );
1882        }
1883
1884        // continue with result data
1885        $responseJson = $result->getBody();
1886
1887        $responseArray = $this->paiaParseJsonAsArray($responseJson);
1888        if (!isset($responseArray['access_token'])) {
1889            throw new ILSException(
1890                'Unknown error! Access denied.'
1891            );
1892        } elseif (!isset($responseArray['patron'])) {
1893            throw new ILSException(
1894                'Login credentials accepted, but got no patron ID?!?'
1895            );
1896        } else {
1897            // at least access_token and patron got returned which is sufficient for
1898            // us, now save all to session
1899            $session = $this->getSession();
1900
1901            $session->patron
1902                = $responseArray['patron'] ?? null;
1903            $session->access_token
1904                = $responseArray['access_token'] ?? null;
1905            $session->scope
1906                = isset($responseArray['scope'])
1907                ? explode(' ', $responseArray['scope']) : null;
1908            $session->expires
1909                = isset($responseArray['expires_in'])
1910                ? (time() + ($responseArray['expires_in'])) : null;
1911
1912            return true;
1913        }
1914    }
1915
1916    /**
1917     * Support method for paiaLogin() -- load user details into session and return
1918     * array of basic user data.
1919     *
1920     * @param array $patron patron ID
1921     *
1922     * @return array
1923     * @throws ILSException
1924     */
1925    protected function paiaGetUserDetails($patron)
1926    {
1927        // check if user has appropriate scope (refer to scope declaration above for
1928        // further details)
1929        if (!$this->paiaCheckScope(self::SCOPE_READ_PATRON)) {
1930            throw new ForbiddenException(
1931                'Exception::access_denied_read_patron'
1932            );
1933        }
1934
1935        $responseJson = $this->paiaGetRequest(
1936            'core/' . $patron,
1937            $this->getSession()->access_token
1938        );
1939        $responseArray = $this->paiaParseJsonAsArray($responseJson);
1940        return $this->paiaParseUserDetails($patron, $responseArray);
1941    }
1942
1943    /**
1944     * Checks if the current scope is set for active session.
1945     *
1946     * @param string $scope The scope to test for with the current session scopes.
1947     *
1948     * @return boolean
1949     */
1950    protected function paiaCheckScope($scope)
1951    {
1952        return (!empty($scope) && is_array($this->getScope()))
1953            ? in_array($scope, $this->getScope()) : false;
1954    }
1955
1956    /**
1957     * Check if storage retrieval request available
1958     *
1959     * This is responsible for determining if an item is requestable
1960     *
1961     * @param string $id     The Bib ID
1962     * @param array  $data   An Array of item data
1963     * @param array  $patron An array of patron data
1964     *
1965     * @return bool True if request is valid, false if not
1966     *
1967     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1968     */
1969    public function checkStorageRetrievalRequestIsValid($id, $data, $patron)
1970    {
1971        return $this->checkRequestIsValid($id, $data, $patron);
1972    }
1973
1974    /**
1975     * Check if hold or recall available
1976     *
1977     * This is responsible for determining if an item is requestable
1978     *
1979     * @param string $id     The Bib ID
1980     * @param array  $data   An Array of item data
1981     * @param array  $patron An array of patron data
1982     *
1983     * @return bool True if request is valid, false if not
1984     *
1985     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1986     */
1987    public function checkRequestIsValid($id, $data, $patron)
1988    {
1989        // TODO: make this more configurable
1990        if (
1991            isset($patron['status']) && $patron['status'] == 0
1992            && isset($patron['expires']) && $patron['expires'] > date('Y-m-d')
1993            && in_array(self::SCOPE_WRITE_ITEMS, $this->getScope())
1994        ) {
1995            return true;
1996        }
1997        return false;
1998    }
1999
2000    /**
2001     * PAIA support method for PAIA core method 'notifications'
2002     *
2003     * @param array $patron Array with patron information
2004     *
2005     * @return array|mixed Array of system notifications for the patron
2006     * @throws \Exception
2007     * @throws ILSException You are not entitled to read notifications
2008     */
2009    protected function paiaGetSystemMessages($patron)
2010    {
2011        // check if user has appropriate scope
2012        if (!$this->paiaCheckScope(self::SCOPE_READ_NOTIFICATIONS)) {
2013            throw new ILSException('You are not entitled to read notifications.');
2014        }
2015
2016        $cacheKey = null;
2017        if ($this->paiaCacheEnabled) {
2018            $cacheKey = $this->getCacheKey(
2019                'notifications_' . $patron['cat_username']
2020            );
2021            $response = $this->getCachedData($cacheKey);
2022            if (!empty($response)) {
2023                return $response;
2024            }
2025        }
2026
2027        try {
2028            $response = $this->paiaGetAsArray(
2029                'core/' . $patron['cat_username'] . '/notifications'
2030            );
2031        } catch (\Exception $e) {
2032            // all error handling is done in paiaHandleErrors
2033            // so pass on the exception
2034            throw $e;
2035        }
2036
2037        $response = $this->enrichNotifications($response);
2038
2039        if ($this->paiaCacheEnabled) {
2040            $this->putCachedData($cacheKey, $response);
2041        }
2042
2043        return $response;
2044    }
2045
2046    /**
2047     * Enriches PAIA notifications response with additional mappings
2048     *
2049     * @param array $notifications list of PAIA notifications
2050     *
2051     * @return array list of enriched PAIA notifications
2052     */
2053    protected function enrichNotifications(array $notifications)
2054    {
2055        // not yet implemented
2056        return $notifications;
2057    }
2058
2059    /**
2060     * PAIA support method for PAIA core method DELETE 'notifications'
2061     *
2062     * @param array  $patron    Array with patron information
2063     * @param string $messageId PAIA service specific ID
2064     * of the notification to remove
2065     * @param bool   $keepCache if set to TRUE the notification cache will survive
2066     * the remote operation, this is used by
2067     * \VuFind\ILS\Driver\PAIA::paiaRemoveSystemMessages
2068     * to avoid unnecessary cache operations
2069     *
2070     * @return array|mixed Array of system notifications for the patron
2071     * @throws \Exception
2072     * @throws ILSException You are not entitled to read notifications
2073     */
2074    protected function paiaRemoveSystemMessage(
2075        $patron,
2076        $messageId,
2077        $keepCache = false
2078    ) {
2079        // check if user has appropriate scope
2080        if (!$this->paiaCheckScope(self::SCOPE_DELETE_NOTIFICATIONS)) {
2081            throw new ILSException('You are not entitled to delete notifications.');
2082        }
2083
2084        try {
2085            $response = $this->paiaDeleteRequest(
2086                'core/'
2087                . $patron['cat_username']
2088                . '/notifications/'
2089                . $this->getPaiaNotificationsId($messageId)
2090            );
2091        } catch (\Exception $e) {
2092            // all error handling is done in paiaHandleErrors
2093            // so pass on the exception
2094            throw $e;
2095        }
2096
2097        if (!$keepCache && $this->paiaCacheEnabled) {
2098            $this->removeCachedData(
2099                $this->getCacheKey('notifications_' . $patron['cat_username'])
2100            );
2101        }
2102
2103        return $response;
2104    }
2105
2106    /**
2107     * Removes multiple System Messages. Bulk deletion is not implemented in PAIA,
2108     * so this method iterates over the set of IDs and removes them separately
2109     *
2110     * @param array $patron     Array with patron information
2111     * @param array $messageIds list of PAIA service specific IDs
2112     * of the notifications to remove
2113     *
2114     * @return bool TRUE if all messages have been successfully removed,
2115     * otherwise FALSE
2116     * @throws ILSException
2117     */
2118    protected function paiaRemoveSystemMessages($patron, array $messageIds)
2119    {
2120        foreach ($messageIds as $messageId) {
2121            if (!$this->paiaRemoveSystemMessage($patron, $messageId, true)) {
2122                return false;
2123            }
2124        }
2125
2126        if ($this->paiaCacheEnabled) {
2127            $this->removeCachedData(
2128                $this->getCacheKey('notifications_' . $patron['cat_username'])
2129            );
2130        }
2131
2132        return true;
2133    }
2134
2135    /**
2136     * Get notification identifier from message identifier
2137     *
2138     * @param string $messageId Message identifier
2139     *
2140     * @return string
2141     */
2142    protected function getPaiaNotificationsId($messageId)
2143    {
2144        return $messageId;
2145    }
2146
2147    /**
2148     * DELETE data on foreign host
2149     *
2150     * @param string $file         DELETE target URL
2151     * @param string $access_token PAIA access token for current session
2152     *
2153     * @return bool|string
2154     * @throws ILSException
2155     */
2156    protected function paiaDeleteRequest($file, $access_token = null)
2157    {
2158        if (null === $access_token) {
2159            $access_token = $this->getSession()->access_token;
2160        }
2161
2162        $http_headers = [
2163            'Authorization' => 'Bearer ' . $access_token,
2164            'Content-type' => 'application/json; charset=UTF-8',
2165        ];
2166
2167        try {
2168            $client = $this->httpService->createClient(
2169                $this->paiaURL . $file,
2170                \Laminas\Http\Request::METHOD_DELETE,
2171                $this->paiaTimeout
2172            );
2173            $client->setHeaders($http_headers);
2174            $result = $client->send();
2175        } catch (\Exception $e) {
2176            $this->throwAsIlsException($e);
2177        }
2178
2179        if (!$result->isSuccess()) {
2180            // log error for debugging
2181            $this->debug(
2182                'HTTP status ' . $result->getStatusCode() .
2183                ' received'
2184            );
2185            return false;
2186        }
2187        // return TRUE on success
2188        return true;
2189    }
2190
2191    /**
2192     * Check whether the patron has any blocks on their account.
2193     *
2194     * @param array $patron Patron data from patronLogin().
2195     *
2196     * @return mixed A boolean false if no blocks are in place and an array
2197     * of block reasons if blocks are in place
2198     *
2199     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
2200     */
2201    public function getAccountBlocks($patron)
2202    {
2203        $blocks = [];
2204
2205        foreach ($this->accountBlockNotificationsForMissingScopes as $scope => $message) {
2206            if (!$this->paiaCheckScope($scope)) {
2207                $blocks[$scope] = $message;
2208            }
2209        }
2210
2211        // Special case: if update patron is missing, we don't need to also add
2212        // more specific messages.
2213        if (isset($blocks[self::SCOPE_UPDATE_PATRON])) {
2214            unset($blocks[self::SCOPE_UPDATE_PATRON_NAME]);
2215            unset($blocks[self::SCOPE_UPDATE_PATRON_EMAIL]);
2216            unset($blocks[self::SCOPE_UPDATE_PATRON_ADDRESS]);
2217        }
2218
2219        return count($blocks) ? array_values($blocks) : false;
2220    }
2221}