Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 235 |
|
0.00% |
0 / 7 |
CRAP | |
0.00% |
0 / 1 |
HoldsController | |
0.00% |
0 / 235 |
|
0.00% |
0 / 7 |
3660 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
listAction | |
0.00% |
0 / 58 |
|
0.00% |
0 / 1 |
306 | |||
editAction | |
0.00% |
0 / 74 |
|
0.00% |
0 / 1 |
272 | |||
getPickupLocationsForEdit | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
110 | |||
getUpdateFieldsFromGatheredDetails | |
0.00% |
0 / 58 |
|
0.00% |
0 / 1 |
210 | |||
getHoldUpdateResultsContainer | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getCacheId | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | /** |
4 | * Holds Controller |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) Villanova University 2010. |
9 | * Copyright (C) The National Library of Finland 2021. |
10 | * |
11 | * This program is free software; you can redistribute it and/or modify |
12 | * it under the terms of the GNU General Public License version 2, |
13 | * as published by the Free Software Foundation. |
14 | * |
15 | * This program is distributed in the hope that it will be useful, |
16 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
17 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
18 | * GNU General Public License for more details. |
19 | * |
20 | * You should have received a copy of the GNU General Public License |
21 | * along with this program; if not, write to the Free Software |
22 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
23 | * |
24 | * @category VuFind |
25 | * @package Controller |
26 | * @author Demian Katz <demian.katz@villanova.edu> |
27 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
28 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
29 | * @link https://vufind.org Main Site |
30 | */ |
31 | |
32 | namespace VuFind\Controller; |
33 | |
34 | use Laminas\Cache\Storage\StorageInterface; |
35 | use Laminas\ServiceManager\ServiceLocatorInterface; |
36 | use VuFind\Exception\ILS as ILSException; |
37 | use VuFind\Validator\CsrfInterface; |
38 | |
39 | use function count; |
40 | use function in_array; |
41 | use function is_array; |
42 | |
43 | /** |
44 | * Controller for the user holds area. |
45 | * |
46 | * @category VuFind |
47 | * @package Controller |
48 | * @author Demian Katz <demian.katz@villanova.edu> |
49 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
50 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
51 | * @link https://vufind.org Main Site |
52 | */ |
53 | class HoldsController extends AbstractBase |
54 | { |
55 | use Feature\CatchIlsExceptionsTrait; |
56 | use \VuFind\Cache\CacheTrait; |
57 | |
58 | /** |
59 | * CSRF validator |
60 | * |
61 | * @var CsrfInterface |
62 | */ |
63 | protected $csrf; |
64 | |
65 | /** |
66 | * Constructor |
67 | * |
68 | * @param ServiceLocatorInterface $sm Service locator |
69 | * @param CsrfInterface $csrf CSRF validator |
70 | * @param StorageInterface $cache Cache |
71 | */ |
72 | public function __construct( |
73 | ServiceLocatorInterface $sm, |
74 | CsrfInterface $csrf, |
75 | StorageInterface $cache |
76 | ) { |
77 | parent::__construct($sm); |
78 | $this->csrf = $csrf; |
79 | $this->setCacheStorage($cache); |
80 | // Cache the data related to holds for up to 10 minutes: |
81 | $this->cacheLifetime = 600; |
82 | } |
83 | |
84 | /** |
85 | * Send list of holds to view |
86 | * |
87 | * @return mixed |
88 | */ |
89 | public function listAction() |
90 | { |
91 | // Stop now if the user does not have valid catalog credentials available: |
92 | if (!is_array($patron = $this->catalogLogin())) { |
93 | return $patron; |
94 | } |
95 | |
96 | // Connect to the ILS: |
97 | $catalog = $this->getILS(); |
98 | |
99 | // Process cancel requests if necessary: |
100 | $cancelStatus = $catalog->checkFunction('cancelHolds', compact('patron')); |
101 | $view = $this->createViewModel(); |
102 | $view->cancelResults = $cancelStatus |
103 | ? $this->holds()->cancelHolds($catalog, $patron) : []; |
104 | // If we need to confirm |
105 | if (!is_array($view->cancelResults)) { |
106 | return $view->cancelResults; |
107 | } |
108 | |
109 | // Process any update request results stored in the session: |
110 | $updateResultsContainer = $this->getHoldUpdateResultsContainer(); |
111 | $holdUpdateResults = $updateResultsContainer->results ?? null; |
112 | if ($holdUpdateResults) { |
113 | $view->updateResults = $holdUpdateResults; |
114 | $updateResultsContainer->results = null; |
115 | } |
116 | // Process update requests if necessary: |
117 | if ($this->params()->fromPost('updateSelected')) { |
118 | $selectedIds = $this->params()->fromPost('selectedIDS'); |
119 | if (empty($selectedIds)) { |
120 | $this->flashMessenger()->addErrorMessage('hold_empty_selection'); |
121 | if ($this->inLightbox()) { |
122 | return $this->getRefreshResponse(); |
123 | } |
124 | } else { |
125 | return $this->forwardTo('Holds', 'Edit'); |
126 | } |
127 | } |
128 | |
129 | // By default, assume we will not need to display a cancel or update form: |
130 | $view->cancelForm = false; |
131 | $view->updateForm = false; |
132 | |
133 | // Get held item details: |
134 | $result = $catalog->getMyHolds($patron); |
135 | $driversNeeded = []; |
136 | $this->holds()->resetValidation(); |
137 | $holdConfig = $catalog->checkFunction('Holds', compact('patron')); |
138 | foreach ($result as $current) { |
139 | // Add cancel details if appropriate: |
140 | $current = $this->holds()->addCancelDetails( |
141 | $catalog, |
142 | $current, |
143 | $cancelStatus, |
144 | $patron |
145 | ); |
146 | if ( |
147 | $cancelStatus && $cancelStatus['function'] !== 'getCancelHoldLink' |
148 | && isset($current['cancel_details']) |
149 | ) { |
150 | // Enable cancel form if necessary: |
151 | $view->cancelForm = true; |
152 | } |
153 | |
154 | // Add update details if appropriate |
155 | if (isset($current['updateDetails'])) { |
156 | if ( |
157 | empty($holdConfig['updateFields']) |
158 | || '' === $current['updateDetails'] |
159 | ) { |
160 | unset($current['updateDetails']); |
161 | } else { |
162 | $view->updateForm = true; |
163 | $this->holds()->rememberValidId($current['updateDetails']); |
164 | } |
165 | } |
166 | |
167 | $driversNeeded[] = $current; |
168 | } |
169 | // Cache the current list of requests for editing: |
170 | $this->putCachedData( |
171 | $this->getCacheId($patron, 'holds'), |
172 | $driversNeeded |
173 | ); |
174 | |
175 | // Get List of PickUp Libraries based on patron's home library |
176 | try { |
177 | $pickupCacheId = $this->getCacheId($patron, 'pickup'); |
178 | $view->pickup = $this->getCachedData($pickupCacheId); |
179 | if (null === $view->pickup) { |
180 | $view->pickup = $catalog->getPickUpLocations($patron); |
181 | $this->putCachedData($pickupCacheId, $view->pickup); |
182 | } |
183 | } catch (\Exception $e) { |
184 | // Do nothing; if we're unable to load information about pickup |
185 | // locations, they are not supported and we should ignore them. |
186 | } |
187 | |
188 | $view->recordList = $this->ilsRecords()->getDrivers($driversNeeded); |
189 | $view->accountStatus = $this->ilsRecords() |
190 | ->collectRequestStats($view->recordList); |
191 | return $view; |
192 | } |
193 | |
194 | /** |
195 | * Edit holds |
196 | * |
197 | * @return mixed |
198 | */ |
199 | public function editAction() |
200 | { |
201 | $this->ilsExceptionResponse = $this->createViewModel( |
202 | [ |
203 | 'selectedIDS' => [], |
204 | 'fields' => [], |
205 | ] |
206 | ); |
207 | |
208 | // Stop now if the user does not have valid catalog credentials available: |
209 | if (!is_array($patron = $this->catalogLogin())) { |
210 | return $patron; |
211 | } |
212 | |
213 | // Connect to the ILS: |
214 | $catalog = $this->getILS(); |
215 | |
216 | $holdConfig = $catalog->checkFunction('Holds', compact('patron')); |
217 | $selectedIds = $this->params()->fromPost('selectedIDS') |
218 | ?: $this->params()->fromQuery('selectedIDS'); |
219 | if (empty($holdConfig['updateFields']) || empty($selectedIds)) { |
220 | // Shouldn't be here. Redirect back to holds. |
221 | return $this->inLightbox() |
222 | ? $this->getRefreshResponse() |
223 | : $this->redirect()->toRoute('holds-list'); |
224 | } |
225 | // If the user input contains a value not found in the session |
226 | // legal list, something has been tampered with -- abort the process. |
227 | if (!$this->holds()->validateIds($selectedIds)) { |
228 | $this->flashMessenger() |
229 | ->addErrorMessage('error_inconsistent_parameters'); |
230 | return $this->inLightbox() |
231 | ? $this->getRefreshResponse() |
232 | : $this->redirect()->toRoute('holds-list'); |
233 | } |
234 | |
235 | $pickupLocationInfo = $this->getPickupLocationsForEdit( |
236 | $patron, |
237 | $selectedIds, |
238 | $holdConfig['pickUpLocationCheckLimit'] |
239 | ); |
240 | |
241 | $gatheredDetails = $this->params()->fromPost('gatheredDetails', []); |
242 | if ($this->params()->fromPost('updateHolds')) { |
243 | if (!$this->csrf->isValid($this->params()->fromPost('csrf'))) { |
244 | throw new \VuFind\Exception\BadRequest( |
245 | 'error_inconsistent_parameters' |
246 | ); |
247 | } |
248 | |
249 | $updateFields = $this->getUpdateFieldsFromGatheredDetails( |
250 | $holdConfig, |
251 | $gatheredDetails, |
252 | $pickupLocationInfo['pickupLocations'] |
253 | ); |
254 | if ($updateFields) { |
255 | $results |
256 | = $catalog->updateHolds($selectedIds, $updateFields, $patron); |
257 | $successful = 0; |
258 | $failed = 0; |
259 | foreach ($results as $result) { |
260 | if ($result['success']) { |
261 | ++$successful; |
262 | } else { |
263 | ++$failed; |
264 | } |
265 | } |
266 | // Store results in the session so that they can be displayed when |
267 | // the user is redirected back to the holds list: |
268 | $this->getHoldUpdateResultsContainer()->results = $results; |
269 | if ($successful) { |
270 | $msg = $this->translate( |
271 | 'hold_edit_success_items', |
272 | ['%%count%%' => $successful] |
273 | ); |
274 | $this->flashMessenger()->addSuccessMessage($msg); |
275 | } |
276 | if ($failed) { |
277 | $msg = $this->translate( |
278 | 'hold_edit_failed_items', |
279 | ['%%count%%' => $failed] |
280 | ); |
281 | $this->flashMessenger()->addErrorMessage($msg); |
282 | } |
283 | return $this->inLightbox() |
284 | ? $this->getRefreshResponse() |
285 | : $this->redirect()->toRoute('holds-list'); |
286 | } |
287 | } |
288 | |
289 | $view = $this->createViewModel( |
290 | [ |
291 | 'selectedIDS' => $selectedIds, |
292 | 'fields' => $holdConfig['updateFields'], |
293 | 'gatheredDetails' => $gatheredDetails, |
294 | 'pickupLocations' => $pickupLocationInfo['pickupLocations'], |
295 | 'conflictingPickupLocations' => $pickupLocationInfo['differences'], |
296 | 'helpTextHtml' => $holdConfig['updateHelpText'], |
297 | ] |
298 | ); |
299 | |
300 | return $view; |
301 | } |
302 | |
303 | /** |
304 | * Get list of pickup locations based on the first selected hold. This may not be |
305 | * perfect as pickup locations may differ per hold, but it's the best we can do. |
306 | * |
307 | * @param array $patron Patron information |
308 | * @param array $selectedIds Selected holds |
309 | * @param int $checkLimit Maximum number of pickup location checks to make |
310 | * (0 = no limit) |
311 | * |
312 | * @return array An array of any common pickup locations and a flag |
313 | * indicating any differences between them. |
314 | */ |
315 | protected function getPickupLocationsForEdit( |
316 | array $patron, |
317 | array $selectedIds, |
318 | int $checkLimit = 0 |
319 | ): ?array { |
320 | $catalog = $this->getILS(); |
321 | // Get holds from cache if available: |
322 | $holds = $this->getCachedData($this->getCacheId($patron, 'holds')) |
323 | ?? $catalog->getMyHolds($patron); |
324 | $checks = 0; |
325 | $pickupLocations = []; |
326 | $differences = false; |
327 | foreach ($holds as $hold) { |
328 | if (in_array((string)($hold['updateDetails'] ?? ''), $selectedIds)) { |
329 | try { |
330 | $locations = $catalog->getPickUpLocations($patron, $hold); |
331 | if (!$pickupLocations) { |
332 | $pickupLocations = $locations; |
333 | } else { |
334 | $ids1 = array_column($pickupLocations, 'locationID'); |
335 | $ids2 = array_column($locations, 'locationID'); |
336 | if ( |
337 | count($ids1) !== count($ids2) || array_diff($ids1, $ids2) |
338 | ) { |
339 | $differences = true; |
340 | // Find out any common pickup locations: |
341 | $common = array_intersect($ids1, $ids2); |
342 | if (!$common) { |
343 | $pickupLocations = []; |
344 | break; |
345 | } |
346 | $pickupLocations = array_filter( |
347 | $pickupLocations, |
348 | function ($location) use ($common) { |
349 | return in_array( |
350 | $location['locationID'], |
351 | $common |
352 | ); |
353 | } |
354 | ); |
355 | } |
356 | } |
357 | ++$checks; |
358 | if ($checkLimit && $checks >= $checkLimit) { |
359 | break; |
360 | } |
361 | } catch (ILSException $e) { |
362 | $this->flashMessenger() |
363 | ->addErrorMessage('ils_connection_failed'); |
364 | } |
365 | } |
366 | } |
367 | |
368 | return compact('pickupLocations', 'differences'); |
369 | } |
370 | |
371 | /** |
372 | * Get fields to update from details gathered from the user |
373 | * |
374 | * @param array $holdConfig Hold configuration from the driver |
375 | * @param array $gatheredDetails Details gathered from the user |
376 | * @param array $pickupLocations Valid pickup locations |
377 | * |
378 | * @return null|array Array of fields to update or null on validation error |
379 | */ |
380 | protected function getUpdateFieldsFromGatheredDetails( |
381 | array $holdConfig, |
382 | array $gatheredDetails, |
383 | array $pickupLocations |
384 | ): ?array { |
385 | $validPickup = true; |
386 | $selectedPickupLocation = $gatheredDetails['pickUpLocation'] ?? ''; |
387 | if ('' !== $selectedPickupLocation) { |
388 | $validPickup = $this->holds()->validatePickUpInput( |
389 | $selectedPickupLocation, |
390 | $holdConfig['updateFields'], |
391 | $pickupLocations |
392 | ); |
393 | } |
394 | $dateValidationResults = [ |
395 | 'errors' => [], |
396 | ]; |
397 | $frozenThroughValidationResults = [ |
398 | 'frozenThroughTS' => null, |
399 | 'errors' => [], |
400 | ]; |
401 | // The dates are not required unless one of them is set, so check that first: |
402 | if ( |
403 | !empty($gatheredDetails['startDate']) |
404 | || !empty($gatheredDetails['requiredBy']) |
405 | ) { |
406 | $dateValidationResults = $this->holds()->validateDates( |
407 | $gatheredDetails['startDate'] ?? null, |
408 | $gatheredDetails['requiredBy'] ?? null, |
409 | $holdConfig['updateFields'] |
410 | ); |
411 | } |
412 | if (in_array('frozenThrough', $holdConfig['updateFields'])) { |
413 | $frozenThroughValidationResults = $this->holds()->validateFrozenThrough( |
414 | $gatheredDetails['frozenThrough'] ?? null, |
415 | $holdConfig['updateFields'] |
416 | ); |
417 | $dateValidationResults['errors'] = array_unique( |
418 | array_merge( |
419 | $dateValidationResults['errors'], |
420 | $frozenThroughValidationResults['errors'] |
421 | ) |
422 | ); |
423 | } |
424 | if (!$validPickup) { |
425 | $this->flashMessenger()->addErrorMessage('hold_invalid_pickup'); |
426 | } |
427 | foreach ($dateValidationResults['errors'] as $msg) { |
428 | $this->flashMessenger()->addErrorMessage($msg); |
429 | } |
430 | if (!$validPickup || $dateValidationResults['errors']) { |
431 | return null; |
432 | } |
433 | |
434 | $updateFields = []; |
435 | if ($selectedPickupLocation !== '') { |
436 | $updateFields['pickUpLocation'] = $selectedPickupLocation; |
437 | } |
438 | if ($gatheredDetails['startDate'] ?? '' !== '') { |
439 | $updateFields['startDate'] = $gatheredDetails['startDate']; |
440 | $updateFields['startDateTS'] |
441 | = $dateValidationResults['startDateTS']; |
442 | } |
443 | if (($gatheredDetails['requiredBy'] ?? '') !== '') { |
444 | $updateFields['requiredBy'] = $gatheredDetails['requiredBy']; |
445 | $updateFields['requiredByTS'] |
446 | = $dateValidationResults['requiredByTS']; |
447 | } |
448 | if (($gatheredDetails['frozen'] ?? '') !== '') { |
449 | $updateFields['frozen'] = $gatheredDetails['frozen'] === '1'; |
450 | if (($gatheredDetails['frozenThrough']) ?? '' !== '') { |
451 | $updateFields['frozenThrough'] |
452 | = $gatheredDetails['frozenThrough']; |
453 | $updateFields['frozenThroughTS'] |
454 | = $frozenThroughValidationResults['frozenThroughTS']; |
455 | } |
456 | } |
457 | |
458 | return $updateFields; |
459 | } |
460 | |
461 | /** |
462 | * Return a session container for hold update results. |
463 | * |
464 | * @return \Laminas\Session\Container |
465 | */ |
466 | protected function getHoldUpdateResultsContainer() |
467 | { |
468 | return new \Laminas\Session\Container( |
469 | 'hold_update', |
470 | $this->serviceLocator->get(\Laminas\Session\SessionManager::class) |
471 | ); |
472 | } |
473 | |
474 | /** |
475 | * Get a unique cache id for a patron |
476 | * |
477 | * @param array $patron Patron |
478 | * @param string $type Type of cached data |
479 | * |
480 | * @return string |
481 | */ |
482 | protected function getCacheId(array $patron, string $type): string |
483 | { |
484 | return "$type::" . $patron['id'] . '::' |
485 | . ($patron['cat_id'] ?? $patron['cat_username'] ?? ''); |
486 | } |
487 | } |