Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
1.09% |
17 / 1559 |
|
5.19% |
4 / 77 |
CRAP | |
0.00% |
0 / 1 |
SierraRest | |
1.09% |
17 / 1559 |
|
5.19% |
4 / 77 |
171926.86 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setConfig | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
init | |
3.57% |
2 / 56 |
|
0.00% |
0 / 1 |
119.49 | |||
getInnReachDb | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
getStatus | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getStatuses | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getHolding | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getPurchaseHistory | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getNewItems | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
findReserves | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
patronLogin | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
42 | |||
getRequestBlocks | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getAccountBlocks | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getMyProfile | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
72 | |||
getMyTransactions | |
0.00% |
0 / 64 |
|
0.00% |
0 / 1 |
210 | |||
getRenewDetails | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
renewMyItems | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
20 | |||
getMyTransactionHistory | |
0.00% |
0 / 48 |
|
0.00% |
0 / 1 |
132 | |||
purgeTransactionHistory | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
42 | |||
getMyHolds | |
0.00% |
0 / 110 |
|
0.00% |
0 / 1 |
1122 | |||
cancelHolds | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
12 | |||
updateHolds | |
0.00% |
0 / 52 |
|
0.00% |
0 / 1 |
182 | |||
getPickUpLocations | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
42 | |||
getDefaultPickUpLocation | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
checkRequestIsValid | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
56 | |||
placeHold | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
182 | |||
getMyFines | |
0.00% |
0 / 49 |
|
0.00% |
0 / 1 |
90 | |||
changePassword | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
20 | |||
getConfig | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
20 | |||
supportsMethod | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
30 | |||
extractId | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
extractVolume | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
makeRequest | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
30 | |||
requestCallback | |
0.00% |
0 / 69 |
|
0.00% |
0 / 1 |
380 | |||
getApiUrlFromHierarchy | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
renewAccessToken | |
0.00% |
0 / 47 |
|
0.00% |
0 / 1 |
72 | |||
getPatronAuthorizationCode | |
0.00% |
0 / 76 |
|
0.00% |
0 / 1 |
240 | |||
createHttpClient | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
formatCacheKey | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getBibCallNumber | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
getDueStatus | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
getItemStatusesForBib | |
0.00% |
0 / 120 |
|
0.00% |
0 / 1 |
650 | |||
extractCallNumber | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
getOrderMessages | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
12 | |||
getHoldingsData | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
72 | |||
extractFieldsFromApiData | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
182 | |||
getLocationName | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
30 | |||
translateLocation | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
statusSortFunction | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
translateOpacMessage | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
mapStatusCode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getItemStatus | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
72 | |||
isHoldable | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
210 | |||
getPatronBlocks | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
30 | |||
pickupLocationSortFunction | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
pickUpLocationIsValid | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
holdError | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
formatErrorMessage | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
getCachedRecordData | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
putCachedRecordData | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getBibRecord | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getRecords | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
30 | |||
getBibRecords | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getItemRecords | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getItemsForBibRecord | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
72 | |||
extractBibId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
formatBibId | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
isPatronSpecificAccess | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getPatronInformationFromAuthToken | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
30 | |||
authenticatePatron | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
42 | |||
validatePatron | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
authenticatePatronV5 | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
56 | |||
authenticatePatronV6 | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
30 | |||
getItemsWithBibsForTransactions | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
132 | |||
checkTitleHoldRules | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
210 | |||
getInnReachHoldTitleInfoFromId | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
20 | |||
getInnReachCheckoutTitleInfoFromId | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | |
3 | /** |
4 | * III Sierra REST API driver |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) The National Library of Finland 2016-2024. |
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 Ere Maijala <ere.maijala@helsinki.fi> |
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 Laminas\Log\LoggerAwareInterface; |
33 | use VuFind\Date\DateException; |
34 | use VuFind\Exception\ILS as ILSException; |
35 | use VuFind\I18n\Translator\TranslatorAwareInterface; |
36 | use VuFindHttp\HttpServiceAwareInterface; |
37 | |
38 | use function call_user_func_array; |
39 | use function count; |
40 | use function func_get_args; |
41 | use function in_array; |
42 | use function intval; |
43 | use function is_array; |
44 | use function is_callable; |
45 | use function is_string; |
46 | use function strlen; |
47 | |
48 | /** |
49 | * III Sierra REST API driver |
50 | * |
51 | * @category VuFind |
52 | * @package ILS_Drivers |
53 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
54 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
55 | * @link https://vufind.org/wiki/development:plugins:ils_drivers Wiki |
56 | */ |
57 | class SierraRest extends AbstractBase implements |
58 | TranslatorAwareInterface, |
59 | HttpServiceAwareInterface, |
60 | LoggerAwareInterface, |
61 | \VuFind\I18n\HasSorterInterface |
62 | { |
63 | use \VuFind\Cache\CacheTrait; |
64 | use \VuFind\Log\LoggerAwareTrait { |
65 | logError as error; |
66 | } |
67 | use \VuFindHttp\HttpServiceAwareTrait; |
68 | use \VuFind\I18n\Translator\TranslatorAwareTrait; |
69 | use \VuFind\I18n\HasSorterTrait; |
70 | use \VuFind\Service\Feature\RetryTrait; |
71 | use \VuFind\Config\Feature\ExplodeSettingTrait; |
72 | |
73 | /** |
74 | * Fixed field number for location in holdings records |
75 | * |
76 | * @var string |
77 | */ |
78 | public const HOLDINGS_LOCATION_FIELD = '40'; |
79 | |
80 | /** |
81 | * Sierra INN-Reach Database connection |
82 | * |
83 | * @var ?resource |
84 | */ |
85 | protected $innReachDb = null; |
86 | |
87 | /** |
88 | * Fixed field number for item code 2 (ICODE2) in item records |
89 | * |
90 | * @var string |
91 | */ |
92 | public const ITEM_ICODE2_FIELD = '60'; |
93 | |
94 | /** |
95 | * Fixed field number for item type (I TYPE) in item records |
96 | * |
97 | * @var string |
98 | */ |
99 | public const ITEM_ITYPE_FIELD = '61'; |
100 | |
101 | /** |
102 | * Fixed field number for item last checkin date (LCHKIN) in item records |
103 | * |
104 | * @var string |
105 | */ |
106 | public const ITEM_CHECKIN_DATE_FIELD = '68'; |
107 | |
108 | /** |
109 | * Fixed field number for OPAC message (OPACMSG) in item records |
110 | * |
111 | * @var string |
112 | */ |
113 | public const ITEM_OPAC_MESSAGE_FIELD = '108'; |
114 | |
115 | /** |
116 | * Driver configuration |
117 | * |
118 | * @var array |
119 | */ |
120 | protected $config; |
121 | |
122 | /** |
123 | * Date converter |
124 | * |
125 | * @var \VuFind\Date\Converter |
126 | */ |
127 | protected $dateConverter; |
128 | |
129 | /** |
130 | * Factory function for constructing the SessionContainer. |
131 | * |
132 | * @var callable |
133 | */ |
134 | protected $sessionFactory; |
135 | |
136 | /** |
137 | * Session cache |
138 | * |
139 | * @var \Laminas\Session\Container |
140 | */ |
141 | protected $sessionCache; |
142 | |
143 | /** |
144 | * Whether item holds are enabled |
145 | * |
146 | * @var bool |
147 | */ |
148 | protected $itemHoldsEnabled; |
149 | |
150 | /** |
151 | * Item codes (ICODE2 in Sierra) for which item level hold is not allowed |
152 | * |
153 | * @var array |
154 | */ |
155 | protected $itemHoldExcludedItemCodes = []; |
156 | |
157 | /** |
158 | * Item types (I TYPE in Sierra) for which item level hold is not allowed |
159 | * |
160 | * @var array |
161 | */ |
162 | protected $itemHoldExcludedItemTypes = []; |
163 | |
164 | /** |
165 | * Bib levels for which item level hold is allowed. If null, negation of |
166 | * titleHoldBibLevels is used instead. |
167 | * |
168 | * @var ?array |
169 | */ |
170 | protected $itemHoldBibLevels = null; |
171 | |
172 | /** |
173 | * Bib levels for which title level hold is allowed |
174 | * |
175 | * @var array |
176 | */ |
177 | protected $titleHoldBibLevels = []; |
178 | |
179 | /** |
180 | * Default pickup location |
181 | * |
182 | * @var string |
183 | */ |
184 | protected $defaultPickUpLocation = ''; |
185 | |
186 | /** |
187 | * Item statuses that allow placing an hold |
188 | * |
189 | * @var array |
190 | */ |
191 | protected $validHoldStatuses = []; |
192 | |
193 | /** |
194 | * Title hold rules |
195 | * |
196 | * @var array |
197 | */ |
198 | protected $titleHoldRules = []; |
199 | |
200 | /** |
201 | * Item statuses that count when $titleHoldRules contains "item". |
202 | * |
203 | * @var array |
204 | */ |
205 | protected $titleHoldValidHoldStatuses = []; |
206 | |
207 | /** |
208 | * Item codes (ICODE2 in Sierra) that cause an item to be ignored when |
209 | * $titleHoldRules contains "item". |
210 | * |
211 | * @var array |
212 | */ |
213 | protected $titleHoldExcludedItemCodes = []; |
214 | |
215 | /** |
216 | * Item types (I TYPE in Sierra) that cause an item to be ignored when |
217 | * $titleHoldRules contains "item". |
218 | * |
219 | * @var array |
220 | */ |
221 | protected $titleHoldExcludedItemTypes = []; |
222 | |
223 | /** |
224 | * Mappings from item status codes to VuFind strings |
225 | * |
226 | * @var array |
227 | */ |
228 | protected $itemStatusMappings = [ |
229 | '!' => 'On Holdshelf', |
230 | 't' => 'In Transit', |
231 | 'o' => 'On Reference Desk', |
232 | 'k' => 'In Repair', |
233 | 'm' => 'Missing', |
234 | 'n' => 'Long Overdue', |
235 | '$' => 'Lost--Library Applied', |
236 | 'p' => '', |
237 | 'z' => 'Claims Returned', |
238 | 's' => 'On Search', |
239 | 'd' => 'In Process', |
240 | '-' => 'On Shelf', |
241 | 'Charged' => 'Charged', |
242 | 'Ordered' => 'Ordered', |
243 | ]; |
244 | |
245 | /** |
246 | * Mappings from patron block codes to VuFind strings |
247 | */ |
248 | protected $patronBlockMappings = []; |
249 | |
250 | /** |
251 | * Mappings from fine types to VuFind strings |
252 | * |
253 | * @var array |
254 | */ |
255 | protected $fineTypeMappings = []; |
256 | |
257 | /** |
258 | * Status codes indicating that a hold is available for pickup |
259 | * |
260 | * @var array |
261 | */ |
262 | protected $holdAvailableCodes = ['b', 'j', 'i']; |
263 | |
264 | /** |
265 | * Status codes indicating that a hold is in transit |
266 | * |
267 | * @var array |
268 | */ |
269 | protected $holdInTransitCodes = ['t']; |
270 | |
271 | /** |
272 | * Available API version |
273 | * |
274 | * Functionality requiring a specific minimum version: |
275 | * |
276 | * v5: |
277 | * - last pickup date for holds |
278 | * v5.1 (technically still v5 but added in a later revision): |
279 | * - summary holdings information (especially for serials) |
280 | * |
281 | * Note that API version 3 is deprecated in Sierra 5.1 and will be removed later |
282 | * on (reported March 2020). |
283 | * |
284 | * @var int |
285 | */ |
286 | protected $apiVersion = 6; |
287 | |
288 | /** |
289 | * API base path |
290 | * |
291 | * This should correspond to $apiVersion above |
292 | * |
293 | * @var string |
294 | */ |
295 | protected $apiBase = 'v6'; |
296 | |
297 | /** |
298 | * Statistic group to use e.g. when renewing loans or placing holds |
299 | * |
300 | * @var ?int |
301 | */ |
302 | protected $statGroup = null; |
303 | |
304 | /** |
305 | * Whether to sort items by enumchron. Default is true. |
306 | * |
307 | * @var array |
308 | */ |
309 | protected $sortItemsByEnumChron; |
310 | |
311 | /** |
312 | * Whether to allow canceling of available holds |
313 | * |
314 | * @var bool |
315 | */ |
316 | protected $allowCancelingAvailableRequests = false; |
317 | |
318 | /** |
319 | * Whether to check hold freezability up front. Not enabled by default since |
320 | * Sierra versions prior to 5.6 return holds slowly if canFreeze is requested. |
321 | * |
322 | * @var bool |
323 | */ |
324 | protected $checkFreezability = false; |
325 | |
326 | /** |
327 | * Number of retries in case an API request fails with a retryable error (see |
328 | * $retryableRequestExceptionPatterns below). |
329 | * |
330 | * @var int |
331 | */ |
332 | protected $httpRetryCount = 2; |
333 | |
334 | /** |
335 | * Exception message regexp patterns for request errors that can be retried |
336 | * |
337 | * @var array |
338 | */ |
339 | protected $retryableRequestExceptionPatterns = [ |
340 | // cURL adapter: |
341 | '/Error in cURL request: Empty reply from server/', |
342 | // Socket adapter: |
343 | '/A valid response status line was not found in the provided string/', |
344 | ]; |
345 | |
346 | /** |
347 | * Bib cache entry life time in seconds |
348 | * |
349 | * @var int |
350 | */ |
351 | protected $bibCacheTTL = 300; |
352 | |
353 | /** |
354 | * Item cache entry life time in seconds |
355 | * |
356 | * @var int |
357 | */ |
358 | protected $itemCacheTTL = 300; |
359 | |
360 | /** |
361 | * Life time in seconds for cached items of a bibliographic record |
362 | * |
363 | * It is recommended to keep this fairly short to ensure that any recent changes |
364 | * (such as placing a hold) are reflected correctly in holdings. |
365 | * |
366 | * @var int |
367 | */ |
368 | protected $bibItemsCacheTTL = 2; |
369 | |
370 | /** |
371 | * Default list of bib fields to request from Sierra. This list must include |
372 | * at least 'title' and 'publishYear' needed to compose holds list and fines |
373 | * list. The cached entry will be augmented with any additional fields as needed, |
374 | * within the cache life time (see $bibCacheTTL). |
375 | * |
376 | * @var array |
377 | */ |
378 | protected $defaultBibFields = ['default']; |
379 | |
380 | /** |
381 | * Default list of item fields to request from Sierra. This list must include at |
382 | * least the fields needed to compose holdings and determine holdability. |
383 | * |
384 | * @var array |
385 | */ |
386 | protected $defaultItemFields = [ |
387 | 'default', |
388 | 'fixedFields', |
389 | 'varFields', |
390 | ]; |
391 | |
392 | /** |
393 | * Constructor |
394 | * |
395 | * @param \VuFind\Date\Converter $dateConverter Date converter object |
396 | * @param callable $sessionFactory Factory function returning |
397 | * SessionContainer object |
398 | */ |
399 | public function __construct( |
400 | \VuFind\Date\Converter $dateConverter, |
401 | $sessionFactory |
402 | ) { |
403 | $this->dateConverter = $dateConverter; |
404 | $this->sessionFactory = $sessionFactory; |
405 | } |
406 | |
407 | /** |
408 | * Set configuration. |
409 | * |
410 | * Set the configuration for the driver. |
411 | * |
412 | * @param array $config Configuration array (usually loaded from a VuFind .ini |
413 | * file whose name corresponds with the driver class name). |
414 | * |
415 | * @return void |
416 | */ |
417 | public function setConfig($config) |
418 | { |
419 | $this->config = $config; |
420 | } |
421 | |
422 | /** |
423 | * Initialize the driver. |
424 | * |
425 | * Validate configuration and perform all resource-intensive tasks needed to |
426 | * make the driver active. |
427 | * |
428 | * @throws ILSException |
429 | * @return void |
430 | */ |
431 | public function init() |
432 | { |
433 | if (empty($this->config)) { |
434 | throw new ILSException('Configuration needs to be set.'); |
435 | } |
436 | |
437 | // Validate config |
438 | $required = ['host', 'client_key', 'client_secret']; |
439 | foreach ($required as $current) { |
440 | if (!isset($this->config['Catalog'][$current])) { |
441 | throw new ILSException("Missing Catalog/{$current} config setting."); |
442 | } |
443 | } |
444 | |
445 | $holdCfg = $this->config['Holds'] ?? []; |
446 | |
447 | $this->validHoldStatuses = $this->explodeSetting($holdCfg['valid_hold_statuses'] ?? ''); |
448 | $this->itemHoldsEnabled = $holdCfg['enableItemHolds'] ?? true; |
449 | $this->itemHoldExcludedItemCodes |
450 | = $this->explodeSetting($holdCfg['item_hold_excluded_item_codes'] ?? ''); |
451 | $this->itemHoldExcludedItemTypes |
452 | = $this->explodeSetting($holdCfg['item_hold_excluded_item_types'] ?? ''); |
453 | $this->itemHoldBibLevels = isset($holdCfg['item_hold_bib_levels']) |
454 | ? $this->explodeSetting($holdCfg['item_hold_bib_levels'] ?? '') |
455 | : null; |
456 | |
457 | $this->titleHoldValidHoldStatuses = $this->explodeSetting( |
458 | $holdCfg['title_hold_valid_hold_statuses'] |
459 | ?? $holdCfg['valid_hold_statuses'] |
460 | ?? '' |
461 | ); |
462 | $this->titleHoldBibLevels = $this->explodeSetting($holdCfg['title_hold_bib_levels'] ?? ''); |
463 | $this->titleHoldRules = $this->explodeSetting($holdCfg['title_hold_rules'] ?? ''); |
464 | $this->titleHoldExcludedItemCodes |
465 | = $this->explodeSetting($holdCfg['title_hold_excluded_item_codes'] ?? ''); |
466 | $this->titleHoldExcludedItemTypes |
467 | = $this->explodeSetting($holdCfg['title_hold_excluded_item_types'] ?? ''); |
468 | |
469 | $this->allowCancelingAvailableRequests |
470 | = $holdCfg['allowCancelingAvailableRequests'] ?? false; |
471 | $this->defaultPickUpLocation = $holdCfg['defaultPickUpLocation'] ?? ''; |
472 | if ($this->defaultPickUpLocation === 'user-selected') { |
473 | $this->defaultPickUpLocation = false; |
474 | } |
475 | $this->checkFreezability = (bool)($holdCfg['checkFreezability'] ?? false); |
476 | |
477 | if (!empty($this->config['ItemStatusMappings'])) { |
478 | $this->itemStatusMappings = array_merge( |
479 | $this->itemStatusMappings, |
480 | $this->config['ItemStatusMappings'] |
481 | ); |
482 | } |
483 | $this->patronBlockMappings = $this->config['PatronBlockMappings'] ?? []; |
484 | $this->fineTypeMappings = (array)($this->config['FineTypeMappings'] ?? []); |
485 | |
486 | if (isset($this->config['Catalog']['api_version'])) { |
487 | $this->apiVersion = $this->config['Catalog']['api_version']; |
488 | $this->apiBase = 'v' . floor($this->apiVersion); |
489 | } |
490 | if ($statGroup = $this->config['Catalog']['statgroup'] ?? null) { |
491 | if ($this->apiVersion >= 6) { |
492 | $this->statGroup = (int)$statGroup; |
493 | } else { |
494 | $this->logWarning("Ignoring statgroup for API Version {$this->apiVersion}"); |
495 | } |
496 | } |
497 | |
498 | if (null !== ($retries = $this->config['Catalog']['http_retries'] ?? null)) { |
499 | $this->httpRetryCount = (int)$retries; |
500 | } |
501 | |
502 | $this->sortItemsByEnumChron |
503 | = $this->config['Holdings']['sort_by_enum_chron'] ?? true; |
504 | |
505 | // Init session cache for session-specific data |
506 | $namespace = md5( |
507 | $this->config['Catalog']['host'] . '|' |
508 | . $this->config['Catalog']['client_key'] |
509 | ); |
510 | $factory = $this->sessionFactory; |
511 | $this->sessionCache = $factory($namespace); |
512 | } |
513 | |
514 | /** |
515 | * Establish INN-Reach database connection |
516 | * |
517 | * @return ?resource |
518 | */ |
519 | protected function getInnReachDb() |
520 | { |
521 | if (null === $this->innReachDb) { |
522 | try { |
523 | $conn_string = $this->config['InnReach']['sierra_db']; |
524 | $connection = pg_connect($conn_string); |
525 | $this->innReachDb = $connection; |
526 | } catch (\Exception $e) { |
527 | $this->logWarning("INN-Reach: Could not connect to the Sierra database: {$e}"); |
528 | $this->innReachDb = null; |
529 | } |
530 | } |
531 | return $this->innReachDb; |
532 | } |
533 | |
534 | /** |
535 | * Get Status |
536 | * |
537 | * This is responsible for retrieving the status information of a certain |
538 | * record. |
539 | * |
540 | * @param string $id The record id to retrieve the holdings for |
541 | * |
542 | * @return array An associative array with the following keys: |
543 | * id, availability (boolean), status, location, reserve, callnumber. |
544 | */ |
545 | public function getStatus($id) |
546 | { |
547 | return $this->getItemStatusesForBib($id, $this->config['Holdings']['check_holdings_in_results'] ?? true); |
548 | } |
549 | |
550 | /** |
551 | * Get Statuses |
552 | * |
553 | * This is responsible for retrieving the status information for a |
554 | * collection of records. |
555 | * |
556 | * @param array $ids The array of record ids to retrieve the status for |
557 | * |
558 | * @return mixed An array of getStatus() return values on success. |
559 | */ |
560 | public function getStatuses($ids) |
561 | { |
562 | $items = []; |
563 | foreach ($ids as $id) { |
564 | $items[] = $this->getStatus($id); |
565 | } |
566 | return $items; |
567 | } |
568 | |
569 | /** |
570 | * Get Holding |
571 | * |
572 | * This is responsible for retrieving the holding information of a certain |
573 | * record. |
574 | * |
575 | * @param string $id The record id to retrieve the holdings for |
576 | * @param array $patron Patron data |
577 | * @param array $options Extra options (not currently used) |
578 | * |
579 | * @return mixed On success, an associative array with the following keys: |
580 | * id, availability (boolean), status, location, reserve, callnumber, duedate, |
581 | * number, barcode. |
582 | * |
583 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
584 | */ |
585 | public function getHolding($id, array $patron = null, array $options = []) |
586 | { |
587 | return $this->getItemStatusesForBib($id, true, $patron); |
588 | } |
589 | |
590 | /** |
591 | * Get Purchase History |
592 | * |
593 | * This is responsible for retrieving the acquisitions history data for the |
594 | * specific record (usually recently received issues of a serial). |
595 | * |
596 | * @param string $id The record id to retrieve the info for |
597 | * |
598 | * @return mixed An array with the acquisitions data on success. |
599 | * |
600 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
601 | */ |
602 | public function getPurchaseHistory($id) |
603 | { |
604 | return []; |
605 | } |
606 | |
607 | /** |
608 | * Get New Items |
609 | * |
610 | * Retrieve the IDs of items recently added to the catalog. |
611 | * |
612 | * @param int $page Page number of results to retrieve (counting starts at 1) |
613 | * @param int $limit The size of each page of results to retrieve |
614 | * @param int $daysOld The maximum age of records to retrieve in days (max. 30) |
615 | * @param int $fundId optional fund ID to use for limiting results (use a value |
616 | * returned by getFunds, or exclude for no limit); note that "fund" may be a |
617 | * misnomer - if funds are not an appropriate way to limit your new item |
618 | * results, you can return a different set of values from getFunds. The |
619 | * important thing is that this parameter supports an ID returned by getFunds, |
620 | * whatever that may mean. |
621 | * |
622 | * @return array Associative array with 'count' and 'results' keys |
623 | * |
624 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
625 | */ |
626 | public function getNewItems($page, $limit, $daysOld, $fundId = null) |
627 | { |
628 | return ['count' => 0, 'results' => []]; |
629 | } |
630 | |
631 | /** |
632 | * Find Reserves |
633 | * |
634 | * Obtain information on course reserves. |
635 | * |
636 | * @param string $course ID from getCourses (empty string to match all) |
637 | * @param string $inst ID from getInstructors (empty string to match all) |
638 | * @param string $dept ID from getDepartments (empty string to match all) |
639 | * |
640 | * @return mixed An array of associative arrays representing reserve items. |
641 | * |
642 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
643 | */ |
644 | public function findReserves($course, $inst, $dept) |
645 | { |
646 | return []; |
647 | } |
648 | |
649 | /** |
650 | * Patron Login |
651 | * |
652 | * This is responsible for authenticating a patron against the catalog. |
653 | * |
654 | * @param string $username The patron username |
655 | * @param string $password The patron password |
656 | * |
657 | * @return mixed Associative array of patron info on successful login, |
658 | * null on unsuccessful login. |
659 | * |
660 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
661 | */ |
662 | public function patronLogin($username, $password) |
663 | { |
664 | // If we are using a patron-specific access grant, we can bypass |
665 | // authentication as the credentials are verified when the access token is |
666 | // requested. |
667 | if ($this->isPatronSpecificAccess()) { |
668 | $patron = $this->getPatronInformationFromAuthToken($username, $password); |
669 | if (!$patron) { |
670 | return null; |
671 | } |
672 | } else { |
673 | $patron = $this->authenticatePatron($username, $password); |
674 | if (!$patron) { |
675 | return null; |
676 | } |
677 | } |
678 | |
679 | $firstname = ''; |
680 | $lastname = ''; |
681 | if (!empty($patron['names'])) { |
682 | $name = $patron['names'][0]; |
683 | $parts = explode(', ', $name, 2); |
684 | $lastname = $parts[0]; |
685 | $firstname = $parts[1] ?? ''; |
686 | } |
687 | return [ |
688 | 'id' => $patron['id'], |
689 | 'firstname' => $firstname, |
690 | 'lastname' => $lastname, |
691 | 'cat_username' => $username, |
692 | 'cat_password' => $password, |
693 | 'email' => !empty($patron['emails']) ? $patron['emails'][0] : '', |
694 | 'major' => null, |
695 | 'college' => null, |
696 | ]; |
697 | } |
698 | |
699 | /** |
700 | * Check whether the patron is blocked from placing requests (holds/ILL/SRR). |
701 | * |
702 | * @param array $patron Patron data from patronLogin(). |
703 | * |
704 | * @return mixed A boolean false if no blocks are in place and an array |
705 | * of block reasons if blocks are in place |
706 | */ |
707 | public function getRequestBlocks($patron) |
708 | { |
709 | return $this->getPatronBlocks($patron); |
710 | } |
711 | |
712 | /** |
713 | * Check whether the patron has any blocks on their account. |
714 | * |
715 | * @param array $patron Patron data from patronLogin(). |
716 | * |
717 | * @return mixed A boolean false if no blocks are in place and an array |
718 | * of block reasons if blocks are in place |
719 | */ |
720 | public function getAccountBlocks($patron) |
721 | { |
722 | return $this->getPatronBlocks($patron); |
723 | } |
724 | |
725 | /** |
726 | * Get Patron Profile |
727 | * |
728 | * This is responsible for retrieving the profile for a specific patron. |
729 | * |
730 | * @param array $patron The patron array |
731 | * |
732 | * @throws ILSException |
733 | * @return array Array of the patron's profile data on success. |
734 | */ |
735 | public function getMyProfile($patron) |
736 | { |
737 | $result = $this->makeRequest( |
738 | [$this->apiBase, 'patrons', $patron['id']], |
739 | [ |
740 | 'fields' => 'default,names,emails,phones,addresses', |
741 | ], |
742 | 'GET', |
743 | $patron |
744 | ); |
745 | |
746 | if (empty($result)) { |
747 | return []; |
748 | } |
749 | $firstname = ''; |
750 | $lastname = ''; |
751 | $address = ''; |
752 | $zip = ''; |
753 | $city = ''; |
754 | if (!empty($result['names'])) { |
755 | $nameParts = explode(', ', $result['names'][0], 2); |
756 | $lastname = $nameParts[0]; |
757 | $firstname = $nameParts[1] ?? ''; |
758 | } |
759 | if (!empty($result['addresses'][0]['lines'][1])) { |
760 | $address = $result['addresses'][0]['lines'][0]; |
761 | $postalParts = explode(' ', $result['addresses'][0]['lines'][1], 2); |
762 | if (isset($postalParts[1])) { |
763 | $zip = $postalParts[0]; |
764 | $city = $postalParts[1]; |
765 | } else { |
766 | $city = $postalParts[0]; |
767 | } |
768 | } |
769 | $expirationDate = !empty($result['expirationDate']) |
770 | ? $this->dateConverter->convertToDisplayDate( |
771 | 'Y-m-d', |
772 | $result['expirationDate'] |
773 | ) : null; |
774 | return [ |
775 | 'firstname' => $firstname, |
776 | 'lastname' => $lastname, |
777 | 'phone' => !empty($result['phones'][0]['number']) |
778 | ? $result['phones'][0]['number'] : '', |
779 | 'email' => !empty($result['emails']) ? $result['emails'][0] : '', |
780 | 'address1' => $address, |
781 | 'zip' => $zip, |
782 | 'city' => $city, |
783 | 'birthdate' => $result['birthDate'] ?? '', |
784 | 'expiration_date' => $expirationDate, |
785 | ]; |
786 | } |
787 | |
788 | /** |
789 | * Get Patron Transactions |
790 | * |
791 | * This is responsible for retrieving all transactions (i.e. checked out items) |
792 | * by a specific patron. |
793 | * |
794 | * @param array $patron The patron array from patronLogin |
795 | * @param array $params Parameters |
796 | * |
797 | * @throws DateException |
798 | * @throws ILSException |
799 | * @return array Array of the patron's transactions on success. |
800 | */ |
801 | public function getMyTransactions($patron, $params = []) |
802 | { |
803 | $pageSize = $params['limit'] ?? 50; |
804 | $offset = isset($params['page']) ? ($params['page'] - 1) * $pageSize : 0; |
805 | |
806 | $result = $this->makeRequest( |
807 | [$this->apiBase, 'patrons', $patron['id'], 'checkouts'], |
808 | [ |
809 | 'limit' => $pageSize, |
810 | 'offset' => $offset, |
811 | 'fields' => 'default,numberOfRenewals,callNumber,barcode', |
812 | ], |
813 | 'GET', |
814 | $patron |
815 | ); |
816 | if (empty($result['entries'])) { |
817 | return [ |
818 | 'count' => $result['total'], |
819 | 'records' => [], |
820 | ]; |
821 | } |
822 | |
823 | $items = $this->getItemsWithBibsForTransactions($result['entries'], $patron); |
824 | $transactions = []; |
825 | foreach ($result['entries'] as $entry) { |
826 | $transaction = [ |
827 | 'id' => '', |
828 | 'checkout_id' => $this->extractId($entry['id']), |
829 | 'item_id' => $this->extractId($entry['item']), |
830 | 'barcode' => $entry['barcode'], |
831 | 'duedate' => $this->dateConverter->convertToDisplayDate( |
832 | 'Y-m-d', |
833 | $entry['dueDate'] |
834 | ), |
835 | 'dueStatus' => $this->getDueStatus($entry), |
836 | 'renew' => $entry['numberOfRenewals'], |
837 | 'renewable' => true, // assumption, who knows? |
838 | ]; |
839 | if (!empty($entry['recallDate'])) { |
840 | $date = $this->dateConverter->convertToDisplayDate( |
841 | 'Y-m-d', |
842 | $entry['recallDate'] |
843 | ); |
844 | $transaction['message'] |
845 | = $this->translate('item_recalled', ['%%date%%' => $date]); |
846 | } |
847 | $item = $items[$transaction['item_id']] ?? null; |
848 | $transaction['volume'] = $item ? $this->extractVolume($item) : ''; |
849 | if (!empty($item['bib'])) { |
850 | $bib = $item['bib']; |
851 | $transaction['id'] = $this->formatBibId($bib['id']); |
852 | if (!empty($bib['title'])) { |
853 | $transaction['title'] = $bib['title']; |
854 | } |
855 | if (!empty($bib['publishYear'])) { |
856 | $transaction['publication_year'] = $bib['publishYear']; |
857 | } |
858 | } |
859 | $transactions[] = $transaction; |
860 | } |
861 | if ($this->config['InnReach']['enabled'] ?? false) { |
862 | foreach ($transactions as $n => $transaction) { |
863 | $irIdentifier = $this->config['InnReach']['identifier']; |
864 | if ($transaction['item_id'] && strstr($transaction['item_id'], $irIdentifier)) { |
865 | $irCheckoutId = $transaction['checkout_id']; |
866 | $irItemId = $transaction['item_id']; |
867 | $innReach = $this->getInnReachCheckoutTitleInfoFromId($irCheckoutId, $irItemId); |
868 | |
869 | if (!empty($innReach)) { |
870 | $transactions[$n]['title'] = $innReach['title']; |
871 | $transactions[$n]['author'] = $innReach['author']; |
872 | } |
873 | } |
874 | } |
875 | } |
876 | |
877 | return [ |
878 | 'count' => $result['total'], |
879 | 'records' => $transactions, |
880 | ]; |
881 | } |
882 | |
883 | /** |
884 | * Get Renew Details |
885 | * |
886 | * @param array $checkOutDetails An array of item data |
887 | * |
888 | * @return string Data for use in a form field |
889 | */ |
890 | public function getRenewDetails($checkOutDetails) |
891 | { |
892 | return $checkOutDetails['checkout_id'] . '|' . $checkOutDetails['item_id']; |
893 | } |
894 | |
895 | /** |
896 | * Renew My Items |
897 | * |
898 | * Function for attempting to renew a patron's items. The data in |
899 | * $renewDetails['details'] is determined by getRenewDetails(). |
900 | * |
901 | * @param array $renewDetails An array of data required for renewing items |
902 | * including the Patron ID and an array of renewal IDS |
903 | * |
904 | * @return array An array of renewal information keyed by item ID |
905 | */ |
906 | public function renewMyItems($renewDetails) |
907 | { |
908 | $patron = $renewDetails['patron']; |
909 | $finalResult = ['details' => []]; |
910 | |
911 | foreach ($renewDetails['details'] as $details) { |
912 | [$checkoutId, $itemId] = explode('|', $details); |
913 | $result = $this->makeRequest( |
914 | [$this->apiBase, 'patrons', 'checkouts', $checkoutId, 'renewal'], |
915 | [], |
916 | 'POST', |
917 | $patron, |
918 | false, |
919 | $this->statGroup ? ['statgroup' => $this->statGroup] : [] |
920 | ); |
921 | if (!empty($result['code'])) { |
922 | $msg = $this->formatErrorMessage( |
923 | $result['description'] ?? $result['name'] |
924 | ); |
925 | $finalResult['details'][$itemId] = [ |
926 | 'item_id' => $itemId, |
927 | 'success' => false, |
928 | 'sysMessage' => $msg, |
929 | ]; |
930 | } else { |
931 | $newDate = $this->dateConverter->convertToDisplayDate( |
932 | 'Y-m-d', |
933 | $result['dueDate'] |
934 | ); |
935 | $finalResult['details'][$itemId] = [ |
936 | 'item_id' => $itemId, |
937 | 'success' => true, |
938 | 'new_date' => $newDate, |
939 | ]; |
940 | } |
941 | } |
942 | return $finalResult; |
943 | } |
944 | |
945 | /** |
946 | * Get Patron Transaction History |
947 | * |
948 | * This is responsible for retrieving all historic transactions (i.e. checked |
949 | * out items) by a specific patron. |
950 | * |
951 | * @param array $patron The patron array from patronLogin |
952 | * @param array $params Parameters |
953 | * |
954 | * @throws DateException |
955 | * @throws ILSException |
956 | * @return array Array of the patron's historic transactions on success. |
957 | */ |
958 | public function getMyTransactionHistory($patron, $params) |
959 | { |
960 | $pageSize = $params['limit'] ?? 50; |
961 | $offset = isset($params['page']) ? ($params['page'] - 1) * $pageSize : 0; |
962 | $sortOrder = isset($params['sort']) && 'checkout asc' === $params['sort'] |
963 | ? 'asc' : 'desc'; |
964 | $result = $this->makeRequest( |
965 | [$this->apiBase, 'patrons', $patron['id'], 'checkouts', 'history'], |
966 | [ |
967 | 'limit' => $pageSize, |
968 | 'offset' => $offset, |
969 | 'sortField' => 'outDate', |
970 | 'sortOrder' => $sortOrder, |
971 | ], |
972 | 'GET', |
973 | $patron |
974 | ); |
975 | if (!empty($result['code'])) { |
976 | return [ |
977 | 'success' => false, |
978 | 'status' => 146 === $result['code'] |
979 | ? 'ils_transaction_history_disabled' |
980 | : 'ils_connection_failed', |
981 | ]; |
982 | } |
983 | |
984 | $items = $this->getItemsWithBibsForTransactions($result['entries'], $patron); |
985 | $transactions = []; |
986 | foreach ($result['entries'] as $entry) { |
987 | $transaction = [ |
988 | 'id' => '', |
989 | 'row_id' => $this->extractId($entry['id']), |
990 | 'item_id' => $this->extractId($entry['item']), |
991 | 'checkoutDate' => $this->dateConverter->convertToDisplayDate( |
992 | 'Y-m-d', |
993 | $entry['outDate'] |
994 | ), |
995 | ]; |
996 | $item = $items[$transaction['item_id']] ?? null; |
997 | $transaction['volume'] = $item ? $this->extractVolume($item) : ''; |
998 | if (!empty($item['bib'])) { |
999 | $bib = $item['bib']; |
1000 | $transaction['id'] = $this->formatBibId($bib['id']); |
1001 | |
1002 | if (!empty($bib['title'])) { |
1003 | $transaction['title'] = $bib['title']; |
1004 | } |
1005 | if (!empty($bib['publishYear'])) { |
1006 | $transaction['publication_year'] = $bib['publishYear']; |
1007 | } |
1008 | } |
1009 | $transactions[] = $transaction; |
1010 | } |
1011 | |
1012 | return [ |
1013 | 'count' => $result['total'] ?? 0, |
1014 | 'transactions' => $transactions, |
1015 | ]; |
1016 | } |
1017 | |
1018 | /** |
1019 | * Purge Patron Transaction History |
1020 | * |
1021 | * @param array $patron The patron array from patronLogin |
1022 | * @param ?array $ids IDs to purge, or null for all |
1023 | * |
1024 | * @throws ILSException |
1025 | * @return array Associative array of the results |
1026 | */ |
1027 | public function purgeTransactionHistory(array $patron, ?array $ids): array |
1028 | { |
1029 | if (null === $ids) { |
1030 | $result = $this->makeRequest( |
1031 | [ |
1032 | 'v6', 'patrons', $patron['id'], 'checkouts', 'history', |
1033 | ], |
1034 | '', |
1035 | 'DELETE', |
1036 | $patron |
1037 | ); |
1038 | if (!empty($result['code'])) { |
1039 | return [ |
1040 | 'success' => false, |
1041 | 'status' => $this->formatErrorMessage( |
1042 | $result['description'] ?? $result['name'] |
1043 | ), |
1044 | ]; |
1045 | } |
1046 | } else { |
1047 | foreach ($ids as $id) { |
1048 | $result = $this->makeRequest( |
1049 | [ |
1050 | 'v6', 'patrons', $patron['id'], 'checkouts', 'history', $id, |
1051 | ], |
1052 | '', |
1053 | 'DELETE', |
1054 | $patron |
1055 | ); |
1056 | if (!empty($result['code'])) { |
1057 | return [ |
1058 | 'success' => false, |
1059 | 'status' => $this->formatErrorMessage( |
1060 | $result['description'] ?? $result['name'] |
1061 | ), |
1062 | ]; |
1063 | } |
1064 | } |
1065 | } |
1066 | |
1067 | return [ |
1068 | 'success' => true, |
1069 | 'status' => null === $ids |
1070 | ? 'loan_history_all_purged' : 'loan_history_selected_purged', |
1071 | 'sysMessage' => '', |
1072 | ]; |
1073 | } |
1074 | |
1075 | /** |
1076 | * Get Patron Holds |
1077 | * |
1078 | * This is responsible for retrieving all holds by a specific patron. |
1079 | * |
1080 | * @param array $patron The patron array from patronLogin |
1081 | * |
1082 | * @throws DateException |
1083 | * @throws ILSException |
1084 | * @return array Array of the patron's holds on success. |
1085 | * @todo Support for handling frozen and pickup location change |
1086 | */ |
1087 | public function getMyHolds($patron) |
1088 | { |
1089 | $fields = 'default,location,priorityQueueLength'; |
1090 | if ($this->apiVersion >= 5) { |
1091 | $fields .= ',pickupByDate'; |
1092 | } |
1093 | if ($this->apiVersion >= 6) { |
1094 | $fields .= ',notNeededAfterDate'; |
1095 | } |
1096 | $freezeEnabled = in_array( |
1097 | 'frozen', |
1098 | explode(':', $this->config['Holds']['updateFields'] ?? '') |
1099 | ); |
1100 | if ($useCanFreeze = $freezeEnabled && $this->checkFreezability) { |
1101 | $fields .= ',canFreeze'; |
1102 | } |
1103 | |
1104 | $result = $this->makeRequest( |
1105 | [$this->apiBase, 'patrons', $patron['id'], 'holds'], |
1106 | [ |
1107 | 'limit' => 10000, |
1108 | 'fields' => $fields, |
1109 | ], |
1110 | 'GET', |
1111 | $patron |
1112 | ); |
1113 | if (!isset($result['entries'])) { |
1114 | return []; |
1115 | } |
1116 | // Collect all item and bib records to fetch: |
1117 | $itemIds = []; |
1118 | $bibIds = []; |
1119 | foreach ($result['entries'] as $entry) { |
1120 | $recordId = $this->extractId($entry['record']); |
1121 | if ($entry['recordType'] === 'i') { |
1122 | $itemIds[] = $recordId; |
1123 | } elseif ($entry['recordType'] === 'b') { |
1124 | $bibIds[] = $recordId; |
1125 | } |
1126 | } |
1127 | // Fetch items in a batch and add any bib id's from them: |
1128 | $items = $this->getItemRecords($itemIds, null, $patron); |
1129 | foreach ($items as $item) { |
1130 | if (!empty($item['bibIds'])) { |
1131 | $bibIds[] = $item['bibIds'][0]; |
1132 | } |
1133 | } |
1134 | // Fetch bibs in a batch: |
1135 | $bibs = $this->getBibRecords($bibIds, null, $patron); |
1136 | |
1137 | $holds = []; |
1138 | foreach ($result['entries'] as $entry) { |
1139 | $bibId = null; |
1140 | $itemId = null; |
1141 | $title = ''; |
1142 | $volume = ''; |
1143 | $publicationYear = ''; |
1144 | if ($entry['recordType'] == 'i') { |
1145 | $itemId = $this->extractId($entry['record']); |
1146 | // Fetch bib ID from item |
1147 | $item = $items[$itemId] ?? []; |
1148 | if (!empty($item['bibIds'])) { |
1149 | $bibId = $item['bibIds'][0]; |
1150 | } |
1151 | $volume = $this->extractVolume($item); |
1152 | } elseif ($entry['recordType'] == 'b') { |
1153 | $bibId = $this->extractId($entry['record']); |
1154 | } |
1155 | if (!empty($bibId)) { |
1156 | // Fetch bib information |
1157 | $bib = $bibs[$bibId] ?? []; |
1158 | $title = $bib['title'] ?? ''; |
1159 | $publicationYear = $bib['publishYear'] ?? ''; |
1160 | } |
1161 | $available = in_array($entry['status']['code'], $this->holdAvailableCodes); |
1162 | $inTransit = in_array($entry['status']['code'], $this->holdInTransitCodes); |
1163 | if ($entry['priority'] >= $entry['priorityQueueLength']) { |
1164 | // This can happen, no idea why |
1165 | $position = $entry['priorityQueueLength'] . ' / ' |
1166 | . $entry['priorityQueueLength']; |
1167 | } else { |
1168 | $position = $entry['priority'] . ' / ' |
1169 | . $entry['priorityQueueLength']; |
1170 | } |
1171 | $lastPickup = !empty($entry['pickupByDate']) |
1172 | ? $this->dateConverter->convertToDisplayDate( |
1173 | 'Y-m-d', |
1174 | $entry['pickupByDate'] |
1175 | ) : ''; |
1176 | $requestId = $this->extractId($entry['id']); |
1177 | // Allow the user to attempt update if frozen status is togglable or the |
1178 | // hold is not available or in transit. |
1179 | // Checking if the hold can be frozen is optional since it's slow on |
1180 | // Sierra versions prior to 5.6. |
1181 | $frozenTogglable = $useCanFreeze |
1182 | ? !empty($entry['frozen']) || !empty($entry['canFreeze']) |
1183 | : $freezeEnabled; |
1184 | $updateDetails = ($frozenTogglable || (!$available && !$inTransit)) |
1185 | ? $requestId : ''; |
1186 | $cancelDetails = $this->allowCancelingAvailableRequests |
1187 | || (!$available && !$inTransit) ? $requestId : ''; |
1188 | $holds[] = [ |
1189 | 'id' => $this->formatBibId($bibId), |
1190 | 'reqnum' => $requestId, |
1191 | 'item_id' => $itemId ? $itemId : $this->extractId($entry['id']), |
1192 | // note that $entry['pickupLocation']['name'] may contain misleading |
1193 | // text, so we instead use the code here: |
1194 | 'location' => $entry['pickupLocation']['code'], |
1195 | 'create' => $this->dateConverter->convertToDisplayDate( |
1196 | 'Y-m-d', |
1197 | $entry['placed'] |
1198 | ), |
1199 | 'expire' => !empty($entry['notNeededAfterDate']) |
1200 | ? $this->dateConverter->convertToDisplayDate( |
1201 | 'Y-m-d', |
1202 | $entry['notNeededAfterDate'] |
1203 | ) : null, |
1204 | 'last_pickup_date' => $lastPickup, |
1205 | 'position' => $position, |
1206 | 'available' => $available, |
1207 | 'in_transit' => $inTransit, |
1208 | 'volume' => $volume, |
1209 | 'publication_year' => $publicationYear, |
1210 | 'title' => $title, |
1211 | 'frozen' => !empty($entry['frozen']), |
1212 | 'cancel_details' => $cancelDetails, |
1213 | 'updateDetails' => $updateDetails, |
1214 | ]; |
1215 | } |
1216 | |
1217 | if ($this->config['InnReach']['enabled'] ?? false) { |
1218 | foreach ($holds as $n => $hold) { |
1219 | if (!empty($hold['item_id']) && strstr($hold['item_id'], $this->config['InnReach']['identifier'])) { |
1220 | $id = $hold['id']; |
1221 | $volume = $hold['volume']; |
1222 | |
1223 | $innReach = $this->getInnReachHoldTitleInfoFromId($hold['reqnum'], $hold['id']); |
1224 | if (!empty($innReach)) { |
1225 | $holds[$n]['id'] = $innReach['id']; |
1226 | $holds[$n]['title'] = $innReach['title']; |
1227 | $holds[$n]['author'] = $innReach['author']; |
1228 | } |
1229 | } |
1230 | } |
1231 | } |
1232 | return $holds; |
1233 | } |
1234 | |
1235 | /** |
1236 | * Cancel Holds |
1237 | * |
1238 | * Attempts to Cancel a hold. The data in $cancelDetails['details'] is taken from |
1239 | * holds' cancel_details field. |
1240 | * |
1241 | * @param array $cancelDetails An array of item and patron data |
1242 | * |
1243 | * @return array An array of data on each request including |
1244 | * whether or not it was successful and a system message (if available) |
1245 | */ |
1246 | public function cancelHolds($cancelDetails) |
1247 | { |
1248 | $details = $cancelDetails['details']; |
1249 | $patron = $cancelDetails['patron']; |
1250 | $count = 0; |
1251 | $response = []; |
1252 | |
1253 | foreach ($details as $holdId) { |
1254 | $result = $this->makeRequest( |
1255 | [$this->apiBase, 'patrons', 'holds', $holdId], |
1256 | '', |
1257 | 'DELETE', |
1258 | $patron |
1259 | ); |
1260 | |
1261 | if (!empty($result['code'])) { |
1262 | $msg = $this->formatErrorMessage( |
1263 | $result['description'] ?? $result['name'] |
1264 | ); |
1265 | $response[$holdId] = [ |
1266 | 'item_id' => $holdId, |
1267 | 'success' => false, |
1268 | 'status' => 'hold_cancel_fail', |
1269 | 'sysMessage' => $msg, |
1270 | ]; |
1271 | } else { |
1272 | $response[$holdId] = [ |
1273 | 'item_id' => $holdId, |
1274 | 'success' => true, |
1275 | 'status' => 'hold_cancel_success', |
1276 | ]; |
1277 | ++$count; |
1278 | } |
1279 | } |
1280 | return ['count' => $count, 'items' => $response]; |
1281 | } |
1282 | |
1283 | /** |
1284 | * Update holds |
1285 | * |
1286 | * This is responsible for changing the status of hold requests |
1287 | * |
1288 | * @param array $holdsDetails The details identifying the holds |
1289 | * @param array $fields An associative array of fields to be updated |
1290 | * @param array $patron Patron array |
1291 | * |
1292 | * @return array Associative array of the results |
1293 | */ |
1294 | public function updateHolds( |
1295 | array $holdsDetails, |
1296 | array $fields, |
1297 | array $patron |
1298 | ): array { |
1299 | $results = []; |
1300 | foreach ($holdsDetails as $requestId) { |
1301 | // Fetch existing hold status: |
1302 | $reqFields = 'default' . (isset($fields['frozen']) ? ',canFreeze' : ''); |
1303 | $hold = $this->makeRequest( |
1304 | [$this->apiBase, 'patrons', 'holds', $requestId], |
1305 | [ |
1306 | 'fields' => $reqFields, |
1307 | ], |
1308 | 'GET', |
1309 | $patron |
1310 | ); |
1311 | $available |
1312 | = in_array($hold['status']['code'], $this->holdAvailableCodes); |
1313 | $inTransit |
1314 | = in_array($hold['status']['code'], $this->holdInTransitCodes); |
1315 | |
1316 | // Check if we can do the requested changes: |
1317 | $updateFields = []; |
1318 | $fieldsSkipped = false; |
1319 | if (isset($fields['frozen']) && $hold['frozen'] !== $fields['frozen']) { |
1320 | if ($fields['frozen'] && !$hold['canFreeze']) { |
1321 | $fieldsSkipped = true; |
1322 | } else { |
1323 | $updateFields['freeze'] = $fields['frozen']; |
1324 | } |
1325 | } |
1326 | if (isset($fields['pickUpLocation'])) { |
1327 | if ($available || $inTransit) { |
1328 | $fieldsSkipped = true; |
1329 | } else { |
1330 | $updateFields['pickupLocation'] = $fields['pickUpLocation']; |
1331 | } |
1332 | } |
1333 | |
1334 | if (!$updateFields) { |
1335 | $results[$requestId] = [ |
1336 | 'success' => false, |
1337 | 'status' => 'hold_error_update_blocked_status', |
1338 | ]; |
1339 | } else { |
1340 | $result = $this->makeRequest( |
1341 | [$this->apiBase, 'patrons', 'holds', $requestId], |
1342 | json_encode($updateFields), |
1343 | 'PUT', |
1344 | $patron |
1345 | ); |
1346 | |
1347 | if (!empty($result['code'])) { |
1348 | $results[$requestId] = [ |
1349 | 'success' => false, |
1350 | 'status' => $this->formatErrorMessage( |
1351 | $result['description'] ?? $result['name'] |
1352 | ), |
1353 | ]; |
1354 | } elseif ($fieldsSkipped) { |
1355 | $results[$requestId] = [ |
1356 | 'success' => false, |
1357 | 'status' => 'hold_error_update_blocked_status', |
1358 | ]; |
1359 | } else { |
1360 | $results[$requestId] = [ |
1361 | 'success' => true, |
1362 | ]; |
1363 | } |
1364 | } |
1365 | } |
1366 | |
1367 | return $results; |
1368 | } |
1369 | |
1370 | /** |
1371 | * Get Pick Up Locations |
1372 | * |
1373 | * This is responsible for gettting a list of valid library locations for |
1374 | * holds / recall retrieval |
1375 | * |
1376 | * @param array $patron Patron information returned by the patronLogin |
1377 | * method. |
1378 | * @param array $holdDetails Optional array, only passed in when getting a list |
1379 | * in the context of placing or editing a hold. When placing a hold, it contains |
1380 | * most of the same values passed to placeHold, minus the patron data. When |
1381 | * editing a hold it contains all the hold information returned by getMyHolds. |
1382 | * May be used to limit the pickup options or may be ignored. The driver must |
1383 | * not add new options to the return array based on this data or other areas of |
1384 | * VuFind may behave incorrectly. |
1385 | * |
1386 | * @throws ILSException |
1387 | * @return array An array of associative arrays with locationID and |
1388 | * locationDisplay keys |
1389 | * |
1390 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
1391 | */ |
1392 | public function getPickUpLocations($patron = false, $holdDetails = null) |
1393 | { |
1394 | if (!empty($this->config['pickUpLocations'])) { |
1395 | $locations = []; |
1396 | foreach ($this->config['pickUpLocations'] as $id => $location) { |
1397 | $locations[] = [ |
1398 | 'locationID' => $id, |
1399 | 'locationDisplay' => $this->translateLocation( |
1400 | ['code' => $id, 'name' => $location] |
1401 | ), |
1402 | ]; |
1403 | } |
1404 | return $locations; |
1405 | } |
1406 | |
1407 | $result = $this->makeRequest( |
1408 | [$this->apiBase, 'branches', 'pickupLocations'], |
1409 | [ |
1410 | 'limit' => 10000, |
1411 | 'offset' => 0, |
1412 | 'language' => $this->getTranslatorLocale(), |
1413 | ], |
1414 | 'GET', |
1415 | $patron |
1416 | ); |
1417 | if (!empty($result['code'])) { |
1418 | // An error was returned |
1419 | $this->error( |
1420 | "Request for pickup locations returned error code: {$result['code']}" |
1421 | . ", HTTP status: {$result['httpStatus']}, name: {$result['name']}" |
1422 | ); |
1423 | throw new ILSException('Problem with Sierra REST API.'); |
1424 | } |
1425 | if (empty($result)) { |
1426 | return []; |
1427 | } |
1428 | |
1429 | $locations = []; |
1430 | foreach ($result as $entry) { |
1431 | $locations[] = [ |
1432 | 'locationID' => $entry['code'], |
1433 | 'locationDisplay' => $this->translateLocation( |
1434 | ['code' => $entry['code'], 'name' => $entry['name']] |
1435 | ), |
1436 | ]; |
1437 | } |
1438 | |
1439 | usort($locations, [$this, 'pickupLocationSortFunction']); |
1440 | return $locations; |
1441 | } |
1442 | |
1443 | /** |
1444 | * Get Default Pick Up Location |
1445 | * |
1446 | * Returns the default pick up location |
1447 | * |
1448 | * @param array $patron Patron information returned by the patronLogin |
1449 | * method. |
1450 | * @param array $holdDetails Optional array, only passed in when getting a list |
1451 | * in the context of placing a hold; contains most of the same values passed to |
1452 | * placeHold, minus the patron data. May be used to limit the pickup options |
1453 | * or may be ignored. |
1454 | * |
1455 | * @return false|string The default pickup location for the patron or false |
1456 | * if the user has to choose. |
1457 | * |
1458 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
1459 | */ |
1460 | public function getDefaultPickUpLocation($patron = false, $holdDetails = null) |
1461 | { |
1462 | return $this->defaultPickUpLocation; |
1463 | } |
1464 | |
1465 | /** |
1466 | * Check if request is valid |
1467 | * |
1468 | * This is responsible for determining if an item is requestable |
1469 | * |
1470 | * @param string $id The Bib ID |
1471 | * @param array $data An Array of item data |
1472 | * @param array $patron An array of patron data |
1473 | * |
1474 | * @return bool True if request is valid, false if not |
1475 | */ |
1476 | public function checkRequestIsValid($id, $data, $patron) |
1477 | { |
1478 | if ($this->getPatronBlocks($patron)) { |
1479 | return false; |
1480 | } |
1481 | $level = $data['level'] ?? 'copy'; |
1482 | if ('title' === $level) { |
1483 | $fields = ['bibLevel']; |
1484 | if (in_array('order', $this->titleHoldRules)) { |
1485 | $fields[] = 'orders'; |
1486 | } |
1487 | $bib = $this->getBibRecord($id, $fields, $patron); |
1488 | if ( |
1489 | !isset($bib['bibLevel']['code']) |
1490 | || !in_array($bib['bibLevel']['code'], $this->titleHoldBibLevels) |
1491 | ) { |
1492 | return false; |
1493 | } |
1494 | if (!$this->checkTitleHoldRules($bib, $patron)) { |
1495 | return false; |
1496 | } |
1497 | } |
1498 | return true; |
1499 | } |
1500 | |
1501 | /** |
1502 | * Place Hold |
1503 | * |
1504 | * Attempts to place a hold or recall on a particular item and returns |
1505 | * an array with result details or throws an exception on failure of support |
1506 | * classes |
1507 | * |
1508 | * @param array $holdDetails An array of item and patron data |
1509 | * |
1510 | * @throws ILSException |
1511 | * @return mixed An array of data on the request including |
1512 | * whether or not it was successful and a system message (if available) |
1513 | */ |
1514 | public function placeHold($holdDetails) |
1515 | { |
1516 | $patron = $holdDetails['patron']; |
1517 | $level = isset($holdDetails['level']) && !empty($holdDetails['level']) |
1518 | ? $holdDetails['level'] : 'copy'; |
1519 | $pickUpLocation = !empty($holdDetails['pickUpLocation']) |
1520 | ? $holdDetails['pickUpLocation'] : $this->defaultPickUpLocation; |
1521 | $itemId = $holdDetails['item_id'] ?? false; |
1522 | $comment = $holdDetails['comment'] ?? ''; |
1523 | $bibId = $this->extractBibId($holdDetails['id']); |
1524 | |
1525 | if ($level == 'copy' && empty($itemId)) { |
1526 | throw new ILSException("Hold level is 'copy', but item ID is empty"); |
1527 | } |
1528 | |
1529 | // Make sure pickup location is valid |
1530 | if (!$this->pickUpLocationIsValid($pickUpLocation, $patron, $holdDetails)) { |
1531 | return $this->holdError('hold_invalid_pickup', false); |
1532 | } |
1533 | |
1534 | $request = [ |
1535 | 'recordType' => $level == 'copy' ? 'i' : 'b', |
1536 | 'recordNumber' => (int)($level == 'copy' ? $itemId : $bibId), |
1537 | 'pickupLocation' => $pickUpLocation, |
1538 | ]; |
1539 | if (!empty($holdDetails['requiredByTS'])) { |
1540 | $request['neededBy'] = gmdate('Y-m-d', $holdDetails['requiredByTS']); |
1541 | } |
1542 | if ($comment) { |
1543 | $request['note'] = $comment; |
1544 | } |
1545 | if ($this->statGroup) { |
1546 | $request['statgroup'] = $this->statGroup; |
1547 | } |
1548 | |
1549 | $result = $this->makeRequest( |
1550 | [$this->apiBase, 'patrons', $patron['id'], 'holds', 'requests'], |
1551 | json_encode($request), |
1552 | 'POST', |
1553 | $patron |
1554 | ); |
1555 | |
1556 | if (!empty($result['code'])) { |
1557 | return $this->holdError($result['description'] ?? $result['name']); |
1558 | } |
1559 | return ['success' => true]; |
1560 | } |
1561 | |
1562 | /** |
1563 | * Get Patron Fines |
1564 | * |
1565 | * This is responsible for retrieving all fines by a specific patron. |
1566 | * |
1567 | * @param array $patron The patron array from patronLogin |
1568 | * |
1569 | * @throws DateException |
1570 | * @throws ILSException |
1571 | * @return array Array of the patron's fines on success. |
1572 | */ |
1573 | public function getMyFines($patron) |
1574 | { |
1575 | $result = $this->makeRequest( |
1576 | [$this->apiBase, 'patrons', $patron['id'], 'fines'], |
1577 | [ |
1578 | 'limit' => 10000, |
1579 | ], |
1580 | 'GET', |
1581 | $patron |
1582 | ); |
1583 | |
1584 | if (!isset($result['entries'])) { |
1585 | return []; |
1586 | } |
1587 | |
1588 | // Collect all item records to fetch: |
1589 | $itemIds = []; |
1590 | foreach ($result['entries'] as $entry) { |
1591 | if (!empty($entry['item'])) { |
1592 | $itemIds[] = $this->extractId($entry['item']); |
1593 | } |
1594 | } |
1595 | // Fetch items in a batch and list the bibs: |
1596 | $items = $this->getItemRecords($itemIds, null, $patron); |
1597 | $bibIds = []; |
1598 | foreach ($items as $item) { |
1599 | if (!empty($item['bibIds'])) { |
1600 | $bibIds[] = $item['bibIds'][0]; |
1601 | } |
1602 | } |
1603 | // Fetch bibs in a batch: |
1604 | $bibs = $this->getBibRecords($bibIds, null, $patron); |
1605 | |
1606 | $fines = []; |
1607 | foreach ($result['entries'] as $entry) { |
1608 | $amount = $entry['itemCharge'] + $entry['processingFee'] |
1609 | + $entry['billingFee']; |
1610 | $balance = $amount - $entry['paidAmount']; |
1611 | $type = $entry['chargeType']['display'] ?? ''; |
1612 | $bibId = null; |
1613 | $title = null; |
1614 | if (!empty($entry['item'])) { |
1615 | $itemId = $this->extractId($entry['item']); |
1616 | // Fetch bib ID from item |
1617 | $item = $items[$itemId] ?? []; |
1618 | if (!empty($item['bibIds'])) { |
1619 | $bibId = $item['bibIds'][0]; |
1620 | // Fetch bib information |
1621 | $bib = $bibs[$bibId] ?? []; |
1622 | $title = $bib['title'] ?? ''; |
1623 | } |
1624 | } |
1625 | |
1626 | $fines[] = [ |
1627 | 'amount' => $amount * 100, |
1628 | 'fine' => $this->fineTypeMappings[$type] ?? $type, |
1629 | 'description' => $entry['description'] ?? '', |
1630 | 'balance' => $balance * 100, |
1631 | 'createdate' => $this->dateConverter->convertToDisplayDate( |
1632 | 'Y-m-d', |
1633 | $entry['assessedDate'] |
1634 | ), |
1635 | 'checkout' => '', |
1636 | 'id' => $this->formatBibId($bibId), |
1637 | 'title' => $title, |
1638 | ]; |
1639 | } |
1640 | return $fines; |
1641 | } |
1642 | |
1643 | /** |
1644 | * Change Password |
1645 | * |
1646 | * Attempts to change patron password (PIN code) |
1647 | * |
1648 | * @param array $details An array of patron id and old and new password: |
1649 | * |
1650 | * 'patron' The patron array from patronLogin |
1651 | * 'oldPassword' Old password |
1652 | * 'newPassword' New password |
1653 | * |
1654 | * @return array An array of data on the request including |
1655 | * whether or not it was successful and a system message (if available) |
1656 | */ |
1657 | public function changePassword($details) |
1658 | { |
1659 | // Force new login |
1660 | $this->sessionCache->accessTokenPatron = ''; |
1661 | $patron = $this->patronLogin( |
1662 | $details['patron']['cat_username'], |
1663 | $details['oldPassword'] |
1664 | ); |
1665 | if (null === $patron) { |
1666 | return [ |
1667 | 'success' => false, 'status' => 'authentication_error_invalid', |
1668 | ]; |
1669 | } |
1670 | |
1671 | $newPIN = preg_replace('/[^\d]/', '', trim($details['newPassword'])); |
1672 | if (strlen($newPIN) != 4) { |
1673 | return [ |
1674 | 'success' => false, 'status' => 'password_error_invalid', |
1675 | ]; |
1676 | } |
1677 | |
1678 | $request = ['pin' => $newPIN]; |
1679 | |
1680 | $result = $this->makeRequest( |
1681 | [$this->apiBase, 'patrons', $patron['id']], |
1682 | json_encode($request), |
1683 | 'PUT', |
1684 | $patron |
1685 | ); |
1686 | |
1687 | if (!empty($result['code'])) { |
1688 | return [ |
1689 | 'success' => false, |
1690 | 'status' => $this->formatErrorMessage( |
1691 | $result['description'] ?? $result['name'] |
1692 | ), |
1693 | ]; |
1694 | } |
1695 | return ['success' => true, 'status' => 'change_password_ok']; |
1696 | } |
1697 | |
1698 | /** |
1699 | * Public Function which retrieves renew, hold and cancel settings from the |
1700 | * driver ini file. |
1701 | * |
1702 | * @param string $function The name of the feature to be checked |
1703 | * @param array $params Optional feature-specific parameters (array) |
1704 | * |
1705 | * @return array An array with key-value pairs. |
1706 | * |
1707 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
1708 | */ |
1709 | public function getConfig($function, $params = []) |
1710 | { |
1711 | if ('getMyTransactions' === $function) { |
1712 | return [ |
1713 | 'max_results' => 100, |
1714 | ]; |
1715 | } |
1716 | if ('getMyTransactionHistory' === $function) { |
1717 | if (empty($this->config['TransactionHistory']['enabled'])) { |
1718 | return false; |
1719 | } |
1720 | return [ |
1721 | 'max_results' => 100, |
1722 | 'sort' => [ |
1723 | 'checkout desc' => 'sort_checkout_date_desc', |
1724 | 'checkout asc' => 'sort_checkout_date_asc', |
1725 | ], |
1726 | 'default_sort' => 'checkout desc', |
1727 | 'purge_all' => $this->config['TransactionHistory']['purgeAll'] ?? true, |
1728 | 'purge_selected' => $this->config['TransactionHistory']['purgeSelected'] ?? true, |
1729 | ]; |
1730 | } |
1731 | return $this->config[$function] ?? false; |
1732 | } |
1733 | |
1734 | /** |
1735 | * Helper method to determine whether or not a certain method can be |
1736 | * called on this driver. Required method for any smart drivers. |
1737 | * |
1738 | * @param string $method The name of the called method. |
1739 | * @param array $params Array of passed parameters |
1740 | * |
1741 | * @return bool True if the method can be called with the given parameters, |
1742 | * false otherwise. |
1743 | * |
1744 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
1745 | */ |
1746 | public function supportsMethod($method, $params) |
1747 | { |
1748 | // Changing password is only available if properly configured. |
1749 | if ($method == 'changePassword') { |
1750 | return isset($this->config['changePassword']); |
1751 | } |
1752 | // Loan history is only available if properly configured |
1753 | if ($method == 'getMyTransactionHistory') { |
1754 | return !empty($this->config['TransactionHistory']['enabled']); |
1755 | } |
1756 | if ($method == 'purgeTransactionHistory') { |
1757 | return !empty($this->config['TransactionHistory']['enabled']) |
1758 | && $this->apiVersion >= 6; |
1759 | } |
1760 | return is_callable([$this, $method]); |
1761 | } |
1762 | |
1763 | /** |
1764 | * Extract an ID from a URL (last number) |
1765 | * |
1766 | * @param string $url URL containing the ID |
1767 | * |
1768 | * @return string ID |
1769 | */ |
1770 | protected function extractId($url) |
1771 | { |
1772 | $parts = explode('/', $url); |
1773 | return end($parts); |
1774 | } |
1775 | |
1776 | /** |
1777 | * Extract volume from item record's varFields |
1778 | * |
1779 | * @param array $item Item record from Sierra |
1780 | * |
1781 | * @return string |
1782 | */ |
1783 | protected function extractVolume($item) |
1784 | { |
1785 | foreach ($item['varFields'] ?? [] as $varField) { |
1786 | if ($varField['fieldTag'] == 'v') { |
1787 | return trim($varField['subfields'][0]['content'] ?? ''); |
1788 | } |
1789 | } |
1790 | return ''; |
1791 | } |
1792 | |
1793 | /** |
1794 | * Make Request |
1795 | * |
1796 | * Makes a request to the Sierra REST API |
1797 | * |
1798 | * @param array $hierarchy Array of values to embed in the URL path of the |
1799 | * request |
1800 | * @param array $params A keyed array of query data |
1801 | * @param string $method The http request method to use (Default is GET) |
1802 | * @param array $patron Patron information, if available |
1803 | * @param bool $returnStatus Whether to return HTTP status code and response |
1804 | * as a keyed array instead of just the response |
1805 | * @param array $queryParams Additional query params that are added to the URL |
1806 | * regardless of request type |
1807 | * |
1808 | * @throws ILSException |
1809 | * @return mixed JSON response decoded to an associative array, an array of HTTP |
1810 | * status code and JSON response when $returnStatus is true or null on |
1811 | * authentication error when using patron-specific access |
1812 | */ |
1813 | protected function makeRequest( |
1814 | $hierarchy, |
1815 | $params = [], |
1816 | $method = 'GET', |
1817 | $patron = false, |
1818 | $returnStatus = false, |
1819 | $queryParams = [] |
1820 | ) { |
1821 | // Status logging callback: |
1822 | $statusCallback = function ( |
1823 | $attempt, |
1824 | $exception |
1825 | ) use ( |
1826 | $hierarchy, |
1827 | $params, |
1828 | $method |
1829 | ): void { |
1830 | $apiUrl = $this->getApiUrlFromHierarchy($hierarchy); |
1831 | $status = $exception |
1832 | ? (' failed (' . $exception->getMessage() . ')') |
1833 | : ' succeeded'; |
1834 | $msg = "$method request for '$apiUrl' with params " |
1835 | . $this->varDump($params) |
1836 | . "$status on attempt $attempt"; |
1837 | $this->logWarning($msg); |
1838 | }; |
1839 | |
1840 | // Callback that checks for a retryable exception: |
1841 | $retryableCallback = function ($attempt, $exception) { |
1842 | // Get the original HTTP exception: |
1843 | if (!($previous = $exception->getPrevious())) { |
1844 | return false; |
1845 | } |
1846 | $msg = $previous->getMessage(); |
1847 | foreach ($this->retryableRequestExceptionPatterns as $pattern) { |
1848 | if (preg_match($pattern, $msg)) { |
1849 | return true; |
1850 | } |
1851 | } |
1852 | return false; |
1853 | }; |
1854 | |
1855 | $args = func_get_args(); |
1856 | return $this->callWithRetry( |
1857 | function () use ($args) { |
1858 | return call_user_func_array([$this, 'requestCallback'], $args); |
1859 | }, |
1860 | $statusCallback, |
1861 | [ |
1862 | 'retryCount' => $this->httpRetryCount, |
1863 | 'retryableExceptionCallback' => $retryableCallback, |
1864 | ] |
1865 | ); |
1866 | } |
1867 | |
1868 | /** |
1869 | * Callback used by makeRequest |
1870 | * |
1871 | * @param array $hierarchy Array of values to embed in the URL path of the |
1872 | * request |
1873 | * @param array $params A keyed array of query data |
1874 | * @param string $method The http request method to use (Default is GET) |
1875 | * @param array $patron Patron information, if available |
1876 | * @param bool $returnStatus Whether to return HTTP status code and response |
1877 | * as a keyed array instead of just the response |
1878 | * @param array $queryParams Additional query params that are added to the URL |
1879 | * regardless of request type |
1880 | * |
1881 | * @throws ILSException |
1882 | * @return mixed JSON response decoded to an associative array, an array of HTTP |
1883 | * status code and JSON response when $returnStatus is true or null on |
1884 | * authentication error when using patron-specific access |
1885 | */ |
1886 | protected function requestCallback( |
1887 | $hierarchy, |
1888 | $params = [], |
1889 | $method = 'GET', |
1890 | $patron = false, |
1891 | $returnStatus = false, |
1892 | $queryParams = [] |
1893 | ) { |
1894 | // Clear current access token if it's not specific to the given patron |
1895 | if ( |
1896 | $patron && $this->isPatronSpecificAccess() |
1897 | && $this->sessionCache->accessTokenPatron != $patron['cat_username'] |
1898 | ) { |
1899 | $this->sessionCache->accessToken = null; |
1900 | } |
1901 | |
1902 | // Renew authentication token as necessary |
1903 | if (null === $this->sessionCache->accessToken) { |
1904 | if (!$this->renewAccessToken($patron)) { |
1905 | return null; |
1906 | } |
1907 | } |
1908 | |
1909 | // Set up the request |
1910 | $apiUrl = $this->getApiUrlFromHierarchy($hierarchy); |
1911 | // Add additional query parameters directly to the URL because they cannot be |
1912 | // added with setParameterGet for POST request: |
1913 | if ($queryParams) { |
1914 | $apiUrl .= '?' . http_build_query($queryParams); |
1915 | } |
1916 | |
1917 | // Create proxy request |
1918 | $client = $this->createHttpClient($apiUrl); |
1919 | |
1920 | // Add params |
1921 | if ($method == 'GET') { |
1922 | $client->setParameterGet($params); |
1923 | } else { |
1924 | if (is_string($params)) { |
1925 | $client->getRequest()->setContent($params); |
1926 | } else { |
1927 | $client->setParameterPost($params); |
1928 | } |
1929 | } |
1930 | |
1931 | // Set authorization header |
1932 | $headers = $client->getRequest()->getHeaders(); |
1933 | $headers->addHeaderLine( |
1934 | 'Authorization', |
1935 | "Bearer {$this->sessionCache->accessToken}" |
1936 | ); |
1937 | if (is_string($params)) { |
1938 | $headers->addHeaderLine('Content-Type', 'application/json'); |
1939 | } |
1940 | |
1941 | $locale = $this->getTranslatorLocale(); |
1942 | if ($locale != 'en') { |
1943 | $locale .= ', en;q=0.8'; |
1944 | } |
1945 | $headers->addHeaderLine('Accept-Language', $locale); |
1946 | |
1947 | // Send request and retrieve response |
1948 | $startTime = microtime(true); |
1949 | try { |
1950 | $response = $client->setMethod($method)->send(); |
1951 | } catch (\Exception $e) { |
1952 | $params = $method == 'GET' |
1953 | ? $client->getRequest()->getQuery()->toString() |
1954 | : $client->getRequest()->getPost()->toString(); |
1955 | $this->error( |
1956 | "$method request for '$apiUrl' with params '$params' and contents '" |
1957 | . $client->getRequest()->getContent() . "' caused exception: " |
1958 | . $e->getMessage() |
1959 | ); |
1960 | throw new ILSException('Problem with Sierra REST API.', 0, $e); |
1961 | } |
1962 | // If we get a 401, we need to renew the access token and try again |
1963 | if ($response->getStatusCode() == 401) { |
1964 | if (!$this->renewAccessToken($patron)) { |
1965 | return null; |
1966 | } |
1967 | $client->getRequest()->getHeaders()->addHeaderLine( |
1968 | 'Authorization', |
1969 | "Bearer {$this->sessionCache->accessToken}" |
1970 | ); |
1971 | $response = $client->send(); |
1972 | } |
1973 | $result = $response->getBody(); |
1974 | |
1975 | $this->debug( |
1976 | '[' . round(microtime(true) - $startTime, 4) . 's]' |
1977 | . " $method request $apiUrl" . PHP_EOL . 'response: ' . PHP_EOL |
1978 | . $result |
1979 | ); |
1980 | |
1981 | // Handle errors as complete failures only if the API call didn't return |
1982 | // valid JSON that the caller can handle |
1983 | $decodedResult = json_decode($result, true); |
1984 | if (!$response->isSuccess() && null === $decodedResult) { |
1985 | $params = $method == 'GET' |
1986 | ? $client->getRequest()->getQuery()->toString() |
1987 | : $client->getRequest()->getPost()->toString(); |
1988 | $this->error( |
1989 | "$method request for '$apiUrl' with params '$params' and contents '" |
1990 | . $client->getRequest()->getContent() . "' failed: " |
1991 | . $response->getStatusCode() . ': ' . $response->getReasonPhrase() |
1992 | . ', response content: ' . $response->getBody() |
1993 | ); |
1994 | throw new ILSException('Problem with Sierra REST API.'); |
1995 | } |
1996 | |
1997 | return $returnStatus |
1998 | ? [ |
1999 | 'statusCode' => $response->getStatusCode(), |
2000 | 'response' => $decodedResult, |
2001 | ] : $decodedResult; |
2002 | } |
2003 | |
2004 | /** |
2005 | * Build an API URL from a hierarchy array |
2006 | * |
2007 | * @param array $hierarchy Hierarchy |
2008 | * |
2009 | * @return string |
2010 | */ |
2011 | protected function getApiUrlFromHierarchy(array $hierarchy): string |
2012 | { |
2013 | $url = $this->config['Catalog']['host']; |
2014 | foreach ($hierarchy as $value) { |
2015 | $url .= '/' . urlencode($value); |
2016 | } |
2017 | return $url; |
2018 | } |
2019 | |
2020 | /** |
2021 | * Renew the API access token and store it in the cache. |
2022 | * Throw an exception if there is an error. |
2023 | * |
2024 | * @param array $patron Patron information, if available |
2025 | * |
2026 | * @return bool True on success, false on patron login failure |
2027 | * @throws ILSException |
2028 | */ |
2029 | protected function renewAccessToken($patron = false) |
2030 | { |
2031 | $patronCode = false; |
2032 | if ($patron && $this->isPatronSpecificAccess()) { |
2033 | if (!($patronCode = $this->getPatronAuthorizationCode($patron))) { |
2034 | return false; |
2035 | } |
2036 | } |
2037 | |
2038 | // Set up the request |
2039 | $apiUrl = $this->config['Catalog']['host'] . '/token'; |
2040 | |
2041 | // Create proxy request |
2042 | $client = $this->createHttpClient($apiUrl); |
2043 | |
2044 | // Set headers |
2045 | $headers = $client->getRequest()->getHeaders(); |
2046 | $authorization = $this->config['Catalog']['client_key'] . ':' . |
2047 | $this->config['Catalog']['client_secret']; |
2048 | $headers->addHeaderLine( |
2049 | 'Authorization', |
2050 | 'Basic ' . base64_encode($authorization) |
2051 | ); |
2052 | $params = []; |
2053 | if ($patronCode) { |
2054 | $params['grant_type'] = 'authorization_code'; |
2055 | $params['code'] = $patronCode; |
2056 | $params['redirect_uri'] = $this->config['Catalog']['redirect_uri']; |
2057 | } else { |
2058 | $params['grant_type'] = 'client_credentials'; |
2059 | } |
2060 | $client->setParameterPost($params); |
2061 | |
2062 | // Send request and retrieve response |
2063 | $startTime = microtime(true); |
2064 | try { |
2065 | $response = $client->setMethod('POST')->send(); |
2066 | } catch (\Exception $e) { |
2067 | $this->error( |
2068 | "POST request for '$apiUrl' caused exception: " |
2069 | . $e->getMessage() |
2070 | ); |
2071 | throw new ILSException('Problem with Sierra REST API.', 0, $e); |
2072 | } |
2073 | |
2074 | if (!$response->isSuccess()) { |
2075 | $this->error( |
2076 | "POST request for '$apiUrl' with contents '" |
2077 | . $client->getRequest()->getContent() . "' failed: " |
2078 | . $response->getStatusCode() . ': ' . $response->getReasonPhrase() |
2079 | . ', response content: ' . $response->getBody() |
2080 | ); |
2081 | throw new ILSException('Problem with Sierra REST API.'); |
2082 | } |
2083 | $result = $response->getBody(); |
2084 | |
2085 | $this->debug( |
2086 | '[' . round(microtime(true) - $startTime, 4) . 's]' |
2087 | . " GET request $apiUrl" . PHP_EOL . 'response: ' . PHP_EOL |
2088 | . $result |
2089 | ); |
2090 | |
2091 | $json = json_decode($result, true); |
2092 | $this->sessionCache->accessToken = $json['access_token']; |
2093 | $this->sessionCache->accessTokenPatron = $patronCode |
2094 | ? $patron['cat_username'] : null; |
2095 | return true; |
2096 | } |
2097 | |
2098 | /** |
2099 | * Login and retrieve authorization code for the patron |
2100 | * |
2101 | * @param array $patron Patron information |
2102 | * |
2103 | * @return string|bool |
2104 | * @throws ILSException |
2105 | */ |
2106 | protected function getPatronAuthorizationCode($patron) |
2107 | { |
2108 | // Do a patron login and then perform an authorization grant request |
2109 | $redirectUri = $this->config['Catalog']['redirect_uri']; |
2110 | $params = [ |
2111 | 'client_id' => $this->config['Catalog']['client_key'], |
2112 | 'redirect_uri' => $redirectUri, |
2113 | 'state' => 'auth', |
2114 | 'response_type' => 'code', |
2115 | ]; |
2116 | $apiUrl = $this->config['Catalog']['host'] . '/authorize' |
2117 | . '?' . http_build_query($params); |
2118 | |
2119 | // First request the login form to get the hidden fields and cookies |
2120 | $client = $this->createHttpClient($apiUrl); |
2121 | try { |
2122 | $response = $client->send(); |
2123 | } catch (\Exception $e) { |
2124 | $this->error( |
2125 | "GET request for '$apiUrl' caused exception: " |
2126 | . $e->getMessage() |
2127 | ); |
2128 | throw new ILSException('Problem with Sierra REST API.', 0, $e); |
2129 | } |
2130 | |
2131 | $doc = new \DOMDocument(); |
2132 | if (!@$doc->loadHTML($response->getBody())) { |
2133 | $this->error('Could not parse the III CAS login form'); |
2134 | throw new ILSException('Problem with Sierra login.'); |
2135 | } |
2136 | $usernameField = $this->config['Authentication']['username_field'] ?? 'code'; |
2137 | $passwordField = $this->config['Authentication']['password_field'] ?? 'pin'; |
2138 | $postParams = [ |
2139 | $usernameField => $patron['cat_username'], |
2140 | $passwordField => $patron['cat_password'], |
2141 | ]; |
2142 | foreach ($doc->getElementsByTagName('input') as $input) { |
2143 | if ($input->getAttribute('type') == 'hidden') { |
2144 | $postParams[$input->getAttribute('name')] |
2145 | = $input->getAttribute('value'); |
2146 | } |
2147 | } |
2148 | $postUrl = $client->getUri(); |
2149 | if ($form = $doc->getElementById('fm1')) { |
2150 | if ($action = $form->getAttribute('action')) { |
2151 | $actionUrl = new \Laminas\Uri\Http($action); |
2152 | if ($actionUrl->getScheme()) { |
2153 | $postUrl = $actionUrl; |
2154 | } else { |
2155 | $postUrl->setPath($actionUrl->getPath()); |
2156 | $postUrl->setQuery($actionUrl->getQuery()); |
2157 | } |
2158 | } |
2159 | } |
2160 | |
2161 | // Collect cookies for session etc. |
2162 | $cookies = $client->getCookies(); |
2163 | |
2164 | // Reset client |
2165 | $client->reset(); |
2166 | $client->addCookie($cookies); |
2167 | |
2168 | // Disable automatic following of redirects |
2169 | $client->setOptions(['maxredirects' => 0]); |
2170 | $adapter = $client->getAdapter(); |
2171 | if ($adapter instanceof \Laminas\Http\Client\Adapter\Curl) { |
2172 | $adapter->setCurlOption(CURLOPT_FOLLOWLOCATION, false); |
2173 | } |
2174 | |
2175 | // Send the login request |
2176 | $client->setParameterPost($postParams); |
2177 | $response = $client->setMethod('POST')->send(); |
2178 | if (!$response->isSuccess() && !$response->isRedirect()) { |
2179 | $this->error( |
2180 | "POST request for '" . $client->getRequest()->getUriString() |
2181 | . "' did not return 302 redirect: " |
2182 | . $response->getStatusCode() . ': ' |
2183 | . $response->getReasonPhrase() |
2184 | . ', response content: ' . $response->getBody() |
2185 | ); |
2186 | throw new ILSException('Problem with Sierra login.'); |
2187 | } |
2188 | |
2189 | // Process redirects here until the configured redirect url is reached or |
2190 | // the sanity check for redirect count fails. |
2191 | $patronCode = false; |
2192 | $redirectCount = 0; |
2193 | while ($response->isRedirect() && ++$redirectCount < 10) { |
2194 | $location = $response->getHeaders()->get('Location')->getUri(); |
2195 | if (strncmp($location, $redirectUri, strlen($redirectUri)) === 0) { |
2196 | // Don't try to parse the URI since Sierra creates it wrong if |
2197 | // the redirect_uri sent to it already contains a question mark. |
2198 | if (!preg_match('/code=([^&\?]+)/', $location, $matches)) { |
2199 | $this->error( |
2200 | "Could not parse authentication code from '$location'" |
2201 | ); |
2202 | throw new ILSException('Problem with Sierra login.'); |
2203 | } |
2204 | $patronCode = $matches[1]; |
2205 | break; |
2206 | } |
2207 | $cookies = array_merge($cookies, $client->getCookies()); |
2208 | $client->reset(); |
2209 | $client->addCookie($cookies); |
2210 | $client->setUri($location); |
2211 | $client->setMethod('GET'); |
2212 | $response = $client->send(); |
2213 | } |
2214 | |
2215 | return $patronCode; |
2216 | } |
2217 | |
2218 | /** |
2219 | * Create a HTTP client |
2220 | * |
2221 | * @param string $url Request URL |
2222 | * |
2223 | * @return \Laminas\Http\Client |
2224 | */ |
2225 | protected function createHttpClient($url) |
2226 | { |
2227 | $client = $this->httpService->createClient($url); |
2228 | |
2229 | // Set timeout value |
2230 | $timeout = $this->config['Catalog']['http_timeout'] ?? 30; |
2231 | // Make sure keepalive is disabled as this is known to cause problems: |
2232 | $client->setOptions( |
2233 | ['timeout' => $timeout, 'useragent' => 'VuFind', 'keepalive' => false] |
2234 | ); |
2235 | |
2236 | // Set Accept header |
2237 | $client->getRequest()->getHeaders()->addHeaderLine( |
2238 | 'Accept', |
2239 | 'application/json' |
2240 | ); |
2241 | |
2242 | return $client; |
2243 | } |
2244 | |
2245 | /** |
2246 | * Add instance-specific context to a cache key suffix to ensure that |
2247 | * multiple drivers don't accidentally share values in the cache. |
2248 | * |
2249 | * @param string $key Cache key suffix |
2250 | * |
2251 | * @return string |
2252 | */ |
2253 | protected function formatCacheKey($key) |
2254 | { |
2255 | return 'SierraRest-' . md5($this->config['Catalog']['host'] . "|$key"); |
2256 | } |
2257 | |
2258 | /** |
2259 | * Extract a bib call number from a bib record (if configured to do so). |
2260 | * |
2261 | * @param array $bib Bib record |
2262 | * |
2263 | * @return string |
2264 | */ |
2265 | protected function getBibCallNumber($bib) |
2266 | { |
2267 | $result = empty($this->config['CallNumber']['bib_fields']) |
2268 | ? '' : $this->extractFieldsFromApiData( |
2269 | [$bib], // wrap $bib in array to conform to expected format |
2270 | $this->config['CallNumber']['bib_fields'] |
2271 | ); |
2272 | return is_array($result) ? reset($result) : $result; |
2273 | } |
2274 | |
2275 | /** |
2276 | * Get due status for a checkout |
2277 | * |
2278 | * @param array $checkout Checkout |
2279 | * |
2280 | * @return string |
2281 | */ |
2282 | protected function getDueStatus(array $checkout): string |
2283 | { |
2284 | try { |
2285 | $dueDateTime = $this->dateConverter |
2286 | ->convertToDateTime('Y-m-d', $checkout['dueDate']); |
2287 | $dueDateTime->setTime(23, 59, 59, 999); |
2288 | $now = new \DateTime(); |
2289 | if ($now > $dueDateTime) { |
2290 | return 'overdue'; |
2291 | } |
2292 | if ($dueDateTime->diff($now)->days < 1) { |
2293 | return 'due'; |
2294 | } |
2295 | } catch (\VuFind\Date\DateException $e) { |
2296 | // Due date not parseable, do nothing... |
2297 | } |
2298 | return ''; |
2299 | } |
2300 | |
2301 | /** |
2302 | * Get Item Statuses |
2303 | * |
2304 | * This is responsible for retrieving the status information of a certain |
2305 | * record. |
2306 | * |
2307 | * @param string $id The record id to retrieve the holdings for |
2308 | * @param bool $checkHoldings Whether to check holdings records |
2309 | * @param ?array $patron Patron information, if available |
2310 | * |
2311 | * @return array An associative array with the following keys: |
2312 | * id, availability (boolean), status, location, reserve, callnumber. |
2313 | */ |
2314 | protected function getItemStatusesForBib(string $id, bool $checkHoldings, ?array $patron = null): array |
2315 | { |
2316 | $bibFields = ['bibLevel']; |
2317 | // If we need to look at bib call numbers, retrieve varFields: |
2318 | if (!empty($this->config['CallNumber']['bib_fields'])) { |
2319 | $bibFields[] = 'varFields'; |
2320 | } |
2321 | // Retrieve orders if needed: |
2322 | if (!empty($this->config['Holdings']['display_orders'])) { |
2323 | $bibFields[] = 'orders'; |
2324 | } |
2325 | $bib = $this->getBibRecord($id, $bibFields, $patron); |
2326 | $bibCallNumber = $this->getBibCallNumber($bib); |
2327 | $orders = []; |
2328 | foreach ($bib['orders'] ?? [] as $order) { |
2329 | $location = $order['location']['code']; |
2330 | $orders[$location][] = $order; |
2331 | } |
2332 | $holdingsData = []; |
2333 | if ($checkHoldings && $this->apiVersion >= 5.1) { |
2334 | $holdingsResult = $this->makeRequest( |
2335 | [$this->apiBase, 'holdings'], |
2336 | [ |
2337 | 'bibIds' => $this->extractBibId($id), |
2338 | 'deleted' => 'false', |
2339 | 'suppressed' => 'false', |
2340 | 'fields' => 'fixedFields,varFields', |
2341 | ], |
2342 | 'GET' |
2343 | ); |
2344 | foreach ($holdingsResult['entries'] ?? [] as $entry) { |
2345 | $location = ''; |
2346 | foreach ($entry['fixedFields'] as $code => $field) { |
2347 | if ( |
2348 | (string)$code === static::HOLDINGS_LOCATION_FIELD |
2349 | || $field['label'] === 'LOCATION' |
2350 | ) { |
2351 | $location = $field['value']; |
2352 | break; |
2353 | } |
2354 | } |
2355 | if ('' === $location) { |
2356 | continue; |
2357 | } |
2358 | $holdingsData[$location][] = $entry; |
2359 | } |
2360 | } |
2361 | |
2362 | $items = $this->getItemsForBibRecord($id, null, $patron); |
2363 | $statuses = []; |
2364 | $sort = 0; |
2365 | foreach ($items as $item) { |
2366 | $location = $this->translateLocation($item['location']); |
2367 | [$status, $duedate, $notes] = $this->getItemStatus($item); |
2368 | $available = $status == $this->mapStatusCode('-'); |
2369 | // OPAC message |
2370 | if (isset($item['fixedFields'][static::ITEM_OPAC_MESSAGE_FIELD])) { |
2371 | $opacMsg = $item['fixedFields'][static::ITEM_OPAC_MESSAGE_FIELD]; |
2372 | $trimmedMsg = trim($opacMsg['value']); |
2373 | if (strlen($trimmedMsg) && $trimmedMsg != '-') { |
2374 | $notes[] = $this->translateOpacMessage( |
2375 | trim($opacMsg['value']) |
2376 | ); |
2377 | } |
2378 | } |
2379 | $callNumber = isset($item['callNumber']) |
2380 | ? $this->extractCallNumber($item['callNumber']) |
2381 | : $bibCallNumber; |
2382 | $volume = isset($item['varFields']) ? $this->extractVolume($item) : ''; |
2383 | |
2384 | $entry = [ |
2385 | 'id' => $id, |
2386 | 'item_id' => $item['id'], |
2387 | 'location' => $location, |
2388 | 'availability' => $available, |
2389 | 'status' => $status, |
2390 | 'reserve' => 'N', |
2391 | 'callnumber' => trim($callNumber), |
2392 | 'duedate' => $duedate, |
2393 | 'number' => trim($volume), |
2394 | 'barcode' => $item['barcode'] ?? '', |
2395 | 'sort' => $sort--, |
2396 | ]; |
2397 | if ($notes) { |
2398 | $entry['item_notes'] = $notes; |
2399 | } |
2400 | |
2401 | if ($this->isHoldable($item, $bib)) { |
2402 | $entry['is_holdable'] = true; |
2403 | $entry['level'] = 'copy'; |
2404 | $entry['addLink'] = true; |
2405 | } else { |
2406 | $entry['is_holdable'] = false; |
2407 | } |
2408 | |
2409 | $locationCode = $item['location']['code'] ?? ''; |
2410 | if (!empty($holdingsData[$locationCode])) { |
2411 | $entry += $this->getHoldingsData($holdingsData[$locationCode]); |
2412 | $holdingsData[$locationCode]['_hasItems'] = true; |
2413 | } |
2414 | |
2415 | $statuses[] = $entry; |
2416 | } |
2417 | |
2418 | // Add holdings that don't have items |
2419 | foreach ($holdingsData as $locationCode => $holdings) { |
2420 | if (!empty($holdings['_hasItems'])) { |
2421 | continue; |
2422 | } |
2423 | |
2424 | $location = $this->translateLocation( |
2425 | ['code' => $locationCode, 'name' => ''] |
2426 | ); |
2427 | $code = $locationCode; |
2428 | while ('' === $location && $code) { |
2429 | $location = $this->getLocationName($code); |
2430 | $code = substr($code, 0, -1); |
2431 | } |
2432 | $entry = [ |
2433 | 'id' => $id, |
2434 | 'item_id' => 'HLD_' . $holdings[0]['id'], |
2435 | 'location' => $location, |
2436 | 'callnumber' => '', |
2437 | 'requests_placed' => 0, |
2438 | 'number' => '', |
2439 | 'status' => '', |
2440 | 'use_unknown_message' => true, |
2441 | 'reserve' => 'N', |
2442 | 'availability' => false, |
2443 | 'duedate' => '', |
2444 | 'barcode' => '', |
2445 | 'sort' => $sort--, |
2446 | ]; |
2447 | $entry += $this->getHoldingsData($holdings); |
2448 | |
2449 | $statuses[] = $entry; |
2450 | } |
2451 | |
2452 | // Add orders |
2453 | foreach ($orders as $locationCode => $orderSet) { |
2454 | $location = $this->translateLocation($orderSet[0]['location']); |
2455 | $statuses[] = [ |
2456 | 'id' => $id, |
2457 | 'item_id' => "ORDER_{$id}_$locationCode", |
2458 | 'location' => $location, |
2459 | 'callnumber' => trim($bibCallNumber), |
2460 | 'number' => '', |
2461 | 'status' => $this->mapStatusCode('Ordered'), |
2462 | 'reserve' => 'N', |
2463 | 'item_notes' => $this->getOrderMessages($orderSet), |
2464 | 'availability' => false, |
2465 | 'duedate' => '', |
2466 | 'barcode' => '', |
2467 | 'sort' => $sort--, |
2468 | ]; |
2469 | } |
2470 | |
2471 | usort($statuses, [$this, 'statusSortFunction']); |
2472 | return $statuses; |
2473 | } |
2474 | |
2475 | /** |
2476 | * Extract the actual call number from item's call number field |
2477 | * |
2478 | * @param string $callNumber Call number field |
2479 | * |
2480 | * @return string |
2481 | */ |
2482 | protected function extractCallNumber(string $callNumber): string |
2483 | { |
2484 | return str_starts_with($callNumber, '|a') ? substr($callNumber, 2) : $callNumber; |
2485 | } |
2486 | |
2487 | /** |
2488 | * Get textual messages for orders |
2489 | * |
2490 | * @param array $orders Orders |
2491 | * |
2492 | * @return array |
2493 | */ |
2494 | protected function getOrderMessages(array $orders): array |
2495 | { |
2496 | $messages = []; |
2497 | foreach ($orders as $order) { |
2498 | $messages[] = $this->translate( |
2499 | [ |
2500 | 'HoldingStatus', |
2501 | 1 === $order['copies'] |
2502 | ? 'copy_ordered_on_date' |
2503 | : 'copies_ordered_on_date', |
2504 | ], |
2505 | [ |
2506 | '%%copies%%' => $order['copies'], |
2507 | '%%date%%' => $this->dateConverter->convertToDisplayDate( |
2508 | 'Y-m-d', |
2509 | $order['date'] |
2510 | ), |
2511 | ] |
2512 | ); |
2513 | } |
2514 | return $messages; |
2515 | } |
2516 | |
2517 | /** |
2518 | * Get holdings fields according to configuration |
2519 | * |
2520 | * @param array $holdings Holdings records |
2521 | * |
2522 | * @return array |
2523 | */ |
2524 | protected function getHoldingsData($holdings) |
2525 | { |
2526 | $result = []; |
2527 | // Get Notes |
2528 | if (isset($this->config['Holdings']['notes'])) { |
2529 | $data = $this->extractFieldsFromApiData( |
2530 | $holdings, |
2531 | $this->config['Holdings']['notes'] |
2532 | ); |
2533 | if ($data) { |
2534 | $result['notes'] = $data; |
2535 | } |
2536 | } |
2537 | |
2538 | // Get Summary (may be multiple lines) |
2539 | $data = $this->extractFieldsFromApiData( |
2540 | $holdings, |
2541 | $this->config['Holdings']['summary'] ?? 'h' |
2542 | ); |
2543 | if ($data) { |
2544 | $result['summary'] = $data; |
2545 | } |
2546 | |
2547 | // Get Supplements |
2548 | if (isset($this->config['Holdings']['supplements'])) { |
2549 | $data = $this->extractFieldsFromApiData( |
2550 | $holdings, |
2551 | $this->config['Holdings']['supplements'] |
2552 | ); |
2553 | if ($data) { |
2554 | $result['supplements'] = $data; |
2555 | } |
2556 | } |
2557 | |
2558 | // Get Indexes |
2559 | if (isset($this->config['Holdings']['indexes'])) { |
2560 | $data = $this->extractFieldsFromApiData( |
2561 | $holdings, |
2562 | $this->config['Holdings']['indexes'] |
2563 | ); |
2564 | if ($data) { |
2565 | $result['indexes'] = $data; |
2566 | } |
2567 | } |
2568 | return $result; |
2569 | } |
2570 | |
2571 | /** |
2572 | * Get fields from holdings or bib API response according to the field spec. |
2573 | * |
2574 | * @param array $response API response data |
2575 | * @param array|string $fieldSpecs Array or colon-separated list of |
2576 | * field/subfield specifications (3 chars for field code and then subfields, |
2577 | * e.g. 866az) |
2578 | * |
2579 | * @return string|string[] Results as a string if single, array if multiple |
2580 | */ |
2581 | protected function extractFieldsFromApiData($response, $fieldSpecs) |
2582 | { |
2583 | if (!is_array($fieldSpecs)) { |
2584 | $fieldSpecs = explode(':', $fieldSpecs); |
2585 | } |
2586 | $result = []; |
2587 | foreach ($response as $row) { |
2588 | foreach ($fieldSpecs as $fieldSpec) { |
2589 | $fieldCode = substr($fieldSpec, 0, 3); |
2590 | $subfieldCodes = substr($fieldSpec, 3); |
2591 | $fields = $row['varFields'] ?? []; |
2592 | foreach ($fields as $field) { |
2593 | if ( |
2594 | ($field['marcTag'] ?? '') !== $fieldCode |
2595 | && ($field['fieldTag'] ?? '') !== $fieldCode |
2596 | ) { |
2597 | continue; |
2598 | } |
2599 | $subfields = $field['subfields'] ?? [ |
2600 | [ |
2601 | 'tag' => '', |
2602 | 'content' => $field['content'] ?? '', |
2603 | ], |
2604 | ]; |
2605 | $line = []; |
2606 | foreach ($subfields as $subfield) { |
2607 | if ( |
2608 | $subfieldCodes |
2609 | && !str_contains( |
2610 | $subfieldCodes, |
2611 | (string)$subfield['tag'] |
2612 | ) |
2613 | ) { |
2614 | continue; |
2615 | } |
2616 | $line[] = $subfield['content']; |
2617 | } |
2618 | if ($line) { |
2619 | $result[] = implode(' ', $line); |
2620 | } |
2621 | } |
2622 | } |
2623 | } |
2624 | if (!$result) { |
2625 | return ''; |
2626 | } |
2627 | return isset($result[1]) ? $result : $result[0]; |
2628 | } |
2629 | |
2630 | /** |
2631 | * Get name for a location code |
2632 | * |
2633 | * @param string $locationCode Location code |
2634 | * |
2635 | * @return string |
2636 | */ |
2637 | protected function getLocationName($locationCode) |
2638 | { |
2639 | $locations = $this->getCachedData('locations'); |
2640 | if (null === $locations) { |
2641 | $locations = []; |
2642 | $result = $this->makeRequest( |
2643 | [$this->apiBase, 'branches'], |
2644 | [ |
2645 | 'limit' => 10000, |
2646 | ], |
2647 | 'GET' |
2648 | ); |
2649 | if (!empty($result['code'])) { |
2650 | // An error was returned |
2651 | $this->error( |
2652 | "Request for branches returned error code: {$result['code']}, " |
2653 | . "HTTP status: {$result['httpStatus']}, name: {$result['name']}" |
2654 | ); |
2655 | throw new ILSException('Problem with Sierra REST API.'); |
2656 | } |
2657 | foreach (($result['entries'] ?? []) as $branch) { |
2658 | foreach (($branch['locations'] ?? []) as $location) { |
2659 | $locations[$location['code']] = $this->translateLocation( |
2660 | $location |
2661 | ); |
2662 | } |
2663 | } |
2664 | $this->putCachedData('locations', $locations); |
2665 | } |
2666 | return $locations[$locationCode] ?? ''; |
2667 | } |
2668 | |
2669 | /** |
2670 | * Translate location name |
2671 | * |
2672 | * @param array $location Location |
2673 | * |
2674 | * @return string |
2675 | */ |
2676 | protected function translateLocation($location) |
2677 | { |
2678 | $prefix = 'location_'; |
2679 | if (!empty($this->config['Catalog']['id'])) { |
2680 | $prefix .= $this->config['Catalog']['id'] . '_'; |
2681 | } |
2682 | return $this->translate( |
2683 | $prefix . trim($location['code']), |
2684 | null, |
2685 | $location['name'] |
2686 | ); |
2687 | } |
2688 | |
2689 | /** |
2690 | * Status item sort function |
2691 | * |
2692 | * @param array $a First status record to compare |
2693 | * @param array $b Second status record to compare |
2694 | * |
2695 | * @return int |
2696 | */ |
2697 | protected function statusSortFunction($a, $b) |
2698 | { |
2699 | $result = $this->getSorter()->compare($a['location'], $b['location']); |
2700 | if ($result === 0 && $this->sortItemsByEnumChron) { |
2701 | $result = strnatcmp($b['number'] ?? '', $a['number'] ?? ''); |
2702 | } |
2703 | if ($result === 0) { |
2704 | $result = $a['sort'] - $b['sort']; |
2705 | } |
2706 | return $result; |
2707 | } |
2708 | |
2709 | /** |
2710 | * Translate OPAC message |
2711 | * |
2712 | * @param string $code OPAC message code |
2713 | * |
2714 | * @return string |
2715 | */ |
2716 | protected function translateOpacMessage($code) |
2717 | { |
2718 | $prefix = 'opacmsg_'; |
2719 | if (!empty($this->config['Catalog']['id'])) { |
2720 | $prefix .= $this->config['Catalog']['id'] . '_'; |
2721 | } |
2722 | return $this->translate("$prefix$code", null, $code); |
2723 | } |
2724 | |
2725 | /** |
2726 | * Get the human-readable equivalent of a status code. |
2727 | * |
2728 | * @param string $code Code to map |
2729 | * @param string $default Default value if no mapping found |
2730 | * |
2731 | * @return string |
2732 | */ |
2733 | protected function mapStatusCode($code, $default = null) |
2734 | { |
2735 | return trim($this->itemStatusMappings[$code] ?? $default ?? $code); |
2736 | } |
2737 | |
2738 | /** |
2739 | * Get status for an item |
2740 | * |
2741 | * @param array $item Item from Sierra |
2742 | * |
2743 | * @return array Status string, possible due date and any notes |
2744 | */ |
2745 | protected function getItemStatus($item) |
2746 | { |
2747 | $duedate = ''; |
2748 | $notes = []; |
2749 | $status = $this->mapStatusCode( |
2750 | trim($item['status']['code']), |
2751 | isset($item['status']['display']) |
2752 | ? ucwords(strtolower($item['status']['display'])) |
2753 | : '-' |
2754 | ); |
2755 | // For some reason at least API v2.0 returns "ON SHELF" even when the |
2756 | // item is out. Use duedate to check if it's actually checked out. |
2757 | if (isset($item['status']['duedate'])) { |
2758 | $duedate = $this->dateConverter->convertToDisplayDate( |
2759 | \DateTime::ATOM, |
2760 | $item['status']['duedate'] |
2761 | ); |
2762 | $status = $this->mapStatusCode('Charged'); |
2763 | } else { |
2764 | switch ($status) { |
2765 | case '-': |
2766 | $status = $this->mapStatusCode('-'); |
2767 | break; |
2768 | case 'Lib Use Only': |
2769 | $status = $this->mapStatusCode('o'); |
2770 | break; |
2771 | } |
2772 | } |
2773 | if ($status == $this->mapStatusCode('-')) { |
2774 | // Check for checkin date |
2775 | $today = $this->dateConverter->convertToDisplayDate('U', time()); |
2776 | if (isset($item['fixedFields'][static::ITEM_CHECKIN_DATE_FIELD])) { |
2777 | $checkedIn = $this->dateConverter->convertToDisplayDate( |
2778 | \DateTime::ATOM, |
2779 | $item['fixedFields'][static::ITEM_CHECKIN_DATE_FIELD]['value'] |
2780 | ); |
2781 | if ($checkedIn == $today) { |
2782 | $notes[] = $this->translate('Returned today'); |
2783 | } |
2784 | } |
2785 | } |
2786 | return [$status, $duedate, $notes]; |
2787 | } |
2788 | |
2789 | /** |
2790 | * Determine whether an item is holdable |
2791 | * |
2792 | * @param array $item Item from Sierra |
2793 | * @param array $bib Bib record from Sierra |
2794 | * |
2795 | * @return bool |
2796 | */ |
2797 | protected function isHoldable(array $item, array $bib): bool |
2798 | { |
2799 | if (!$this->itemHoldsEnabled) { |
2800 | return false; |
2801 | } |
2802 | |
2803 | if (null === ($bibLevel = $bib['bibLevel']['code'] ?? null)) { |
2804 | return false; |
2805 | } |
2806 | if (null === $this->itemHoldBibLevels) { |
2807 | // No item hold bib levels defined; allow only bib level NOT allowed |
2808 | // for title hold for back-compatibility: |
2809 | if (in_array($bibLevel, $this->titleHoldBibLevels)) { |
2810 | return false; |
2811 | } |
2812 | } else { |
2813 | // Bib level needs to be allowed for item level holds: |
2814 | if (!in_array($bibLevel, $this->itemHoldBibLevels)) { |
2815 | return false; |
2816 | } |
2817 | } |
2818 | |
2819 | if (!empty($this->validHoldStatuses)) { |
2820 | [$status] = $this->getItemStatus($item); |
2821 | if (!in_array($status, $this->validHoldStatuses)) { |
2822 | return false; |
2823 | } |
2824 | } |
2825 | if ( |
2826 | $this->itemHoldExcludedItemCodes |
2827 | && isset($item['fixedFields'][static::ITEM_ICODE2_FIELD]) |
2828 | ) { |
2829 | $code = $item['fixedFields'][static::ITEM_ICODE2_FIELD]['value']; |
2830 | if (in_array($code, $this->itemHoldExcludedItemCodes)) { |
2831 | return false; |
2832 | } |
2833 | } |
2834 | if ( |
2835 | $this->itemHoldExcludedItemTypes |
2836 | && isset($item['fixedFields'][static::ITEM_ITYPE_FIELD]) |
2837 | ) { |
2838 | $code = $item['fixedFields'][static::ITEM_ITYPE_FIELD]['value']; |
2839 | if (in_array($code, $this->itemHoldExcludedItemTypes)) { |
2840 | return false; |
2841 | } |
2842 | } |
2843 | return true; |
2844 | } |
2845 | |
2846 | /** |
2847 | * Get patron's blocks, if any |
2848 | * |
2849 | * @param array $patron Patron |
2850 | * |
2851 | * @return mixed A boolean false if no blocks are in place and an array |
2852 | * of block reasons if blocks are in place |
2853 | */ |
2854 | protected function getPatronBlocks($patron) |
2855 | { |
2856 | $patronId = $patron['id']; |
2857 | $cacheId = "blocks|$patronId"; |
2858 | $blockReason = $this->getCachedData($cacheId); |
2859 | if (null === $blockReason) { |
2860 | $result = $this->makeRequest( |
2861 | [$this->apiBase, 'patrons', $patronId], |
2862 | [], |
2863 | 'GET', |
2864 | $patron |
2865 | ); |
2866 | if ( |
2867 | !empty($result['blockInfo']) |
2868 | && trim($result['blockInfo']['code']) != '-' |
2869 | ) { |
2870 | $code = trim($result['blockInfo']['code']); |
2871 | $blockReason = [$this->patronBlockMappings[$code] ?? $code]; |
2872 | } else { |
2873 | $blockReason = []; |
2874 | } |
2875 | $this->putCachedData($cacheId, $blockReason); |
2876 | } |
2877 | return empty($blockReason) ? false : $blockReason; |
2878 | } |
2879 | |
2880 | /** |
2881 | * Pickup location sort function |
2882 | * |
2883 | * @param array $a First pickup location record to compare |
2884 | * @param array $b Second pickup location record to compare |
2885 | * |
2886 | * @return int |
2887 | */ |
2888 | protected function pickupLocationSortFunction($a, $b) |
2889 | { |
2890 | $result = $this->getSorter()->compare( |
2891 | $a['locationDisplay'], |
2892 | $b['locationDisplay'] |
2893 | ); |
2894 | if ($result == 0) { |
2895 | $result = $a['locationID'] - $b['locationID']; |
2896 | } |
2897 | return $result; |
2898 | } |
2899 | |
2900 | /** |
2901 | * Is the selected pickup location valid for the hold? |
2902 | * |
2903 | * @param string $pickUpLocation Selected pickup location |
2904 | * @param array $patron Patron information returned by the patronLogin |
2905 | * method. |
2906 | * @param array $holdDetails Details of hold being placed |
2907 | * |
2908 | * @return bool |
2909 | */ |
2910 | protected function pickUpLocationIsValid($pickUpLocation, $patron, $holdDetails) |
2911 | { |
2912 | $pickUpLibs = $this->getPickUpLocations($patron, $holdDetails); |
2913 | foreach ($pickUpLibs as $location) { |
2914 | if ($location['locationID'] == $pickUpLocation) { |
2915 | return true; |
2916 | } |
2917 | } |
2918 | return false; |
2919 | } |
2920 | |
2921 | /** |
2922 | * Hold Error |
2923 | * |
2924 | * Returns a Hold Error Message |
2925 | * |
2926 | * @param string $msg An error message string |
2927 | * @param bool $ilsMsg Whether the error is an ILS error message (needs formatting and any translations prefix) |
2928 | * |
2929 | * @return array An array with a success (boolean) and sysMessage key |
2930 | */ |
2931 | protected function holdError($msg, bool $ilsMsg = true) |
2932 | { |
2933 | return [ |
2934 | 'success' => false, |
2935 | 'sysMessage' => $ilsMsg ? $this->formatErrorMessage($msg) : $msg, |
2936 | ]; |
2937 | } |
2938 | |
2939 | /** |
2940 | * Format an error message received from Sierra |
2941 | * |
2942 | * @param string $msg An error message string |
2943 | * |
2944 | * @return string |
2945 | */ |
2946 | protected function formatErrorMessage($msg) |
2947 | { |
2948 | // Remove prefix like "WebPAC Error" or "XCirc error" |
2949 | $msg = preg_replace('/.* [eE]rror\s*:\s*/', '', $msg); |
2950 | // Handle non-ascii characters that are returned in a wrongly encoded format |
2951 | // (e.g. {u00E4} instead of \u00E4) |
2952 | $msg = preg_replace_callback( |
2953 | '/\{u([0-9a-fA-F]{4})\}/', |
2954 | function ($matches) { |
2955 | return mb_convert_encoding( |
2956 | pack('H*', $matches[1]), |
2957 | 'UTF-8', |
2958 | 'UCS-2BE' |
2959 | ); |
2960 | }, |
2961 | $msg |
2962 | ); |
2963 | return ($this->config['Catalog']['translationPrefix'] ?? '') . $msg; |
2964 | } |
2965 | |
2966 | /** |
2967 | * Get record data from cache and check that it has the requested fields |
2968 | * |
2969 | * @param string $cacheId Cache entry ID |
2970 | * @param array $fields Requested fields |
2971 | * |
2972 | * @return array Array with cached data if available, and fields (existing or |
2973 | * required) |
2974 | */ |
2975 | protected function getCachedRecordData(string $cacheId, array $fields): array |
2976 | { |
2977 | if ($cached = $this->getCachedData($cacheId)) { |
2978 | if (!array_diff($fields, $cached['fields'])) { |
2979 | // We already have all required fields cached: |
2980 | return $cached; |
2981 | } |
2982 | } |
2983 | $cached = [ |
2984 | 'data' => [], |
2985 | 'fields' => array_unique([...$fields, ...($cached['fields'] ?? [])]), |
2986 | ]; |
2987 | |
2988 | return $cached; |
2989 | } |
2990 | |
2991 | /** |
2992 | * Insert record data and its field list into the cache |
2993 | * |
2994 | * @param string $cacheId Cache entry ID |
2995 | * @param array $fields Fields contained in the data |
2996 | * @param array $data Data |
2997 | * @param int $ttl Cache entry life time |
2998 | * |
2999 | * @return void |
3000 | */ |
3001 | protected function putCachedRecordData(string $cacheId, array $fields, array $data, int $ttl): void |
3002 | { |
3003 | $this->putCachedData($cacheId, compact('data', 'fields'), $ttl); |
3004 | } |
3005 | |
3006 | /** |
3007 | * Fetch fields for a bib record from Sierra |
3008 | * |
3009 | * Note: This method can return cached data |
3010 | * |
3011 | * @param string $id Bib record id |
3012 | * @param ?array $fields Fields to request or null for defaults |
3013 | * @param ?array $patron Patron information, if available |
3014 | * |
3015 | * @return ?array |
3016 | */ |
3017 | protected function getBibRecord(string $id, ?array $fields = null, ?array $patron = null): ?array |
3018 | { |
3019 | $result = $this->getBibRecords([$id], $fields, $patron); |
3020 | return $result[$id] ?? null; |
3021 | } |
3022 | |
3023 | /** |
3024 | * Fetch fields for records from Sierra |
3025 | * |
3026 | * Note: This method can return cached data |
3027 | * |
3028 | * @param array $ids Record ids |
3029 | * @param string $type Record type ('bib' or 'item') |
3030 | * @param array $fields Fields to request |
3031 | * @param int $ttl Cache TTL |
3032 | * @param ?array $patron Patron information, if available |
3033 | * |
3034 | * @return ?array |
3035 | */ |
3036 | protected function getRecords( |
3037 | array $ids, |
3038 | string $type, |
3039 | array $fields, |
3040 | int $ttl, |
3041 | ?array $patron = null |
3042 | ): ?array { |
3043 | $result = []; |
3044 | $requiredFields = $fields; |
3045 | foreach ($ids as &$id) { |
3046 | $cached = $this->getCachedRecordData("$type|$id", $fields); |
3047 | if ($cached['data']) { |
3048 | // We already have all required fields cached: |
3049 | $result[$id] = $cached['data']; |
3050 | $id = null; |
3051 | } |
3052 | $requiredFields = array_unique( |
3053 | [ |
3054 | ...$requiredFields, |
3055 | ...$cached['fields'], |
3056 | ] |
3057 | ); |
3058 | } |
3059 | // Unset reference: |
3060 | unset($id); |
3061 | $ids = array_filter($ids); |
3062 | // Return if we had all records in cache: |
3063 | if (!$ids) { |
3064 | return $result; |
3065 | } |
3066 | // Fetch requested fields as well as any cached fields to keep everything in |
3067 | // sync (note that Sierra has default limit of 50 that applies even if you |
3068 | // fetch a list of id's, so we need to override that): |
3069 | $records = $this->makeRequest( |
3070 | [$this->apiBase, $type . 's'], |
3071 | [ |
3072 | 'id' => implode(',', $ids), |
3073 | 'fields' => implode(',', $requiredFields), |
3074 | 'limit' => count($ids), |
3075 | ], |
3076 | 'GET', |
3077 | $patron |
3078 | ); |
3079 | foreach ($records['entries'] ?? [] as $record) { |
3080 | $id = $this->extractId($record['id']); |
3081 | $this->putCachedRecordData("$type|$id", $requiredFields, $record, $ttl); |
3082 | $result[$id] = $record; |
3083 | } |
3084 | return $result; |
3085 | } |
3086 | |
3087 | /** |
3088 | * Fetch fields for bib records from Sierra |
3089 | * |
3090 | * Note: This method can return cached data |
3091 | * |
3092 | * @param array $ids Bib record ids |
3093 | * @param ?array $fields Fields to request or null for defaults |
3094 | * @param ?array $patron Patron information, if available |
3095 | * |
3096 | * @return ?array |
3097 | */ |
3098 | protected function getBibRecords(array $ids, ?array $fields = null, ?array $patron = null): ?array |
3099 | { |
3100 | $fields ??= $this->defaultBibFields; |
3101 | return $this->getRecords($ids, 'bib', $fields, $this->bibCacheTTL, $patron); |
3102 | } |
3103 | |
3104 | /** |
3105 | * Fetch fields for item records from Sierra |
3106 | * |
3107 | * Note: This method can return cached data |
3108 | * |
3109 | * @param array $ids Item record ids |
3110 | * @param ?array $fields Fields to request or null for defaults |
3111 | * @param ?array $patron Patron information, if available |
3112 | * |
3113 | * @return ?array |
3114 | */ |
3115 | protected function getItemRecords(array $ids, ?array $fields = null, ?array $patron = null): ?array |
3116 | { |
3117 | $fields ??= $this->defaultItemFields; |
3118 | return $this->getRecords($ids, 'item', $fields, $this->itemCacheTTL, $patron); |
3119 | } |
3120 | |
3121 | /** |
3122 | * Get all items for a bib record |
3123 | * |
3124 | * Note: This method can return cached data |
3125 | * |
3126 | * @param string $id Bib record id |
3127 | * @param ?array $fields Fields to request or null for defaults |
3128 | * @param ?array $patron Patron information, if available |
3129 | * |
3130 | * @return array |
3131 | */ |
3132 | protected function getItemsForBibRecord( |
3133 | string $id, |
3134 | ?array $fields = null, |
3135 | ?array $patron = null |
3136 | ): array { |
3137 | $fields ??= $this->defaultItemFields; |
3138 | |
3139 | $cacheId = "bib-items|$id"; |
3140 | $cached = $this->getCachedRecordData($cacheId, $fields); |
3141 | if ($cached['data']) { |
3142 | // We already have all required fields cached: |
3143 | return $cached['data']; |
3144 | } |
3145 | $items = []; |
3146 | $offset = 0; |
3147 | $limit = 50; |
3148 | $result = null; |
3149 | while (null === $result || $limit === $result['total']) { |
3150 | // Fetch requested fields as well as any cached fields to keep everything |
3151 | // in sync: |
3152 | $result = $this->makeRequest( |
3153 | [$this->apiBase, 'items'], |
3154 | [ |
3155 | 'bibIds' => $this->extractBibId($id), |
3156 | 'deleted' => 'false', |
3157 | 'suppressed' => 'false', |
3158 | 'fields' => implode(',', $cached['fields']), |
3159 | 'limit' => $limit, |
3160 | 'offset' => $offset, |
3161 | ], |
3162 | 'GET', |
3163 | $patron |
3164 | ); |
3165 | if (empty($result['entries'])) { |
3166 | if (!empty($result['httpStatus']) && 404 !== $result['httpStatus']) { |
3167 | $msg = "Item status request failed: {$result['httpStatus']}"; |
3168 | if (!empty($result['description'])) { |
3169 | $msg .= " ({$result['description']})"; |
3170 | } |
3171 | throw new ILSException($msg); |
3172 | } |
3173 | break; |
3174 | } |
3175 | $items = [...$items, ...$result['entries']]; |
3176 | $offset += $limit; |
3177 | } |
3178 | $this->putCachedRecordData($cacheId, $cached['fields'], $items, $this->bibItemsCacheTTL); |
3179 | return $items; |
3180 | } |
3181 | |
3182 | /** |
3183 | * Extract a numeric bib ID value from a string that may be prefixed. |
3184 | * |
3185 | * @param string $id Bib record id (with or without .b prefix) |
3186 | * |
3187 | * @return int |
3188 | */ |
3189 | protected function extractBibId($id) |
3190 | { |
3191 | // If the .b prefix is found, strip it and the trailing checksum: |
3192 | return str_starts_with($id, '.b') ? substr($id, 2, -1) : $id; |
3193 | } |
3194 | |
3195 | /** |
3196 | * If the system is configured to use full prefixed bib IDs, add the prefix |
3197 | * and checksum. |
3198 | * |
3199 | * @param int $id Bib ID that may need to be prefixed. |
3200 | * |
3201 | * @return string |
3202 | */ |
3203 | protected function formatBibId($id) |
3204 | { |
3205 | // Simple case: prefixing is disabled, so return ID unmodified: |
3206 | if (!($this->config['Catalog']['use_prefixed_ids'] ?? false)) { |
3207 | return $id; |
3208 | } |
3209 | |
3210 | // If we got this far, we need to generate a check digit: |
3211 | $multiplier = 2; |
3212 | $sum = 0; |
3213 | for ($x = strlen($id) - 1; $x >= 0; $x--) { |
3214 | $current = substr($id, $x, 1); |
3215 | $sum += $multiplier * intval($current); |
3216 | $multiplier++; |
3217 | } |
3218 | $checksum = $sum % 11; |
3219 | $finalChecksum = $checksum === 10 ? 'x' : $checksum; |
3220 | return '.b' . $id . $finalChecksum; |
3221 | } |
3222 | |
3223 | /** |
3224 | * Check if we re using a patron-specific access token |
3225 | * |
3226 | * @return bool |
3227 | */ |
3228 | protected function isPatronSpecificAccess() |
3229 | { |
3230 | return !empty($this->config['Catalog']['redirect_uri']); |
3231 | } |
3232 | |
3233 | /** |
3234 | * Get patron information via authentication token when using patron-specific |
3235 | * access |
3236 | * |
3237 | * @param string $username The patron username |
3238 | * @param string $password The patron password |
3239 | * |
3240 | * @return array |
3241 | */ |
3242 | protected function getPatronInformationFromAuthToken( |
3243 | string $username, |
3244 | string $password |
3245 | ): array { |
3246 | $credentials = [ |
3247 | 'cat_username' => $username, |
3248 | 'cat_password' => $password, |
3249 | ]; |
3250 | $result = $this->makeRequest( |
3251 | [$this->apiBase, 'info', 'token'], |
3252 | [], |
3253 | 'GET', |
3254 | $credentials |
3255 | ); |
3256 | if (null === $result) { |
3257 | return []; |
3258 | } |
3259 | if (empty($result['patronId'])) { |
3260 | throw new ILSException('No patronId in token response'); |
3261 | } |
3262 | |
3263 | $result = $this->makeRequest( |
3264 | [$this->apiBase, 'patrons', $result['patronId']], |
3265 | ['fields' => 'names,emails'], |
3266 | 'GET', |
3267 | $credentials |
3268 | ); |
3269 | if (null === $result || !empty($result['code'])) { |
3270 | return []; |
3271 | } |
3272 | return $result; |
3273 | } |
3274 | |
3275 | /** |
3276 | * Authenticate a patron |
3277 | * |
3278 | * Returns patron information on success and null on failure |
3279 | * |
3280 | * @param string $username Username |
3281 | * @param string $password Password |
3282 | * |
3283 | * @return array|null |
3284 | */ |
3285 | protected function authenticatePatron( |
3286 | string $username, |
3287 | ?string $password |
3288 | ): ?array { |
3289 | $authMethod = $this->config['Authentication']['method'] ?? 'native'; |
3290 | $validationField = $this->config['Authentication']['patron_validation_field'] |
3291 | ?? null; |
3292 | // patrons/auth endpoint is only supported on API version >= 6, without |
3293 | // custom validation configured: |
3294 | if ( |
3295 | $this->apiVersion >= 6 && null !== $password |
3296 | && empty($validationField) |
3297 | ) { |
3298 | return $this->authenticatePatronV6($username, $password, $authMethod); |
3299 | } |
3300 | |
3301 | if ('native' !== $authMethod) { |
3302 | $this->logError( |
3303 | 'Sierra REST API level set too low for authentication method' |
3304 | . " '$authMethod'. Only 'native' is supported." |
3305 | ); |
3306 | throw new ILSException('API level set too low'); |
3307 | } |
3308 | |
3309 | // Depending on validation settings, use either normal PIN-based auth, |
3310 | // or bypass PIN check and validate a different field. |
3311 | return empty($validationField) |
3312 | ? $this->authenticatePatronV5($username, $password) |
3313 | : $this->validatePatron( |
3314 | $this->authenticatePatronV5($username, null), |
3315 | $validationField, |
3316 | $password |
3317 | ); |
3318 | } |
3319 | |
3320 | /** |
3321 | * Perform extra validation of retrieved user, if configured to do so. Returns |
3322 | * patron data if value, null otherwise. |
3323 | * |
3324 | * @param ?array $patron Output of authenticatePatronV5() |
3325 | * @param string $validationField Field to use for validation |
3326 | * @param ?string $password Value to use in validation |
3327 | * |
3328 | * @return ?array |
3329 | * @throws \Exception |
3330 | */ |
3331 | protected function validatePatron( |
3332 | ?array $patron, |
3333 | string $validationField, |
3334 | ?string $password |
3335 | ): ?array { |
3336 | // If the validation field is a valid, supported value, perform validation: |
3337 | if (in_array($validationField, ['email', 'name'])) { |
3338 | return in_array($password, $patron[$validationField . 's'] ?? []) |
3339 | ? $patron : null; |
3340 | } |
3341 | // Throw an exception if we got an unexpected configuration: |
3342 | throw new \Exception( |
3343 | "Unexpected patron_validation_field: $validationField" |
3344 | ); |
3345 | } |
3346 | |
3347 | /** |
3348 | * Authenticate a patron using the API version 5 endpoints |
3349 | * |
3350 | * Returns patron information on success and null on failure |
3351 | * |
3352 | * @param string $username Username |
3353 | * @param string $password Password |
3354 | * |
3355 | * @return array|null |
3356 | */ |
3357 | protected function authenticatePatronV5( |
3358 | string $username, |
3359 | ?string $password |
3360 | ): ?array { |
3361 | // Validate a password unless it's null: |
3362 | if (null !== $password) { |
3363 | $request = [ |
3364 | 'barcode' => $username, |
3365 | 'pin' => $password, |
3366 | 'caseSensitivity' => false, |
3367 | ]; |
3368 | try { |
3369 | // Note: hard-coded to use v5 API: |
3370 | $result = $this->makeRequest( |
3371 | ['v5', 'patrons', 'validate'], |
3372 | json_encode($request), |
3373 | 'POST', |
3374 | false, |
3375 | true |
3376 | ); |
3377 | } catch (ILSException $e) { |
3378 | return null; |
3379 | } |
3380 | if (!$result || $result['statusCode'] != 204) { |
3381 | return null; |
3382 | } |
3383 | } |
3384 | |
3385 | $varField = $this->config['Authentication']['patron_lookup_field'] ?? 'b'; |
3386 | $result = $this->makeRequest( |
3387 | [$this->apiBase, 'patrons', 'find'], |
3388 | [ |
3389 | 'varFieldTag' => $varField, |
3390 | 'varFieldContent' => $username, |
3391 | 'fields' => 'names,emails', |
3392 | ] |
3393 | ); |
3394 | if (!$result || !empty($result['code'])) { |
3395 | return null; |
3396 | } |
3397 | return $result; |
3398 | } |
3399 | |
3400 | /** |
3401 | * Authenticate a patron using the API version 6 patrons/auth endpoint |
3402 | * |
3403 | * Returns patron information on success and null on failure |
3404 | * |
3405 | * @param string $username Username |
3406 | * @param string $password Password |
3407 | * @param string $method Authentication method |
3408 | * |
3409 | * @return array|null |
3410 | */ |
3411 | protected function authenticatePatronV6( |
3412 | string $username, |
3413 | string $password, |
3414 | string $method |
3415 | ): ?array { |
3416 | $request = [ |
3417 | 'authMethod' => $method, |
3418 | 'patronId' => $username, |
3419 | 'patronSecret' => $password, |
3420 | ]; |
3421 | $result = $this->makeRequest( |
3422 | ['v6', 'patrons', 'auth'], |
3423 | json_encode($request), |
3424 | 'POST' |
3425 | ); |
3426 | if (!$result || !empty($result['code'])) { |
3427 | return null; |
3428 | } |
3429 | $result = $this->makeRequest( |
3430 | [$this->apiBase, 'patrons', $result], |
3431 | ['fields' => 'names,emails'] |
3432 | ); |
3433 | if (!$result || !empty($result['code'])) { |
3434 | return null; |
3435 | } |
3436 | return $result; |
3437 | } |
3438 | |
3439 | /** |
3440 | * Get items and their bibs for an array of transactions |
3441 | * |
3442 | * @param array $transactions Transaction list |
3443 | * @param array $patron The patron array from patronLogin |
3444 | * |
3445 | * @return array |
3446 | */ |
3447 | protected function getItemsWithBibsForTransactions( |
3448 | array $transactions, |
3449 | array $patron |
3450 | ): array { |
3451 | if (!$transactions) { |
3452 | return []; |
3453 | } |
3454 | // Fetch items and collect bib id mappings if available: |
3455 | $itemIds = []; |
3456 | $bibIdsToItems = []; |
3457 | foreach ($transactions as $transaction) { |
3458 | $itemId = $this->extractId($transaction['item']); |
3459 | $itemIds[] = $itemId; |
3460 | // Historical transactions include the bib id. Collect them here so that |
3461 | // we can get the bib data even if the item doesn't exist anymore: |
3462 | if ($bibId = $transaction['bib'] ?? null) { |
3463 | $bibIdsToItems[$this->extractId($bibId)][$itemId] = true; |
3464 | } |
3465 | } |
3466 | if ($this->config['InnReach']['enabled'] ?? false) { |
3467 | foreach ($itemIds as $key => $iRId) { |
3468 | if (strstr($iRId, $this->config['InnReach']['identifier'])) { |
3469 | unset($itemIds[$key]); |
3470 | } |
3471 | } |
3472 | } |
3473 | // Get items and collect further bib id mappings: |
3474 | $items = $this->getItemRecords($itemIds, null, $patron); |
3475 | foreach ($items as $itemId => $item) { |
3476 | if ($bibId = (string)($item['bibIds'][0] ?? '')) { |
3477 | // Collect all item id's for each bib: |
3478 | $bibIdsToItems[$bibId][$itemId] = true; |
3479 | } |
3480 | } |
3481 | // Fetch bibs for the items: |
3482 | foreach ($this->getBibRecords(array_keys($bibIdsToItems), null, $patron) as $bib) { |
3483 | // Add bib data to the items: |
3484 | foreach (array_keys($bibIdsToItems[(string)$bib['id']]) as $itemId) { |
3485 | $items[$itemId]['bib'] = $bib; |
3486 | } |
3487 | } |
3488 | |
3489 | return $items; |
3490 | } |
3491 | |
3492 | /** |
3493 | * Check if bib matches title hold rules |
3494 | * |
3495 | * @param array $bib Bibliographic record fields |
3496 | * @param array $patron An array of patron data |
3497 | * |
3498 | * @return bool True if request is valid, false if not |
3499 | */ |
3500 | protected function checkTitleHoldRules(array $bib, array $patron): bool |
3501 | { |
3502 | if (!$this->titleHoldRules) { |
3503 | return true; |
3504 | } |
3505 | |
3506 | if ( |
3507 | in_array('order', $this->titleHoldRules) |
3508 | && !empty($bib['orders']) |
3509 | ) { |
3510 | return true; |
3511 | } |
3512 | |
3513 | if (in_array('item', $this->titleHoldRules)) { |
3514 | $items = $this->getItemsForBibRecord($bib['id'], null, $patron); |
3515 | foreach ($items as $item) { |
3516 | if (!empty($this->titleHoldValidHoldStatuses)) { |
3517 | [$status] = $this->getItemStatus($item); |
3518 | if (!in_array($status, $this->titleHoldValidHoldStatuses)) { |
3519 | continue; |
3520 | } |
3521 | } |
3522 | if ( |
3523 | $this->titleHoldExcludedItemCodes |
3524 | && isset($item['fixedFields'][static::ITEM_ICODE2_FIELD]) |
3525 | ) { |
3526 | $code = $item['fixedFields'][static::ITEM_ICODE2_FIELD]['value']; |
3527 | if (in_array($code, $this->titleHoldExcludedItemCodes)) { |
3528 | continue; |
3529 | } |
3530 | } |
3531 | if ( |
3532 | $this->titleHoldExcludedItemTypes |
3533 | && isset($item['fixedFields'][static::ITEM_ITYPE_FIELD]) |
3534 | ) { |
3535 | $code = $item['fixedFields'][static::ITEM_ITYPE_FIELD]['value']; |
3536 | if (in_array($code, $this->titleHoldExcludedItemTypes)) { |
3537 | continue; |
3538 | } |
3539 | } |
3540 | return true; |
3541 | } |
3542 | } |
3543 | return false; |
3544 | } |
3545 | |
3546 | /** |
3547 | * Gets title information for holds placed in an INN-Reach system |
3548 | * |
3549 | * @param $holdId the id of the hold from Sierra |
3550 | * @param $bibId the id of the bib from Sierra |
3551 | * |
3552 | * @return array |
3553 | * |
3554 | * @throws ILSException |
3555 | */ |
3556 | protected function getInnReachHoldTitleInfoFromId($holdId, $bibId): array |
3557 | { |
3558 | $db = $this->getInnReachDb(); |
3559 | $titleInfo = []; |
3560 | if ($db) { |
3561 | try { |
3562 | $query = 'SELECT |
3563 | bib_record_property.best_title as title, |
3564 | bib_record_property.best_author as author, |
3565 | --hold.status, -- this shows sierra hold status not inn-reach status |
3566 | bib_record_property.best_title_norm as sort_title |
3567 | FROM |
3568 | sierra_view.hold, |
3569 | sierra_view.bib_record_item_record_link, |
3570 | sierra_view.bib_record_property |
3571 | WHERE |
3572 | hold.id = $1 |
3573 | AND hold.is_ir=true |
3574 | AND hold.record_id = bib_record_item_record_link.item_record_id |
3575 | AND bib_record_item_record_link.bib_record_id = bib_record_property.bib_record_id'; |
3576 | pg_prepare($this->innReachDb, 'prep_query', $query); |
3577 | $results = pg_execute($this->innReachDb, 'prep_query', [$holdId]); |
3578 | if ($result = pg_fetch_array($results, 0)) { |
3579 | $titleInfo['id'] = $bibId; |
3580 | $titleInfo['title'] = $result[0]; |
3581 | $titleInfo['author'] = $result[1]; |
3582 | } |
3583 | } catch (\Exception $e) { |
3584 | $this->throwAsIlsException($e); |
3585 | } |
3586 | } else { |
3587 | $titleInfo['id'] = ''; |
3588 | $titleInfo['title'] = 'Unknown Title'; |
3589 | $titleInfo['author'] = 'Unknown Author'; |
3590 | } |
3591 | return $titleInfo; |
3592 | } |
3593 | |
3594 | /** |
3595 | * Gets title information for checked out items from INN-Reach systems |
3596 | * |
3597 | * @param $checkOutId the id of the checkout from Sierra |
3598 | * @param $bibId the id of the bib from Sierra |
3599 | * |
3600 | * @return array |
3601 | * |
3602 | * @throws ILSException |
3603 | */ |
3604 | protected function getInnReachCheckoutTitleInfoFromId($checkOutId, $bibId): array |
3605 | { |
3606 | $db = $this->getInnReachDb(); |
3607 | $titleInfo = []; |
3608 | if ($db) { |
3609 | try { |
3610 | $query = 'SELECT |
3611 | bib_record_property.best_title as title, |
3612 | bib_record_property.best_author as author, |
3613 | bib_record_property.best_title_norm as sort_title |
3614 | FROM |
3615 | sierra_view.checkout, |
3616 | sierra_view.bib_record_item_record_link, |
3617 | sierra_view.bib_record_property |
3618 | WHERE |
3619 | checkout.id = $1 |
3620 | AND checkout.item_record_id = bib_record_item_record_link.item_record_id |
3621 | AND bib_record_item_record_link.bib_record_id = bib_record_property.bib_record_id'; |
3622 | pg_prepare($this->innReachDb, 'prep_query', $query); |
3623 | $results = pg_execute($this->innReachDb, 'prep_query', [$checkOutId]); |
3624 | if ($result = pg_fetch_array($results, 0)) { |
3625 | $titleInfo['id'] = $bibId; |
3626 | $titleInfo['title'] = $result[0]; |
3627 | $titleInfo['author'] = $result[1]; |
3628 | } |
3629 | } catch (\Exception $e) { |
3630 | $this->throwAsIlsException($e); |
3631 | } |
3632 | } else { |
3633 | $titleInfo['id'] = ''; |
3634 | $titleInfo['title'] = 'Unknown Title'; |
3635 | $titleInfo['author'] = 'Unknown Author'; |
3636 | } |
3637 | return $titleInfo; |
3638 | } |
3639 | } |