Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
71.77% |
605 / 843 |
|
46.27% |
31 / 67 |
CRAP | |
0.00% |
0 / 1 |
Folio | |
71.77% |
605 / 843 |
|
46.27% |
31 / 67 |
1120.23 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setConfig | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getBibIdType | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
debugRequest | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
30 | |||
getCacheKey | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
preRequest | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
renewTenantToken | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
checkTenantToken | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
init | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
getInstanceById | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
20 | |||
getBibId | |
27.27% |
3 / 11 |
|
0.00% |
0 / 1 |
19.85 | |||
escapeCql | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getInstanceByBibId | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
3.01 | |||
getStatus | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getStatuses | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getConfig | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isHoldable | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
5 | |||
getLocations | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
3 | |||
getLocationData | |
37.50% |
6 / 16 |
|
0.00% |
0 / 1 |
5.20 | |||
chooseCallNumber | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
formatNote | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
getHoldingDetailsForItem | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
2 | |||
formatHoldingItem | |
100.00% |
50 / 50 |
|
100.00% |
1 / 1 |
3 | |||
sortHoldings | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
getBoundWithRecords | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
2 | |||
getHolding | |
96.72% |
59 / 61 |
|
0.00% |
0 / 1 |
12 | |||
getDateTimeFromString | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getDueDate | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
5.01 | |||
useLegacyAuthentication | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
performOkapiUsernamePasswordAuthentication | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
extractTokenFromResponse | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
4.02 | |||
patronLoginWithOkapi | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
2.06 | |||
getUserWithCql | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
2 | |||
fetchUserWithCql | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getPagedResults | |
87.50% |
14 / 16 |
|
0.00% |
0 / 1 |
4.03 | |||
patronLogin | |
89.29% |
25 / 28 |
|
0.00% |
0 / 1 |
8.08 | |||
getUserById | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getMyProfile | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
2 | |||
getMyTransactions | |
22.86% |
8 / 35 |
|
0.00% |
0 / 1 |
11.35 | |||
getRenewDetails | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
renewMyItems | |
68.18% |
30 / 44 |
|
0.00% |
0 / 1 |
4.52 | |||
getPickupLocations | |
63.64% |
7 / 11 |
|
0.00% |
0 / 1 |
2.19 | |||
getMyHolds | |
95.00% |
57 / 60 |
|
0.00% |
0 / 1 |
8 | |||
getModuleMajorVersion | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
3.00 | |||
getRequestTypeList | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
performHoldRequest | |
89.47% |
17 / 19 |
|
0.00% |
0 / 1 |
4.02 | |||
placeHold | |
91.67% |
33 / 36 |
|
0.00% |
0 / 1 |
11.07 | |||
getCancelHoldDetails | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
cancelHolds | |
97.06% |
33 / 34 |
|
0.00% |
0 / 1 |
7 | |||
getCourseResourceList | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
getDepartments | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getInstructors | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
getCourses | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
getCourseDetails | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
getInstructorIds | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
findReserves | |
0.00% |
0 / 36 |
|
0.00% |
0 / 1 |
306 | |||
getMyFines | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
12 | |||
userObjectToNameString | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
formatUserNameForProxyList | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
loadProxyUserData | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
6 | |||
getProxiedUsers | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getProxyingUsers | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getRequestBlocks | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getPurchaseHistory | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getFunds | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getMyTransactionHistory | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getNewItems | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | /** |
4 | * FOLIO REST API driver |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) Villanova University 2018-2023. |
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 Chris Hallberg <challber@villanova.edu> |
26 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
27 | * @link https://vufind.org/wiki/development:plugins:ils_drivers Wiki |
28 | */ |
29 | |
30 | namespace VuFind\ILS\Driver; |
31 | |
32 | use DateTime; |
33 | use DateTimeZone; |
34 | use Exception; |
35 | use Laminas\Http\Response; |
36 | use VuFind\Exception\ILS as ILSException; |
37 | use VuFind\I18n\Translator\TranslatorAwareInterface; |
38 | use VuFindHttp\HttpServiceAwareInterface as HttpServiceAwareInterface; |
39 | |
40 | use function array_key_exists; |
41 | use function count; |
42 | use function in_array; |
43 | use function is_int; |
44 | use function is_object; |
45 | use function is_string; |
46 | |
47 | /** |
48 | * FOLIO REST API driver |
49 | * |
50 | * @category VuFind |
51 | * @package ILS_Drivers |
52 | * @author Chris Hallberg <challber@villanova.edu> |
53 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
54 | * @link https://vufind.org/wiki/development:plugins:ils_drivers Wiki |
55 | */ |
56 | class Folio extends AbstractAPI implements |
57 | HttpServiceAwareInterface, |
58 | TranslatorAwareInterface |
59 | { |
60 | use \VuFindHttp\HttpServiceAwareTrait; |
61 | use \VuFind\I18n\Translator\TranslatorAwareTrait; |
62 | use \VuFind\Log\LoggerAwareTrait { |
63 | logWarning as warning; |
64 | logError as error; |
65 | } |
66 | |
67 | use \VuFind\Cache\CacheTrait { |
68 | getCacheKey as protected getBaseCacheKey; |
69 | } |
70 | |
71 | /** |
72 | * Authentication tenant (X-Okapi-Tenant) |
73 | * |
74 | * @var string |
75 | */ |
76 | protected $tenant = null; |
77 | |
78 | /** |
79 | * Authentication token (X-Okapi-Token) |
80 | * |
81 | * @var string |
82 | */ |
83 | protected $token = null; |
84 | |
85 | /** |
86 | * Factory function for constructing the SessionContainer. |
87 | * |
88 | * @var callable |
89 | */ |
90 | protected $sessionFactory; |
91 | |
92 | /** |
93 | * Session cache |
94 | * |
95 | * @var \Laminas\Session\Container |
96 | */ |
97 | protected $sessionCache; |
98 | |
99 | /** |
100 | * Date converter |
101 | * |
102 | * @var \VuFind\Date\Converter |
103 | */ |
104 | protected $dateConverter; |
105 | |
106 | /** |
107 | * Default availability messages, in case they are not defined in Folio.ini |
108 | * |
109 | * @var string[] |
110 | */ |
111 | protected $defaultAvailabilityStatuses = ['Open - Awaiting pickup']; |
112 | |
113 | /** |
114 | * Default in_transit messages, in case they are not defined in Folio.ini |
115 | * |
116 | * @var string[] |
117 | */ |
118 | protected $defaultInTransitStatuses = [ |
119 | 'Open - In transit', |
120 | 'Open - Awaiting delivery', |
121 | ]; |
122 | |
123 | /** |
124 | * Constructor |
125 | * |
126 | * @param \VuFind\Date\Converter $dateConverter Date converter object |
127 | * @param callable $sessionFactory Factory function returning |
128 | * SessionContainer object |
129 | */ |
130 | public function __construct( |
131 | \VuFind\Date\Converter $dateConverter, |
132 | $sessionFactory |
133 | ) { |
134 | $this->dateConverter = $dateConverter; |
135 | $this->sessionFactory = $sessionFactory; |
136 | } |
137 | |
138 | /** |
139 | * Set the configuration for the driver. |
140 | * |
141 | * @param array $config Configuration array (usually loaded from a VuFind .ini |
142 | * file whose name corresponds with the driver class name). |
143 | * |
144 | * @throws ILSException if base url excluded |
145 | * @return void |
146 | */ |
147 | public function setConfig($config) |
148 | { |
149 | parent::setConfig($config); |
150 | $this->tenant = $this->config['API']['tenant']; |
151 | } |
152 | |
153 | /** |
154 | * Get the type of FOLIO ID used to match up with VuFind's bib IDs. |
155 | * |
156 | * @return string |
157 | */ |
158 | protected function getBibIdType() |
159 | { |
160 | // Normalize string to tolerate minor variations in config file: |
161 | return trim(strtolower($this->config['IDs']['type'] ?? 'instance')); |
162 | } |
163 | |
164 | /** |
165 | * Function that obscures and logs debug data |
166 | * |
167 | * @param string $method Request method |
168 | * (GET/POST/PUT/DELETE/etc.) |
169 | * @param string $path Request URL |
170 | * @param array $params Request parameters |
171 | * @param \Laminas\Http\Headers $req_headers Headers object |
172 | * |
173 | * @return void |
174 | */ |
175 | protected function debugRequest($method, $path, $params, $req_headers) |
176 | { |
177 | // Only log non-GET requests, unless configured otherwise |
178 | if ( |
179 | $method == 'GET' |
180 | && !($this->config['API']['debug_get_requests'] ?? false) |
181 | ) { |
182 | return; |
183 | } |
184 | // remove passwords |
185 | $logParams = $params; |
186 | if (isset($logParams['password'])) { |
187 | unset($logParams['password']); |
188 | } |
189 | // truncate headers for token obscuring |
190 | $logHeaders = $req_headers->toArray(); |
191 | if (isset($logHeaders['X-Okapi-Token'])) { |
192 | $logHeaders['X-Okapi-Token'] = substr( |
193 | $logHeaders['X-Okapi-Token'], |
194 | 0, |
195 | 30 |
196 | ) . '...'; |
197 | } |
198 | |
199 | $this->debug( |
200 | $method . ' request.' . |
201 | ' URL: ' . $path . '.' . |
202 | ' Params: ' . $this->varDump($logParams) . '.' . |
203 | ' Headers: ' . $this->varDump($logHeaders) |
204 | ); |
205 | } |
206 | |
207 | /** |
208 | * Add instance-specific context to a cache key suffix (to ensure that |
209 | * multiple drivers don't accidentally share values in the cache. |
210 | * |
211 | * @param string $key Cache key suffix |
212 | * |
213 | * @return string |
214 | */ |
215 | protected function getCacheKey($key = null) |
216 | { |
217 | // Override the base class formatting with FOLIO-specific details |
218 | // to ensure proper caching in a MultiBackend environment. |
219 | return 'FOLIO-' |
220 | . md5("{$this->tenant}|$key"); |
221 | } |
222 | |
223 | /** |
224 | * (From AbstractAPI) Allow default corrections to all requests |
225 | * |
226 | * Add X-Okapi headers and Content-Type to every request |
227 | * |
228 | * @param \Laminas\Http\Headers $headers the request headers |
229 | * @param object $params the parameters object |
230 | * |
231 | * @return array |
232 | */ |
233 | public function preRequest(\Laminas\Http\Headers $headers, $params) |
234 | { |
235 | $headers->addHeaderLine('Accept', 'application/json'); |
236 | if (!$headers->has('Content-Type')) { |
237 | $headers->addHeaderLine('Content-Type', 'application/json'); |
238 | } |
239 | $headers->addHeaderLine('X-Okapi-Tenant', $this->tenant); |
240 | if ($this->token != null) { |
241 | $headers->addHeaderLine('X-Okapi-Token', $this->token); |
242 | } |
243 | return [$headers, $params]; |
244 | } |
245 | |
246 | /** |
247 | * Login and receive a new token |
248 | * |
249 | * @return void |
250 | */ |
251 | protected function renewTenantToken() |
252 | { |
253 | $this->token = null; |
254 | $response = $this->performOkapiUsernamePasswordAuthentication( |
255 | $this->config['API']['username'], |
256 | $this->config['API']['password'] |
257 | ); |
258 | $this->token = $this->extractTokenFromResponse($response); |
259 | $this->sessionCache->folio_token = $this->token; |
260 | $this->debug( |
261 | 'Token renewed. Username: ' . $this->config['API']['username'] . |
262 | ' Token: ' . substr($this->token, 0, 30) . '...' |
263 | ); |
264 | } |
265 | |
266 | /** |
267 | * Check if our token is still valid |
268 | * |
269 | * Method taken from Stripes JS (loginServices.js:validateUser) |
270 | * |
271 | * @return void |
272 | */ |
273 | protected function checkTenantToken() |
274 | { |
275 | $response = $this->makeRequest('GET', '/users', [], [], [401, 403]); |
276 | if ($response->getStatusCode() >= 400) { |
277 | $this->token = null; |
278 | $this->renewTenantToken(); |
279 | } |
280 | } |
281 | |
282 | /** |
283 | * Initialize the driver. |
284 | * |
285 | * Check or renew our auth token |
286 | * |
287 | * @return void |
288 | */ |
289 | public function init() |
290 | { |
291 | $factory = $this->sessionFactory; |
292 | $this->sessionCache = $factory($this->tenant); |
293 | if ($this->sessionCache->folio_token ?? false) { |
294 | $this->token = $this->sessionCache->folio_token; |
295 | $this->debug( |
296 | 'Token taken from cache: ' . substr($this->token, 0, 30) . '...' |
297 | ); |
298 | } |
299 | if ($this->token == null) { |
300 | $this->renewTenantToken(); |
301 | } else { |
302 | $this->checkTenantToken(); |
303 | } |
304 | } |
305 | |
306 | /** |
307 | * Given some kind of identifier (instance, holding or item), retrieve the |
308 | * associated instance object from FOLIO. |
309 | * |
310 | * @param string $instanceId Instance ID, if available. |
311 | * @param string $holdingId Holding ID, if available. |
312 | * @param string $itemId Item ID, if available. |
313 | * |
314 | * @return object |
315 | */ |
316 | protected function getInstanceById( |
317 | $instanceId = null, |
318 | $holdingId = null, |
319 | $itemId = null |
320 | ) { |
321 | if ($instanceId == null) { |
322 | if ($holdingId == null) { |
323 | if ($itemId == null) { |
324 | throw new \Exception('No IDs provided to getInstanceObject.'); |
325 | } |
326 | $response = $this->makeRequest( |
327 | 'GET', |
328 | '/item-storage/items/' . $itemId |
329 | ); |
330 | $item = json_decode($response->getBody()); |
331 | $holdingId = $item->holdingsRecordId; |
332 | } |
333 | $response = $this->makeRequest( |
334 | 'GET', |
335 | '/holdings-storage/holdings/' . $holdingId |
336 | ); |
337 | $holding = json_decode($response->getBody()); |
338 | $instanceId = $holding->instanceId; |
339 | } |
340 | $response = $this->makeRequest( |
341 | 'GET', |
342 | '/inventory/instances/' . $instanceId |
343 | ); |
344 | return json_decode($response->getBody()); |
345 | } |
346 | |
347 | /** |
348 | * Given an instance object or identifer, or a holding or item identifier, |
349 | * determine an appropriate value to use as VuFind's bibliographic ID. |
350 | * |
351 | * @param string $instanceOrInstanceId Instance object or ID (will be looked up |
352 | * using holding or item ID if not provided) |
353 | * @param string $holdingId Holding-level id (optional) |
354 | * @param string $itemId Item-level id (optional) |
355 | * |
356 | * @return string Appropriate bib id retrieved from FOLIO identifiers |
357 | */ |
358 | protected function getBibId( |
359 | $instanceOrInstanceId = null, |
360 | $holdingId = null, |
361 | $itemId = null |
362 | ) { |
363 | $idType = $this->getBibIdType(); |
364 | |
365 | // Special case: if we're using instance IDs and we already have one, |
366 | // short-circuit the lookup process: |
367 | if ($idType === 'instance' && is_string($instanceOrInstanceId)) { |
368 | return $instanceOrInstanceId; |
369 | } |
370 | |
371 | $instance = is_object($instanceOrInstanceId) |
372 | ? $instanceOrInstanceId |
373 | : $this->getInstanceById($instanceOrInstanceId, $holdingId, $itemId); |
374 | |
375 | switch ($idType) { |
376 | case 'hrid': |
377 | return $instance->hrid; |
378 | case 'instance': |
379 | return $instance->id; |
380 | } |
381 | |
382 | throw new \Exception('Unsupported ID type: ' . $idType); |
383 | } |
384 | |
385 | /** |
386 | * Escape a string for use in a CQL query. |
387 | * |
388 | * @param string $in Input string |
389 | * |
390 | * @return string |
391 | */ |
392 | protected function escapeCql($in) |
393 | { |
394 | return str_replace('"', '\"', str_replace('&', '%26', $in)); |
395 | } |
396 | |
397 | /** |
398 | * Retrieve FOLIO instance using VuFind's chosen bibliographic identifier. |
399 | * |
400 | * @param string $bibId Bib-level id |
401 | * |
402 | * @return object |
403 | */ |
404 | protected function getInstanceByBibId($bibId) |
405 | { |
406 | // Figure out which ID type to use in the CQL query; if the user configured |
407 | // instance IDs, use the 'id' field, otherwise pass the setting through |
408 | // directly: |
409 | $idType = $this->getBibIdType(); |
410 | $idField = $idType === 'instance' ? 'id' : $idType; |
411 | |
412 | $query = [ |
413 | 'query' => '(' . $idField . '=="' . $this->escapeCql($bibId) . '")', |
414 | ]; |
415 | $response = $this->makeRequest('GET', '/instance-storage/instances', $query); |
416 | $instances = json_decode($response->getBody()); |
417 | if (count($instances->instances ?? []) == 0) { |
418 | throw new ILSException('Item Not Found'); |
419 | } |
420 | return $instances->instances[0]; |
421 | } |
422 | |
423 | /** |
424 | * Get raw object of item from inventory/items/ |
425 | * |
426 | * @param string $itemId Item-level id |
427 | * |
428 | * @return array |
429 | */ |
430 | public function getStatus($itemId) |
431 | { |
432 | $holding = $this->getHolding($itemId); |
433 | return $holding['holdings'] ?? []; |
434 | } |
435 | |
436 | /** |
437 | * This method calls getStatus for an array of records or implement a bulk method |
438 | * |
439 | * @param array $idList Item-level ids |
440 | * |
441 | * @return array values from getStatus |
442 | */ |
443 | public function getStatuses($idList) |
444 | { |
445 | $status = []; |
446 | foreach ($idList as $id) { |
447 | $status[] = $this->getStatus($id); |
448 | } |
449 | return $status; |
450 | } |
451 | |
452 | /** |
453 | * Retrieves renew, hold and cancel settings from the driver ini file. |
454 | * |
455 | * @param string $function The name of the feature to be checked |
456 | * @param array $params Optional feature-specific parameters (array) |
457 | * |
458 | * @return array An array with key-value pairs. |
459 | * |
460 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
461 | */ |
462 | public function getConfig($function, $params = []) |
463 | { |
464 | return $this->config[$function] ?? false; |
465 | } |
466 | |
467 | /** |
468 | * Check item location against list of configured locations |
469 | * where holds should be offered |
470 | * |
471 | * @param string $locationName locationName from getHolding |
472 | * |
473 | * @return bool |
474 | */ |
475 | protected function isHoldable($locationName) |
476 | { |
477 | $mode = $this->config['Holds']['excludeHoldLocationsCompareMode'] ?? 'exact'; |
478 | $excludeLocs = (array)($this->config['Holds']['excludeHoldLocations'] ?? []); |
479 | |
480 | // Exclude checking by regex match |
481 | if (trim(strtolower($mode)) == 'regex') { |
482 | foreach ($excludeLocs as $pattern) { |
483 | $match = @preg_match($pattern, $locationName); |
484 | // Invalid regex, skip this pattern |
485 | if ($match === false) { |
486 | $this->logWarning( |
487 | 'Invalid regex found in excludeHoldLocations: ' . |
488 | $pattern |
489 | ); |
490 | continue; |
491 | } |
492 | if ($match === 1) { |
493 | return false; |
494 | } |
495 | } |
496 | return true; |
497 | } |
498 | // Otherwise exclude checking by exact match |
499 | return !in_array($locationName, $excludeLocs); |
500 | } |
501 | |
502 | /** |
503 | * Gets locations from the /locations endpoint and sets |
504 | * an array of location IDs to display names. |
505 | * Display names are set from discoveryDisplayName, or name |
506 | * if discoveryDisplayName is not available. |
507 | * |
508 | * @return array |
509 | */ |
510 | protected function getLocations() |
511 | { |
512 | $cacheKey = 'locationMap'; |
513 | $locationMap = $this->getCachedData($cacheKey); |
514 | if (null === $locationMap) { |
515 | $locationMap = []; |
516 | foreach ( |
517 | $this->getPagedResults( |
518 | 'locations', |
519 | '/locations' |
520 | ) as $location |
521 | ) { |
522 | $name = $location->discoveryDisplayName ?? $location->name; |
523 | $code = $location->code; |
524 | $isActive = $location->isActive ?? true; |
525 | $locationMap[$location->id] = compact('name', 'code', 'isActive'); |
526 | } |
527 | $this->putCachedData($cacheKey, $locationMap); |
528 | } |
529 | return $locationMap; |
530 | } |
531 | |
532 | /** |
533 | * Get Inventory Location Name |
534 | * |
535 | * @param string $locationId UUID of item location |
536 | * |
537 | * @return array with the display name and code of location |
538 | */ |
539 | protected function getLocationData($locationId) |
540 | { |
541 | $locationMap = $this->getLocations(); |
542 | $name = ''; |
543 | $code = ''; |
544 | $isActive = true; |
545 | if (array_key_exists($locationId, $locationMap)) { |
546 | return $locationMap[$locationId]; |
547 | } else { |
548 | // if key is not found in cache, the location could have |
549 | // been added before the cache expired so check again |
550 | $locationResponse = $this->makeRequest( |
551 | 'GET', |
552 | '/locations/' . $locationId |
553 | ); |
554 | if ($locationResponse->isSuccess()) { |
555 | $location = json_decode($locationResponse->getBody()); |
556 | $name = $location->discoveryDisplayName ?? $location->name; |
557 | $code = $location->code; |
558 | $isActive = $location->isActive ?? $isActive; |
559 | } |
560 | } |
561 | |
562 | return compact('name', 'code', 'isActive'); |
563 | } |
564 | |
565 | /** |
566 | * Choose a call number and callnumber prefix. |
567 | * |
568 | * @param string $hCallNumP Holding-level call number prefix |
569 | * @param string $hCallNum Holding-level call number |
570 | * @param string $iCallNumP Item-level call number prefix |
571 | * @param string $iCallNum Item-level call number |
572 | * |
573 | * @return array with call number and call number prefix. |
574 | */ |
575 | protected function chooseCallNumber($hCallNumP, $hCallNum, $iCallNumP, $iCallNum) |
576 | { |
577 | if (empty($iCallNum)) { |
578 | return ['callnumber_prefix' => $hCallNumP, 'callnumber' => $hCallNum]; |
579 | } |
580 | return ['callnumber_prefix' => $iCallNumP, 'callnumber' => $iCallNum]; |
581 | } |
582 | |
583 | /** |
584 | * Support method: format a note for display |
585 | * |
586 | * @param object $note Note object decoded from FOLIO JSON. |
587 | * |
588 | * @return string |
589 | */ |
590 | protected function formatNote($note): string |
591 | { |
592 | return !($note->staffOnly ?? false) && !empty($note->note) |
593 | ? $note->note : ''; |
594 | } |
595 | |
596 | /** |
597 | * Support method for getHolding(): extract details from the holding record that |
598 | * will be needed by formatHoldingItem() below. |
599 | * |
600 | * @param object $holding FOLIO holding record (decoded from JSON) |
601 | * |
602 | * @return array |
603 | */ |
604 | protected function getHoldingDetailsForItem($holding): array |
605 | { |
606 | $textFormatter = function ($supplement) { |
607 | $format = '%s %s'; |
608 | $supStat = $supplement->statement ?? ''; |
609 | $supNote = $supplement->note ?? ''; |
610 | $statement = trim( |
611 | // Avoid duplicate display if note and statement are identical: |
612 | $supStat === $supNote ? $supStat : sprintf($format, $supStat, $supNote) |
613 | ); |
614 | return $statement; |
615 | }; |
616 | $id = $holding->id; |
617 | $holdingNotes = array_filter( |
618 | array_map([$this, 'formatNote'], $holding->notes ?? []) |
619 | ); |
620 | $hasHoldingNotes = !empty(implode($holdingNotes)); |
621 | $holdingsStatements = array_values(array_filter(array_map( |
622 | $textFormatter, |
623 | $holding->holdingsStatements ?? [] |
624 | ))); |
625 | $holdingsSupplements = array_values(array_filter(array_map( |
626 | $textFormatter, |
627 | $holding->holdingsStatementsForSupplements ?? [] |
628 | ))); |
629 | $holdingsIndexes = array_values(array_filter(array_map( |
630 | $textFormatter, |
631 | $holding->holdingsStatementsForIndexes ?? [] |
632 | ))); |
633 | $holdingCallNumber = $holding->callNumber ?? ''; |
634 | $holdingCallNumberPrefix = $holding->callNumberPrefix ?? ''; |
635 | return compact( |
636 | 'id', |
637 | 'holdingNotes', |
638 | 'hasHoldingNotes', |
639 | 'holdingsStatements', |
640 | 'holdingsSupplements', |
641 | 'holdingsIndexes', |
642 | 'holdingCallNumber', |
643 | 'holdingCallNumberPrefix' |
644 | ); |
645 | } |
646 | |
647 | /** |
648 | * Support method for getHolding() -- given a few key details, format an item |
649 | * for inclusion in the return value. |
650 | * |
651 | * @param string $bibId Current bibliographic ID |
652 | * @param array $holdingDetails Holding details produced by |
653 | * getHoldingDetailsForItem() |
654 | * @param object $item FOLIO item record (decoded from JSON) |
655 | * @param int $number The current item number (position within |
656 | * current holdings record) |
657 | * @param string $dueDateValue The due date to display to the user |
658 | * @param array $boundWithRecords Any bib records this holding is bound with |
659 | * |
660 | * @return array |
661 | */ |
662 | protected function formatHoldingItem( |
663 | string $bibId, |
664 | array $holdingDetails, |
665 | $item, |
666 | $number, |
667 | string $dueDateValue, |
668 | $boundWithRecords, |
669 | ): array { |
670 | $itemNotes = array_filter( |
671 | array_map([$this, 'formatNote'], $item->notes ?? []) |
672 | ); |
673 | $locationId = $item->effectiveLocation->id; |
674 | $locationData = $this->getLocationData($locationId); |
675 | $locationName = $locationData['name']; |
676 | $locationCode = $locationData['code']; |
677 | $locationIsActive = $locationData['isActive']; |
678 | // concatenate enumeration fields if present |
679 | $enum = implode( |
680 | ' ', |
681 | array_filter( |
682 | [ |
683 | $item->volume ?? null, |
684 | $item->enumeration ?? null, |
685 | $item->chronology ?? null, |
686 | ] |
687 | ) |
688 | ); |
689 | $callNumberData = $this->chooseCallNumber( |
690 | $holdingDetails['holdingCallNumberPrefix'], |
691 | $holdingDetails['holdingCallNumber'], |
692 | $item->effectiveCallNumberComponents->prefix |
693 | ?? $item->itemLevelCallNumberPrefix ?? '', |
694 | $item->effectiveCallNumberComponents->callNumber |
695 | ?? $item->itemLevelCallNumber ?? '' |
696 | ); |
697 | |
698 | return $callNumberData + [ |
699 | 'id' => $bibId, |
700 | 'item_id' => $item->id, |
701 | 'holdings_id' => $holdingDetails['id'], |
702 | 'number' => $number, |
703 | 'enumchron' => $enum, |
704 | 'barcode' => $item->barcode ?? '', |
705 | 'status' => $item->status->name, |
706 | 'duedate' => $dueDateValue, |
707 | 'availability' => $item->status->name == 'Available', |
708 | 'is_holdable' => $this->isHoldable($locationName), |
709 | 'holdings_notes' => $holdingDetails['hasHoldingNotes'] |
710 | ? $holdingDetails['holdingNotes'] : null, |
711 | 'item_notes' => !empty(implode($itemNotes)) ? $itemNotes : null, |
712 | 'summary' => array_unique($holdingDetails['holdingsStatements']), |
713 | 'supplements' => $holdingDetails['holdingsSupplements'], |
714 | 'indexes' => $holdingDetails['holdingsIndexes'], |
715 | 'location' => $locationName, |
716 | 'location_code' => $locationCode, |
717 | 'folio_location_is_active' => $locationIsActive, |
718 | 'reserve' => 'TODO', |
719 | 'addLink' => true, |
720 | 'bound_with_records' => $boundWithRecords, |
721 | ]; |
722 | } |
723 | |
724 | /** |
725 | * Given a holdings array and a sort field, sort the array. |
726 | * |
727 | * @param array $holdings Holdings to sort |
728 | * @param string $sortField Sort field |
729 | * |
730 | * @return array |
731 | */ |
732 | protected function sortHoldings(array $holdings, string $sortField): array |
733 | { |
734 | usort( |
735 | $holdings, |
736 | function ($a, $b) use ($sortField) { |
737 | return strnatcasecmp($a[$sortField], $b[$sortField]); |
738 | } |
739 | ); |
740 | // Renumber the re-sorted batch: |
741 | $nbCount = count($holdings); |
742 | for ($nbIndex = 0; $nbIndex < $nbCount; $nbIndex++) { |
743 | $holdings[$nbIndex]['number'] = $nbIndex + 1; |
744 | } |
745 | return $holdings; |
746 | } |
747 | |
748 | /** |
749 | * Get all bib records bound-with this item, including |
750 | * the directly-linked bib record. |
751 | * |
752 | * @param object $item The item record |
753 | * |
754 | * @return array An array of key metadata for each bib record |
755 | */ |
756 | protected function getBoundWithRecords($item) |
757 | { |
758 | $boundWithRecords = []; |
759 | // Get the full item record, which includes the boundWithTitles data |
760 | $response = $this->makeRequest( |
761 | 'GET', |
762 | '/inventory/items/' . $item->id |
763 | ); |
764 | $item = json_decode($response->getBody()); |
765 | foreach ($item->boundWithTitles ?? [] as $boundWithTitle) { |
766 | $boundWithRecords[] = [ |
767 | 'title' => $boundWithTitle->briefInstance?->title, |
768 | 'bibId' => $this->getBibId($boundWithTitle->briefInstance->id), |
769 | ]; |
770 | } |
771 | return $boundWithRecords; |
772 | } |
773 | |
774 | /** |
775 | * This method queries the ILS for holding information. |
776 | * |
777 | * @param string $bibId Bib-level id |
778 | * @param array $patron Patron login information from $this->patronLogin |
779 | * @param array $options Extra options (not currently used) |
780 | * |
781 | * @return array An array of associative holding arrays |
782 | * |
783 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
784 | */ |
785 | public function getHolding($bibId, array $patron = null, array $options = []) |
786 | { |
787 | $showDueDate = $this->config['Availability']['showDueDate'] ?? true; |
788 | $showTime = $this->config['Availability']['showTime'] ?? false; |
789 | $maxNumDueDateItems = $this->config['Availability']['maxNumberItems'] ?? 5; |
790 | $dueDateItemCount = 0; |
791 | |
792 | $instance = $this->getInstanceByBibId($bibId); |
793 | $query = [ |
794 | 'query' => '(instanceId=="' . $instance->id |
795 | . '" NOT discoverySuppress==true)', |
796 | ]; |
797 | $items = []; |
798 | $folioItemSort = $this->config['Holdings']['folio_sort'] ?? ''; |
799 | $vufindItemSort = $this->config['Holdings']['vufind_sort'] ?? ''; |
800 | foreach ( |
801 | $this->getPagedResults( |
802 | 'holdingsRecords', |
803 | '/holdings-storage/holdings', |
804 | $query |
805 | ) as $holding |
806 | ) { |
807 | $rawQuery = '(holdingsRecordId=="' . $holding->id . '")'; |
808 | if (!empty($folioItemSort)) { |
809 | $rawQuery .= ' sortby ' . $folioItemSort; |
810 | } |
811 | $query = ['query' => $rawQuery]; |
812 | $holdingDetails = $this->getHoldingDetailsForItem($holding); |
813 | $nextBatch = []; |
814 | $sortNeeded = false; |
815 | $number = 0; |
816 | foreach ( |
817 | $this->getPagedResults( |
818 | 'items', |
819 | '/inventory/items-by-holdings-id', |
820 | $query |
821 | ) as $item |
822 | ) { |
823 | if ($item->discoverySuppress ?? false) { |
824 | continue; |
825 | } |
826 | $number++; |
827 | $dueDateValue = ''; |
828 | if ( |
829 | $item->status->name == 'Checked out' |
830 | && $showDueDate |
831 | && $dueDateItemCount < $maxNumDueDateItems |
832 | ) { |
833 | $dueDateValue = $this->getDueDate($item->id, $showTime); |
834 | $dueDateItemCount++; |
835 | } |
836 | if ($item->isBoundWith ?? false) { |
837 | $boundWithRecords = $this->getBoundWithRecords($item); |
838 | } |
839 | $nextItem = $this->formatHoldingItem( |
840 | $bibId, |
841 | $holdingDetails, |
842 | $item, |
843 | $number, |
844 | $dueDateValue, |
845 | $boundWithRecords ?? [] |
846 | ); |
847 | if (!empty($vufindItemSort) && !empty($nextItem[$vufindItemSort])) { |
848 | $sortNeeded = true; |
849 | } |
850 | $nextBatch[] = $nextItem; |
851 | } |
852 | $items = array_merge( |
853 | $items, |
854 | $sortNeeded |
855 | ? $this->sortHoldings($nextBatch, $vufindItemSort) : $nextBatch |
856 | ); |
857 | } |
858 | return [ |
859 | 'total' => count($items), |
860 | 'holdings' => $items, |
861 | 'electronic_holdings' => [], |
862 | ]; |
863 | } |
864 | |
865 | /** |
866 | * Convert a FOLIO date string to a DateTime object. |
867 | * |
868 | * @param string $str FOLIO date string |
869 | * |
870 | * @return DateTime |
871 | */ |
872 | protected function getDateTimeFromString(string $str): DateTime |
873 | { |
874 | $dateTime = new DateTime($str, new DateTimeZone('UTC')); |
875 | $localTimezone = (new DateTime())->getTimezone(); |
876 | $dateTime->setTimezone($localTimezone); |
877 | return $dateTime; |
878 | } |
879 | |
880 | /** |
881 | * Support method for getHolding(): obtaining the Due Date from OKAPI |
882 | * by calling /circulation/loans with the item->id, adjusting the |
883 | * timezone and formatting in universal time with or without due time |
884 | * |
885 | * @param string $itemId ID for the item to query |
886 | * @param bool $showTime Determines if date or date & time is returned |
887 | * |
888 | * @return string |
889 | */ |
890 | protected function getDueDate($itemId, $showTime) |
891 | { |
892 | $query = 'itemId==' . $itemId; |
893 | foreach ( |
894 | $this->getPagedResults( |
895 | 'loans', |
896 | '/circulation/loans', |
897 | compact('query') |
898 | ) as $loan |
899 | ) { |
900 | // many loans are returned for an item, the one we want |
901 | // is the one without a returnDate |
902 | if (!isset($loan->returnDate) && isset($loan->dueDate)) { |
903 | $dueDate = $this->getDateTimeFromString($loan->dueDate); |
904 | $method = $showTime |
905 | ? 'convertToDisplayDateAndTime' : 'convertToDisplayDate'; |
906 | return $this->dateConverter->$method('U', $dueDate->format('U')); |
907 | } |
908 | } |
909 | return ''; |
910 | } |
911 | |
912 | /** |
913 | * Should we use the legacy authentication mechanism? |
914 | * |
915 | * @return bool |
916 | */ |
917 | protected function useLegacyAuthentication(): bool |
918 | { |
919 | return $this->config['API']['legacy_authentication'] ?? true; |
920 | } |
921 | |
922 | /** |
923 | * Support method to perform a username/password login to Okapi. |
924 | * |
925 | * @param string $username The patron username |
926 | * @param string $password The patron password |
927 | * |
928 | * @return Response |
929 | */ |
930 | protected function performOkapiUsernamePasswordAuthentication(string $username, string $password): Response |
931 | { |
932 | $tenant = $this->config['API']['tenant']; |
933 | $credentials = compact('tenant', 'username', 'password'); |
934 | // Get token |
935 | return $this->makeRequest( |
936 | method: 'POST', |
937 | path: $this->useLegacyAuthentication() ? '/authn/login' : '/authn/login-with-expiry', |
938 | params: json_encode($credentials), |
939 | debugParams: '{"username":"...","password":"..."}' |
940 | ); |
941 | } |
942 | |
943 | /** |
944 | * Given a response from performOkapiUsernamePasswordAuthentication(), |
945 | * extract the token value. |
946 | * |
947 | * @param Response $response Response from performOkapiUsernamePasswordAuthentication(). |
948 | * |
949 | * @return string |
950 | */ |
951 | protected function extractTokenFromResponse(Response $response): string |
952 | { |
953 | if ($this->useLegacyAuthentication()) { |
954 | return $response->getHeaders()->get('X-Okapi-Token')->getFieldValue(); |
955 | } |
956 | $folioUrl = $this->config['API']['base_url']; |
957 | $cookies = new \Laminas\Http\Cookies(); |
958 | $cookies->addCookiesFromResponse($response, $folioUrl); |
959 | $results = $cookies->getAllCookies(); |
960 | foreach ($results as $cookie) { |
961 | if ($cookie->getName() == 'folioAccessToken') { |
962 | return $cookie->getValue(); |
963 | } |
964 | } |
965 | throw new \Exception('Could not find token in response'); |
966 | } |
967 | |
968 | /** |
969 | * Support method for patronLogin(): authenticate the patron with an Okapi |
970 | * login attempt. Returns a CQL query for retrieving more information about |
971 | * the authenticated user. |
972 | * |
973 | * @param string $username The patron username |
974 | * @param string $password The patron password |
975 | * |
976 | * @return string |
977 | */ |
978 | protected function patronLoginWithOkapi($username, $password) |
979 | { |
980 | $response = $this->performOkapiUsernamePasswordAuthentication($username, $password); |
981 | $debugMsg = 'User logged in. User: ' . $username . '.'; |
982 | // We've authenticated the user with Okapi, but we only have their |
983 | // username; set up a query to retrieve full info below. |
984 | $query = 'username == ' . $username; |
985 | // Replace admin with user as tenant if configured to do so: |
986 | if ($this->config['User']['use_user_token'] ?? false) { |
987 | $this->token = $this->extractTokenFromResponse($response); |
988 | $debugMsg .= ' Token: ' . substr($this->token, 0, 30) . '...'; |
989 | } |
990 | $this->debug($debugMsg); |
991 | return $query; |
992 | } |
993 | |
994 | /** |
995 | * Support method for patronLogin(): authenticate the patron with a CQL looup. |
996 | * Returns the CQL query for retrieving more information about the user. |
997 | * |
998 | * @param string $username The patron username |
999 | * @param string $password The patron password |
1000 | * |
1001 | * @return string |
1002 | */ |
1003 | protected function getUserWithCql($username, $password) |
1004 | { |
1005 | // Construct user query using barcode, username, etc. |
1006 | $usernameField = $this->config['User']['username_field'] ?? 'username'; |
1007 | $passwordField = $this->config['User']['password_field'] ?? false; |
1008 | $cql = $this->config['User']['cql'] |
1009 | ?? '%%username_field%% == "%%username%%"' |
1010 | . ($passwordField ? ' and %%password_field%% == "%%password%%"' : ''); |
1011 | $placeholders = [ |
1012 | '%%username_field%%', |
1013 | '%%password_field%%', |
1014 | '%%username%%', |
1015 | '%%password%%', |
1016 | ]; |
1017 | $values = [ |
1018 | $usernameField, |
1019 | $passwordField, |
1020 | $this->escapeCql($username), |
1021 | $this->escapeCql($password), |
1022 | ]; |
1023 | return str_replace($placeholders, $values, $cql); |
1024 | } |
1025 | |
1026 | /** |
1027 | * Given a CQL query, fetch a single user; if we get an unexpected count, treat |
1028 | * that as an unsuccessful login by returning null. |
1029 | * |
1030 | * @param string $query CQL query |
1031 | * |
1032 | * @return object |
1033 | */ |
1034 | protected function fetchUserWithCql($query) |
1035 | { |
1036 | $response = $this->makeRequest('GET', '/users', compact('query')); |
1037 | $json = json_decode($response->getBody()); |
1038 | return count($json->users ?? []) === 1 ? $json->users[0] : null; |
1039 | } |
1040 | |
1041 | /** |
1042 | * Helper function to retrieve paged results from FOLIO API |
1043 | * |
1044 | * @param string $responseKey Key containing values to collect in response |
1045 | * @param string $interface FOLIO api interface to call |
1046 | * @param array $query CQL query |
1047 | * @param int $limit How many results to retrieve from FOLIO per call |
1048 | * |
1049 | * @return array |
1050 | */ |
1051 | protected function getPagedResults($responseKey, $interface, $query = [], $limit = 1000) |
1052 | { |
1053 | $offset = 0; |
1054 | |
1055 | do { |
1056 | $combinedQuery = array_merge($query, compact('offset', 'limit')); |
1057 | $response = $this->makeRequest( |
1058 | 'GET', |
1059 | $interface, |
1060 | $combinedQuery |
1061 | ); |
1062 | $json = json_decode($response->getBody()); |
1063 | if (!$response->isSuccess() || !$json) { |
1064 | $msg = $json->errors[0]->message ?? json_last_error_msg(); |
1065 | throw new ILSException("Error: '$msg' fetching '$responseKey'"); |
1066 | } |
1067 | $totalEstimate = $json->totalRecords ?? 0; |
1068 | foreach ($json->$responseKey ?? [] as $item) { |
1069 | yield $item ?? ''; |
1070 | } |
1071 | $offset += $limit; |
1072 | |
1073 | // Continue until the current offset is greater than the totalRecords value returned |
1074 | // from the API (which could be an estimate if more than 1000 results are returned). |
1075 | } while ($offset <= $totalEstimate); |
1076 | } |
1077 | |
1078 | /** |
1079 | * Patron Login |
1080 | * |
1081 | * This is responsible for authenticating a patron against the catalog. |
1082 | * |
1083 | * @param string $username The patron username |
1084 | * @param string $password The patron password |
1085 | * |
1086 | * @return mixed Associative array of patron info on successful login, |
1087 | * null on unsuccessful login. |
1088 | */ |
1089 | public function patronLogin($username, $password) |
1090 | { |
1091 | $profile = null; |
1092 | $doOkapiLogin = $this->config['User']['okapi_login'] ?? false; |
1093 | $usernameField = $this->config['User']['username_field'] ?? 'username'; |
1094 | |
1095 | // If the username field is not the default 'username' we will need to |
1096 | // do a lookup to find the correct username value for Okapi login. We also |
1097 | // need to do this lookup if we're skipping Okapi login entirely. |
1098 | if (!$doOkapiLogin || $usernameField !== 'username') { |
1099 | $query = $this->getUserWithCql($username, $password); |
1100 | $profile = $this->fetchUserWithCql($query); |
1101 | if ($profile === null) { |
1102 | return null; |
1103 | } |
1104 | } |
1105 | |
1106 | // If we need to do an Okapi login, we have the information we need to do |
1107 | // it at this point. |
1108 | if ($doOkapiLogin) { |
1109 | try { |
1110 | // If we fetched the profile earlier, we want to use the username |
1111 | // from there; otherwise, we'll use the passed-in version. |
1112 | $query = $this->patronLoginWithOkapi( |
1113 | $profile->username ?? $username, |
1114 | $password |
1115 | ); |
1116 | } catch (Exception $e) { |
1117 | return null; |
1118 | } |
1119 | // If we didn't load a profile earlier, we should do so now: |
1120 | if (!isset($profile)) { |
1121 | $profile = $this->fetchUserWithCql($query); |
1122 | if ($profile === null) { |
1123 | return null; |
1124 | } |
1125 | } |
1126 | } |
1127 | |
1128 | return [ |
1129 | 'id' => $profile->id, |
1130 | 'username' => $username, |
1131 | 'cat_username' => $username, |
1132 | 'cat_password' => $password, |
1133 | 'firstname' => $profile->personal->firstName ?? null, |
1134 | 'lastname' => $profile->personal->lastName ?? null, |
1135 | 'email' => $profile->personal->email ?? null, |
1136 | ]; |
1137 | } |
1138 | |
1139 | /** |
1140 | * Given a user UUID, return the user's profile object (null if not found). |
1141 | * |
1142 | * @param string $id User UUID |
1143 | * |
1144 | * @return ?object |
1145 | */ |
1146 | protected function getUserById(string $id): ?object |
1147 | { |
1148 | $query = ['query' => 'id == "' . $id . '"']; |
1149 | $response = $this->makeRequest('GET', '/users', $query); |
1150 | $users = json_decode($response->getBody()); |
1151 | return $users->users[0] ?? null; |
1152 | } |
1153 | |
1154 | /** |
1155 | * This method queries the ILS for a patron's current profile information |
1156 | * |
1157 | * @param array $patron Patron login information from $this->patronLogin |
1158 | * |
1159 | * @return array Profile data in associative array |
1160 | */ |
1161 | public function getMyProfile($patron) |
1162 | { |
1163 | $profile = $this->getUserById($patron['id']); |
1164 | $expiration = isset($profile->expirationDate) |
1165 | ? $this->dateConverter->convertToDisplayDate( |
1166 | 'Y-m-d H:i', |
1167 | $profile->expirationDate |
1168 | ) |
1169 | : null; |
1170 | return [ |
1171 | 'id' => $profile->id, |
1172 | 'firstname' => $profile->personal->firstName ?? null, |
1173 | 'lastname' => $profile->personal->lastName ?? null, |
1174 | 'address1' => $profile->personal->addresses[0]->addressLine1 ?? null, |
1175 | 'city' => $profile->personal->addresses[0]->city ?? null, |
1176 | 'country' => $profile->personal->addresses[0]->countryId ?? null, |
1177 | 'zip' => $profile->personal->addresses[0]->postalCode ?? null, |
1178 | 'phone' => $profile->personal->phone ?? null, |
1179 | 'mobile_phone' => $profile->personal->mobilePhone ?? null, |
1180 | 'expiration_date' => $expiration, |
1181 | ]; |
1182 | } |
1183 | |
1184 | /** |
1185 | * This method queries the ILS for a patron's current checked out items |
1186 | * |
1187 | * Input: Patron array returned by patronLogin method |
1188 | * Output: Returns an array of associative arrays. |
1189 | * Each associative array contains these keys: |
1190 | * duedate - The item's due date (a string). |
1191 | * dueTime - The item's due time (a string, optional). |
1192 | * dueStatus - A special status – may be 'due' (for items due very soon) |
1193 | * or 'overdue' (for overdue items). (optional). |
1194 | * id - The bibliographic ID of the checked out item. |
1195 | * source - The search backend from which the record may be retrieved |
1196 | * (optional - defaults to Solr). Introduced in VuFind 2.4. |
1197 | * barcode - The barcode of the item (optional). |
1198 | * renew - The number of times the item has been renewed (optional). |
1199 | * renewLimit - The maximum number of renewals allowed |
1200 | * (optional - introduced in VuFind 2.3). |
1201 | * request - The number of pending requests for the item (optional). |
1202 | * volume – The volume number of the item (optional). |
1203 | * publication_year – The publication year of the item (optional). |
1204 | * renewable – Whether or not an item is renewable |
1205 | * (required for renewals). |
1206 | * message – A message regarding the item (optional). |
1207 | * title - The title of the item (optional – only used if the record |
1208 | * cannot be found in VuFind's index). |
1209 | * item_id - this is used to match up renew responses and must match |
1210 | * the item_id in the renew response. |
1211 | * institution_name - Display name of the institution that owns the item. |
1212 | * isbn - An ISBN for use in cover image loading |
1213 | * (optional – introduced in release 2.3) |
1214 | * issn - An ISSN for use in cover image loading |
1215 | * (optional – introduced in release 2.3) |
1216 | * oclc - An OCLC number for use in cover image loading |
1217 | * (optional – introduced in release 2.3) |
1218 | * upc - A UPC for use in cover image loading |
1219 | * (optional – introduced in release 2.3) |
1220 | * borrowingLocation - A string describing the location where the item |
1221 | * was checked out (optional – introduced in release 2.4) |
1222 | * |
1223 | * @param array $patron Patron login information from $this->patronLogin |
1224 | * |
1225 | * @return array Transactions associative arrays |
1226 | */ |
1227 | public function getMyTransactions($patron) |
1228 | { |
1229 | $query = ['query' => 'userId==' . $patron['id'] . ' and status.name==Open']; |
1230 | $transactions = []; |
1231 | foreach ( |
1232 | $this->getPagedResults( |
1233 | 'loans', |
1234 | '/circulation/loans', |
1235 | $query |
1236 | ) as $trans |
1237 | ) { |
1238 | $dueStatus = false; |
1239 | $date = $this->getDateTimeFromString($trans->dueDate); |
1240 | $dueDateTimestamp = $date->getTimestamp(); |
1241 | |
1242 | $now = time(); |
1243 | if ($now > $dueDateTimestamp) { |
1244 | $dueStatus = 'overdue'; |
1245 | } elseif ($now > $dueDateTimestamp - (1 * 24 * 60 * 60)) { |
1246 | $dueStatus = 'due'; |
1247 | } |
1248 | $transactions[] = [ |
1249 | 'duedate' => |
1250 | $this->dateConverter->convertToDisplayDate( |
1251 | 'U', |
1252 | $dueDateTimestamp |
1253 | ), |
1254 | 'dueTime' => |
1255 | $this->dateConverter->convertToDisplayTime( |
1256 | 'U', |
1257 | $dueDateTimestamp |
1258 | ), |
1259 | 'dueStatus' => $dueStatus, |
1260 | 'id' => $this->getBibId($trans->item->instanceId), |
1261 | 'item_id' => $trans->item->id, |
1262 | 'barcode' => $trans->item->barcode, |
1263 | 'renew' => $trans->renewalCount ?? 0, |
1264 | 'renewable' => true, |
1265 | 'title' => $trans->item->title, |
1266 | ]; |
1267 | } |
1268 | return $transactions; |
1269 | } |
1270 | |
1271 | /** |
1272 | * Get FOLIO loan IDs for use in renewMyItems. |
1273 | * |
1274 | * @param array $transaction An single transaction |
1275 | * array from getMyTransactions |
1276 | * |
1277 | * @return string The FOLIO loan ID for this loan |
1278 | */ |
1279 | public function getRenewDetails($transaction) |
1280 | { |
1281 | return $transaction['item_id']; |
1282 | } |
1283 | |
1284 | /** |
1285 | * Attempt to renew a list of items for a given patron. |
1286 | * |
1287 | * @param array $renewDetails An associative array with |
1288 | * patron and details |
1289 | * |
1290 | * @return array $renewResult result of attempt to renew loans |
1291 | */ |
1292 | public function renewMyItems($renewDetails) |
1293 | { |
1294 | $renewalResults = ['details' => []]; |
1295 | foreach ($renewDetails['details'] ?? [] as $loanId) { |
1296 | $requestbody = [ |
1297 | 'itemId' => $loanId, |
1298 | 'userId' => $renewDetails['patron']['id'], |
1299 | ]; |
1300 | try { |
1301 | $response = $this->makeRequest( |
1302 | 'POST', |
1303 | '/circulation/renew-by-id', |
1304 | json_encode($requestbody), |
1305 | [], |
1306 | true |
1307 | ); |
1308 | if ($response->isSuccess()) { |
1309 | $json = json_decode($response->getBody()); |
1310 | $renewal = [ |
1311 | 'success' => true, |
1312 | 'new_date' => $this->dateConverter->convertToDisplayDate( |
1313 | 'Y-m-d H:i', |
1314 | $json->dueDate |
1315 | ), |
1316 | 'new_time' => $this->dateConverter->convertToDisplayTime( |
1317 | 'Y-m-d H:i', |
1318 | $json->dueDate |
1319 | ), |
1320 | 'item_id' => $json->itemId, |
1321 | 'sysMessage' => $json->action, |
1322 | ]; |
1323 | } else { |
1324 | $json = json_decode($response->getBody()); |
1325 | $sysMessage = $json->errors[0]->message; |
1326 | $renewal = [ |
1327 | 'success' => false, |
1328 | 'sysMessage' => $sysMessage, |
1329 | ]; |
1330 | } |
1331 | } catch (Exception $e) { |
1332 | $this->debug( |
1333 | "Unexpected exception renewing $loanId: " . $e->getMessage() |
1334 | ); |
1335 | $renewal = [ |
1336 | 'success' => false, |
1337 | 'sysMessage' => 'Renewal Failed', |
1338 | ]; |
1339 | } |
1340 | $renewalResults['details'][$loanId] = $renewal; |
1341 | } |
1342 | return $renewalResults; |
1343 | } |
1344 | |
1345 | /** |
1346 | * Get Pick Up Locations |
1347 | * |
1348 | * This is responsible get a list of valid locations for holds / recall |
1349 | * retrieval |
1350 | * |
1351 | * @param array $patron Patron information returned by $this->patronLogin |
1352 | * @param array $holdInfo Optional array, only passed in when getting a list |
1353 | * in the context of placing or editing a hold. When placing a hold, it contains |
1354 | * most of the same values passed to placeHold, minus the patron data. When |
1355 | * editing a hold it contains all the hold information returned by getMyHolds. |
1356 | * May be used to limit the pickup options or may be ignored. The driver must |
1357 | * not add new options to the return array based on this data or other areas of |
1358 | * VuFind may behave incorrectly. |
1359 | * |
1360 | * @return array An array of associative arrays with locationID and |
1361 | * locationDisplay keys |
1362 | * |
1363 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
1364 | */ |
1365 | public function getPickupLocations($patron, $holdInfo = null) |
1366 | { |
1367 | $query = ['query' => 'pickupLocation=true']; |
1368 | $locations = []; |
1369 | foreach ( |
1370 | $this->getPagedResults( |
1371 | 'servicepoints', |
1372 | '/service-points', |
1373 | $query |
1374 | ) as $servicepoint |
1375 | ) { |
1376 | $locations[] = [ |
1377 | 'locationID' => $servicepoint->id, |
1378 | 'locationDisplay' => $servicepoint->discoveryDisplayName, |
1379 | ]; |
1380 | } |
1381 | return $locations; |
1382 | } |
1383 | |
1384 | /** |
1385 | * This method queries the ILS for a patron's current holds |
1386 | * |
1387 | * Input: Patron array returned by patronLogin method |
1388 | * Output: Returns an array of associative arrays, one for each hold associated |
1389 | * with the specified account. Each associative array contains these keys: |
1390 | * type - A string describing the type of hold – i.e. hold vs. recall |
1391 | * (optional). |
1392 | * id - The bibliographic record ID associated with the hold (optional). |
1393 | * source - The search backend from which the record may be retrieved |
1394 | * (optional - defaults to Solr). Introduced in VuFind 2.4. |
1395 | * location - A string describing the pickup location for the held item |
1396 | * (optional). In VuFind 1.2, this should correspond with a locationID value from |
1397 | * getPickUpLocations. In VuFind 1.3 and later, it may be either |
1398 | * a locationID value or a raw ready-to-display string. |
1399 | * reqnum - A control number for the request (optional). |
1400 | * expire - The expiration date of the hold (a string). |
1401 | * create - The creation date of the hold (a string). |
1402 | * position – The position of the user in the holds queue (optional) |
1403 | * available – Whether or not the hold is available (true/false) (optional) |
1404 | * item_id – The item id the request item (optional). |
1405 | * volume – The volume number of the item (optional) |
1406 | * publication_year – The publication year of the item (optional) |
1407 | * title - The title of the item |
1408 | * (optional – only used if the record cannot be found in VuFind's index). |
1409 | * isbn - An ISBN for use in cover image loading (optional) |
1410 | * issn - An ISSN for use in cover image loading (optional) |
1411 | * oclc - An OCLC number for use in cover image loading (optional) |
1412 | * upc - A UPC for use in cover image loading (optional) |
1413 | * cancel_details - The cancel token, or a blank string if cancel is illegal |
1414 | * for this hold; if omitted, this will be dynamically generated using |
1415 | * getCancelHoldDetails(). You should only fill this in if it is more efficient |
1416 | * to calculate the value up front; if it is an expensive calculation, you should |
1417 | * omit the value entirely and let getCancelHoldDetails() do its job on demand. |
1418 | * This optional feature was introduced in release 3.1. |
1419 | * |
1420 | * @param array $patron Patron login information from $this->patronLogin |
1421 | * |
1422 | * @return array Associative array of holds information |
1423 | */ |
1424 | public function getMyHolds($patron) |
1425 | { |
1426 | $userQuery = '(requesterId == "' . $patron['id'] . '" ' |
1427 | . 'or proxyUserId == "' . $patron['id'] . '")'; |
1428 | $query = ['query' => '(' . $userQuery . ' and status == Open*)']; |
1429 | $holds = []; |
1430 | foreach ( |
1431 | $this->getPagedResults( |
1432 | 'requests', |
1433 | '/request-storage/requests', |
1434 | $query |
1435 | ) as $hold |
1436 | ) { |
1437 | $requestDate = $this->dateConverter->convertToDisplayDate( |
1438 | 'Y-m-d H:i', |
1439 | $hold->requestDate |
1440 | ); |
1441 | // Set expire date if it was included in the response |
1442 | $expireDate = isset($hold->requestExpirationDate) |
1443 | ? $this->dateConverter->convertToDisplayDate( |
1444 | 'Y-m-d H:i', |
1445 | $hold->requestExpirationDate |
1446 | ) |
1447 | : null; |
1448 | // Set lastPickup Date if provided, format to j M Y |
1449 | $lastPickup = isset($hold->holdShelfExpirationDate) |
1450 | ? $this->dateConverter->convertToDisplayDate( |
1451 | 'Y-m-d H:i', |
1452 | $hold->holdShelfExpirationDate |
1453 | ) |
1454 | : null; |
1455 | $currentHold = [ |
1456 | 'type' => $hold->requestType, |
1457 | 'create' => $requestDate, |
1458 | 'expire' => $expireDate ?? '', |
1459 | 'id' => $this->getBibId( |
1460 | $hold->instanceId, |
1461 | $hold->holdingsRecordId ?? null, |
1462 | $hold->itemId ?? null |
1463 | ), |
1464 | 'item_id' => $hold->itemId ?? null, |
1465 | 'reqnum' => $hold->id, |
1466 | // Title moved from item to instance in Lotus release: |
1467 | 'title' => $hold->instance->title ?? $hold->item->title ?? '', |
1468 | 'available' => in_array( |
1469 | $hold->status, |
1470 | $this->config['Holds']['available'] |
1471 | ?? $this->defaultAvailabilityStatuses |
1472 | ), |
1473 | 'in_transit' => in_array( |
1474 | $hold->status, |
1475 | $this->config['Holds']['in_transit'] |
1476 | ?? $this->defaultInTransitStatuses |
1477 | ), |
1478 | 'last_pickup_date' => $lastPickup, |
1479 | 'position' => $hold->position ?? null, |
1480 | ]; |
1481 | // If this request was created by a proxy user, and the proxy user |
1482 | // is not the current user, we need to indicate their name. |
1483 | if ( |
1484 | ($hold->proxyUserId ?? $patron['id']) !== $patron['id'] |
1485 | && isset($hold->proxy) |
1486 | ) { |
1487 | $currentHold['proxiedBy'] |
1488 | = $this->userObjectToNameString($hold->proxy); |
1489 | } |
1490 | // If this request was not created for the current user, it must be |
1491 | // a proxy request created by the current user. We should indicate this. |
1492 | if ( |
1493 | ($hold->requesterId ?? $patron['id']) !== $patron['id'] |
1494 | && isset($hold->requester) |
1495 | ) { |
1496 | $currentHold['proxiedFor'] |
1497 | = $this->userObjectToNameString($hold->requester); |
1498 | } |
1499 | $holds[] = $currentHold; |
1500 | } |
1501 | return $holds; |
1502 | } |
1503 | |
1504 | /** |
1505 | * Get latest major version of a $moduleName enabled for a tenant. |
1506 | * Result is cached. |
1507 | * |
1508 | * @param string $moduleName module name |
1509 | * |
1510 | * @return int module version or 0 if no module found |
1511 | */ |
1512 | protected function getModuleMajorVersion(string $moduleName): int |
1513 | { |
1514 | $cacheKey = 'module_version:' . $moduleName; |
1515 | $version = $this->getCachedData($cacheKey); |
1516 | if ($version === null) { |
1517 | // get latest version of a module enabled for a tenant |
1518 | $response = $this->makeRequest( |
1519 | 'GET', |
1520 | '/_/proxy/tenants/' . $this->tenant . '/modules?filter=' . $moduleName . '&latest=1' |
1521 | ); |
1522 | |
1523 | // get version major from json result |
1524 | $versions = json_decode($response->getBody()); |
1525 | $latest = $versions[0]->id ?? '0'; |
1526 | preg_match_all('!\d+!', $latest, $matches); |
1527 | $version = (int)($matches[0][0] ?? 0); |
1528 | if ($version === 0) { |
1529 | $this->debug('Unable to find version in ' . $response->getBody()); |
1530 | } else { |
1531 | // Only cache non-zero values, so we don't persist an error condition: |
1532 | $this->putCachedData($cacheKey, $version); |
1533 | } |
1534 | } |
1535 | return $version; |
1536 | } |
1537 | |
1538 | /** |
1539 | * Support method for placeHold(): get a list of request types to try. |
1540 | * |
1541 | * @param string $preferred Method to try first. |
1542 | * |
1543 | * @return array |
1544 | */ |
1545 | protected function getRequestTypeList(string $preferred): array |
1546 | { |
1547 | $backupMethods = (array)($this->config['Holds']['fallback_request_type'] ?? []); |
1548 | return array_merge( |
1549 | [$preferred], |
1550 | array_diff($backupMethods, [$preferred]) |
1551 | ); |
1552 | } |
1553 | |
1554 | /** |
1555 | * Support method for placeHold(): send the request and process the response. |
1556 | * |
1557 | * @param array $requestBody Request body |
1558 | * |
1559 | * @return array |
1560 | * @throws ILSException |
1561 | */ |
1562 | protected function performHoldRequest(array $requestBody): array |
1563 | { |
1564 | $response = $this->makeRequest( |
1565 | 'POST', |
1566 | '/circulation/requests', |
1567 | json_encode($requestBody), |
1568 | [], |
1569 | true |
1570 | ); |
1571 | try { |
1572 | $json = json_decode($response->getBody()); |
1573 | } catch (Exception $e) { |
1574 | $this->throwAsIlsException($e, $response->getBody()); |
1575 | } |
1576 | if ($response->isSuccess() && isset($json->status)) { |
1577 | return [ |
1578 | 'success' => true, |
1579 | 'status' => $json->status, |
1580 | ]; |
1581 | } |
1582 | return [ |
1583 | 'success' => false, |
1584 | 'status' => $json->errors[0]->message ?? '', |
1585 | ]; |
1586 | } |
1587 | |
1588 | /** |
1589 | * Place Hold |
1590 | * |
1591 | * Attempts to place a hold or recall on a particular item and returns |
1592 | * an array with result details. |
1593 | * |
1594 | * @param array $holdDetails An array of item and patron data |
1595 | * |
1596 | * @return mixed An array of data on the request including |
1597 | * whether or not it was successful and a system message (if available) |
1598 | */ |
1599 | public function placeHold($holdDetails) |
1600 | { |
1601 | $default_request = $this->config['Holds']['default_request'] ?? 'Hold'; |
1602 | if ( |
1603 | !empty($holdDetails['requiredByTS']) |
1604 | && !is_int($holdDetails['requiredByTS']) |
1605 | ) { |
1606 | throw new ILSException('hold_date_invalid'); |
1607 | } |
1608 | $requiredBy = !empty($holdDetails['requiredByTS']) |
1609 | ? gmdate('Y-m-d', $holdDetails['requiredByTS']) : null; |
1610 | |
1611 | $isTitleLevel = ($holdDetails['level'] ?? '') === 'title'; |
1612 | if ($isTitleLevel) { |
1613 | $instance = $this->getInstanceByBibId($holdDetails['id']); |
1614 | $baseParams = [ |
1615 | 'instanceId' => $instance->id, |
1616 | 'requestLevel' => 'Title', |
1617 | ]; |
1618 | $preferredRequestType = $default_request; |
1619 | } else { |
1620 | // Note: early Lotus releases require instanceId and holdingsRecordId |
1621 | // to be set here as well, but the requirement was lifted in a hotfix |
1622 | // to allow backward compatibility. If you need compatibility with one |
1623 | // of those versions, you can add additional identifiers here, but |
1624 | // applying the latest hotfix is a better solution! |
1625 | $baseParams = ['itemId' => $holdDetails['item_id']]; |
1626 | $preferredRequestType = ($holdDetails['status'] ?? '') == 'Available' |
1627 | ? 'Page' : $default_request; |
1628 | } |
1629 | // Account for an API spelling change introduced in mod-circulation v24: |
1630 | $fulfillmentKey = $this->getModuleMajorVersion('mod-circulation') >= 24 |
1631 | ? 'fulfillmentPreference' : 'fulfilmentPreference'; |
1632 | $requestBody = $baseParams + [ |
1633 | 'requesterId' => $holdDetails['patron']['id'], |
1634 | 'requestDate' => date('c'), |
1635 | $fulfillmentKey => 'Hold Shelf', |
1636 | 'requestExpirationDate' => $requiredBy, |
1637 | 'pickupServicePointId' => $holdDetails['pickUpLocation'], |
1638 | ]; |
1639 | if (!empty($holdDetails['proxiedUser'])) { |
1640 | $requestBody['requesterId'] = $holdDetails['proxiedUser']; |
1641 | $requestBody['proxyUserId'] = $holdDetails['patron']['id']; |
1642 | } |
1643 | if (!empty($holdDetails['comment'])) { |
1644 | $requestBody['patronComments'] = $holdDetails['comment']; |
1645 | } |
1646 | foreach ($this->getRequestTypeList($preferredRequestType) as $requestType) { |
1647 | $requestBody['requestType'] = $requestType; |
1648 | $result = $this->performHoldRequest($requestBody); |
1649 | if ($result['success']) { |
1650 | break; |
1651 | } |
1652 | } |
1653 | return $result ?? ['success' => false, 'status' => 'Unexpected failure']; |
1654 | } |
1655 | |
1656 | /** |
1657 | * Get FOLIO hold IDs for use in cancelHolds. |
1658 | * |
1659 | * @param array $hold A single hold array from getMyHolds |
1660 | * @param array $patron Patron information from patronLogin |
1661 | * |
1662 | * @return string request ID for this request |
1663 | * |
1664 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
1665 | */ |
1666 | public function getCancelHoldDetails($hold, $patron = []) |
1667 | { |
1668 | return $hold['reqnum']; |
1669 | } |
1670 | |
1671 | /** |
1672 | * Cancel Holds |
1673 | * |
1674 | * Attempts to Cancel a hold or recall on a particular item. The |
1675 | * data in $cancelDetails['details'] is determined by getCancelHoldDetails(). |
1676 | * |
1677 | * @param array $cancelDetails An array of item and patron data |
1678 | * |
1679 | * @return array An array of data on each request including |
1680 | * whether or not it was successful and a system message (if available) |
1681 | */ |
1682 | public function cancelHolds($cancelDetails) |
1683 | { |
1684 | $details = $cancelDetails['details']; |
1685 | $patron = $cancelDetails['patron']; |
1686 | $count = 0; |
1687 | $cancelResult = ['items' => []]; |
1688 | |
1689 | foreach ($details as $requestId) { |
1690 | $response = $this->makeRequest( |
1691 | 'GET', |
1692 | '/circulation/requests/' . $requestId |
1693 | ); |
1694 | $request_json = json_decode($response->getBody()); |
1695 | |
1696 | // confirm request belongs to signed in patron |
1697 | if ( |
1698 | $request_json->requesterId != $patron['id'] |
1699 | && ($request_json->proxyUserId ?? null) != $patron['id'] |
1700 | ) { |
1701 | throw new ILSException('Invalid Request'); |
1702 | } |
1703 | // Change status to Closed and add cancellationID |
1704 | $request_json->status = 'Closed - Cancelled'; |
1705 | $request_json->cancellationReasonId |
1706 | = $this->config['Holds']['cancellation_reason'] |
1707 | ?? '75187e8d-e25a-47a7-89ad-23ba612338de'; |
1708 | $success = false; |
1709 | try { |
1710 | $cancel_response = $this->makeRequest( |
1711 | 'PUT', |
1712 | '/circulation/requests/' . $requestId, |
1713 | json_encode($request_json), |
1714 | [], |
1715 | true |
1716 | ); |
1717 | $success = $cancel_response->getStatusCode() === 204; |
1718 | } catch (\Exception $e) { |
1719 | // Do nothing; the $success flag is already false by default. |
1720 | } |
1721 | $count += $success ? 1 : 0; |
1722 | $cancelResult['items'][$request_json->itemId] = [ |
1723 | 'success' => $success, |
1724 | 'status' => $success ? 'hold_cancel_success' : 'hold_cancel_fail', |
1725 | ]; |
1726 | } |
1727 | $cancelResult['count'] = $count; |
1728 | return $cancelResult; |
1729 | } |
1730 | |
1731 | /** |
1732 | * Obtain a list of course resources, creating an id => value associative array. |
1733 | * |
1734 | * @param string $type Type of resource to retrieve from the API. |
1735 | * @param string $responseKey Key containing useful values in response |
1736 | * (defaults to $type if unspecified) |
1737 | * @param string|array $valueKey Key containing value(s) to extract from |
1738 | * response (defaults to 'name') |
1739 | * @param string $formatStr A sprintf format string for assembling the |
1740 | * parameters retrieved using $valueKey |
1741 | * |
1742 | * @return array |
1743 | */ |
1744 | protected function getCourseResourceList( |
1745 | $type, |
1746 | $responseKey = null, |
1747 | $valueKey = 'name', |
1748 | $formatStr = '%s' |
1749 | ) { |
1750 | $retVal = []; |
1751 | |
1752 | // Results can be paginated, so let's loop until we've gotten everything: |
1753 | foreach ( |
1754 | $this->getPagedResults( |
1755 | $responseKey ?? $type, |
1756 | '/coursereserves/' . $type |
1757 | ) as $item |
1758 | ) { |
1759 | $callback = function ($key) use ($item) { |
1760 | return $item->$key ?? ''; |
1761 | }; |
1762 | $retVal[$item->id] |
1763 | = sprintf($formatStr, ...array_map($callback, (array)$valueKey)); |
1764 | } |
1765 | return $retVal; |
1766 | } |
1767 | |
1768 | /** |
1769 | * Get Departments |
1770 | * |
1771 | * Obtain a list of departments for use in limiting the reserves list. |
1772 | * |
1773 | * @return array An associative array with key = dept. ID, value = dept. name. |
1774 | */ |
1775 | public function getDepartments() |
1776 | { |
1777 | return $this->getCourseResourceList('departments'); |
1778 | } |
1779 | |
1780 | /** |
1781 | * Get Instructors |
1782 | * |
1783 | * Obtain a list of instructors for use in limiting the reserves list. |
1784 | * |
1785 | * @return array An associative array with key = ID, value = name. |
1786 | */ |
1787 | public function getInstructors() |
1788 | { |
1789 | $retVal = []; |
1790 | $ids = array_keys( |
1791 | $this->getCourseResourceList('courselistings', 'courseListings') |
1792 | ); |
1793 | foreach ($ids as $id) { |
1794 | $retVal += $this->getCourseResourceList( |
1795 | 'courselistings/' . $id . '/instructors', |
1796 | 'instructors' |
1797 | ); |
1798 | } |
1799 | return $retVal; |
1800 | } |
1801 | |
1802 | /** |
1803 | * Get Courses |
1804 | * |
1805 | * Obtain a list of courses for use in limiting the reserves list. |
1806 | * |
1807 | * @return array An associative array with key = ID, value = name. |
1808 | */ |
1809 | public function getCourses() |
1810 | { |
1811 | $showCodes = $this->config['CourseReserves']['displayCourseCodes'] ?? false; |
1812 | $courses = $this->getCourseResourceList( |
1813 | 'courses', |
1814 | null, |
1815 | $showCodes ? ['courseNumber', 'name'] : ['name'], |
1816 | $showCodes ? '%s: %s' : '%s' |
1817 | ); |
1818 | $callback = function ($course) { |
1819 | return trim(ltrim($course, ':')); |
1820 | }; |
1821 | return array_map($callback, $courses); |
1822 | } |
1823 | |
1824 | /** |
1825 | * Given a course listing ID, get an array of associated courses. |
1826 | * |
1827 | * @param string $courseListingId Course listing ID |
1828 | * |
1829 | * @return array |
1830 | */ |
1831 | protected function getCourseDetails($courseListingId) |
1832 | { |
1833 | $values = empty($courseListingId) |
1834 | ? [] |
1835 | : $this->getCourseResourceList( |
1836 | 'courselistings/' . $courseListingId . '/courses', |
1837 | 'courses', |
1838 | 'departmentId' |
1839 | ); |
1840 | // Return an array with empty values in it if we can't find any values, |
1841 | // because we want to loop at least once to build our reserves response. |
1842 | return empty($values) ? ['' => ''] : $values; |
1843 | } |
1844 | |
1845 | /** |
1846 | * Given a course listing ID, get an array of associated instructors. |
1847 | * |
1848 | * @param string $courseListingId Course listing ID |
1849 | * |
1850 | * @return array |
1851 | */ |
1852 | protected function getInstructorIds($courseListingId) |
1853 | { |
1854 | $values = empty($courseListingId) |
1855 | ? [] |
1856 | : $this->getCourseResourceList( |
1857 | 'courselistings/' . $courseListingId . '/instructors', |
1858 | 'instructors' |
1859 | ); |
1860 | // Return an array with null in it if we can't find any values, because |
1861 | // we want to loop at least once to build our course reserves response. |
1862 | return empty($values) ? [null] : array_keys($values); |
1863 | } |
1864 | |
1865 | /** |
1866 | * Find Reserves |
1867 | * |
1868 | * Obtain information on course reserves. |
1869 | * |
1870 | * @param string $course ID from getCourses (empty string to match all) |
1871 | * @param string $inst ID from getInstructors (empty string to match all) |
1872 | * @param string $dept ID from getDepartments (empty string to match all) |
1873 | * |
1874 | * @return mixed An array of associative arrays representing reserve items. |
1875 | */ |
1876 | public function findReserves($course, $inst, $dept) |
1877 | { |
1878 | $retVal = []; |
1879 | $query = []; |
1880 | |
1881 | $includeSuppressed = $this->config['CourseReserves']['includeSuppressed'] ?? false; |
1882 | |
1883 | if (!$includeSuppressed) { |
1884 | $query = [ |
1885 | 'query' => 'copiedItem.instanceDiscoverySuppress==false', |
1886 | ]; |
1887 | } |
1888 | |
1889 | // Results can be paginated, so let's loop until we've gotten everything: |
1890 | foreach ( |
1891 | $this->getPagedResults( |
1892 | 'reserves', |
1893 | '/coursereserves/reserves', |
1894 | $query |
1895 | ) as $item |
1896 | ) { |
1897 | $idProperty = $this->getBibIdType() === 'hrid' |
1898 | ? 'instanceHrid' : 'instanceId'; |
1899 | $bibId = $item->copiedItem->$idProperty ?? null; |
1900 | if ($bibId !== null) { |
1901 | $courseData = $this->getCourseDetails( |
1902 | $item->courseListingId ?? null |
1903 | ); |
1904 | $instructorIds = $this->getInstructorIds( |
1905 | $item->courseListingId ?? null |
1906 | ); |
1907 | foreach ($courseData as $courseId => $departmentId) { |
1908 | foreach ($instructorIds as $instructorId) { |
1909 | $retVal[] = [ |
1910 | 'BIB_ID' => $bibId, |
1911 | 'COURSE_ID' => $courseId == '' ? null : $courseId, |
1912 | 'DEPARTMENT_ID' => $departmentId == '' |
1913 | ? null : $departmentId, |
1914 | 'INSTRUCTOR_ID' => $instructorId, |
1915 | ]; |
1916 | } |
1917 | } |
1918 | } |
1919 | } |
1920 | |
1921 | // If the user has requested a filter, apply it now: |
1922 | if (!empty($course) || !empty($inst) || !empty($dept)) { |
1923 | $filter = function ($value) use ($course, $inst, $dept) { |
1924 | return (empty($course) || $course == $value['COURSE_ID']) |
1925 | && (empty($inst) || $inst == $value['INSTRUCTOR_ID']) |
1926 | && (empty($dept) || $dept == $value['DEPARTMENT_ID']); |
1927 | }; |
1928 | return array_filter($retVal, $filter); |
1929 | } |
1930 | return $retVal; |
1931 | } |
1932 | |
1933 | /** |
1934 | * This method queries the ILS for a patron's current fines |
1935 | * |
1936 | * @param array $patron The patron array from patronLogin |
1937 | * |
1938 | * @return array |
1939 | */ |
1940 | public function getMyFines($patron) |
1941 | { |
1942 | $query = ['query' => 'userId==' . $patron['id'] . ' and status.name==Open']; |
1943 | $fines = []; |
1944 | foreach ( |
1945 | $this->getPagedResults( |
1946 | 'accounts', |
1947 | '/accounts', |
1948 | $query |
1949 | ) as $fine |
1950 | ) { |
1951 | $date = date_create($fine->metadata->createdDate); |
1952 | $title = $fine->title ?? null; |
1953 | $bibId = isset($fine->instanceId) |
1954 | ? $this->getBibId($fine->instanceId) |
1955 | : null; |
1956 | $fines[] = [ |
1957 | 'id' => $bibId, |
1958 | 'amount' => $fine->amount * 100, |
1959 | 'balance' => $fine->remaining * 100, |
1960 | 'status' => $fine->paymentStatus->name, |
1961 | 'type' => $fine->feeFineType, |
1962 | 'title' => $title, |
1963 | 'createdate' => date_format($date, 'j M Y'), |
1964 | ]; |
1965 | } |
1966 | return $fines; |
1967 | } |
1968 | |
1969 | /** |
1970 | * Given a user object from the FOLIO API, return a name string. |
1971 | * |
1972 | * @param object $user User object |
1973 | * |
1974 | * @return string |
1975 | */ |
1976 | protected function userObjectToNameString(object $user): string |
1977 | { |
1978 | $firstParts = ($user->firstName ?? '') |
1979 | . ' ' . ($user->middleName ?? ''); |
1980 | $parts = [ |
1981 | trim($user->lastName ?? ''), |
1982 | trim($firstParts), |
1983 | ]; |
1984 | return implode(', ', array_filter($parts)); |
1985 | } |
1986 | |
1987 | /** |
1988 | * Given a user object returned by getUserById(), return a string representing |
1989 | * the user's name. |
1990 | * |
1991 | * @param object $proxy User object from FOLIO |
1992 | * |
1993 | * @return string |
1994 | */ |
1995 | protected function formatUserNameForProxyList(object $proxy): string |
1996 | { |
1997 | return $this->userObjectToNameString($proxy->personal); |
1998 | } |
1999 | |
2000 | /** |
2001 | * Support method for getProxiedUsers() and getProxyingUsers() to load proxy user data. |
2002 | * |
2003 | * This requires the FOLIO user configured in Folio.ini to have the permission: |
2004 | * proxiesfor.collection.get |
2005 | * |
2006 | * @param array $patron The patron array with username and password |
2007 | * @param string $lookupField Field to use for looking up matching users |
2008 | * @param string $displayField Field in response to use for displaying user names |
2009 | * |
2010 | * @return array |
2011 | */ |
2012 | protected function loadProxyUserData(array $patron, string $lookupField, string $displayField): array |
2013 | { |
2014 | $query = [ |
2015 | 'query' => '(' . $lookupField . '=="' . $patron['id'] . '")', |
2016 | ]; |
2017 | $results = []; |
2018 | $proxies = $this->getPagedResults('proxiesFor', '/proxiesfor', $query); |
2019 | foreach ($proxies as $current) { |
2020 | if ( |
2021 | $current->status ?? '' === 'Active' |
2022 | && $current->requestForSponsor ?? '' === 'Yes' |
2023 | && isset($current->$displayField) |
2024 | ) { |
2025 | if ($proxy = $this->getUserById($current->$displayField)) { |
2026 | $results[$proxy->id] = $this->formatUserNameForProxyList($proxy); |
2027 | } |
2028 | } |
2029 | } |
2030 | return $results; |
2031 | } |
2032 | |
2033 | /** |
2034 | * Get list of users for whom the provided patron is a proxy. |
2035 | * |
2036 | * @param array $patron The patron array with username and password |
2037 | * |
2038 | * @return array |
2039 | */ |
2040 | public function getProxiedUsers(array $patron): array |
2041 | { |
2042 | return $this->loadProxyUserData($patron, 'proxyUserId', 'userId'); |
2043 | } |
2044 | |
2045 | /** |
2046 | * Get list of users who act as proxies for the provided patron. |
2047 | * |
2048 | * @param array $patron The patron array with username and password |
2049 | * |
2050 | * @return array |
2051 | * |
2052 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
2053 | */ |
2054 | public function getProxyingUsers(array $patron): array |
2055 | { |
2056 | return $this->loadProxyUserData($patron, 'userId', 'proxyUserId'); |
2057 | } |
2058 | |
2059 | /** |
2060 | * NOT FINISHED BELOW THIS LINE |
2061 | **/ |
2062 | |
2063 | /** |
2064 | * Check for request blocks. |
2065 | * |
2066 | * @param array $patron The patron array with username and password |
2067 | * |
2068 | * @return array|bool An array of block messages or false if there are no blocks |
2069 | * |
2070 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
2071 | */ |
2072 | public function getRequestBlocks($patron) |
2073 | { |
2074 | return false; |
2075 | } |
2076 | |
2077 | /** |
2078 | * Get Purchase History Data |
2079 | * |
2080 | * This is responsible for retrieving the acquisitions history data for the |
2081 | * specific record (usually recently received issues of a serial). It is used |
2082 | * by getHoldings() and getPurchaseHistory() depending on whether the purchase |
2083 | * history is displayed by holdings or in a separate list. |
2084 | * |
2085 | * @param string $bibID The record id to retrieve the info for |
2086 | * |
2087 | * @return array An array with the acquisitions data on success. |
2088 | * |
2089 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
2090 | */ |
2091 | public function getPurchaseHistory($bibID) |
2092 | { |
2093 | return []; |
2094 | } |
2095 | |
2096 | /** |
2097 | * Get Funds |
2098 | * |
2099 | * Return a list of funds which may be used to limit the getNewItems list. |
2100 | * |
2101 | * @return array An associative array with key = fund ID, value = fund name. |
2102 | */ |
2103 | public function getFunds() |
2104 | { |
2105 | return []; |
2106 | } |
2107 | |
2108 | /** |
2109 | * Get Patron Loan History |
2110 | * |
2111 | * This is responsible for retrieving all historic loans (i.e. items previously |
2112 | * checked out and then returned), for a specific patron. |
2113 | * |
2114 | * @param array $patron The patron array from patronLogin |
2115 | * @param array $params Parameters |
2116 | * |
2117 | * @return array Array of the patron's transactions on success. |
2118 | * |
2119 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
2120 | */ |
2121 | public function getMyTransactionHistory($patron, $params) |
2122 | { |
2123 | return[]; |
2124 | } |
2125 | |
2126 | /** |
2127 | * Get New Items |
2128 | * |
2129 | * Retrieve the IDs of items recently added to the catalog. |
2130 | * |
2131 | * @param int $page Page number of results to retrieve (counting starts at 1) |
2132 | * @param int $limit The size of each page of results to retrieve |
2133 | * @param int $daysOld The maximum age of records to retrieve in days (max. 30) |
2134 | * @param int $fundId optional fund ID to use for limiting results (use a value |
2135 | * returned by getFunds, or exclude for no limit); note that "fund" may be a |
2136 | * misnomer - if funds are not an appropriate way to limit your new item |
2137 | * results, you can return a different set of values from getFunds. The |
2138 | * important thing is that this parameter supports an ID returned by getFunds, |
2139 | * whatever that may mean. |
2140 | * |
2141 | * @return array Associative array with 'count' and 'results' keys |
2142 | * |
2143 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
2144 | */ |
2145 | public function getNewItems($page, $limit, $daysOld, $fundId = null) |
2146 | { |
2147 | return []; |
2148 | } |
2149 | } |