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