Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 235
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
HoldsController
0.00% covered (danger)
0.00%
0 / 235
0.00% covered (danger)
0.00%
0 / 7
3660
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 listAction
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
306
 editAction
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
272
 getPickupLocationsForEdit
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
110
 getUpdateFieldsFromGatheredDetails
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
210
 getHoldUpdateResultsContainer
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getCacheId
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
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
32namespace VuFind\Controller;
33
34use Laminas\Cache\Storage\StorageInterface;
35use Laminas\ServiceManager\ServiceLocatorInterface;
36use VuFind\Exception\ILS as ILSException;
37use VuFind\Validator\CsrfInterface;
38
39use function count;
40use function in_array;
41use 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 */
53class 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}