Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.79% covered (danger)
0.79%
7 / 884
5.00% covered (danger)
5.00%
2 / 40
CRAP
0.00% covered (danger)
0.00%
0 / 1
KohaILSDI
0.79% covered (danger)
0.79%
7 / 884
5.00% covered (danger)
5.00%
2 / 40
37706.61
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
6.90% covered (danger)
6.90%
2 / 29
0.00% covered (danger)
0.00%
0 / 1
25.18
 initDb
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
20
 getDb
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 tableExists
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getCacheKey
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getField
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 makeRequest
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 makeIlsdiRequest
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
30
 toKohaDate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getConfig
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 getPickUpLocations
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
156
 getDefaultPickUpLocation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 placeHold
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
132
 getHolding
0.00% covered (danger)
0.00%
0 / 147
0.00% covered (danger)
0.00%
0 / 1
930
 getNewItems
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 getMyFines
0.00% covered (danger)
0.00%
0 / 98
0.00% covered (danger)
0.00%
0 / 1
702
 getMyFinesILS
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 getMyHolds
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 getCancelHoldDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 cancelHolds
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 getMyProfile
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 getAccountBlocks
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 getMyTransactionHistory
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
132
 getMyTransactions
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
30
 getRenewDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renewMyItems
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 getPurchaseHistory
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStatuses
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getSuppressedRecords
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 getDepartments
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getInstructors
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getCourses
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 findReserves
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 patronLogin
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
12
 changePassword
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 displayDate
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 displayDateTime
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 supportsMethod
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * KohaILSDI ILS Driver
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Alex Sassmannshausen, PTFS Europe 2014.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  ILS_Drivers
25 * @author   Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com>
26 * @author   Tom Misilo <misilot@fit.edu>
27 * @author   Josef Moravec <josef.moravec@gmail.com>
28 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
29 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
30 */
31
32namespace VuFind\ILS\Driver;
33
34use Laminas\Log\LoggerAwareInterface;
35use PDO;
36use PDOException;
37use VuFind\Date\DateException;
38use VuFind\Exception\ILS as ILSException;
39use VuFindHttp\HttpServiceAwareInterface;
40
41use function array_slice;
42use function count;
43use function in_array;
44use function intval;
45use function is_callable;
46
47/**
48 * VuFind Driver for Koha, using web APIs (ILSDI)
49 *
50 * Minimum Koha Version: 3.18.6
51 *
52 * @category VuFind
53 * @package  ILS_Drivers
54 * @author   Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com>
55 * @author   Tom Misilo <misilot@fit.edu>
56 * @author   Josef Moravec <josef.moravec@gmail.com>
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 KohaILSDI extends AbstractBase implements HttpServiceAwareInterface, LoggerAwareInterface
61{
62    use \VuFind\Cache\CacheTrait {
63        getCacheKey as protected getBaseCacheKey;
64    }
65    use \VuFindHttp\HttpServiceAwareTrait;
66    use \VuFind\Log\LoggerAwareTrait;
67
68    /**
69     * Web services host
70     *
71     * @var string
72     */
73    protected $host;
74
75    /**
76     * ILS base URL
77     *
78     * @var string
79     */
80    protected $ilsBaseUrl;
81
82    /**
83     * Location codes
84     *
85     * @var array
86     */
87    protected $locations;
88
89    /**
90     * Codes of locations available for pickup
91     *
92     * @var array
93     */
94    protected $pickupEnableBranchcodes;
95
96    /**
97     * Codes of locations always should be available
98     *   - For example reference material or material
99     *     not for loan
100     *
101     * @var array
102     */
103    protected $availableLocationsDefault;
104
105    /**
106     * Default location code
107     *
108     * @var string
109     */
110    protected $defaultLocation;
111
112    /**
113     * Database connection
114     *
115     * @var PDO
116     */
117    protected $db;
118
119    /**
120     * Date converter object
121     *
122     * @var \VuFind\Date\Converter
123     */
124    protected $dateConverter;
125
126    /**
127     * Should validate passwords against Koha system?
128     *
129     * @var bool
130     */
131    protected $validatePasswords;
132
133    /**
134     * Authorised values category for location, defaults to 'LOC'
135     *
136     * @var string
137     */
138    protected $locationAuthorisedValuesCategory;
139
140    /**
141     * Default terms for block types, can be overridden by configuration
142     *
143     * @var array
144     */
145    protected $blockTerms = [
146        'SUSPENSION' => 'Account Suspended',
147        'OVERDUES' => 'Account Blocked (Overdue Items)',
148        'MANUAL' => 'Account Blocked',
149        'DISCHARGE' => 'Account Blocked for Discharge',
150    ];
151
152    /**
153     * Display comments for patron debarments, see KohaILSDI.ini
154     *
155     * @var array
156     */
157    protected $showBlockComments;
158
159    /**
160     * Should we show permanent location (or current)
161     *
162     * @var bool
163     */
164    protected $showPermanentLocation;
165
166    /**
167     * Should we show homebranch instead of holdingbranch
168     *
169     * @var bool
170     */
171    protected $showHomebranch;
172
173    /**
174     * Constructor
175     *
176     * @param \VuFind\Date\Converter $dateConverter Date converter object
177     */
178    public function __construct(\VuFind\Date\Converter $dateConverter)
179    {
180        $this->dateConverter = $dateConverter;
181    }
182
183    /**
184     * Initialize the driver.
185     *
186     * Validate configuration and perform all resource-intensive tasks needed to
187     * make the driver active.
188     *
189     * @throws ILSException
190     * @return void
191     */
192    public function init()
193    {
194        if (empty($this->config)) {
195            throw new ILSException('Configuration needs to be set.');
196        }
197
198        // Base for API address
199        $this->host = $this->config['Catalog']['host'] ?? 'localhost';
200
201        // Storing the base URL of ILS
202        $this->ilsBaseUrl = $this->config['Catalog']['url'] ?? '';
203
204        // Default location defined in 'KohaILSDI.ini'
205        $this->defaultLocation
206            = $this->config['Holds']['defaultPickUpLocation'] ?? null;
207
208        $this->pickupEnableBranchcodes
209            = $this->config['Holds']['pickupLocations'] ?? [];
210
211        // Locations that should default to available, defined in 'KohaILSDI.ini'
212        $this->availableLocationsDefault
213            = $this->config['Other']['availableLocations'] ?? [];
214
215        // If we are using SAML/Shibboleth for authentication for both ourselves
216        // and Koha then we can't validate the patrons passwords against Koha as
217        // they won't have one. (Double negative logic used so that if the config
218        // option isn't present in KohaILSDI.ini then ILS passwords will be
219        // validated)
220        $this->validatePasswords
221            = empty($this->config['Catalog']['dontValidatePasswords']);
222
223        // The Authorised Values Category use for locations should default to 'LOC'
224        $this->locationAuthorisedValuesCategory
225            = $this->config['Catalog']['locationAuthorisedValuesCategory'] ?? 'LOC';
226
227        $this->showPermanentLocation
228            = $this->config['Catalog']['showPermanentLocation'] ?? false;
229
230        $this->showHomebranch = $this->config['Catalog']['showHomebranch'] ?? false;
231
232        $this->debug('Config Summary:');
233        $this->debug('DB Host: ' . $this->host);
234        $this->debug('ILS URL: ' . $this->ilsBaseUrl);
235        $this->debug('Locations: ' . $this->locations);
236        $this->debug('Default Location: ' . $this->defaultLocation);
237
238        // Now override the default with any defined in the `KohaILSDI.ini` config
239        // file
240        foreach (['SUSPENSION','OVERDUES','MANUAL','DISCHARGE'] as $blockType) {
241            if (!empty($this->config['Blocks'][$blockType])) {
242                $this->blockTerms[$blockType] = $this->config['Blocks'][$blockType];
243            }
244        }
245
246        // Allow the users to set if an account block's comments should be included
247        // by setting the block type to true or false () in the `KohaILSDI.ini`
248        // config file (defaults to false if not present)
249        $this->showBlockComments = [];
250
251        foreach (['SUSPENSION','OVERDUES','MANUAL','DISCHARGE'] as $blockType) {
252            $this->showBlockComments[$blockType]
253                = !empty($this->config['Show_Block_Comments'][$blockType]);
254        }
255    }
256
257    /**
258     * Initialize the DB driver.
259     *
260     * Validate configuration and perform all resource-intensive tasks needed to
261     * make the driver active.
262     *
263     * @throws ILSException
264     * @return void
265     */
266    protected function initDb()
267    {
268        if (empty($this->config)) {
269            throw new ILSException('Configuration needs to be set.');
270        }
271
272        //Connect to MySQL
273        try {
274            $this->db = new PDO(
275                'mysql:host=' . $this->host .
276                ';port=' . $this->config['Catalog']['port'] .
277                ';dbname=' . $this->config['Catalog']['database'],
278                $this->config['Catalog']['username'],
279                $this->config['Catalog']['password'],
280                [PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8']
281            );
282
283            // Throw PDOExceptions if something goes wrong
284            $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
285            //Return result set like mysql_fetch_assoc()
286            $this->db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
287            // set communication encoding to utf8
288            $this->db->exec('SET NAMES utf8');
289
290            // Drop the ONLY_FULL_GROUP_BY entry from sql_mode as it breaks this
291            // ILS Driver on modern
292            $setSqlModes = $this->db->prepare('SET sql_mode = :sqlMode');
293
294            $sqlModes = $this->db->query('SELECT @@sql_mode');
295            foreach ($sqlModes as $row) {
296                $sqlMode = implode(
297                    ',',
298                    array_filter(
299                        explode(',', $row['@@sql_mode']),
300                        function ($mode) {
301                            return $mode != 'ONLY_FULL_GROUP_BY';
302                        }
303                    )
304                );
305                $setSqlModes->execute(['sqlMode' => $sqlMode]);
306            }
307        } catch (PDOException $e) {
308            $this->debug('Connection failed: ' . $e->getMessage());
309            $this->throwAsIlsException($e);
310        }
311
312        $this->debug('Connected to DB');
313    }
314
315    /**
316     * Get the database connection (and make sure it is initialized).
317     *
318     * @return PDO
319     */
320    protected function getDb()
321    {
322        if (!$this->db) {
323            $this->initDb();
324        }
325        return $this->db;
326    }
327
328    /**
329     * Check if a table exists in the current database.
330     *
331     * @param string $table Table to search for.
332     *
333     * @return bool
334     */
335    protected function tableExists($table)
336    {
337        $cacheKey = "kohailsdi-tables-$table";
338        $cachedValue = $this->getCachedData($cacheKey);
339        if ($cachedValue !== null) {
340            return $cachedValue;
341        }
342
343        $returnValue = false;
344
345        // Try a select statement against the table
346        // Run it in try/catch in case PDO is in ERRMODE_EXCEPTION.
347        try {
348            $result = $this->getDb()->query("SELECT 1 FROM $table LIMIT 1");
349            // Result is FALSE (no table found) or PDOStatement Object (table found)
350            $returnValue = $result !== false;
351        } catch (PDOException $e) {
352            // We got an exception == table not found
353            $returnValue = false;
354        }
355
356        $this->putCachedData($cacheKey, $returnValue);
357        return $returnValue;
358    }
359
360    /**
361     * Koha ILS-DI driver specific override of method to ensure uniform cache keys
362     * for cached VuFind objects.
363     *
364     * @param string|null $suffix Optional suffix that will get appended to the
365     * object class name calling getCacheKey()
366     *
367     * @return string
368     */
369    protected function getCacheKey($suffix = null)
370    {
371        return $this->getBaseCacheKey(
372            md5($this->ilsBaseUrl) . $suffix
373        );
374    }
375
376    /**
377     * Get Field
378     *
379     * Check $contents is not "", return it; else return $default.
380     *
381     * @param string $contents string to be checked
382     * @param string $default  value to return if $contents is ""
383     *
384     * @return string
385     */
386    protected function getField($contents, $default = 'Unknown')
387    {
388        if ((string)$contents != '') {
389            return (string)$contents;
390        } else {
391            return $default;
392        }
393    }
394
395    /**
396     * Make Request
397     *
398     * Makes a request to the Koha ILSDI API
399     *
400     * @param string $api_query   Query string for request
401     * @param string $http_method HTTP method (default = GET)
402     *
403     * @throws ILSException
404     * @return obj
405     */
406    protected function makeRequest($api_query, $http_method = 'GET')
407    {
408        //$url = $this->host . $this->api_path . $api_query;
409
410        $url = $this->ilsBaseUrl . '?service=' . $api_query;
411
412        $this->debug("URL: '$url'");
413
414        $http_headers = [
415            'Accept: text/xml',
416            'Accept-encoding: plain',
417        ];
418
419        try {
420            $client = $this->httpService->createClient($url);
421
422            $client->setMethod($http_method);
423            $client->setHeaders($http_headers);
424            $result = $client->send();
425        } catch (\Exception $e) {
426            $this->debug('Result is invalid.');
427            $this->throwAsIlsException($e);
428        }
429
430        if (!$result->isSuccess()) {
431            $this->debug('Result is invalid.');
432            throw new ILSException('HTTP error');
433        }
434        $answer = $result->getBody();
435        //$answer = str_replace('xmlns=', 'ns=', $answer);
436        $result = simplexml_load_string($answer);
437        if (!$result) {
438            $this->debug("XML is not valid, URL: $url");
439
440            throw new ILSException(
441                "XML is not valid, URL: $url method: $http_method answer: $answer."
442            );
443        }
444        return $result;
445    }
446
447    /**
448     * Make Ilsdi Request Array
449     *
450     * Makes a request to the Koha ILSDI API
451     *
452     * @param string $service     Called function (GetAvailability,
453     *                            GetRecords,
454     *                            GetAuthorityRecords,
455     *                            LookupPatron,
456     *                            AuthenticatePatron,
457     *                            GetPatronInfo,
458     *                            GetPatronStatus,
459     *                            GetServices,
460     *                            RenewLoan,
461     *                            HoldTitle,
462     *                            HoldItem,
463     *                            CancelHold)
464     * @param array  $params      Key is parameter name, value is parameter value
465     * @param string $http_method HTTP method (default = GET)
466     *
467     * @throws ILSException
468     * @return obj
469     */
470    protected function makeIlsdiRequest($service, $params, $http_method = 'GET')
471    {
472        $start = microtime(true);
473        $url = $this->ilsBaseUrl . '?service=' . $service;
474        foreach ($params as $paramname => $paramvalue) {
475            $url .= "&$paramname=" . urlencode($paramvalue);
476        }
477
478        $this->debug("URL: '$url'");
479
480        $http_headers = [
481            'Accept: text/xml',
482            'Accept-encoding: plain',
483        ];
484
485        try {
486            $client = $this->httpService->createClient($url);
487            $client->setMethod($http_method);
488            $client->setHeaders($http_headers);
489            $result = $client->send();
490        } catch (\Exception $e) {
491            $this->debug('Result is invalid.');
492            $this->throwAsIlsException($e);
493        }
494
495        if (!$result->isSuccess()) {
496            $this->debug('Result is invalid.');
497            throw new ILSException('HTTP error');
498        }
499        $end = microtime(true);
500        $time1 = $end - $start;
501        $start = microtime(true);
502        $result = simplexml_load_string($result->getBody());
503        if (!$result) {
504            $this->debug("XML is not valid, URL: $url");
505
506            throw new ILSException(
507                "XML is not valid, URL: $url"
508            );
509        }
510        $end = microtime(true);
511        $time2 = $end - $start;
512        $this->debug("Request times: $time1 - $time2");
513        return $result;
514    }
515
516    /**
517     * To Koha Date
518     *
519     * Turns a display date into a date format expected by Koha.
520     *
521     * @param ?string $display_date Date to be converted
522     *
523     * @throws ILSException
524     * @return ?string $koha_date
525     */
526    protected function toKohaDate(?string $display_date): ?string
527    {
528        // Convert last interest date from display format to Koha format
529        $koha_date = !empty($display_date)
530            ? $this->dateConverter->convertFromDisplayDate('Y-m-d', $display_date)
531            : null;
532        return $koha_date;
533    }
534
535    /**
536     * Public Function which retrieves renew, hold and cancel settings from the
537     * driver ini file.
538     *
539     * @param string $function The name of the feature to be checked
540     * @param array  $params   Optional feature-specific parameters (array)
541     *
542     * @return array An array with key-value pairs.
543     *
544     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
545     */
546    public function getConfig($function, $params = [])
547    {
548        if ('getMyTransactionHistory' === $function) {
549            if (empty($this->config['TransactionHistory']['enabled'])) {
550                return false;
551            }
552            return [
553                'max_results' => 100,
554                'sort' => [
555                    'checkout desc' => 'sort_checkout_date_desc',
556                    'checkout asc' => 'sort_checkout_date_asc',
557                    'return desc' => 'sort_return_date_desc',
558                    'return asc' => 'sort_return_date_asc',
559                    'due desc' => 'sort_due_date_desc',
560                    'due asc' => 'sort_due_date_asc',
561                ],
562                'default_sort' => 'checkout desc',
563            ];
564        }
565        return $this->config[$function] ?? false;
566    }
567
568    /**
569     * Get Pick Up Locations
570     *
571     * This is responsible for gettting a list of valid library locations for
572     * holds / recall retrieval
573     *
574     * @param array $patron      Patron information returned by the patronLogin
575     * method.
576     * @param array $holdDetails Optional array, only passed in when getting a list
577     * in the context of placing or editing a hold. When placing a hold, it contains
578     * most of the same values passed to placeHold, minus the patron data. When
579     * editing a hold it contains all the hold information returned by getMyHolds.
580     * May be used to limit the pickup options or may be ignored. The driver must
581     * not add new options to the return array based on this data or other areas of
582     * VuFind may behave incorrectly.
583     *
584     * @throws ILSException
585     * @return array An array of associative arrays with locationID and
586     * locationDisplay keys
587     *
588     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
589     */
590    public function getPickUpLocations($patron = false, $holdDetails = null)
591    {
592        if (!$this->locations) {
593            if (!$this->pickupEnableBranchcodes) {
594                // No defaultPickupLocation is defined in config
595                // AND no pickupLocations are defined either
596                if (
597                    isset($holdDetails['item_id']) && (empty($holdDetails['level'])
598                    || $holdDetails['level'] == 'item')
599                ) {
600                    // We try to get the actual branchcode the item is found at
601                    $item_id = $holdDetails['item_id'];
602                    $sql = "SELECT holdingbranch
603                            FROM items
604                            WHERE itemnumber=($item_id)";
605                    try {
606                        $sqlSt = $this->getDb()->prepare($sql);
607                        $sqlSt->execute();
608                        $this->pickupEnableBranchcodes = $sqlSt->fetch();
609                    } catch (PDOException $e) {
610                        $this->debug('Connection failed: ' . $e->getMessage());
611                        $this->throwAsIlsException($e);
612                    }
613                } elseif (
614                    !empty($holdDetails['level'])
615                    && $holdDetails['level'] == 'title'
616                ) {
617                    // We try to get the actual branchcodes the title is found at
618                    $id = $holdDetails['id'];
619                    $sql = "SELECT DISTINCT holdingbranch
620                            FROM items
621                            WHERE biblionumber=($id)";
622                    try {
623                        $sqlSt = $this->getDb()->prepare($sql);
624                        $sqlSt->execute();
625                        foreach ($sqlSt->fetchAll() as $row) {
626                            $this->pickupEnableBranchcodes[] = $row['holdingbranch'];
627                        }
628                    } catch (PDOException $e) {
629                        $this->debug('Connection failed: ' . $e->getMessage());
630                        $this->throwAsIlsException($e);
631                    }
632                }
633            }
634            $branchcodes = "'" . implode(
635                "','",
636                $this->pickupEnableBranchcodes
637            ) . "'";
638            $sql = "SELECT branchcode as locationID,
639                       branchname as locationDisplay
640                    FROM branches
641                    WHERE branchcode IN ($branchcodes)";
642            try {
643                $sqlSt = $this->getDb()->prepare($sql);
644                $sqlSt->execute();
645                $this->locations = $sqlSt->fetchAll();
646            } catch (PDOException $e) {
647                $this->debug('Connection failed: ' . $e->getMessage());
648                $this->throwAsIlsException($e);
649            }
650        }
651        return $this->locations;
652
653        // we get them from the API
654        // FIXME: Not yet possible: API incomplete.
655        // TODO: When API: pull locations dynamically from API.
656        /* $response = $this->makeRequest("organizations/branch"); */
657        /* $locations_response_array = $response->OrganizationsGetRows; */
658        /* foreach ($locations_response_array as $location_response) { */
659        /*     $locations[] = array( */
660        /*         'locationID'      => $location_response->OrganizationID, */
661        /*         'locationDisplay' => $location_response->Name, */
662        /*     ); */
663        /* } */
664    }
665
666    /**
667     * Get Default Pick Up Location
668     *
669     * Returns the default pick up location set in KohaILSDI.ini
670     *
671     * @param array $patron      Patron information returned by the patronLogin
672     * method.
673     * @param array $holdDetails Optional array, only passed in when getting a list
674     * in the context of placing a hold; contains most of the same values passed to
675     * placeHold, minus the patron data.    May be used to limit the pickup options
676     * or may be ignored.
677     *
678     * @return string The default pickup location for the patron.
679     *
680     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
681     */
682    public function getDefaultPickUpLocation($patron = false, $holdDetails = null)
683    {
684        return $this->defaultLocation;
685    }
686
687    /**
688     * Place Hold
689     *
690     * Attempts to place a hold or recall on a particular item and returns
691     * an array with result details or throws an exception on failure of support
692     * classes
693     *
694     * @param array $holdDetails An array of item and patron data
695     *
696     * @throws ILSException
697     * @return mixed An array of data on the request including
698     * whether or not it was successful and a system message (if available)
699     */
700    public function placeHold($holdDetails)
701    {
702        $patron             = $holdDetails['patron'];
703        $patron_id          = $patron['id'];
704        $request_location   = $patron['ip'] ?? '127.0.0.1';
705        $bib_id             = $holdDetails['id'];
706        $item_id            = $holdDetails['item_id'];
707        $pickup_location    = !empty($holdDetails['pickUpLocation'])
708            ? $holdDetails['pickUpLocation'] : $this->defaultLocation;
709        $level              = isset($holdDetails['level'])
710            && !empty($holdDetails['level']) ? $holdDetails['level'] : 'item';
711
712        try {
713            $needed_before_date = $this->toKohaDate(
714                $holdDetails['requiredBy'] ?? null
715            );
716        } catch (\Exception $e) {
717            return [
718                'success' => false,
719                'sysMessage' => 'hold_date_invalid',
720            ];
721        }
722
723        $this->debug('patron: ' . $this->varDump($patron));
724        $this->debug('patron_id: ' . $patron_id);
725        $this->debug('request_location: ' . $request_location);
726        $this->debug('item_id: ' . $item_id);
727        $this->debug('bib_id: ' . $bib_id);
728        $this->debug('pickup loc: ' . $pickup_location);
729        $this->debug('Needed before date: ' . $needed_before_date);
730        $this->debug('Level: ' . $level);
731
732        // The following check is mainly required for certain old buggy Koha versions
733        // that allowed multiple holds from the same user to the same item
734        $sql = 'select count(*) as RCOUNT from reserves where borrowernumber = :rid '
735            . 'and itemnumber = :iid';
736        $reservesSqlStmt = $this->getDb()->prepare($sql);
737        $reservesSqlStmt->execute([':rid' => $patron_id, ':iid' => $item_id]);
738        $reservesCount = $reservesSqlStmt->fetch()['RCOUNT'];
739
740        if ($reservesCount > 0) {
741            $this->debug('Fatal error: Patron has already reserved this item.');
742            return [
743                'success' => false,
744                'sysMessage' => 'It seems you have already reserved this item.',
745            ];
746        }
747
748        if ($level == 'title') {
749            $rqString = "HoldTitle&patron_id=$patron_id&bib_id=$bib_id"
750                . "&request_location=$request_location"
751                . "&pickup_location=$pickup_location";
752        } else {
753            $rqString = "HoldItem&patron_id=$patron_id&bib_id=$bib_id"
754                . "&item_id=$item_id"
755                . "&pickup_location=$pickup_location";
756        }
757        $dateString = empty($needed_before_date)
758            ? '' : "&expiry_date=$needed_before_date";
759
760        $rsp = $this->makeRequest($rqString . $dateString);
761
762        if ($rsp->{'code'} == 'IllegalParameter' && $dateString != '') {
763            // In older versions of Koha, the date parameters were named differently
764            // and even never implemented, so if we got IllegalParameter, we know
765            // the Koha version is before 20.05 and could retry without expiry_date
766            // parameter. See:
767            // https://git.koha-community.org/Koha-community/Koha/commit/c8bf308e1b453023910336308d59566359efc535
768            $rsp = $this->makeRequest($rqString);
769        }
770        //TODO - test this new functionality
771        /*
772        if ( $level == "title" ) {
773            $rsp2 = $this->makeIlsdiRequest("HoldTitle",
774                    array("patron_id" => $patron_id,
775                          "bib_id" => $bib_id,
776                          "request_location" => $request_location,
777                          "pickup_location" => $pickup_location,
778                          "pickup_expiry_date" => $needed_before_date,
779                          "needed_before_date" => $needed_before_date
780                    ));
781        } else {
782            $rsp2 = $this->makeIlsdiRequest("HoldItem",
783                    array("patron_id" => $patron_id,
784                          "bib_id" => $bib_id,
785                          "item_id" => $item_id,
786                          "pickup_location" => $pickup_location,
787                          "pickup_expiry_date" => $needed_before_date,
788                          "needed_before_date" => $needed_before_date
789                    ));
790        }
791        */
792        $this->debug('Title: ' . $rsp->{'title'});
793        $this->debug('Pickup Location: ' . $rsp->{'pickup_location'});
794        $this->debug('Code: ' . $rsp->{'code'});
795
796        if ($rsp->{'code'} != '') {
797            $this->debug('Error Message: ' . $rsp->{'message'});
798            return [
799                'success'    => false,
800                'sysMessage' => $this->getField($rsp->{'code'})
801                                   . $holdDetails['level'],
802            ];
803        }
804        return [
805            'success'    => true,
806            //"sysMessage" => $message,
807        ];
808    }
809
810    /**
811     * Get Holding
812     *
813     * This is responsible for retrieving the holding information of a certain
814     * record.
815     *
816     * @param string $id      The record id to retrieve the holdings for
817     * @param array  $patron  Patron data
818     * @param array  $options Extra options (not currently used)
819     *
820     * @throws DateException
821     * @throws ILSException
822     * @return array         On success, an associative array with the following
823     * keys: id, availability (boolean), status, location, reserve, callnumber,
824     * duedate, number, barcode.
825     *
826     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
827     */
828    public function getHolding($id, array $patron = null, array $options = [])
829    {
830        $this->debug(
831            "Function getHolding($id"
832               . implode(',', (array)$patron)
833               . ') called'
834        );
835
836        $started = microtime(true);
837
838        $holding = [];
839        $available = true;
840        $duedate = $status = '';
841        $loc = '';
842        $locationField = $this->showPermanentLocation
843            ? 'permanent_location' : 'location';
844
845        $sql = "select i.itemnumber as ITEMNO, i.location,
846            COALESCE(av.lib_opac,av.lib,av.authorised_value,i.$locationField)
847                AS LOCATION,
848            i.holdingbranch as HLDBRNCH, i.homebranch as HOMEBRANCH,
849            i.reserves as RESERVES, i.itemcallnumber as CALLNO, i.barcode as BARCODE,
850            i.copynumber as COPYNO, i.notforloan as NOTFORLOAN,
851            i.enumchron AS ENUMCHRON,
852            i.itemnotes as PUBLICNOTES, b.frameworkcode as DOCTYPE,
853            t.frombranch as TRANSFERFROM, t.tobranch as TRANSFERTO,
854            i.itemlost as ITEMLOST, i.itemlost_on AS LOSTON,
855            i.stocknumber as STOCKNUMBER
856            from items i join biblio b on i.biblionumber = b.biblionumber
857            left outer join
858                (SELECT itemnumber, frombranch, tobranch from branchtransfers
859                where datearrived IS NULL) as t USING (itemnumber)
860            left join authorised_values as av
861                on i.$locationField = av.authorised_value
862            where i.biblionumber = :id
863                AND (av.category = :av_category OR av.category IS NULL)
864            order by i.itemnumber DESC";
865        $sqlReserves = 'select count(*) as RESERVESCOUNT from reserves '
866            . 'WHERE biblionumber = :id AND found IS NULL';
867        $sqlWaitingReserve = 'select count(*) as WAITING from reserves '
868            . "WHERE itemnumber = :item_id and found = 'W'";
869        if ($this->tableExists('biblio_metadata')) {
870            $sqlHoldings = 'SELECT '
871                . 'ExtractValue(( SELECT metadata FROM biblio_metadata '
872                . "WHERE biblionumber = :id AND format='marcxml'), "
873                . "'//datafield[@tag=\"866\"]/subfield[@code=\"a\"]') AS MFHD;";
874        } else {
875            $sqlHoldings = 'SELECT ExtractValue(( SELECT marcxml FROM biblioitems '
876                . 'WHERE biblionumber = :id), '
877               . "'//datafield[@tag=\"866\"]/subfield[@code=\"a\"]') AS MFHD;";
878        }
879        try {
880            $itemSqlStmt = $this->getDb()->prepare($sql);
881            $itemSqlStmt->execute(
882                [
883                    ':id' => $id,
884                    ':av_category' => $this->locationAuthorisedValuesCategory,
885                ]
886            );
887            $sqlStmtReserves = $this->getDb()->prepare($sqlReserves);
888            $sqlStmtWaitingReserve = $this->getDb()->prepare($sqlWaitingReserve);
889            $sqlStmtReserves->execute([':id' => $id]);
890            $sqlStmtHoldings = $this->getDb()->prepare($sqlHoldings);
891            $sqlStmtHoldings->execute([':id' => $id]);
892        } catch (PDOException $e) {
893            $this->debug('Connection failed: ' . $e->getMessage());
894            $this->throwAsIlsException($e);
895        }
896
897        $this->debug('Rows count: ' . $itemSqlStmt->rowCount());
898
899        $notes = $sqlStmtHoldings->fetch();
900        $reservesRow = $sqlStmtReserves->fetch();
901        $reservesCount = $reservesRow['RESERVESCOUNT'];
902
903        foreach ($itemSqlStmt->fetchAll() as $rowItem) {
904            $inum = $rowItem['ITEMNO'];
905            $sqlStmtWaitingReserve->execute([':item_id' => $inum]);
906            $waitingReserveRow = $sqlStmtWaitingReserve->fetch();
907            $waitingReserve = $waitingReserveRow['WAITING'];
908            if ($rowItem['LOCATION'] == 'PROC') {
909                $available = false;
910                $status = 'In processing';
911                $duedate = '';
912            } else {
913                $sql = 'select date_due as DUEDATE from issues
914                    where itemnumber = :inum';
915                switch ($rowItem['NOTFORLOAN']) {
916                    case 0:
917                        // If the item is available for loan, then check its current
918                        // status
919                        $issueSqlStmt = $this->getDb()->prepare($sql);
920                        $issueSqlStmt->execute([':inum' => $inum]);
921                        $rowIssue = $issueSqlStmt->fetch();
922                        if ($rowIssue) {
923                            $available = false;
924                            $status = 'Checked out';
925                            $duedate = $rowIssue['DUEDATE'];
926                        } else {
927                            $available = true;
928                            $status = 'Available';
929                            // No due date for an available item
930                            $duedate = '';
931                        }
932                        break;
933                    case 1: // The item is not available for loan
934                    default:
935                        $available = false;
936                        $status = 'Not for loan';
937                        $duedate = '';
938                        break;
939                }
940            }
941            /*
942             * If the Item is in any of locations defined by
943             * availableLocations[] in the KohaILSDI.ini file
944             * the item is considered available
945             */
946
947            if (in_array($rowItem['LOCATION'], $this->availableLocationsDefault)) {
948                $available = true;
949                $duedate = '';
950                $status = 'Available';
951            }
952
953            // If Item is Lost or Missing, provide that status
954            if ($rowItem['ITEMLOST'] > 0) {
955                $available = false;
956                $duedate = $rowItem['LOSTON'];
957                $status = 'Lost/Missing';
958            }
959
960            $duedate_formatted = $this->displayDate($duedate);
961
962            if ($rowItem['HLDBRNCH'] == null && $rowItem['HOMEBRANCH'] == null) {
963                $loc = 'Unknown';
964            } else {
965                $loc = $rowItem['LOCATION'];
966            }
967
968            if ($this->showHomebranch) {
969                $branch = $rowItem['HOMEBRANCH'] ?? $rowItem['HLDBRNCH'] ?? '';
970            } else {
971                $branch = $rowItem['HLDBRNCH'] ?? $rowItem['HOMEBRANCH'] ?? '';
972            }
973
974            $sqlBranch = 'select branchname as BNAME
975                              from branches
976                              where branchcode = :branch';
977            $branchSqlStmt = $this->getDb()->prepare($sqlBranch);
978            //Retrieving the full branch name
979            if ($loc != 'Unknown') {
980                $branchSqlStmt->execute([':branch' => $branch]);
981                $row = $branchSqlStmt->fetch();
982                if ($row) {
983                    $loc = $row['BNAME'] . ' - ' . $loc;
984                }
985            }
986
987            $onTransfer = false;
988            if (
989                ($rowItem['TRANSFERFROM'] != null)
990                && ($rowItem['TRANSFERTO'] != null)
991            ) {
992                $branchSqlStmt->execute([':branch' => $rowItem['TRANSFERFROM']]);
993                $rowFrom = $branchSqlStmt->fetch();
994                $transferfrom = $rowFrom
995                    ? $rowFrom['BNAME'] : $rowItem['TRANSFERFROM'];
996                $branchSqlStmt->execute([':branch' => $rowItem['TRANSFERTO']]);
997                $rowTo = $branchSqlStmt->fetch();
998                $transferto = $rowTo ? $rowTo['BNAME'] : $rowItem['TRANSFERTO'];
999                $status = 'In transit between library locations';
1000                $available = false;
1001                $onTransfer = true;
1002            }
1003
1004            if ($rowItem['DOCTYPE'] == 'PE') {
1005                $rowItem['COPYNO'] = $rowItem['PERIONAME'];
1006            }
1007            if ($waitingReserve) {
1008                $available = false;
1009                $status = 'Waiting';
1010                $waiting = true;
1011            } else {
1012                $waiting = false;
1013            }
1014            $holding[] = [
1015                'id'           => $id,
1016                'availability' => (string)$available,
1017                'item_id'      => $rowItem['ITEMNO'],
1018                'status'       => $status,
1019                'location'     => $loc,
1020                'item_notes'  => (null == $rowItem['PUBLICNOTES']
1021                    ? null : [ $rowItem['PUBLICNOTES'] ]),
1022                'notes'        => $notes['MFHD'],
1023                //'reserve'      => (null == $rowItem['RESERVES'])
1024                //    ? 'N' : $rowItem['RESERVES'],
1025                'reserve'      => 'N',
1026                'callnumber'   =>
1027                    ((null == $rowItem['CALLNO']) || ($rowItem['DOCTYPE'] == 'PE'))
1028                        ? '' : $rowItem['CALLNO'],
1029                'duedate'      => ($onTransfer || $waiting)
1030                    ? '' : (string)$duedate_formatted,
1031                'barcode'      => (null == $rowItem['BARCODE'])
1032                    ? 'Unknown' : $rowItem['BARCODE'],
1033                'number'       =>
1034                    $rowItem['COPYNO'] ?? $rowItem['STOCKNUMBER'] ?? '',
1035                'enumchron'    => $rowItem['ENUMCHRON'] ?? null,
1036                'requests_placed' => $reservesCount ? $reservesCount : 0,
1037                'frameworkcode' => $rowItem['DOCTYPE'],
1038            ];
1039        }
1040
1041        $this->debug(
1042            'Processing finished, rows processed: '
1043            . count($holding) . ', took ' . (microtime(true) - $started) .
1044            ' sec'
1045        );
1046
1047        return $holding;
1048    }
1049
1050    /**
1051     * This method queries the ILS for new items
1052     *
1053     * @param int $page    Page number of results to retrieve (counting starts at 1)
1054     * @param int $limit   The size of each page of results to retrieve
1055     * @param int $daysOld The maximum age of records to retrieve in days (max. 30)
1056     * @param int $fundId  optional fund ID to use for limiting results (use a value
1057     * returned by getFunds, or exclude for no limit); note that "fund" may be a
1058     * misnomer - if funds are not an appropriate way to limit your new item
1059     * results, you can return a different set of values from getFunds. The
1060     * important thing is that this parameter supports an ID returned by getFunds,
1061     * whatever that may mean.
1062     *
1063     * @return array provides a count and the results of new items.
1064     */
1065    public function getNewItems($page, $limit, $daysOld, $fundId = null)
1066    {
1067        $this->debug("getNewItems called $page|$limit|$daysOld|$fundId");
1068
1069        $items = [];
1070        $daysOld = min(abs(intval($daysOld)), 30);
1071        $sql = "SELECT distinct biblionumber as id
1072                FROM items
1073                WHERE itemlost = 0
1074                   and dateaccessioned > DATE_ADD(CURRENT_TIMESTAMP,
1075                      INTERVAL -$daysOld day)
1076                ORDER BY dateaccessioned DESC";
1077
1078        $this->debug($sql);
1079
1080        $itemSqlStmt = $this->getDb()->prepare($sql);
1081        $itemSqlStmt->execute();
1082
1083        $rescount = 0;
1084        foreach ($itemSqlStmt->fetchAll() as $rowItem) {
1085            $items[] = [
1086                'id' => $rowItem['id'],
1087            ];
1088            $rescount++;
1089        }
1090
1091        $this->debug($rescount . ' fetched');
1092
1093        $results = array_slice($items, ($page - 1) * $limit, ($page * $limit) - 1);
1094        return ['count' => $rescount, 'results' => $results];
1095    }
1096
1097    /**
1098     * Get Hold Link
1099     *
1100     * The goal for this method is to return a URL to a "place hold" web page on
1101     * the ILS OPAC. This is used for ILSs that do not support an API or method
1102     * to place Holds.
1103     *
1104     * @param string $id      The id of the bib record
1105     * @param array  $details Item details from getHoldings return array
1106     *
1107     * @return string         URL to ILS's OPAC's place hold screen.
1108     *
1109     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1110     */
1111    /*public function getHoldLink($id, $details)
1112    {
1113        // Web link of the ILS for placing hold on the item
1114        return $this->ilsBaseUrl . "/cgi-bin/koha/opac-reserve.pl?biblionumber=$id";
1115    }*/
1116
1117    /**
1118     * Get Patron Fines
1119     *
1120     * This is responsible for retrieving all fines by a specific patron.
1121     *
1122     * @param array $patron The patron array from patronLogin
1123     *
1124     * @throws DateException
1125     * @throws ILSException
1126     * @return mixed        Array of the patron's fines on success.
1127     */
1128    public function getMyFines($patron)
1129    {
1130        $id = 0;
1131        $transactionLst = [];
1132        $row = $sql = $sqlStmt = '';
1133        try {
1134            $id = $patron['id'];
1135            $sql = 'SELECT al.amount*100 as amount, '
1136                . 'al.amountoutstanding*100 as balance, '
1137                . 'COALESCE(al.credit_type_code, al.debit_type_code) as fine, '
1138                . 'al.date as createdat, items.biblionumber as id, '
1139                . 'al.description as title, issues.date_due as duedate, '
1140                . 'issues.issuedate as issuedate '
1141                . 'FROM `accountlines` al '
1142                . 'LEFT JOIN items USING (itemnumber) '
1143                . 'LEFT JOIN issues USING (issue_id) '
1144                . 'WHERE al.borrowernumber = :id ';
1145            $sqlStmt = $this->getDb()->prepare($sql);
1146            $sqlStmt->execute([':id' => $id]);
1147            foreach ($sqlStmt->fetchAll() as $row) {
1148                switch ($row['fine']) {
1149                    case 'ACCOUNT':
1150                        $fineValue = 'Account creation fee';
1151                        break;
1152                    case 'ACCOUNT_RENEW':
1153                        $fineValue = 'Account renewal fee';
1154                        break;
1155                    case 'LOST':
1156                        $fineValue = 'Lost item';
1157                        break;
1158                    case 'MANUAL':
1159                        $fineValue = 'Manual fee';
1160                        break;
1161                    case 'NEW_CARD':
1162                        $fineValue = 'New card';
1163                        break;
1164                    case 'OVERDUE':
1165                        $fineValue = 'Fine';
1166                        break;
1167                    case 'PROCESSING':
1168                        $fineValue = 'Lost item processing fee';
1169                        break;
1170                    case 'RENT':
1171                        $fineValue = 'Rental fee';
1172                        break;
1173                    case 'RENT_DAILY':
1174                        $fineValue = 'Daily rental fee';
1175                        break;
1176                    case 'RENT_RENEW':
1177                        $fineValue = 'Renewal of rental item';
1178                        break;
1179                    case 'RENT_DAILY_RENEW':
1180                        $fineValue = 'Renewal of daily rental item';
1181                        break;
1182                    case 'RESERVE':
1183                        $fineValue = 'Hold fee';
1184                        break;
1185                    case 'RESERVE_EXPIRED':
1186                        $fineValue = 'Hold waiting too long';
1187                        break;
1188                    case 'Payout':
1189                        $fineValue = 'Payout';
1190                        break;
1191                    case 'PAYMENT':
1192                        $fineValue = 'Payment';
1193                        break;
1194                    case 'WRITEOFF':
1195                        $fineValue = 'Writeoff';
1196                        break;
1197                    case 'FORGIVEN':
1198                        $fineValue = 'Forgiven';
1199                        break;
1200                    case 'CREDIT':
1201                        $fineValue = 'Credit';
1202                        break;
1203                    case 'LOST_FOUND':
1204                        $fineValue = 'Lost item fee refund';
1205                        break;
1206                    case 'OVERPAYMENT':
1207                        $fineValue = 'Overpayment refund';
1208                        break;
1209                    case 'REFUND':
1210                        $fineValue = 'Refund';
1211                        break;
1212                    case 'CANCELLATION':
1213                        $fineValue = 'Cancelled charge';
1214                        break;
1215                    default:
1216                        $fineValue = 'Unknown Charge';
1217                        break;
1218                }
1219
1220                $transactionLst[] = [
1221                    'amount'     => $row['amount'],
1222                    'checkout'   => $this->displayDateTime($row['issuedate']),
1223                    'title'      => $row['title'],
1224                    'fine'       => $fineValue,
1225                    'balance'    => $row['balance'],
1226                    'createdate' => $this->displayDate($row['createdat']),
1227                    'duedate'    => $this->displayDate($row['duedate']),
1228                    'id'         => $row['id'] ?? -1,
1229                ];
1230            }
1231            return $transactionLst;
1232        } catch (PDOException $e) {
1233            $this->throwAsIlsException($e);
1234        }
1235    }
1236
1237    /**
1238     * Get Patron Fines
1239     *
1240     * This is responsible for retrieving all fines by a specific patron.
1241     *
1242     * @param array $patron The patron array from patronLogin
1243     *
1244     * @throws DateException
1245     * @throws ILSException
1246     * @return mixed        Array of the patron's fines on success.
1247     */
1248    public function getMyFinesILS($patron)
1249    {
1250        $id = $patron['id'];
1251        $fineLst = [];
1252
1253        $rsp = $this->makeRequest(
1254            "GetPatronInfo&patron_id=$id" . '&show_contact=0&show_fines=1'
1255        );
1256
1257        $this->debug('ID: ' . $rsp->{'borrowernumber'});
1258        $this->debug('Chrgs: ' . $rsp->{'charges'});
1259
1260        foreach ($rsp->{'fines'}->{'fine'} ?? [] as $fine) {
1261            $fineLst[] = [
1262                'amount'     => 100 * $this->getField($fine->{'amount'}),
1263                // FIXME: require accountlines.itemnumber -> issues.issuedate data
1264                'checkout'   => 'N/A',
1265                'fine'       => $this->getField($fine->{'description'}),
1266                'balance'    => 100 * $this->getField($fine->{'amountoutstanding'}),
1267                'createdate' => $this->displayDate($this->getField($fine->{'date'})),
1268                // FIXME: require accountlines.itemnumber -> issues.date_due data.
1269                'duedate'    => 'N/A',
1270                // FIXME: require accountlines.itemnumber -> items.biblionumber data
1271                'id'         => 'N/A',
1272            ];
1273        }
1274        return $fineLst;
1275    }
1276
1277    /**
1278     * Get Patron Holds
1279     *
1280     * This is responsible for retrieving all holds by a specific patron.
1281     *
1282     * @param array $patron The patron array from patronLogin
1283     *
1284     * @throws DateException
1285     * @throws ILSException
1286     * @return array        Array of the patron's holds on success.
1287     */
1288    public function getMyHolds($patron)
1289    {
1290        $id = $patron['id'];
1291        $holdLst = [];
1292
1293        $rsp = $this->makeRequest(
1294            "GetPatronInfo&patron_id=$id" . '&show_contact=0&show_holds=1'
1295        );
1296
1297        $this->debug('ID: ' . $rsp->{'borrowernumber'});
1298
1299        foreach ($rsp->{'holds'}->{'hold'} ?? [] as $hold) {
1300            $holdLst[] = [
1301                'id'       => $this->getField($hold->{'biblionumber'}),
1302                'location' => $this->getField($hold->{'branchname'}),
1303                'expire'   => isset($hold->{'expirationdate'})
1304                    ? $this->displayDate(
1305                        $this->getField($hold->{'expirationdate'})
1306                    )
1307                    : 'N/A',
1308                'create'   => $this->displayDate(
1309                    $this->getField($hold->{'reservedate'})
1310                ),
1311                'position' => $this->getField($hold->{'priority'}),
1312                'title' => $this->getField($hold->{'title'}),
1313                'available' => ($this->getField($hold->{'found'}) == 'W'),
1314                'reserve_id' => $this->getField($hold->{'reserve_id'}),
1315            ];
1316        }
1317        return $holdLst;
1318    }
1319
1320    /**
1321     * Get Cancel Hold Details
1322     *
1323     * In order to cancel a hold, Koha requires the patron details and
1324     * an item ID. This function returns the item id as a string. This
1325     * value is then used by the CancelHolds function.
1326     *
1327     * @param array $holdDetails A single hold array from getMyHolds
1328     * @param array $patron      Patron information from patronLogin
1329     *
1330     * @return string Data for use in a form field
1331     *
1332     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1333     */
1334    public function getCancelHoldDetails($holdDetails, $patron = [])
1335    {
1336        return $holdDetails['reserve_id'];
1337    }
1338
1339    /**
1340     * Cancel Holds
1341     *
1342     * Attempts to Cancel a hold or recall on a particular item. The
1343     * data in $cancelDetails['details'] is determined by getCancelHoldDetails().
1344     *
1345     * @param array $cancelDetails An array of item and patron data
1346     *
1347     * @return array               An array of data on each request including
1348     * whether or not it was successful and a system message (if available)
1349     */
1350    public function cancelHolds($cancelDetails)
1351    {
1352        $retVal         = ['count' => 0, 'items' => []];
1353        $details        = $cancelDetails['details'];
1354        $patron_id      = $cancelDetails['patron']['id'];
1355        $request_prefix = 'CancelHold&patron_id=' . $patron_id . '&item_id=';
1356
1357        foreach ($details as $cancelItem) {
1358            $rsp = $this->makeRequest($request_prefix . $cancelItem);
1359            if ($rsp->{'code'} != 'Canceled') {
1360                $retVal['items'][$cancelItem] = [
1361                    'success'    => false,
1362                    'status'     => 'hold_cancel_fail',
1363                    'sysMessage' => $this->getField($rsp->{'code'}),
1364                ];
1365            } else {
1366                $retVal['count']++;
1367                $retVal['items'][$cancelItem] = [
1368                    'success' => true,
1369                    'status' => 'hold_cancel_success',
1370                ];
1371            }
1372        }
1373        return $retVal;
1374    }
1375
1376    /**
1377     * Get Patron Profile
1378     *
1379     * This is responsible for retrieving the profile for a specific patron.
1380     *
1381     * @param array $patron The patron array
1382     *
1383     * @throws ILSException
1384     * @return array        Array of the patron's profile data on success.
1385     */
1386    public function getMyProfile($patron)
1387    {
1388        $id = $patron['id'];
1389        $profile = [];
1390
1391        $rsp = $this->makeRequest(
1392            "GetPatronInfo&patron_id=$id" . '&show_contact=1'
1393        );
1394
1395        $this->debug('Code: ' . $rsp->{'code'});
1396        $this->debug('Cardnumber: ' . $rsp->{'cardnumber'});
1397
1398        if ($rsp->{'code'} != 'PatronNotFound') {
1399            $profile = [
1400                'firstname' => $this->getField($rsp->{'firstname'}),
1401                'lastname'  => $this->getField($rsp->{'surname'}),
1402                'address1'  => $this->getField($rsp->{'address'}),
1403                'address2'  => $this->getField($rsp->{'address2'}),
1404                'zip'       => $this->getField($rsp->{'zipcode'}),
1405                'phone'     => $this->getField($rsp->{'phone'}),
1406                'group'     => $this->getField($rsp->{'categorycode'}),
1407            ];
1408            return $profile;
1409        } else {
1410            $this->debug('Error Message: ' . $rsp->{'message'});
1411            return null;
1412        }
1413    }
1414
1415    /**
1416     * Check whether the patron has any blocks on their account.
1417     *
1418     * @param array $patron Patron data from patronLogin
1419     *
1420     * @throws ILSException
1421     *
1422     * @return mixed A boolean false if no blocks are in place and an array
1423     * of block reasons if blocks are in place
1424     */
1425    public function getAccountBlocks($patron)
1426    {
1427        $blocks = [];
1428
1429        try {
1430            $id = $patron['id'];
1431            $sql = 'select type as TYPE, comment as COMMENT ' .
1432                'from borrower_debarments ' .
1433                'where (expiration is null or expiration >= NOW()) ' .
1434                'and borrowernumber = :id';
1435            $sqlStmt = $this->getDb()->prepare($sql);
1436            $sqlStmt->execute([':id' => $id]);
1437
1438            foreach ($sqlStmt->fetchAll() as $row) {
1439                $block = empty($this->blockTerms[$row['TYPE']])
1440                    ? [$row['TYPE']]
1441                    : [$this->blockTerms[$row['TYPE']]];
1442
1443                if (
1444                    !empty($this->showBlockComments[$row['TYPE']])
1445                    && !empty($row['COMMENT'])
1446                ) {
1447                    $block[] = $row['COMMENT'];
1448                }
1449
1450                $blocks[] = implode(' - ', $block);
1451            }
1452        } catch (PDOException $e) {
1453            $this->throwAsIlsException($e);
1454        }
1455
1456        return count($blocks) ? $blocks : false;
1457    }
1458
1459    /**
1460     * Get Patron Loan History
1461     *
1462     * This is responsible for retrieving all historic loans (i.e. items previously
1463     * checked out and then returned), for a specific patron.
1464     *
1465     * @param array $patron The patron array from patronLogin
1466     * @param array $params Parameters
1467     *
1468     * @throws DateException
1469     * @throws ILSException
1470     * @return array        Array of the patron's transactions on success.
1471     */
1472    public function getMyTransactionHistory($patron, $params)
1473    {
1474        $id = 0;
1475        $historicLoans = [];
1476        $row = $sql = $sqlStmt = '';
1477        try {
1478            $id = $patron['id'];
1479
1480            // Get total count first
1481            $sql = 'select count(*) as cnt from old_issues ' .
1482                'where old_issues.borrowernumber = :id';
1483            $sqlStmt = $this->getDb()->prepare($sql);
1484            $sqlStmt->execute([':id' => $id]);
1485            $totalCount = $sqlStmt->fetch()['cnt'];
1486
1487            // Get rows
1488            $limit = isset($params['limit']) ? (int)$params['limit'] : 50;
1489            $start = isset($params['page'])
1490                ? ((int)$params['page'] - 1) * $limit : 0;
1491            if (isset($params['sort'])) {
1492                $parts = explode(' ', $params['sort'], 2);
1493                switch ($parts[0]) {
1494                    case 'return':
1495                        $sort = 'RETURNED';
1496                        break;
1497                    case 'due':
1498                        $sort = 'DUEDATE';
1499                        break;
1500                    default:
1501                        $sort = 'ISSUEDATE';
1502                        break;
1503                }
1504                $sort .= isset($parts[1]) && 'asc' === $parts[1] ? ' asc' : ' desc';
1505            } else {
1506                $sort = 'ISSUEDATE desc';
1507            }
1508            $sql = 'select old_issues.issuedate as ISSUEDATE, ' .
1509                'old_issues.date_due as DUEDATE, items.biblionumber as ' .
1510                'BIBNO, items.barcode BARCODE, old_issues.returndate as RETURNED, ' .
1511                'biblio.title as TITLE ' .
1512                'from old_issues join items ' .
1513                'on old_issues.itemnumber = items.itemnumber ' .
1514                'join biblio on items.biblionumber = biblio.biblionumber ' .
1515                'where old_issues.borrowernumber = :id ' .
1516                "order by $sort limit $start,$limit";
1517            $sqlStmt = $this->getDb()->prepare($sql);
1518
1519            $sqlStmt->execute([':id' => $id]);
1520            foreach ($sqlStmt->fetchAll() as $row) {
1521                $historicLoans[] = [
1522                    'title' => $row['TITLE'],
1523                    'checkoutDate' => $this->displayDateTime($row['ISSUEDATE']),
1524                    'dueDate' => $this->displayDateTime($row['DUEDATE']),
1525                    'id' => $row['BIBNO'],
1526                    'barcode' => $row['BARCODE'],
1527                    'returnDate' => $this->displayDateTime($row['RETURNED']),
1528                ];
1529            }
1530        } catch (PDOException $e) {
1531            $this->throwAsIlsException($e);
1532        }
1533        return [
1534            'count' => $totalCount,
1535            'transactions' => $historicLoans,
1536        ];
1537    }
1538
1539    /**
1540     * Get Patron Transactions
1541     *
1542     * This is responsible for retrieving all transactions (i.e. checked out items)
1543     * by a specific patron.
1544     *
1545     * @param array $patron The patron array from patronLogin
1546     *
1547     * @throws DateException
1548     * @throws ILSException
1549     * @return array        Array of the patron's transactions on success.
1550     */
1551    public function getMyTransactions($patron)
1552    {
1553        $id = $patron['id'];
1554        $transactionLst = [];
1555        $start = microtime(true);
1556        $rsp = $this->makeRequest(
1557            "GetPatronInfo&patron_id=$id" . '&show_contact=0&show_loans=1'
1558        );
1559        $end = microtime(true);
1560        $requestTimes = [$end - $start];
1561
1562        $this->debug('ID: ' . $rsp->{'borrowernumber'});
1563
1564        foreach ($rsp->{'loans'}->{'loan'} ?? [] as $loan) {
1565            $start = microtime(true);
1566            $rsp2 = $this->makeIlsdiRequest(
1567                'GetServices',
1568                [
1569                    'patron_id' => $id,
1570                    'item_id' => $this->getField($loan->{'itemnumber'}),
1571                ]
1572            );
1573            $end = microtime(true);
1574            $requestTimes[] = $end - $start;
1575            $renewable = false;
1576            foreach ($rsp2->{'AvailableFor'} ?? [] as $service) {
1577                if ($this->getField((string)$service) == 'loan renewal') {
1578                    $renewable = true;
1579                }
1580            }
1581
1582            $transactionLst[] = [
1583                'duedate'   => $this->displayDate(
1584                    $this->getField($loan->{'date_due'})
1585                ),
1586                'id'        => $this->getField($loan->{'biblionumber'}),
1587                'item_id'   => $this->getField($loan->{'itemnumber'}),
1588                'barcode'   => $this->getField($loan->{'barcode'}),
1589                'renew'     => $this->getField($loan->{'renewals'}, '0'),
1590                'renewable' => $renewable,
1591            ];
1592        }
1593        foreach ($requestTimes as $time) {
1594            $this->debug("Request time: $time");
1595        }
1596        return $transactionLst;
1597    }
1598
1599    /**
1600     * Get Renew Details
1601     *
1602     * In order to renew an item, Koha requires the patron details and
1603     * an item id. This function returns the item id as a string which
1604     * is then used as submitted form data in checkedOut.php. This
1605     * value is then extracted by the RenewMyItems function.
1606     *
1607     * @param array $checkOutDetails An array of item data
1608     *
1609     * @return string Data for use in a form field
1610     */
1611    public function getRenewDetails($checkOutDetails)
1612    {
1613        return $checkOutDetails['item_id'];
1614    }
1615
1616    /**
1617     * Renew My Items
1618     *
1619     * Function for attempting to renew a patron's items. The data in
1620     * $renewDetails['details'] is determined by getRenewDetails().
1621     *
1622     * @param array $renewDetails An array of data required for
1623     * renewing items including the Patron ID and an array of renewal
1624     * IDS
1625     *
1626     * @return array An array of renewal information keyed by item ID
1627     */
1628    public function renewMyItems($renewDetails)
1629    {
1630        $retVal         = ['blocks' => false, 'details' => []];
1631        $details        = $renewDetails['details'];
1632        $patron_id      = $renewDetails['patron']['id'];
1633        $request_prefix = 'RenewLoan&patron_id=' . $patron_id . '&item_id=';
1634
1635        foreach ($details as $renewItem) {
1636            $rsp = $this->makeRequest($request_prefix . $renewItem);
1637            if ($rsp->{'success'} != '0') {
1638                [$date, $time]
1639                    = explode(' ', $this->getField($rsp->{'date_due'}));
1640                $retVal['details'][$renewItem] = [
1641                    'success'  => true,
1642                    'new_date' => $this->displayDate($date),
1643                    'new_time' => $time,
1644                    'item_id'  => $renewItem,
1645                ];
1646            } else {
1647                $retVal['details'][$renewItem] = [
1648                    'success'    => false,
1649                    'new_date'   => false,
1650                    'item_id'    => $renewItem,
1651                    //"sysMessage" => $this->getField($rsp->{'error'}),
1652                ];
1653            }
1654        }
1655        return $retVal;
1656    }
1657
1658    /**
1659     * Get Purchase History
1660     *
1661     * This is responsible for retrieving the acquisitions history data for the
1662     * specific record (usually recently received issues of a serial).
1663     *
1664     * @param string $id The record id to retrieve the info for
1665     *
1666     * @throws ILSException
1667     * @return array An array with the acquisitions data on success.
1668     *
1669     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1670     */
1671    public function getPurchaseHistory($id)
1672    {
1673        try {
1674            $sql = "SELECT b.title, b.biblionumber,
1675                       CONCAT(s.publisheddate, ' / ',s.serialseq)
1676                         AS 'date and enumeration'
1677                    FROM serial s
1678                    LEFT JOIN biblio b USING (biblionumber)
1679                    WHERE s.STATUS=2 and b.biblionumber = :id
1680                    ORDER BY s.publisheddate DESC";
1681
1682            $sqlStmt = $this->getDb()->prepare($sql);
1683            $sqlStmt->execute(['id' => $id]);
1684
1685            $result = [];
1686            foreach ($sqlStmt->fetchAll() as $rowItem) {
1687                $result[] = ['issue' => $rowItem['date and enumeration']];
1688            }
1689        } catch (PDOException $e) {
1690            $this->throwAsIlsException($e);
1691        }
1692        return $result;
1693    }
1694
1695    /**
1696     * Get Status
1697     *
1698     * This is responsible for retrieving the status information of a certain
1699     * record.
1700     *
1701     * @param string $id The record id to retrieve the holdings for
1702     *
1703     * @throws ILSException
1704     * @return mixed     On success, an associative array with the following keys:
1705     * id, availability (boolean), status, location, reserve, callnumber.
1706     */
1707    public function getStatus($id)
1708    {
1709        return $this->getHolding($id);
1710    }
1711
1712    /**
1713     * Get Statuses
1714     *
1715     * This is responsible for retrieving the status information for a
1716     * collection of records.
1717     *
1718     * @param array $idLst The array of record ids to retrieve the status for
1719     *
1720     * @throws ILSException
1721     * @return array       An array of getStatus() return values on success.
1722     */
1723    public function getStatuses($idLst)
1724    {
1725        $this->debug('IDs:' . implode(',', $idLst));
1726
1727        $statusLst = [];
1728        foreach ($idLst as $id) {
1729            $statusLst[] = $this->getStatus($id);
1730        }
1731        return $statusLst;
1732    }
1733
1734    /**
1735     * Get suppressed records.
1736     *
1737     * @throws ILSException
1738     * @return array ID numbers of suppressed records in the system.
1739     */
1740    public function getSuppressedRecords()
1741    {
1742        try {
1743            if ($this->tableExists('biblio_metadata')) {
1744                $sql = "SELECT biblio.biblionumber AS biblionumber
1745                      FROM biblio
1746                      JOIN biblio_metadata USING (biblionumber)
1747                      WHERE ExtractValue(
1748                        metadata, '//datafield[@tag=\"942\"]/subfield[@code=\"n\"]' )
1749                        IN ('Y', '1')
1750                      AND biblio_metadata.format = 'marcxml'";
1751            } else {
1752                $sql = "SELECT biblio.biblionumber AS biblionumber
1753                      FROM biblioitems
1754                      JOIN biblio USING (biblionumber)
1755                      WHERE ExtractValue(
1756                        marcxml, '//datafield[@tag=\"942\"]/subfield[@code=\"n\"]' )
1757                        IN ('Y', '1')";
1758            }
1759            $sqlStmt = $this->getDb()->prepare($sql);
1760            $sqlStmt->execute();
1761            $result = [];
1762            foreach ($sqlStmt->fetchAll() as $rowItem) {
1763                $result[] = $rowItem['biblionumber'];
1764            }
1765        } catch (PDOException $e) {
1766            $this->throwAsIlsException($e);
1767        }
1768        return $result;
1769    }
1770
1771    /**
1772     * Get Departments
1773     *
1774     * @throws ILSException
1775     * @return array An associative array with key = ID, value = dept. name.
1776     */
1777    public function getDepartments()
1778    {
1779        $deptList = [];
1780
1781        $sql = 'SELECT DISTINCT department as abv, lib_opac AS DEPARTMENT
1782                 FROM courses
1783                 INNER JOIN `authorised_values`
1784                    ON courses.department = `authorised_values`.`authorised_value`';
1785        try {
1786            $sqlStmt = $this->getDb()->prepare($sql);
1787            $sqlStmt->execute();
1788            foreach ($sqlStmt->fetchAll() as $rowItem) {
1789                $deptList[$rowItem['abv']] = $rowItem['DEPARTMENT'];
1790            }
1791        } catch (PDOException $e) {
1792            $this->throwAsIlsException($e);
1793        }
1794        return $deptList;
1795    }
1796
1797    /**
1798     * Get Instructors
1799     *
1800     * @throws ILSException
1801     * @return array An associative array with key = ID, value = name.
1802     */
1803    public function getInstructors()
1804    {
1805        $instList = [];
1806
1807        $sql = "SELECT DISTINCT borrowernumber,
1808                       CONCAT(firstname, ' ', surname) AS name
1809                 FROM course_instructors
1810                 LEFT JOIN borrowers USING(borrowernumber)";
1811
1812        try {
1813            $sqlStmt = $this->getDb()->prepare($sql);
1814            $sqlStmt->execute();
1815            foreach ($sqlStmt->fetchAll() as $rowItem) {
1816                $instList[$rowItem['borrowernumber']] = $rowItem['name'];
1817            }
1818        } catch (PDOException $e) {
1819            $this->throwAsIlsException($e);
1820        }
1821        return $instList;
1822    }
1823
1824    /**
1825     * Get Courses
1826     *
1827     * @throws ILSException
1828     * @return array An associative array with key = ID, value = name.
1829     */
1830    public function getCourses()
1831    {
1832        $courseList = [];
1833
1834        $sql = "SELECT course_id,
1835                CONCAT (course_number, ' - ', course_name) AS course
1836                 FROM courses
1837                 WHERE enabled = 1";
1838        try {
1839            $sqlStmt = $this->getDb()->prepare($sql);
1840            $sqlStmt->execute();
1841            foreach ($sqlStmt->fetchAll() as $rowItem) {
1842                $courseList[$rowItem['course_id']] = $rowItem['course'];
1843            }
1844        } catch (PDOException $e) {
1845            $this->throwAsIlsException($e);
1846        }
1847        return $courseList;
1848    }
1849
1850    /**
1851     * Find Reserves
1852     *
1853     * Obtain information on course reserves.
1854     *
1855     * This version of findReserves was contributed by Matthew Hooper and includes
1856     * support for electronic reserves (though eReserve support is still a work in
1857     * progress).
1858     *
1859     * @param string $course ID from getCourses (empty string to match all)
1860     * @param string $inst   ID from getInstructors (empty string to match all)
1861     * @param string $dept   ID from getDepartments (empty string to match all)
1862     *
1863     * @throws ILSException
1864     * @return array An array of associative arrays representing reserve items.
1865     */
1866    public function findReserves($course, $inst, $dept)
1867    {
1868        $reserveWhere = [];
1869        $bindParams = [];
1870        if ($course != '') {
1871            $reserveWhere[] = 'COURSE_ID = :course';
1872            $bindParams[':course'] = $course;
1873        }
1874        if ($inst != '') {
1875            $reserveWhere[] = 'INSTRUCTOR_ID = :inst';
1876            $bindParams[':inst'] = $inst;
1877        }
1878        if ($dept != '') {
1879            $reserveWhere[] = 'DEPARTMENT_ID = :dept';
1880            $bindParams[':dept'] = $dept;
1881        }
1882        $reserveWhere = empty($reserveWhere) ?
1883            '' : 'HAVING (' . implode(' AND ', $reserveWhere) . ')';
1884
1885        $sql = "SELECT biblionumber AS `BIB_ID`,
1886                       courses.course_id AS `COURSE_ID`,
1887                       course_instructors.borrowernumber as `INSTRUCTOR_ID`,
1888                       courses.department AS `DEPARTMENT_ID`
1889                FROM courses
1890                INNER JOIN `authorised_values`
1891                   ON courses.department = `authorised_values`.`authorised_value`
1892                INNER JOIN `course_reserves` USING (course_id)
1893                INNER JOIN `course_items` USING (ci_id)
1894                INNER JOIN `items` USING (itemnumber)
1895                INNER JOIN `course_instructors` USING (course_id)
1896                INNER JOIN `borrowers` USING (borrowernumber)
1897                WHERE courses.enabled = 'yes' " . $reserveWhere;
1898
1899        try {
1900            $sqlStmt = $this->getDb()->prepare($sql);
1901            $sqlStmt->execute($bindParams);
1902            $result = [];
1903            foreach ($sqlStmt->fetchAll() as $rowItem) {
1904                $result[] = $rowItem;
1905            }
1906        } catch (PDOException $e) {
1907            $this->throwAsIlsException($e);
1908        }
1909        return $result;
1910    }
1911
1912    /**
1913     * Patron Login
1914     *
1915     * This is responsible for authenticating a patron against the catalog.
1916     *
1917     * @param string $username The patron username
1918     * @param string $password The patron's password
1919     *
1920     * @throws ILSException
1921     * @return mixed          Associative array of patron info on successful login,
1922     * null on unsuccessful login.
1923     */
1924    public function patronLogin($username, $password)
1925    {
1926        $request = 'LookupPatron' . '&id=' . urlencode($username)
1927            . '&id_type=userid';
1928
1929        if ($this->validatePasswords) {
1930            $request = 'AuthenticatePatron' . '&username='
1931                . urlencode($username) . '&password=' . $password;
1932        }
1933
1934        $idObj = $this->makeRequest($request);
1935
1936        $this->debug('username: ' . $username);
1937        $this->debug('Code: ' . $idObj->{'code'});
1938        $this->debug('ID: ' . $idObj->{'id'});
1939
1940        $id = $this->getField($idObj->{'id'}, 0);
1941        if ($id) {
1942            $rsp = $this->makeRequest(
1943                "GetPatronInfo&patron_id=$id&show_contact=1"
1944            );
1945            $profile = [
1946                'id'           => $this->getField($idObj->{'id'}),
1947                'firstname'    => $this->getField($rsp->{'firstname'}),
1948                'lastname'     => $this->getField($rsp->{'surname'}),
1949                'cat_username' => $username,
1950                'cat_password' => $password,
1951                'email'        => $this->getField($rsp->{'email'}),
1952                'major'        => null,
1953                'college'      => null,
1954            ];
1955            return $profile;
1956        } else {
1957            return null;
1958        }
1959    }
1960
1961    /**
1962     * Change Password
1963     *
1964     * This method changes patron's password
1965     *
1966     * @param array $detail An associative array with three keys
1967     *      patron      - The patron array from patronLogin
1968     *      oldPassword - Old password
1969     *      newPassword - New password
1970     *
1971     * @return array  An associative array with keys:
1972     *      success - boolean, true if change was made
1973     *      status  - string, A status message - subject to translation
1974     */
1975    public function changePassword($detail)
1976    {
1977        $sql = 'UPDATE borrowers SET password = ? WHERE borrowernumber = ?';
1978        $keyspace = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
1979        $max = mb_strlen($keyspace, '8bit') - 1;
1980        $salt = '';
1981        for ($i = 0; $i < 16; ++$i) { // 16 is length of salt
1982            $salt .= $keyspace[random_int(0, $max)];
1983        }
1984        $salt = base64_encode($salt);
1985        $newPassword_hashed = crypt($detail['newPassword'], '$2a$08$' . $salt);
1986        try {
1987            $stmt = $this->getDb()->prepare($sql);
1988            $result = $stmt->execute(
1989                [ $newPassword_hashed, $detail['patron']['id'] ]
1990            );
1991        } catch (\Exception $e) {
1992            return [ 'success' => false, 'status' => $e->getMessage() ];
1993        }
1994        return [
1995            'success' => $result,
1996            'status' => $result ? 'new_password_success'
1997                : 'password_error_not_unique',
1998        ];
1999    }
2000
2001    /**
2002     * Convert a database date to a displayable date.
2003     *
2004     * @param string $date Date to convert
2005     *
2006     * @return string
2007     */
2008    public function displayDate($date)
2009    {
2010        if (empty($date)) {
2011            return '';
2012        } elseif (preg_match("/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/", $date) === 1) {
2013            // YYYY-MM-DD HH:MM:SS
2014            return $this->dateConverter->convertToDisplayDate('Y-m-d H:i:s', $date);
2015        } elseif (preg_match("/^\d{4}-\d\d-\d\d \d\d:\d\d$/", $date) === 1) {
2016            // YYYY-MM-DD HH:MM
2017            return $this->dateConverter->convertToDisplayDate('Y-m-d H:i', $date);
2018        } elseif (preg_match("/^\d{4}-\d{2}-\d{2}$/", $date) === 1) { // YYYY-MM-DD
2019            return $this->dateConverter->convertToDisplayDate('Y-m-d', $date);
2020        } else {
2021            error_log("Unexpected date format: $date");
2022            return $date;
2023        }
2024    }
2025
2026    /**
2027     * Convert a database datetime to a displayable date and time.
2028     *
2029     * @param string $date Datetime to convert
2030     *
2031     * @return string
2032     */
2033    public function displayDateTime($date)
2034    {
2035        if (empty($date)) {
2036            return '';
2037        } elseif (preg_match("/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/", $date) === 1) {
2038            // YYYY-MM-DD HH:MM:SS
2039            return
2040                $this->dateConverter->convertToDisplayDateAndTime(
2041                    'Y-m-d H:i:s',
2042                    $date
2043                );
2044        } elseif (preg_match("/^\d{4}-\d\d-\d\d \d\d:\d\d$/", $date) === 1) {
2045            // YYYY-MM-DD HH:MM
2046            return
2047                $this->dateConverter->convertToDisplayDateAndTime(
2048                    'Y-m-d H:i',
2049                    $date
2050                );
2051        } else {
2052            error_log("Unexpected date format: $date");
2053            return $date;
2054        }
2055    }
2056
2057    /**
2058     * Helper method to determine whether or not a certain method can be
2059     * called on this driver. Required method for any smart drivers.
2060     *
2061     * @param string $method The name of the called method.
2062     * @param array  $params Array of passed parameters
2063     *
2064     * @return bool True if the method can be called with the given parameters,
2065     * false otherwise.
2066     *
2067     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
2068     */
2069    public function supportsMethod($method, $params)
2070    {
2071        // Loan history is only available if properly configured
2072        if ($method == 'getMyTransactionHistory') {
2073            return !empty($this->config['TransactionHistory']['enabled']);
2074        }
2075        return is_callable([$this, $method]);
2076    }
2077}