Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
81.41% |
127 / 156 |
|
50.00% |
7 / 14 |
CRAP | |
0.00% |
0 / 1 |
NotifyCommand | |
81.41% |
127 / 156 |
|
50.00% |
7 / 14 |
45.79 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
configure | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
msg | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
warn | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
err | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
execute | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
validateSchedule | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
getUserForSearch | |
33.33% |
3 / 9 |
|
0.00% |
0 / 1 |
5.67 | |||
setLanguage | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
3.01 | |||
getObjectForSearch | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
3.04 | |||
getNewRecords | |
87.88% |
29 / 33 |
|
0.00% |
0 / 1 |
6.06 | |||
buildEmail | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
1 | |||
sendEmail | |
33.33% |
6 / 18 |
|
0.00% |
0 / 1 |
5.67 | |||
processViewAlerts | |
84.21% |
16 / 19 |
|
0.00% |
0 / 1 |
8.25 |
1 | <?php |
2 | |
3 | /** |
4 | * Console command: notify users of scheduled searches. |
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 | |
30 | namespace VuFindConsole\Command\ScheduledSearch; |
31 | |
32 | use DateTime; |
33 | use Exception; |
34 | use Laminas\Config\Config; |
35 | use Laminas\View\Renderer\PhpRenderer; |
36 | use Symfony\Component\Console\Attribute\AsCommand; |
37 | use Symfony\Component\Console\Command\Command; |
38 | use Symfony\Component\Console\Input\InputInterface; |
39 | use Symfony\Component\Console\Output\OutputInterface; |
40 | use VuFind\Crypt\SecretCalculator; |
41 | use VuFind\Db\Entity\SearchEntityInterface; |
42 | use VuFind\Db\Entity\UserEntityInterface; |
43 | use VuFind\Db\Service\SearchServiceInterface; |
44 | use VuFind\I18n\Locale\LocaleSettings; |
45 | use VuFind\I18n\Translator\TranslatorAwareInterface; |
46 | use VuFind\Mailer\Mailer; |
47 | use VuFind\Search\Results\PluginManager as ResultsManager; |
48 | |
49 | use function count; |
50 | use function in_array; |
51 | |
52 | /** |
53 | * Console command: notify users of scheduled searches. |
54 | * |
55 | * @category VuFind |
56 | * @package Console |
57 | * @author Demian Katz <demian.katz@villanova.edu> |
58 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
59 | * @link https://vufind.org/wiki/development Wiki |
60 | */ |
61 | #[AsCommand( |
62 | name: 'scheduledsearch/notify', |
63 | description: 'Scheduled Search Notifier' |
64 | )] |
65 | class NotifyCommand extends Command implements TranslatorAwareInterface |
66 | { |
67 | use \VuFind\I18n\Translator\TranslatorAwareTrait; |
68 | use \VuFind\I18n\Translator\LanguageInitializerTrait; |
69 | |
70 | /** |
71 | * Output interface |
72 | * |
73 | * @var OutputInterface |
74 | */ |
75 | protected $output = null; |
76 | |
77 | /** |
78 | * Useful date format value |
79 | * |
80 | * @var string |
81 | */ |
82 | protected $iso8601 = 'Y-m-d\TH:i:s\Z'; |
83 | |
84 | /** |
85 | * URL helper |
86 | * |
87 | * @var \Laminas\View\Helper\Url |
88 | */ |
89 | protected $urlHelper; |
90 | |
91 | /** |
92 | * Number of results to retrieve when performing searches |
93 | * |
94 | * @var int |
95 | */ |
96 | protected $limit = 50; |
97 | |
98 | /** |
99 | * Constructor |
100 | * |
101 | * @param SecretCalculator $secretCalculator Secret calculator |
102 | * @param PhpRenderer $renderer View renderer |
103 | * @param ResultsManager $resultsManager Search results plugin manager |
104 | * @param array $scheduleOptions Configured schedule options |
105 | * @param Config $mainConfig Top-level VuFind configuration |
106 | * @param Mailer $mailer Mail service |
107 | * @param SearchServiceInterface $searchService Search table |
108 | * @param LocaleSettings $localeSettings Locale settings object |
109 | * @param string|null $name The name of the command; passing |
110 | * null means it must be set in configure() |
111 | */ |
112 | public function __construct( |
113 | protected SecretCalculator $secretCalculator, |
114 | protected PhpRenderer $renderer, |
115 | protected ResultsManager $resultsManager, |
116 | protected array $scheduleOptions, |
117 | protected Config $mainConfig, |
118 | protected Mailer $mailer, |
119 | protected SearchServiceInterface $searchService, |
120 | protected LocaleSettings $localeSettings, |
121 | $name = null |
122 | ) { |
123 | $this->urlHelper = $renderer->plugin('url'); |
124 | parent::__construct($name); |
125 | } |
126 | |
127 | /** |
128 | * Configure the command. |
129 | * |
130 | * @return void |
131 | */ |
132 | protected function configure() |
133 | { |
134 | $this->setHelp('Sends scheduled search email notifications.'); |
135 | } |
136 | |
137 | /** |
138 | * Display a message. |
139 | * |
140 | * @param string $msg Message to display |
141 | * |
142 | * @return void |
143 | */ |
144 | protected function msg($msg) |
145 | { |
146 | if (null !== $this->output) { |
147 | $this->output->writeln($msg); |
148 | } |
149 | } |
150 | |
151 | /** |
152 | * Display a warning. |
153 | * |
154 | * @param string $msg Message to display |
155 | * |
156 | * @return void |
157 | */ |
158 | protected function warn($msg) |
159 | { |
160 | $this->msg('WARNING: ' . $msg); |
161 | } |
162 | |
163 | /** |
164 | * Display an error. |
165 | * |
166 | * @param string $msg Message to display |
167 | * |
168 | * @return void |
169 | */ |
170 | protected function err($msg) |
171 | { |
172 | $this->msg('ERROR: ' . $msg); |
173 | } |
174 | |
175 | /** |
176 | * Run the command. |
177 | * |
178 | * @param InputInterface $input Input object |
179 | * @param OutputInterface $output Output object |
180 | * |
181 | * @return int 0 for success |
182 | * |
183 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
184 | */ |
185 | protected function execute(InputInterface $input, OutputInterface $output) |
186 | { |
187 | $this->output = $output; |
188 | $this->processViewAlerts(); |
189 | // Disconnect mailer to prevent exceptions from an attempt to gracefully |
190 | // close the connection on teardown |
191 | $this->mailer->resetConnection(); |
192 | return 0; |
193 | } |
194 | |
195 | /** |
196 | * Validate the schedule (return true if we should send a message). |
197 | * |
198 | * @param \DateTime $todayTime The time the notification job started. |
199 | * @param \DateTime $lastTime Last time notification was sent. |
200 | * @param SearchEntityInterface $s Search row to validate. |
201 | * |
202 | * @return bool |
203 | */ |
204 | protected function validateSchedule($todayTime, $lastTime, $s) |
205 | { |
206 | $schedule = $s->getNotificationFrequency(); |
207 | if (!isset($this->scheduleOptions[$schedule])) { |
208 | $this->err('Search ' . $s->getId() . ": unknown schedule: $schedule"); |
209 | return false; |
210 | } |
211 | $diff = $todayTime->diff($lastTime); |
212 | if ($diff->days < $schedule) { |
213 | $this->msg( |
214 | ' Bypassing search ' . $s->getId() . ': previous execution too recent (' |
215 | . $this->scheduleOptions[$schedule] . ', ' |
216 | . $lastTime->format($this->iso8601) . ')' |
217 | ); |
218 | return false; |
219 | } |
220 | return true; |
221 | } |
222 | |
223 | /** |
224 | * Load and validate a user object associated with the search; return null |
225 | * if there is a problem. |
226 | * |
227 | * @param SearchEntityInterface $s Current search row. |
228 | * |
229 | * @return ?UserEntityInterface |
230 | */ |
231 | protected function getUserForSearch($s) |
232 | { |
233 | if (!$user = $s->getUser()) { |
234 | $this->warn('Search ' . $s->getId() . ': is missing user data.'); |
235 | return null; |
236 | } |
237 | if (!$user->getEmail()) { |
238 | $this->warn( |
239 | 'User ' . $user->getUsername() . ' does not have an email address, bypassing alert ' . $s->getId() |
240 | ); |
241 | return null; |
242 | } |
243 | return $user; |
244 | } |
245 | |
246 | /** |
247 | * Set up the translator language. |
248 | * |
249 | * @param string $userLang User language preference from database (may be empty). |
250 | * |
251 | * @return void |
252 | */ |
253 | protected function setLanguage($userLang) |
254 | { |
255 | // Start with default language setting; override with user language |
256 | // preference if set and valid. Default to English if configuration |
257 | // is missing. |
258 | $language = $this->localeSettings->getDefaultLocale(); |
259 | $allLanguages = array_keys($this->localeSettings->getEnabledLocales()); |
260 | if ($userLang != '' && in_array($userLang, $allLanguages)) { |
261 | $language = $userLang; |
262 | } |
263 | $this->translator->setLocale($language); |
264 | $this->addLanguageToTranslator( |
265 | $this->translator, |
266 | $this->localeSettings, |
267 | $language |
268 | ); |
269 | } |
270 | |
271 | /** |
272 | * Load and validate the results object associated with the search; return false |
273 | * if there is a problem. |
274 | * |
275 | * @param SearchEntityInterface $s Current search row. |
276 | * |
277 | * @return \VuFind\Search\Base\Results|bool |
278 | */ |
279 | protected function getObjectForSearch($s) |
280 | { |
281 | $minSO = $s->getSearchObject(); |
282 | if (!$minSO) { |
283 | $this->err("Problem getting search object from search {$s->getId()}."); |
284 | return false; |
285 | } |
286 | $searchObject = $minSO->deminify($this->resultsManager); |
287 | if (!$searchObject->getOptions()->supportsScheduledSearch()) { |
288 | $this->err( |
289 | 'Unsupported search backend ' . $searchObject->getBackendId() |
290 | . ' for search ' . $searchObject->getSearchId() |
291 | ); |
292 | return false; |
293 | } |
294 | return $searchObject; |
295 | } |
296 | |
297 | /** |
298 | * Given a search results object, fetch records that have changed since the last |
299 | * search. Return false on error. |
300 | * |
301 | * @param \VuFind\Search\Base\Results $searchObject Search results object |
302 | * @param \DateTime $lastTime Last notification time |
303 | * |
304 | * @return array|bool |
305 | */ |
306 | protected function getNewRecords($searchObject, $lastTime) |
307 | { |
308 | // Prepare query |
309 | $params = $searchObject->getParams(); |
310 | $params->setLimit($this->limit); |
311 | $params->setSort('first_indexed desc', true); |
312 | $searchId = $searchObject->getSearchId(); |
313 | try { |
314 | $records = $searchObject->getResults(); |
315 | } catch (\Exception $e) { |
316 | $this->err("Error processing search $searchId: " . $e->getMessage()); |
317 | return false; |
318 | } |
319 | if (empty($records)) { |
320 | $this->msg( |
321 | " No results found for search $searchId" |
322 | ); |
323 | return false; |
324 | } |
325 | $newestRecordDate |
326 | = date($this->iso8601, strtotime($records[0]->getFirstIndexed() ?? '')); |
327 | $lastExecutionDate = $lastTime->format($this->iso8601); |
328 | if ($newestRecordDate < $lastExecutionDate) { |
329 | $this->msg( |
330 | " No new results for search ($searchId): " |
331 | . "$newestRecordDate < $lastExecutionDate" |
332 | ); |
333 | return false; |
334 | } |
335 | $this->msg( |
336 | " New results for search ($searchId): " |
337 | . "$newestRecordDate >= $lastExecutionDate" |
338 | ); |
339 | // Collect records that have been indexed (for the first time) |
340 | // after previous scheduled alert run |
341 | $newRecords = []; |
342 | foreach ($records as $record) { |
343 | $recDate = date($this->iso8601, strtotime($record->getFirstIndexed())); |
344 | if ($recDate < $lastExecutionDate) { |
345 | break; |
346 | } |
347 | $newRecords[] = $record; |
348 | } |
349 | return $newRecords; |
350 | } |
351 | |
352 | /** |
353 | * Build the email message. |
354 | * |
355 | * @param SearchEntityInterface $s Search table row |
356 | * @param UserEntityInterface $user User owning search row |
357 | * @param \VuFind\Search\Base\Results $searchObject Search results object |
358 | * @param array $newRecords New results in search |
359 | * |
360 | * @return string |
361 | */ |
362 | protected function buildEmail($s, $user, $searchObject, $newRecords) |
363 | { |
364 | $viewBaseUrl = $searchUrl = $s->getNotificationBaseUrl(); |
365 | $searchUrl .= ($this->urlHelper)( |
366 | $searchObject->getOptions()->getSearchAction() |
367 | ) . $searchObject->getUrlQuery()->getParams(false); |
368 | $secret = $this->secretCalculator->getSearchUnsubscribeSecret($s); |
369 | $unsubscribeUrl = $s->getNotificationBaseUrl() |
370 | . ($this->urlHelper)('myresearch-unsubscribe') |
371 | . "?id={$s->getId()}&key=$secret"; |
372 | $userInstitution = $this->mainConfig->Site->institution; |
373 | $params = $searchObject->getParams(); |
374 | // Filter function to only pass along selected checkboxes: |
375 | $selectedCheckboxes = function ($data) { |
376 | return $data['selected'] ?? false; |
377 | }; |
378 | $viewParams = [ |
379 | 'user' => $user, |
380 | 'records' => $newRecords, |
381 | 'info' => [ |
382 | 'baseUrl' => $viewBaseUrl, |
383 | 'description' => $params->getDisplayQuery(), |
384 | 'recordCount' => count($newRecords), |
385 | 'url' => $searchUrl, |
386 | 'unsubscribeUrl' => $unsubscribeUrl, |
387 | 'checkboxFilters' => array_filter( |
388 | $params->getCheckboxFacets(), |
389 | $selectedCheckboxes |
390 | ), |
391 | 'filters' => $params->getFilterList(true), |
392 | 'userInstitution' => $userInstitution, |
393 | ], |
394 | ]; |
395 | return $this->renderer |
396 | ->render('Email/scheduled-alert.phtml', $viewParams); |
397 | } |
398 | |
399 | /** |
400 | * Try to send an email message to a user. Return true on success, false on |
401 | * error. |
402 | * |
403 | * @param UserEntityInterface $user User to email |
404 | * @param string $message Email message body |
405 | * |
406 | * @return bool |
407 | */ |
408 | protected function sendEmail($user, $message) |
409 | { |
410 | $subject = $this->mainConfig->Site->title |
411 | . ': ' . $this->translate('Scheduled Alert Results'); |
412 | $from = $this->mainConfig->Site->email; |
413 | $to = $user->getEmail(); |
414 | try { |
415 | $this->mailer->send($to, $from, $subject, $message); |
416 | return true; |
417 | } catch (\Exception $e) { |
418 | $this->msg( |
419 | 'Initial email send failed; resetting connection and retrying...' |
420 | ); |
421 | } |
422 | // If we got this far, the first attempt threw an exception; let's reset |
423 | // the connection, then try again.... |
424 | $this->mailer->resetConnection(); |
425 | try { |
426 | $this->mailer->send($to, $from, $subject, $message); |
427 | } catch (\Exception $e) { |
428 | $this->err( |
429 | "Failed to send message to {$user->getEmail()}: " . $e->getMessage() |
430 | ); |
431 | return false; |
432 | } |
433 | // If we got here, the retry was a success! |
434 | return true; |
435 | } |
436 | |
437 | /** |
438 | * Send scheduled alerts for a view. |
439 | * |
440 | * @return void |
441 | */ |
442 | protected function processViewAlerts() |
443 | { |
444 | $todayTime = new \DateTime(); |
445 | $scheduled = $this->searchService->getScheduledSearches(); |
446 | $this->msg(sprintf('Processing %d searches', count($scheduled))); |
447 | foreach ($scheduled as $s) { |
448 | $lastTime = $s->getLastNotificationSent(); |
449 | if ( |
450 | !$this->validateSchedule($todayTime, $lastTime, $s) |
451 | || !($user = $this->getUserForSearch($s)) |
452 | || !($searchObject = $this->getObjectForSearch($s)) |
453 | || !($newRecords = $this->getNewRecords($searchObject, $lastTime)) |
454 | ) { |
455 | continue; |
456 | } |
457 | // Set email language |
458 | $this->setLanguage($user->getLastLanguage()); |
459 | |
460 | // Prepare email content |
461 | $message = $this->buildEmail($s, $user, $searchObject, $newRecords); |
462 | if (!$this->sendEmail($user, $message)) { |
463 | // If email send failed, move on to the next user without updating |
464 | // the database table. |
465 | continue; |
466 | } |
467 | try { |
468 | $s->setLastNotificationSent(new DateTime()); |
469 | $this->searchService->persistEntity($s); |
470 | } catch (Exception) { |
471 | $this->err("Error updating last_executed date for search {$s->getId()}"); |
472 | } |
473 | } |
474 | $this->msg('Done processing searches'); |
475 | } |
476 | } |