Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.07% covered (success)
91.07%
102 / 112
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
IndexReservesCommand
91.07% covered (success)
91.07%
102 / 112
50.00% covered (danger)
50.00%
2 / 4
24.41
0.00% covered (danger)
0.00%
0 / 1
 configure
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
1
 buildReservesIndex
87.10% covered (warning)
87.10%
27 / 31
0.00% covered (danger)
0.00%
0 / 1
7.11
 getCsvReader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
88.00% covered (warning)
88.00%
44 / 50
0.00% covered (danger)
0.00%
0 / 1
15.39
1<?php
2
3/**
4 * Console command: index course reserves into Solr.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2020.
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  Console
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development Wiki
28 */
29
30namespace VuFindConsole\Command\Util;
31
32use Symfony\Component\Console\Attribute\AsCommand;
33use Symfony\Component\Console\Input\InputInterface;
34use Symfony\Component\Console\Input\InputOption;
35use Symfony\Component\Console\Output\OutputInterface;
36use VuFind\Reserves\CsvReader;
37use VuFindSearch\Backend\Solr\Document\UpdateDocument;
38use VuFindSearch\Backend\Solr\Record\SerializableRecord;
39
40use function count;
41use function in_array;
42use function ini_get;
43
44/**
45 * Console command: index course reserves into Solr.
46 *
47 * @category VuFind
48 * @package  Console
49 * @author   Demian Katz <demian.katz@villanova.edu>
50 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
51 * @link     https://vufind.org/wiki/development Wiki
52 */
53#[AsCommand(
54    name: 'util/index_reserves',
55    description: 'Course reserves index builder'
56)]
57class IndexReservesCommand extends AbstractSolrAndIlsCommand
58{
59    /**
60     * Default delimiter for reading files
61     *
62     * @var string
63     */
64    protected $defaultDelimiter = ',';
65
66    /**
67     * Default template for reading files
68     *
69     * @var string
70     */
71    protected $defaultTemplate = 'BIB_ID,COURSE,INSTRUCTOR,DEPARTMENT';
72
73    /**
74     * Keys required in the data to create a valid reserves index.
75     *
76     * @var string[]
77     */
78    protected $requiredKeys = ['INSTRUCTOR_ID', 'COURSE_ID', 'DEPARTMENT_ID'];
79
80    /**
81     * Configure the command.
82     *
83     * @return void
84     */
85    protected function configure()
86    {
87        $this
88            ->setHelp(
89                'This tool populates your course reserves Solr index. If run with'
90                . ' no options, it will attempt to load data from your ILS.'
91                . ' Switches may be used to index from delimited files instead.'
92            )->addOption(
93                'filename',
94                'f',
95                InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
96                'file(s) containing delimited values'
97            )->addOption(
98                'delimiter',
99                'd',
100                InputOption::VALUE_REQUIRED,
101                'specifies the delimiter used in file(s)',
102                $this->defaultDelimiter
103            )->addOption(
104                'template',
105                't',
106                InputOption::VALUE_REQUIRED,
107                'provides a template showing where important values can be found '
108                . "within the file.\nThe template is a comma-separated list of "
109                . "values. Choose from:\n"
110                . "BIB_ID     - bibliographic ID\n"
111                . "COURSE     - course name\n"
112                . "DEPARTMENT - department name\n"
113                . "INSTRUCTOR - instructor name\n"
114                . "SKIP       - ignore data in this position\n",
115                $this->defaultTemplate
116            );
117    }
118
119    /**
120     * Build the reserves index from date returned by the ILS driver,
121     * specifically: getInstructors, getDepartments, getCourses, findReserves
122     *
123     * @param array $instructors Array of instructors $instructor_id => $instructor
124     * @param array $courses     Array of courses     $course_id => $course
125     * @param array $departments Array of department  $dept_id => $department
126     * @param array $reserves    Array of reserves records from driver's
127     * findReserves.
128     *
129     * @return UpdateDocument
130     */
131    protected function buildReservesIndex(
132        $instructors,
133        $courses,
134        $departments,
135        $reserves
136    ) {
137        $index = [];
138        foreach ($reserves as $record) {
139            $requiredKeysFound
140                = count(array_intersect(array_keys($record), $this->requiredKeys));
141            if ($requiredKeysFound < count($this->requiredKeys)) {
142                throw new \Exception(
143                    implode(' and/or ', $this->requiredKeys) . ' fields ' .
144                    'not present in reserve records. Please update ILS driver.'
145                );
146            }
147            $instructorId = $record['INSTRUCTOR_ID'];
148            $courseId = $record['COURSE_ID'];
149            $departmentId = $record['DEPARTMENT_ID'];
150            $id = $courseId . '|' . $instructorId . '|' . $departmentId;
151
152            if (!isset($index[$id])) {
153                $index[$id] = [
154                    'id' => $id,
155                    'bib_id' => [],
156                    'instructor_id' => $instructorId,
157                    'instructor' => $instructors[$instructorId] ?? '',
158                    'course_id' => $courseId,
159                    'course' => $courses[$courseId] ?? '',
160                    'department_id' => $departmentId,
161                    'department' => $departments[$departmentId] ?? '',
162                ];
163            }
164            if (!in_array($record['BIB_ID'], $index[$id]['bib_id'])) {
165                $index[$id]['bib_id'][] = $record['BIB_ID'];
166            }
167        }
168
169        $updates = new UpdateDocument();
170        foreach ($index as $id => $data) {
171            if (!empty($data['bib_id'])) {
172                $updates->addRecord(new SerializableRecord($data));
173            }
174        }
175        return $updates;
176    }
177
178    /**
179     * Construct a CSV reader.
180     *
181     * @param array|string $files     Array of files to load (or single filename).
182     * @param string       $delimiter Delimiter used by file(s).
183     * @param string       $template  Template showing field positions within
184     * file(s). Comma-separated list containing BIB_ID, INSTRUCTOR, COURSE,
185     * DEPARTMENT and/or SKIP. Default = BIB_ID,COURSE,INSTRUCTOR,DEPARTMENT
186     *
187     * @return CsvReader
188     */
189    protected function getCsvReader(
190        $files,
191        string $delimiter,
192        string $template
193    ): CsvReader {
194        return new CsvReader($files, $delimiter, $template);
195    }
196
197    /**
198     * Run the command.
199     *
200     * @param InputInterface  $input  Input object
201     * @param OutputInterface $output Output object
202     *
203     * @return int 0 for success
204     */
205    protected function execute(InputInterface $input, OutputInterface $output)
206    {
207        // Check time limit; increase if necessary:
208        if (ini_get('max_execution_time') < 3600) {
209            ini_set('max_execution_time', '3600');
210        }
211
212        $delimiter = $input->getOption('delimiter');
213        $template = $input->getOption('template');
214
215        if ($file = $input->getOption('filename')) {
216            try {
217                $reader = $this->getCsvReader($file, $delimiter, $template);
218                $instructors = $reader->getInstructors();
219                $courses = $reader->getCourses();
220                $departments = $reader->getDepartments();
221                $reserves = $reader->getReserves();
222            } catch (\Exception $e) {
223                $output->writeln($e->getMessage());
224                return 1;
225            }
226        } elseif ($delimiter !== $this->defaultDelimiter) {
227            $output->writeln('-d (delimiter) is meaningless without -f (filename)');
228            return 1;
229        } elseif ($template !== $this->defaultTemplate) {
230            $output->writeln('-t (template) is meaningless without -f (filename)');
231            return 1;
232        } else {
233            try {
234                // Connect to ILS and load data:
235                $instructors = $this->catalog->getInstructors();
236                $courses = $this->catalog->getCourses();
237                $departments = $this->catalog->getDepartments();
238                $reserves = $this->catalog->findReserves('', '', '');
239            } catch (\Exception $e) {
240                $output->writeln($e->getMessage());
241                return 1;
242            }
243        }
244
245        // Make sure we have reserves and at least one of: instructors, courses,
246        // departments:
247        if (
248            (!empty($instructors) || !empty($courses) || !empty($departments))
249            && !empty($reserves)
250        ) {
251            // Delete existing records
252            $this->solr->deleteAll('SolrReserves');
253
254            // Build and Save the index
255            $index = $this->buildReservesIndex(
256                $instructors,
257                $courses,
258                $departments,
259                $reserves
260            );
261            $this->solr->save('SolrReserves', $index);
262
263            // Commit and Optimize the Solr Index
264            $this->solr->commit('SolrReserves');
265            $this->solr->optimize('SolrReserves');
266
267            $output->writeln('Successfully loaded ' . count($reserves) . ' rows.');
268            return 0;
269        }
270        $missing = array_merge(
271            empty($instructors) ? ['instructors'] : [],
272            empty($courses) ? ['courses'] : [],
273            empty($departments) ? ['departments'] : [],
274            empty($reserves) ? ['reserves'] : []
275        );
276        $output->writeln(
277            'Unable to load data. No data found for: ' . implode(', ', $missing)
278        );
279        return 1;
280    }
281}