diff --git a/local/config/finna/config.ini.sample b/local/config/finna/config.ini.sample index 02d8d97a368..64584a4aead 100644 --- a/local/config/finna/config.ini.sample +++ b/local/config/finna/config.ini.sample @@ -311,6 +311,10 @@ library_cards = true ; memory problems for users with a large number of historic loans). Default = 50 ;historic_loan_page_size = 50 +; Limit for how many historic transactions to fetch at most from ILS +; when downloading loan history +loan_history_download_batch_limit = 1000 + ; Whether to display the item barcode for each loan. Default is false. display_checked_out_item_barcode = true diff --git a/local/languages/finna/en-gb.ini b/local/languages/finna/en-gb.ini index b70edda3d22..427f6baa798 100644 --- a/local/languages/finna/en-gb.ini +++ b/local/languages/finna/en-gb.ini @@ -279,6 +279,7 @@ Download = "Download" Download recording = "Download recording" Download the file = "Download the file" Download video = "Download video" +download_checkout_history_info_html = "" Due Date = "Due date" due_date_email_link_title = "You can see and renew the loans here:" due_date_email_subject = "Loans due soon" @@ -606,10 +607,11 @@ list_order_saved = "Sort order saved" list-tags-info = "Add a new keyword" Loading = "Loading" Loan Details = "Loan Details" -loan_history_download = "Export all" -loan_history_download_csv = "Export CSV" -loan_history_download_ods = "Export OpenOffice (ods)" -loan_history_download_xlsx = "Export Excel (xlsx)" +loan_history_download = "Download loan history" +loan_history_download_part = "Download part %%part%%/%%lastPart%%" +loan_history_download_csv = "CSV" +loan_history_download_ods = "OpenOffice (ods)" +loan_history_download_xlsx = "Excel (xlsx)" loan_history_purge = "Purge History" loan_history_purge_prompt_html = "Are you sure you will purge the loan history? Cleared loan history cannot be retrieved anymore." loan_history_purge_selected = "Purge Selected" diff --git a/local/languages/finna/fi.ini b/local/languages/finna/fi.ini index 3a1816079ba..b012bc34766 100644 --- a/local/languages/finna/fi.ini +++ b/local/languages/finna/fi.ini @@ -271,6 +271,7 @@ Download = "Lataa" Download recording = "Lataa äänite" Download the file = "Lataa tiedosto" Download video = "Lataa video" +download_checkout_history_info_html = "" Due Date = "Eräpäivä" due_date_email_link_title = "Voit tarkistaa lainojen tiedot ja uusia ne osoitteessa:" due_date_email_subject = "Lainoja erääntymässä pian" @@ -596,10 +597,11 @@ list_order_saved = "Järjestys tallennettu" list-tags-info = "Lisää uusi avainsana" Loading = "Ladataan" Loan Details = "Lainan tiedot" -loan_history_download = "Vie kaikki" -loan_history_download_csv = "Vie CSV" -loan_history_download_ods = "Vie OpenOffice (ods)" -loan_history_download_xlsx = "Vie Excel (xlsx)" +loan_history_download = "Lataa lainaushistoria" +loan_history_download_part = "Lataa osa %%part%%/%%lastPart%%" +loan_history_download_csv = "CSV" +loan_history_download_ods = "OpenOffice (ods)" +loan_history_download_xlsx = "Excel (xlsx)" loan_history_purge = "Tyhjennä historia" loan_history_purge_prompt_html = "Oletko varma, että haluat tyhjentää lainaushistoriasi? Tyhjennettyä historiaa ei saa takaisin." loan_history_purge_selected = "Poista valitut" diff --git a/local/languages/finna/se.ini b/local/languages/finna/se.ini index bce5462f15c..08e7e48ec65 100644 --- a/local/languages/finna/se.ini +++ b/local/languages/finna/se.ini @@ -578,6 +578,7 @@ list-tags-info = "Lasit ođđa beassansáni" Loading = "Láddejuvvo" Loan Details = "Luoikama dieđut" loan_history_download = "Doalvvo visot" +loan_history_download_part = "Doalvvo siidu %%part%%/%%lastPart%%" loan_history_download_csv = "Doalvvo CSV" loan_history_download_ods = "Doalvvo OpenOffice (ods)" loan_history_download_xlsx = "Doalvvo Excel (xlsx)" @@ -1063,6 +1064,7 @@ Show As List = "Dieđut ja govat" Show Holdings = "Čájet oažžasuvvandieđuid" Show Image Information = "Čájet gova dieđuid" Download the image = "Ládde gova" +download_checkout_history_info_html = "" show_all_results = "Čájet visot bohtosiid (%%total%%)" show_barcode_instructions_html = "Don sáhtát šaddat čuozihit cázastatkoda dehe stellet šearpma šearratvuođa, vai lohkki dohkkeha cázastatkoda." show_barcode_title = "Šleađggalaš girjerádjogoarta" diff --git a/local/languages/finna/sv.ini b/local/languages/finna/sv.ini index 6221987b788..383bd3ce265 100644 --- a/local/languages/finna/sv.ini +++ b/local/languages/finna/sv.ini @@ -270,6 +270,7 @@ Download = "Ladda ner" Download recording = "Ladda ner inspelning" Download the file = "Ladda ner filen" Download video = "Ladda ner video" +download_checkout_history_info_html = "" Due Date = "Förfallodag" due_date_email_link_title = "Du kan kontrollera uppgifter om lån och förnya dem på adressen:" due_date_email_subject = "Lån som snart förfaller" @@ -594,10 +595,11 @@ list_order_saved = "Sortering sparad" list-tags-info = "Lägg till nytt nyckelord" Loading = "Laddar" Loan Details = "Information om lånet" -loan_history_download = "Exportera alla" -loan_history_download_csv = "Exportera CSV" -loan_history_download_ods = "Exportera OpenOffice (ods)" -loan_history_download_xlsx = "Exportera Excel (xlsx)" +loan_history_download = "Ladda lånehistorik" +loan_history_download_part = "Ladda del %%part%%/%%lastPart%%" +loan_history_download_csv = "CSV" +loan_history_download_ods = "OpenOffice (ods)" +loan_history_download_xlsx = "Excel (xlsx)" loan_history_purge = "Rensa historiken" loan_history_purge_prompt_html = "Är du säker på att du vill rensa din utlåningshistorik? Raderad historik kan inte återskapas." loan_history_purge_selected = "Radera valda" diff --git a/module/Finna/config/module.config.php b/module/Finna/config/module.config.php index 8070493a52d..dd325ba433d 100644 --- a/module/Finna/config/module.config.php +++ b/module/Finna/config/module.config.php @@ -495,6 +495,10 @@ 'Finna\AjaxHandler\GetUserListFactory', 'Finna\AjaxHandler\GetUserLists' => 'Finna\AjaxHandler\GetUserListsFactory', + 'Finna\AjaxHandler\GetCheckoutHistory' => + 'Finna\AjaxHandler\GetCheckoutHistoryFactory', + 'Finna\AjaxHandler\GetCheckoutHistoryFile' => + 'Finna\AjaxHandler\GetCheckoutHistoryFactory', 'Finna\AjaxHandler\ReservationList' => 'Finna\AjaxHandler\ReservationListFactory', 'Finna\AjaxHandler\ImportFavorites' => @@ -517,6 +521,8 @@ 'getAccountNotifications' => 'Finna\AjaxHandler\GetAccountNotifications', 'getAuthorityInfo' => 'Finna\AjaxHandler\GetAuthorityInfo', 'getAuthorityFullInfo' => 'Finna\AjaxHandler\GetAuthorityFullInfo', + 'getCheckoutHistory' => 'Finna\AjaxHandler\GetCheckoutHistory', + 'getCheckoutHistoryFile' => 'Finna\AjaxHandler\GetCheckoutHistoryFile', 'getContentFeed' => 'Finna\AjaxHandler\GetContentFeed', 'getDescription' => 'Finna\AjaxHandler\GetDescription', 'getModel' => 'Finna\AjaxHandler\GetModel', @@ -1110,8 +1116,8 @@ 'LibraryCards/ResetPassword', 'LocationService/Modal', 'MetaLib/Home', 'MetaLib/Search', 'MetaLib/Advanced', - 'MyResearch/DownloadLoanHistory', 'MyResearch/SaveCustomOrder', 'MyResearch/SaveHistoricLoans', + 'MyResearch/DownloadCheckoutHistory', 'OrganisationInfo/Home', 'PCI/Home', 'PCI/Search', 'PCI/Record', 'Search/StreetSearch', diff --git a/module/Finna/src/Finna/AjaxHandler/GetCheckoutHistory.php b/module/Finna/src/Finna/AjaxHandler/GetCheckoutHistory.php new file mode 100644 index 00000000000..340d5221578 --- /dev/null +++ b/module/Finna/src/Finna/AjaxHandler/GetCheckoutHistory.php @@ -0,0 +1,181 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace Finna\AjaxHandler; + +use Laminas\Mvc\Controller\Plugin\Params; +use VuFind\Auth\ILSAuthenticator; +use VuFind\Db\Entity\UserEntityInterface; +use VuFind\ILS\Connection; +use VuFind\ILS\PaginationHelper; +use VuFind\Session\Settings as SessionSettings; + +/** + * GetCheckoutHistory AJAX handler + * + * @category VuFind + * @package AJAX + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class GetCheckoutHistory extends \VuFind\AjaxHandler\AbstractIlsAndUserAction +{ + /** + * Cache for patron + * + * @var array + */ + protected array $cachedPatron = []; + + /** + * Cache for function config. + * + * @var array + */ + protected array $cachedFunctionConfig = []; + + /** + * Constructor + * + * @param SessionSettings $ss Session settings + * @param Connection $ils ILS connection + * @param ILSAuthenticator $ilsAuthenticator ILS authenticator + * @param ?UserEntityInterface $user Logged in user (or null) + * @param \VuFind\Record\Loader $recordLoader Record loader + * @param int $batchLimit Config specified default batch limit + * @param int $defaultPageSize Default page size set in config.ini + */ + public function __construct( + SessionSettings $ss, + Connection $ils, + ILSAuthenticator $ilsAuthenticator, + ?UserEntityInterface $user, + protected \VuFind\Record\Loader $recordLoader, + protected int $batchLimit = 1000, + protected int $defaultPageSize = 50 + ) { + if ($this->batchLimit < $defaultPageSize) { + $this->batchLimit = $defaultPageSize; + } + parent::__construct($ss, $ils, $ilsAuthenticator, $user); + } + + /** + * Handle a request. + * + * @param Params $params Parameter helper from controller + * + * @return array [response data, HTTP status code] + */ + public function handleRequest(Params $params) + { + $this->disableSessionWrites(); // avoid session write timing bug + $result = $this->getCheckoutHistoryResult(); + if ($result['success'] === false) { + return $this->formatResponse($result['message'], $result['status']); + } + $calculatedResults = $this->calculateLimitsFromResult($result); + return $this->formatResponse(['parts' => $calculatedResults['parts']]); + } + + /** + * Calculate limits used to fetch data from the results obtained from getCheckoutHistoryResult. + * + * @param array $result Checkout history result + * + * @return array + */ + public function calculateLimitsFromResult(array $result): array + { + $resultCount = $result['function_result']['count'] ?? 1; + $paginationHelper = new PaginationHelper(); + $paginator = $paginationHelper->getPaginator( + $result['pageOptions'], + $result['function_result']['count'], + $result['function_result']['transactions'] + ); + $pageLimit = $paginator ? $paginator->getItemCountPerPage() : $this->defaultPageSize; + $parts = $pageLimit > $this->batchLimit + ? floor($resultCount / $this->batchLimit) + : ceil($resultCount / $this->batchLimit); + return [ + 'pageLimit' => $paginator ? $paginator->getItemCountPerPage() : $this->defaultPageSize, + 'pageCount' => $paginator ? $paginator->count() : 1, + 'parts' => $parts, + ]; + } + + /** + * Get checkout history result for user if available + * + * @param int $page First page to get from ils + * @param ?int $limit Current limit for the page size or null for default + * + * @return array + */ + public function getCheckoutHistoryResult(int $page = 1, ?int $limit = null): array + { + $getErrorMessage = function ($message, $status) { + $success = false; + $message = $this->translate($message); + return compact('success', 'message', 'status'); + }; + if (!$this->cachedPatron) { + $patron = $this->ilsAuthenticator->storedCatalogLogin(); + if (!$this->user || !$patron) { + return $getErrorMessage('You must be logged in first', self::STATUS_HTTP_NEED_AUTH); + } + $this->cachedPatron = $patron; + } + // Check function config + if (!$this->cachedFunctionConfig) { + $this->cachedFunctionConfig = $this->ils->checkFunction('getMyTransactionHistory', $this->cachedPatron); + if (false === $this->cachedFunctionConfig) { + return $getErrorMessage('ils_action_unavailable', self::STATUS_HTTP_UNAVAILABLE); + } + } + $paginationHelper = new PaginationHelper(); + $pageOptions = $paginationHelper->getOptions( + $page, + null, + $limit ?? $this->defaultPageSize, + $this->cachedFunctionConfig + ); + $result = $this->ils->getMyTransactionHistory($this->cachedPatron, $pageOptions['ilsParams']); + if (isset($result['success']) && !$result['success']) { + return $getErrorMessage('An error has occurred', self::STATUS_HTTP_ERROR); + } + return [ + 'success' => true, + 'function_result' => $result, + 'pageOptions' => $pageOptions, + ]; + } +} diff --git a/module/Finna/src/Finna/AjaxHandler/GetCheckoutHistoryFactory.php b/module/Finna/src/Finna/AjaxHandler/GetCheckoutHistoryFactory.php new file mode 100644 index 00000000000..1c9e9ee4fae --- /dev/null +++ b/module/Finna/src/Finna/AjaxHandler/GetCheckoutHistoryFactory.php @@ -0,0 +1,78 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace Finna\AjaxHandler; + +use Laminas\ServiceManager\Exception\ServiceNotCreatedException; +use Laminas\ServiceManager\Exception\ServiceNotFoundException; +use Psr\Container\ContainerExceptionInterface as ContainerException; +use Psr\Container\ContainerInterface; +use VuFind\AjaxHandler\AbstractIlsAndUserActionFactory; + +/** + * Factory for GetCheckoutHistory Ajax handler + * + * @category VuFind + * @package AJAX + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class GetCheckoutHistoryFactory extends AbstractIlsAndUserActionFactory +{ + /** + * Create an object + * + * @param ContainerInterface $container Service manager + * @param string $requestedName Service being created + * @param null|array $options Extra options (optional) + * + * @return object + * + * @throws ServiceNotFoundException if unable to resolve the service. + * @throws ServiceNotCreatedException if an exception is raised when + * creating a service. + * @throws ContainerException&\Throwable if any other error occurs + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke( + ContainerInterface $container, + $requestedName, + array $options = null + ) { + $config = $container->get(\VuFind\Config\PluginManager::class)->get('config'); + $options = [ + $container->get(\VuFind\Record\Loader::class), + $config->Catalog->loan_history_download_batch_limit ?? 1000, + $config->Catalog->historic_loan_page_size ?? 50, + ]; + return parent::__invoke($container, $requestedName, $options); + } +} diff --git a/module/Finna/src/Finna/AjaxHandler/GetCheckoutHistoryFile.php b/module/Finna/src/Finna/AjaxHandler/GetCheckoutHistoryFile.php new file mode 100644 index 00000000000..57cc47f85e9 --- /dev/null +++ b/module/Finna/src/Finna/AjaxHandler/GetCheckoutHistoryFile.php @@ -0,0 +1,227 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace Finna\AjaxHandler; + +use Exception; +use Laminas\Mvc\Controller\Plugin\Params; +use PhpOffice\PhpSpreadsheet\Cell\AdvancedValueBinder; +use PhpOffice\PhpSpreadsheet\Cell\Cell; +use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Writer\Csv; +use PhpOffice\PhpSpreadsheet\Writer\Ods; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx; + +/** + * GetCheckoutHistoryFile AJAX handler + * + * @category VuFind + * @package AJAX + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class GetCheckoutHistoryFile extends GetCheckoutHistory +{ + /** + * Options for the file format to be requested. + * + * @var array + */ + protected $exportFormats = [ + 'xlsx' => [ + 'mediaType' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'writer' => Xlsx::class, + ], + 'ods' => [ + 'mediaType' => 'application/vnd.oasis.opendocument.spreadsheet', + 'writer' => Ods::class, + ], + 'csv' => [ + 'mediaType' => 'text/csv', + 'writer' => Csv::class, + ], + ]; + + /** + * Helper boolean for checking if this returns stream as a response + * + * @var bool + */ + public bool $supportsStream = true; + + /** + * Handle a request. + * + * @param Params $params Parameter helper from controller + * + * @return array [response data, HTTP status code] + */ + public function handleRequest(Params $params) + { + $this->disableSessionWrites(); // avoid session write timing bug + $result = $this->getCheckoutHistoryResult(); + if ($result['success'] === false) { + return $this->formatResponse($result['message'], $result['status']); + } + try { + // Get requested history part as a file to be downloaded + $part = $params->fromQuery('part', 1); + $fileFormat = $params->fromQuery('format', 'csv'); + $calculatedResults = $this->calculateLimitsFromResult($result); + return $this->getHistoryAsFile( + $part, + $calculatedResults['pageLimit'], + $calculatedResults['pageCount'], + $fileFormat + ); + } catch (Exception $e) { + return $this->formatResponse( + $this->translate('An error has occurred'), + self::STATUS_HTTP_ERROR + ); + } + } + + /** + * Create a file for transaction history + * + * @param int $part Part of the transaction history to download + * @param int $limit Limit for how many transactions one fetch from ils fetches + * @param int $pagesCount Total amount of pages the user has in history + * @param string $fileFormat Format of the file to generate + * + * @return array [fileName => name of the file, mediaType => media type, filePointer => pointer for the resource] + */ + private function getHistoryAsFile( + int $part = 1, + int $limit = 50, + int $pagesCount = 1, + string $fileFormat = 'csv' + ): array { + // Calculate how many times required to fetch from ILS to achieve the $batchLimit + $pagesToFetch = 1; + $firstPageToFetch = 1; + $lastPageToFetch = 1; + if ($pagesCount > 1) { + $pagesToFetch = ceil($this->batchLimit / $limit); + $firstPageToFetch += ($pagesToFetch * ($part - 1)); + $lastPageToFetch += min(($pagesToFetch * $part) - 1, $pagesCount); + } + $tmpPath = 'php://temp/maxmemory:' . (5 * 1024 * 1024); + $tmp = fopen($tmpPath, 'r+'); + + $transactions = []; + for ($i = $firstPageToFetch; $i <= $lastPageToFetch; $i++) { + $result = $this->getCheckoutHistoryResult($i, $limit); + if ($result['success'] === false) { + fclose($tmp); + return $this->formatResponse($result['message'], $result['status']); + } + // Break if no transactions found + if (empty($result['function_result']['transactions'])) { + break; + } + $transactions = [...$transactions, ...$result['function_result']['transactions']]; + } + $ids = []; + foreach ($transactions as $current) { + $id = $current['id'] ?? ''; + $source = $current['source'] ?? DEFAULT_SEARCH_BACKEND; + $ids[] = compact('id', 'source'); + } + $records = $this->recordLoader->loadBatch($ids, true); + $header = [ + $this->translate('Title'), + $this->translate('Format'), + $this->translate('Author'), + $this->translate('Publication Year'), + $this->translate('Institution'), + $this->translate('Borrowing Location'), + $this->translate('Checkout Date'), + $this->translate('Return Date'), + $this->translate('Due Date'), + ]; + $spreadsheet = new Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + $worksheet->fromArray($header); + + if ('xlsx' === $fileFormat) { + Cell::setValueBinder(new AdvancedValueBinder()); + } + + foreach ($transactions as $i => $current) { + $driver = $records[$i]; + $format = $driver->getFormats(); + $format = end($format); + $author = $driver->tryMethod('getNonPresenterAuthors'); + + $loan = []; + $loan[] = $current['title'] ?? $driver->getTitle() ?? ''; + $loan[] = $this->translate($format); + $loan[] = $author[0]['name'] ?? ''; + $loan[] = $current['publication_year'] ?? ''; + $loan[] = empty($current['institution_name']) + ? '' + : $this->translateWithPrefix('location_', $current['institution_name']); + $loan[] = empty($current['borrowingLocation']) + ? '' + : $this->translateWithPrefix('location_', $current['borrowingLocation']); + $loan[] = $current['checkoutDate'] ?? ''; + $loan[] = $current['returnDate'] ?? ''; + $loan[] = $current['dueDate'] ?? ''; + + $nextRow = $worksheet->getHighestRow() + 1; + $worksheet->fromArray($loan, null, 'A' . (string)$nextRow); + } + if ('xlsx' === $fileFormat) { + $worksheet->getStyle('G2:I' . $worksheet->getHighestRow()) + ->getNumberFormat() + ->setFormatCode('dd.mm.yyyy'); + foreach (['G', 'H', 'I'] as $col) { + $worksheet->getColumnDimension($col)->setAutoSize(true); + } + } + $writer = new $this->exportFormats[$fileFormat]['writer']($spreadsheet); + $writer->save($tmp); + $fileName = 'finna-loan-history-parts-' . $firstPageToFetch; + if ($firstPageToFetch !== $lastPageToFetch) { + $fileName .= '-' . $lastPageToFetch; + } + $fileName .= ".$fileFormat"; + + rewind($tmp); + + return $this->formatResponse([ + 'fileName' => $fileName, + 'mediaType' => $this->exportFormats[$fileFormat]['mediaType'], + 'filePointer' => $tmp, + ], 200); + } +} diff --git a/module/Finna/src/Finna/Controller/AjaxController.php b/module/Finna/src/Finna/Controller/AjaxController.php index 378f21948b9..2a05abc5525 100644 --- a/module/Finna/src/Finna/Controller/AjaxController.php +++ b/module/Finna/src/Finna/Controller/AjaxController.php @@ -54,4 +54,60 @@ public function onlinePaymentNotifyAction() // Use text/html to avoid any output return $this->callAjaxMethod('onlinePaymentNotify', 'text/html'); } + + /** + * Handle a file download with AJAX call + * + * @return \Laminas\Http\Response + */ + public function fileAction() + { + $method = $this->params()->fromQuery('method'); + if (!$method) { + return $this->getAjaxResponse('text/plain', ['error' => 'Parameter "method" missing'], 400); + } + // Check the AJAX handler plugin manager for the method. + if (!$this->ajaxManager) { + throw new \Exception('AJAX Handler Plugin Manager missing.'); + } + if ($this->ajaxManager->has($method)) { + try { + $handler = $this->ajaxManager->get($method); + if ($handler->supportsStream ?? false) { + [$data, $status] = $handler->handleRequest($this->params()); + if ($status === 200) { + return $this->getFileResponse($data); + } + } + } catch (\Exception $e) { + return $this->getExceptionResponse('text/plain', $e); + } + } + + // If we got this far, we can't handle the requested method: + return $this->getAjaxResponse( + 'text/plain', + $this->translate('Invalid Method'), + \VuFind\AjaxHandler\AjaxHandlerInterface::STATUS_HTTP_BAD_REQUEST + ); + } + + /** + * Send output data and exit. + * + * @param mixed $data The response data + * + * @return \Laminas\Http\Response + * @throws \Exception + */ + protected function getFileResponse($data) + { + $response = $this->getResponse(); + $headers = $response->getHeaders(); + $headers->addHeaderLine('Content-type', $data['mediaType']); + $headers->addHeaderLine('Content-Disposition', 'attachment; filename="' . $data['fileName'] . '"'); + $headers->addHeaderLine('Cache-Control', 'no-cache, must-revalidate'); + $headers->addHeaderLine('Expires', 'Mon, 26 Jul 1997 05:00:00 GMT'); + return $response->setContent(stream_get_contents($data['filePointer'])); + } } diff --git a/module/Finna/src/Finna/Controller/MyResearchController.php b/module/Finna/src/Finna/Controller/MyResearchController.php index b5e9fa9fca0..6d92c935e63 100644 --- a/module/Finna/src/Finna/Controller/MyResearchController.php +++ b/module/Finna/src/Finna/Controller/MyResearchController.php @@ -40,12 +40,6 @@ use Finna\Db\Service\FinnaUserListServiceInterface; use Finna\Db\Service\FinnaUserServiceInterface; use Finna\Db\Service\UserListService as FinnaUserListService; -use PhpOffice\PhpSpreadsheet\Cell\AdvancedValueBinder; -use PhpOffice\PhpSpreadsheet\Cell\Cell; -use PhpOffice\PhpSpreadsheet\Spreadsheet; -use PhpOffice\PhpSpreadsheet\Writer\Csv; -use PhpOffice\PhpSpreadsheet\Writer\Ods; -use PhpOffice\PhpSpreadsheet\Writer\Xlsx; use VuFind\Db\Entity\UserEntityInterface; use VuFind\Db\Service\SearchServiceInterface; use VuFind\Db\Service\UserListServiceInterface; @@ -83,21 +77,6 @@ class MyResearchController extends \VuFind\Controller\MyResearchController use FinnaPersonalInformationSupportTrait; use Feature\FinnaUserListTrait; - protected $exportFormats = [ - 'xlsx' => [ - 'mediaType' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'writer' => Xlsx::class, - ], - 'ods' => [ - 'mediaType' => 'application/vnd.oasis.opendocument.spreadsheet', - 'writer' => Ods::class, - ], - 'csv' => [ - 'mediaType' => 'text/csv', - 'writer' => Csv::class, - ], - ]; - /** * Catalog Login Action * @@ -1333,131 +1312,31 @@ public function importAction() /** * Download historic loans * - * @return mixed + * @return mixed + * @deprecated Use AjaxHandler/GetCheckoutHistory */ public function downloadLoanHistoryAction() { - if (!is_array($patron = $this->catalogLogin())) { - return $patron; - } - $catalog = $this->getILS(); - - // Check function config - $functionConfig = $catalog->checkFunction( - 'getMyTransactionHistory', - $patron - ); - if (false === $functionConfig) { - $this->flashMessenger()->addErrorMessage('ils_action_unavailable'); - return $this->redirect()->toRoute('checkouts-history'); - } - $fileFormat = $this->params()->fromQuery('format', ''); - if (!in_array($fileFormat, ['ods', 'csv', 'xlsx'])) { - throw new \Exception('Invalid parameters.'); - } - - $recordLoader = $this->serviceLocator->get(\VuFind\Record\Loader::class); - $page = 1; - try { - $tmp = fopen('php://temp/maxmemory:' . (5 * 1024 * 1024), 'r+'); - $header = [ - $this->translate('Title'), - $this->translate('Format'), - $this->translate('Author'), - $this->translate('Publication Year'), - $this->translate('Institution'), - $this->translate('Borrowing Location'), - $this->translate('Checkout Date'), - $this->translate('Return Date'), - $this->translate('Due Date'), - ]; - $spreadsheet = new Spreadsheet(); - $worksheet = $spreadsheet->getActiveSheet(); - $worksheet->fromArray($header); - if ('xlsx' === $fileFormat) { - Cell::setValueBinder(new AdvancedValueBinder()); - } - do { - // Try to use large page size, but take ILS limits into account - $pageOptions = $this->getPaginationHelper() - ->getOptions($page, null, 1000, $functionConfig); - $result = $catalog - ->getMyTransactionHistory($patron, $pageOptions['ilsParams']); - - if (isset($result['success']) && !$result['success']) { - $this->flashMessenger()->addErrorMessage($result['status']); - return $this->redirect()->toRoute('checkouts-history'); - } - - $ids = []; - foreach ($result['transactions'] as $current) { - $id = $current['id'] ?? ''; - $source = $current['source'] ?? DEFAULT_SEARCH_BACKEND; - $ids[] = compact('id', 'source'); - } - $records = $recordLoader->loadBatch($ids, true); - foreach ($result['transactions'] as $i => $current) { - $driver = $records[$i]; - $format = $driver->getFormats(); - $format = end($format); - $author = $driver->tryMethod('getNonPresenterAuthors'); - - $loan = []; - $loan[] = $current['title'] ?? $driver->getTitle() ?? ''; - $loan[] = $this->translate($format); - $loan[] = $author[0]['name'] ?? ''; - $loan[] = $current['publication_year'] ?? ''; - $loan[] = empty($current['institution_name']) - ? '' - : $this->translateWithPrefix('location_', $current['institution_name']); - $loan[] = empty($current['borrowingLocation']) - ? '' - : $this->translateWithPrefix('location_', $current['borrowingLocation']); - $loan[] = $current['checkoutDate'] ?? ''; - $loan[] = $current['returnDate'] ?? ''; - $loan[] = $current['dueDate'] ?? ''; - - $nextRow = $worksheet->getHighestRow() + 1; - $worksheet->fromArray($loan, null, 'A' . (string)$nextRow); - } - - $pageEnd = $pageOptions['ilsPaging'] - ? ceil($result['count'] / $pageOptions['limit']) - : 1; - $page++; - } while ($page <= $pageEnd); - if ('xlsx' === $fileFormat) { - $worksheet->getStyle('G2:I' . $worksheet->getHighestRow()) - ->getNumberFormat() - ->setFormatCode('dd.mm.yyyy'); - foreach (['G', 'H', 'I'] as $col) { - $worksheet->getColumnDimension($col)->setAutoSize(true); - } - } - $response = $this->getResponse(); - $response->getHeaders() - ->addHeaderLine( - 'Content-Type', - $this->exportFormats[$fileFormat]['mediaType'] - ); - $writer = new $this->exportFormats[$fileFormat]['writer']($spreadsheet); - $writer->save($tmp); - - $response->getHeaders() - ->addHeaderLine( - 'Content-Disposition', - 'attachment; filename="finna-loan-history.' . $fileFormat . '"' - ); - - rewind($tmp); + } - $response->setContent(stream_get_contents($tmp)); - } catch (\Exception $e) { - $this->flashMessenger()->addErrorMessage('An error has occurred'); - return $this->redirect()->toRoute('checkouts-history'); + /** + * Returns template for downloading checkouts history + * + * @return mixed + */ + public function downloadCheckoutHistoryAction() + { + // Retrieve user object and force login if necessary: + if (!$this->getUser()) { + return $this->forceLogin(); } - - return $response; + $view = $this->createViewModel( + [ + 'format' => $this->params()->fromQuery('format', 'csv'), + ] + ); + $view->setTemplate('myresearch/download-checkout-history'); + return $view; } /** diff --git a/module/Finna/tests/unit-tests/src/FinnaTest/AjaxHandler/GetCheckoutHistoryTest.php b/module/Finna/tests/unit-tests/src/FinnaTest/AjaxHandler/GetCheckoutHistoryTest.php new file mode 100644 index 00000000000..de83ce3bec3 --- /dev/null +++ b/module/Finna/tests/unit-tests/src/FinnaTest/AjaxHandler/GetCheckoutHistoryTest.php @@ -0,0 +1,311 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Page + */ + +namespace FinnaTest\AjaxHandler; + +use Finna\AjaxHandler\GetCheckoutHistory; +use Finna\AjaxHandler\GetCheckoutHistoryFactory; +use Laminas\Config\Config; +use VuFind\Auth\ILSAuthenticator; +use VuFind\Auth\Manager; +use VuFind\Db\Entity\UserEntityInterface; +use VuFind\ILS\Connection; + +/** + * GetCheckoutHistory test class. + * + * @category VuFind + * @package Tests + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Page + */ +class GetCheckoutHistoryTest extends \VuFindTest\Unit\AjaxHandlerTestCase +{ + /** + * Set up a GetCheckoutHistory handler for testing. + * + * @param ?UserEntityInterface $user Return value for getUserObject() in auth manager + * @param Config $testConfig Default values for testing config settings + * + * @return GetCheckoutHistory + */ + protected function getHandler( + ?UserEntityInterface $user = null, + Config $testConfig = new Config([]) + ): GetCheckoutHistory { + // Set up auth manager with user: + $this->container->set(Manager::class, $this->getMockAuthManager($user)); + $mockConfigManager = $this->container->createMock(\VuFind\Config\PluginManager::class, ['get']); + $mockConfigManager->expects($this->once())->method('get')->with('config')->willReturn($testConfig); + $this->container->set(\VuFind\Config\PluginManager::class, $mockConfigManager); + // Build the handler: + $factory = new GetCheckoutHistoryFactory(); + return $factory($this->container, GetCheckoutHistory::class); + } + + /** + * Data provider for testSuccess + * + * @return array + */ + public static function getSuccessfulData(): array + { + return [ + 'batch limit is higher' => [ + 50, + 1000, + [ + 'success' => true, + 'transactions' => [[]], + 'count' => 10000, + ], + ['parts' => 10], + ], + 'batch limit is same' => [ + 50, + 50, + [ + 'success' => true, + 'transactions' => [[]], + 'count' => 10000, + ], + ['parts' => 200], + ], + 'batch limit is lower' => [ + 50, + 10, + [ + 'success' => true, + 'transactions' => [[]], + 'count' => 10000, + ], + ['parts' => 200], + ], + 'results lower than batch limit' => [ + 50, + 1000, + [ + 'success' => true, + 'transactions' => [[]], + 'count' => 21, + ], + ['parts' => 1], + ], + 'no history' => [ + 50, + 10, + [ + 'success' => true, + 'transactions' => [], + 'count' => 0, + ], + ['parts' => 0], + ], + 'different default than usual' => [ + 15, + 1000, + [ + 'success' => true, + 'transactions' => [], + 'count' => 10000, + ], + ['parts' => 10], + ], + 'test with very small limits' => [ + 3, + 2, + [ + 'success' => true, + 'transactions' => [], + 'count' => 7, + ], + ['parts' => 3], + ], + 'test with nothing set as limits' => [ + 0, + 1000, + [ + 'success' => true, + 'transactions' => [], + 'count' => 10000, + ], + ['parts' => 10], + ], + ]; + } + + /** + * Data provider for testSuccess + * + * @return array + */ + public static function getFailuresData(): array + { + return [ + 'failure from getMyTransactions' => [ + 50, + 1000, + [ + 'success' => false, + 'transactions' => [[]], + 'count' => 10000, + ], + ['An error has occurred', 500], + ], + ]; + } + + /** + * Test successful response + * + * @param int $defaultPageSize Default page size to set in config + * @param int $batchLimit Default batch limit to set in config + * @param array $transactionResult Array containing success, transactions and count of all transactions + * @param array $expected What is the expected result + * + * @return void + * + * @dataProvider getSuccessfulData + */ + public function testSuccess(int $defaultPageSize, int $batchLimit, array $transactionResult, array $expected) + { + $this->assertEquals( + [$expected], + $this->runSuccessfulTest($defaultPageSize, $batchLimit, $transactionResult) + ); + } + + /** + * Test failures + * + * @param int $defaultPageSize Default page size to set in config + * @param int $batchLimit Default batch limit to set in config + * @param array $transactionResult Array containing success, transactions and count of all transactions + * @param array $expected What is the expected result + * + * @return void + * + * @dataProvider getFailuresData + */ + public function testFailures(int $defaultPageSize, int $batchLimit, array $transactionResult, array $expected) + { + $this->assertEquals( + $expected, + $this->runSuccessfulTest($defaultPageSize, $batchLimit, $transactionResult) + ); + } + + /** + * Test the AJAX handler's response when no one is logged in. + * + * @return void + */ + public function testLoggedOutUser(): void + { + $handler = $this->getHandler(); + $this->assertEquals( + ['You must be logged in first', 401], + $handler->handleRequest($this->getParamsHelper([])) + ); + } + + /** + * Generic support function for successful request tests. + * + * @param int $limit Default page limit + * @param int $batchLimit Default batch limit + * @param array $transactionResult Result from getMyTransactionHistory + * + * @return array + */ + protected function runSuccessfulTest($limit, $batchLimit, $transactionResult = []): array + { + /** + * Create a wrapper class for connection as it is little bit difficult to mock + */ + $wrapperClass = new class ($transactionResult) extends Connection { + /** + * Override constructor + * + * @param array $transactionResult Result from getMyTransactionHistory + * + * @return void + */ + public function __construct(protected array $transactionResult = []) + { + } + + /** + * Override checkFunction + * + * @param string $function Function to check + * @param ?array $params Params to use or null + * + * @return array + */ + public function checkFunction($function, $params = null) + { + return [ + 'max_results' => 50, + ]; + } + + /** + * GetMyTransactionHistory mock + * + * @param array $patron Mock patron array + * @param array $params Contains info about ils specified limits + * + * @return array + */ + public function getMyTransactionHistory($patron, $params): array + { + return $this->transactionResult ?: [ + 'success' => true, + 'transactions' => [[]], + 'count' => 10000, + ]; + } + }; + $ilsAuth = $this->container + ->createMock(ILSAuthenticator::class, ['storedCatalogLogin']); + $ilsAuth->expects($this->any())->method('storedCatalogLogin')->willReturn([3]); + $this->container->set(Connection::class, $wrapperClass); + $this->container->set(ILSAuthenticator::class, $ilsAuth); + $config = new Config([ + 'Catalog' => [ + 'historic_loan_page_size' => $limit, + 'loan_history_download_batch_limit' => $batchLimit, + ], + ]); + $handler = $this->getHandler($this->getMockUser(), $config); + return $handler->handleRequest($this->getParamsHelper([])); + } +} diff --git a/themes/finna2/js/finna-checkout-history.js b/themes/finna2/js/finna-checkout-history.js new file mode 100644 index 00000000000..afa89e3b985 --- /dev/null +++ b/themes/finna2/js/finna-checkout-history.js @@ -0,0 +1,135 @@ +/*global finna, VuFind */ +finna.checkoutHistory = (function checkoutHistory() { + /** + * Selector used to obtain download button + * @member {string} downloadButtonSelector + */ + const downloadButtonSelector = 'button.js-download-checkout-history'; + + /** + * Holder for download button element. + * @member {HTMLButtonElement} downloadButton + */ + let downloadButton; + + /** + * Current part to be downloaded + * @member {number} currentPart + */ + let currentPart = 0; + + /** + * Last part to be downloaded + * @member {number} lastPart + */ + let lastPart = -1; + + /** + * Set the download button text to match next loadable part + * @returns {void} + */ + function syncButtonText() { + const textTemplate = VuFind.translate('loan_history_download_part'); + downloadButton.textContent = `${textTemplate.replace('%%part%%', currentPart).replace('%%lastPart%%', lastPart)}`; + } + + /** + * Display a spinner inside the download button + */ + function displaySpinner() + { + const spinnerElement = VuFind.icon('spinner', {}, true); + downloadButton.replaceChildren(spinnerElement, ` ${VuFind.translate('loading_ellipsis')}`); + } + + /** + * Request part of a checkout history to download + */ + function getCheckoutHistoryPart() + { + displaySpinner(); + const searchParams = new URLSearchParams( + { + method: "getCheckoutHistoryFile", + part: currentPart, + format: downloadButton.dataset.format + } + ); + let filename; + fetch (`${VuFind.path}/AJAX/FILE?${searchParams}`) + .then(response => { + if (!response.ok) { + throw new Error(''); + } + const header = response.headers.get('Content-Disposition'); + const parts = header.split(';'); + filename = parts[1].split('=')[1].replaceAll("\"", ""); + return response.blob(); + }).then((blob) => { + const url = window.URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); // we need to append the element to the dom -> otherwise it will not work in firefox + a.click(); + a.remove(); + if (currentPart < lastPart) { + currentPart++; + } + syncButtonText(); + }).catch((reason) => { + console.warn(reason); + syncButtonText(); + }); + } + + /** + * Initializes download checkout history + * @returns {void} + */ + function init() { + downloadButton = document.querySelector(downloadButtonSelector); + if (!downloadButton) { + return; + } + + // Check if there is cached results + if (lastPart > -1) { + currentPart = 1; + syncButtonText(); + downloadButton.addEventListener('click', (e) => { + e.preventDefault(); + getCheckoutHistoryPart(); + }); + } else { + displaySpinner(); + fetch (`${VuFind.path}/AJAX/JSON?method=getCheckoutHistory`) + .then(response => { + if (!response.ok) { + throw new Error('Error occurred.'); + } + return response.json(); + }).then(result => { + if (!result.data) { + return; + } + currentPart = 1; + if (result.data && result.data.parts) { + lastPart = result.data.parts; + syncButtonText(); + downloadButton.addEventListener('click', (e) => { + e.preventDefault(); + getCheckoutHistoryPart(); + }); + } + }).catch(error => { + downloadButton.style.display = 'none'; + console.warn(error); + }); + } + } + + return { + init: init + }; +})(); diff --git a/themes/finna2/templates/checkouts/history.phtml b/themes/finna2/templates/checkouts/history.phtml index b1198c577cc..fe3b90f23e5 100644 --- a/themes/finna2/templates/checkouts/history.phtml +++ b/themes/finna2/templates/checkouts/history.phtml @@ -89,17 +89,17 @@ transactions)): ?>
-