Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 232 |
|
0.00% |
0 / 8 |
CRAP | |
0.00% |
0 / 1 |
TranslationNotificationsSubmitJob | |
0.00% |
0 / 232 |
|
0.00% |
0 / 8 |
1190 | |
0.00% |
0 / 1 |
newJob | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
__construct | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
run | |
0.00% |
0 / 146 |
|
0.00% |
0 / 1 |
182 | |||
getSourceLanguage | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
fetchTranslators | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
20 | |||
getJobsForUser | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
72 | |||
addUserJobsToList | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
getCurrentTotalJobs | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\TranslationNotifications\Jobs; |
5 | |
6 | use Exception; |
7 | use IJobSpecification; |
8 | use JobSpecification; |
9 | use ManualLogEntry; |
10 | use MediaWiki\Config\Config; |
11 | use MediaWiki\Extension\Translate\PageTranslation\TranslatablePage; |
12 | use MediaWiki\Extension\TranslationNotifications\Utilities\LanguageSet; |
13 | use MediaWiki\Extension\TranslationNotifications\Utilities\TranslationNotifyUser; |
14 | use MediaWiki\JobQueue\JobQueueGroupFactory; |
15 | use MediaWiki\Languages\LanguageFactory; |
16 | use MediaWiki\Languages\LanguageNameUtils; |
17 | use MediaWiki\Title\Title; |
18 | use MediaWiki\User\Options\UserOptionsManager; |
19 | use MediaWiki\User\User; |
20 | use MediaWiki\User\UserFactory; |
21 | use MediaWiki\WikiMap\WikiMap; |
22 | use Wikimedia\Rdbms\IConnectionProvider; |
23 | use Wikimedia\Rdbms\IExpression; |
24 | use Wikimedia\Rdbms\IResultWrapper; |
25 | use Wikimedia\Rdbms\LikeValue; |
26 | |
27 | /** |
28 | * Handles a notification request. Uses the TranslationNotifyUsers to create the necessary jobs |
29 | * to deliver users messages based on their preferences. |
30 | * @ingroup JobQueue |
31 | * @license GPL-2.0-or-later |
32 | */ |
33 | class TranslationNotificationsSubmitJob extends GenericTranslationNotificationsJob { |
34 | |
35 | /** Id of the current wiki */ |
36 | private string $currentWikiId; |
37 | private UserOptionsManager $userOptionsManager; |
38 | private JobQueueGroupFactory $jobQueueGroupFactory; |
39 | private LanguageNameUtils $languageNameUtils; |
40 | private LanguageFactory $languageFactory; |
41 | private UserFactory $userFactory; |
42 | private IConnectionProvider $connectionProvider; |
43 | private Config $mainConfig; |
44 | private const JOB_NAME = 'TranslationNotificationsSubmitJob'; |
45 | |
46 | /** Returns an instance of the TranslationNotificationsSubmitJob */ |
47 | public static function newJob( |
48 | Title $title, array $requestData, int $notifierId, string $translatorLang |
49 | ): IJobSpecification { |
50 | return new JobSpecification( |
51 | self::JOB_NAME, |
52 | [ |
53 | 'requestData' => $requestData, |
54 | 'notifierId' => $notifierId, |
55 | 'translatorLanguage' => $translatorLang |
56 | ], |
57 | [], |
58 | $title |
59 | ); |
60 | } |
61 | |
62 | public function __construct( |
63 | Title $title, |
64 | array $params, |
65 | UserOptionsManager $userOptionsManager, |
66 | JobQueueGroupFactory $jobQueueGroupFactory, |
67 | LanguageNameUtils $languageNameUtils, |
68 | LanguageFactory $languageFactory, |
69 | UserFactory $userFactory, |
70 | IConnectionProvider $connectionProvider, |
71 | Config $mainConfig |
72 | ) { |
73 | $this->userOptionsManager = $userOptionsManager; |
74 | $this->jobQueueGroupFactory = $jobQueueGroupFactory; |
75 | $this->languageNameUtils = $languageNameUtils; |
76 | $this->languageFactory = $languageFactory; |
77 | $this->userFactory = $userFactory; |
78 | $this->connectionProvider = $connectionProvider; |
79 | $this->mainConfig = $mainConfig; |
80 | parent::__construct( self::JOB_NAME, $title, $params ); |
81 | $this->currentWikiId = WikiMap::getCurrentWikiDbDomain()->getId(); |
82 | } |
83 | |
84 | /** Execute the job */ |
85 | public function run(): bool { |
86 | $this->logInfo( 'Processing translation notification submit request.' ); |
87 | |
88 | $params = $this->params; |
89 | $translatableTitle = $this->title; |
90 | $notifier = $this->userFactory->newFromId( $params['notifierId'] ); |
91 | $sourceLanguage = $this->getSourceLanguage( $translatableTitle ); |
92 | $translatorLangCode = $params['translatorLanguage']; |
93 | |
94 | // Request information |
95 | $notificationText = $params['requestData']['notificationText']; |
96 | $priority = $params['requestData']['priority']; |
97 | $selectedLanguages = $params['requestData']['selectedLanguages']; |
98 | $deadlineDate = $params['requestData']['deadlineDate']; |
99 | $languageSet = $params['requestData']['languageSet']; |
100 | |
101 | if ( !$languageSet instanceof LanguageSet ) { |
102 | $languageSet = LanguageSet::fromArray( $languageSet ); |
103 | } |
104 | |
105 | $translatorsToNotify = $this->fetchTranslators( $selectedLanguages, $sourceLanguage, $languageSet ); |
106 | |
107 | $this->logInfo( |
108 | 'Found ' . $translatorsToNotify->numRows() . |
109 | ' translators to notify with the given conditions.', |
110 | [ |
111 | 'selectedLanguages' => $selectedLanguages |
112 | ] |
113 | ); |
114 | |
115 | $frequencies = [ |
116 | 'always' => 0, |
117 | 'week' => 604800, // seconds in week |
118 | 'month' => 2678400, // seconds in month |
119 | 'weekly' => 604800, // seconds in week |
120 | 'monthly' => 2678400, // seconds in month |
121 | 'none' => null |
122 | ]; |
123 | $currentUnixTime = wfTimestamp(); |
124 | $currentDBTime = $this->connectionProvider |
125 | ->getReplicaDatabase() |
126 | ->timestamp( $currentUnixTime ); |
127 | |
128 | $timestampOptionName = 'translationnotifications-timestamp'; |
129 | |
130 | $allLanguages = array_keys( $this->languageNameUtils->getLanguageNames() ); |
131 | $languagesToNotify = []; |
132 | |
133 | switch ( $languageSet->getOption() ) { |
134 | case LanguageSet::ALL: |
135 | $languagesToNotify = array_diff( $allLanguages, [ $sourceLanguage ] ); |
136 | break; |
137 | case LanguageSet::SOME: |
138 | $languagesToNotify = $selectedLanguages; |
139 | break; |
140 | case LanguageSet::ALL_EXCEPT_SOME: |
141 | $languagesToNotify = array_diff( $allLanguages, array_merge( |
142 | $selectedLanguages, [ $sourceLanguage ] |
143 | ) ); |
144 | break; |
145 | } |
146 | |
147 | $notifyUser = new TranslationNotifyUser( |
148 | $translatableTitle, |
149 | $notifier, |
150 | $this->mainConfig->get( 'LocalInterwikis' ), |
151 | $this->mainConfig->get( 'NoReplyAddress' ), |
152 | $this->mainConfig->get( 'TranslationNotificationsAlwaysHttpsInEmail' ), |
153 | [ |
154 | 'text' => $notificationText, |
155 | 'priority' => $priority, |
156 | 'deadline' => $deadlineDate, |
157 | 'languagesToNotify' => $languagesToNotify |
158 | ] |
159 | ); |
160 | |
161 | $this->logInfo( 'Starting notification job creation for translators...' ); |
162 | |
163 | $jobsByTarget = []; |
164 | $stats = [ |
165 | 'jobNoPref' => 0, |
166 | 'jobEmail' => 0, |
167 | 'jobEmailDisabled' => 0, |
168 | 'jobTalkPage' => 0, |
169 | 'jobTalkPageOther' => 0, |
170 | 'tooEarly' => 0, |
171 | 'sendingFailed' => 0, |
172 | 'processedUsers' => 0, |
173 | 'unsubscribed' => 0 |
174 | ]; |
175 | |
176 | $usersWithEmptyWikiId = []; |
177 | foreach ( $translatorsToNotify as $translator ) { |
178 | $user = $this->userFactory->newFromId( (int)$translator->up_user )->getInstanceForUpdate(); |
179 | |
180 | $userTranslationFrequency = |
181 | $frequencies[$this->userOptionsManager->getOption( $user, 'translationnotifications-freq' )]; |
182 | |
183 | if ( $userTranslationFrequency === null ) { |
184 | $stats['processedUsers']++; |
185 | $stats['unsubscribed']++; |
186 | continue; |
187 | } |
188 | |
189 | $userTimestamp = $this->userOptionsManager->getOption( $user, $timestampOptionName ); |
190 | $userUnixTimestamp = ( $userTimestamp == null ) ? |
191 | wfTimestamp( TS_UNIX, '20120101000000' ) : // An old timestamp |
192 | wfTimestamp( TS_UNIX, $userTimestamp ); |
193 | |
194 | $timeSinceNotification = (int)$currentUnixTime - (int)$userUnixTimestamp; |
195 | |
196 | if ( $timeSinceNotification > $userTranslationFrequency ) { |
197 | $this->logDebug( "Deciding notification to be sent to user: {$user->getId()}" ); |
198 | |
199 | try { |
200 | $userJobs = $this->getJobsForUser( |
201 | $user, |
202 | $notifyUser, |
203 | $this->currentWikiId, |
204 | $usersWithEmptyWikiId |
205 | ); |
206 | $this->addUserJobsToList( $userJobs, $jobsByTarget, $stats ); |
207 | |
208 | $this->userOptionsManager->setOption( $user, $timestampOptionName, $currentDBTime ); |
209 | $user->saveSettings(); |
210 | |
211 | $stats['processedUsers']++; |
212 | } catch ( Exception $e ) { |
213 | $stats['sendingFailed']++; |
214 | $this->logError( |
215 | "Error while generating notification for user - {$user->getId()}.\n Exception: {$e}." |
216 | ); |
217 | } |
218 | } else { |
219 | $this->logDebug( "Skipping sending of notification to user: {$user->getId()}" ); |
220 | $stats['tooEarly']++; |
221 | } |
222 | |
223 | $this->logDebug( "Finished processing user: {$user->getId()}." ); |
224 | } |
225 | |
226 | foreach ( $jobsByTarget as $wiki => $jobs ) { |
227 | $this->logInfo( "Wiki: $wiki, Jobs: " . count( $jobs ) ); |
228 | $this->jobQueueGroupFactory->makeJobQueueGroup( $wiki )->push( $jobs ); |
229 | } |
230 | |
231 | if ( $usersWithEmptyWikiId ) { |
232 | // Empty wiki ID found for some jobs. Log this information. See: T342903 |
233 | $this->logWarn( |
234 | 'Following notification jobs had an empty target wiki id: {param}', |
235 | [ 'param' => implode( ', ', $usersWithEmptyWikiId ) ] |
236 | ); |
237 | } |
238 | |
239 | // Add a log entry |
240 | $languagesForLog = ''; |
241 | if ( count( $selectedLanguages ) ) { |
242 | $languagesForLog = $this->languageFactory->getLanguage( $translatorLangCode ) |
243 | ->commaList( $selectedLanguages ); |
244 | } |
245 | |
246 | $logEntry = new ManualLogEntry( 'notifytranslators', 'sent' ); |
247 | $logEntry->setPerformer( $notifier ); |
248 | $logEntry->setTarget( $translatableTitle ); |
249 | $logEntry->setParameters( [ |
250 | '4::languagesForLog' => $languagesForLog, |
251 | '5::deadlineDate' => $deadlineDate, |
252 | '6::priority' => $priority, |
253 | '7::sentSuccess' => $stats['processedUsers'], |
254 | '8::sentFail' => $stats['sendingFailed'], |
255 | '9::tooEarly' => $stats['tooEarly'], |
256 | '11:plain' => $languageSet->getOptionName(), |
257 | ] ); |
258 | |
259 | $logId = $logEntry->insert(); |
260 | $logEntry->publish( $logId ); |
261 | |
262 | // Log the stats |
263 | $stats[ 'jobTotal' ] = $this->getCurrentTotalJobs( $stats ); |
264 | $stats[ 'jobTotalWithSkipped' ] = $this->getCurrentTotalJobs( $stats, true ); |
265 | $this->logInfo( |
266 | "Finished processing. Overall info: " . |
267 | "Too early: {tooEarly}, Sending failed: {sendingFailed}, Users processed: {processedUsers}. " . |
268 | "Jobs info: " . |
269 | "Total: {jobTotal}, Total with skipped & unsubscribed: {jobTotalWithSkipped}, " . |
270 | "Email: {jobEmail}, Email disabled: {jobEmailDisabled}, Talk page: {jobTalkPage}, " . |
271 | "Talk page other wiki: {jobTalkPageOther}, No preference: {jobNoPref}.", |
272 | $stats |
273 | ); |
274 | |
275 | return true; |
276 | } |
277 | |
278 | private function getSourceLanguage( Title $translatablePageTitle ): string { |
279 | $translatablePage = TranslatablePage::newFromTitle( $translatablePageTitle ); |
280 | return $translatablePage->getMessageGroup()->getSourceLanguage(); |
281 | } |
282 | |
283 | /** Returns translators who match the given language criteria. */ |
284 | private function fetchTranslators( |
285 | array $selectedLanguages, |
286 | string $sourceLanguage, |
287 | LanguageSet $languageSet |
288 | ): IResultWrapper { |
289 | $dbr = $this->connectionProvider->getReplicaDatabase(); |
290 | $queryBuilder = $dbr->newSelectQueryBuilder() |
291 | ->select( 'up_user' ) |
292 | ->from( 'user_properties' ) |
293 | ->where( $dbr->expr( 'up_property', IExpression::LIKE, |
294 | new LikeValue( 'translationnotifications-lang-', $dbr->anyString() ) |
295 | ) ) |
296 | ->caller( __METHOD__ ) |
297 | ->distinct(); |
298 | switch ( $languageSet->getOption() ) { |
299 | case LanguageSet::ALL: |
300 | $queryBuilder->andWhere( $dbr->expr( 'up_value', '!=', $sourceLanguage ) ); |
301 | break; |
302 | case LanguageSet::SOME: |
303 | $queryBuilder->andWhere( $dbr->expr( 'up_value', '=', $selectedLanguages ) ); |
304 | break; |
305 | case LanguageSet::ALL_EXCEPT_SOME: |
306 | $selectedLanguages[] = $sourceLanguage; |
307 | $queryBuilder->andWhere( $dbr->expr( 'up_value', '!=', $selectedLanguages ) ); |
308 | break; |
309 | } |
310 | return $queryBuilder->fetchResultSet(); |
311 | } |
312 | |
313 | /** Return jobs for a user based on the user's preferences. */ |
314 | private function getJobsForUser( |
315 | User $user, |
316 | TranslationNotifyUser $notifyUser, |
317 | string $currentWikiId, |
318 | array &$usersWithEmptyWikiId |
319 | ): array { |
320 | $jobs = []; |
321 | |
322 | // Email notification |
323 | if ( $this->userOptionsManager->getOption( $user, 'translationnotifications-cmethod-email' ) ) { |
324 | if ( $this->userOptionsManager->getOption( $user, 'disablemail' ) ) { |
325 | // For some reason the user signed up to receive Translation Notifications emails, |
326 | // but receiving email is disabled in the user's preferences. |
327 | // To be on the safe side, disable the email contact method. |
328 | $this->userOptionsManager->setOption( $user, 'translationnotifications-cmethod-email', false ); |
329 | $jobs[] = [ $currentWikiId, 'jobEmailDisabled', null ]; |
330 | } elseif ( $this->userOptionsManager->getOption( $user, 'translationnotifications-freq' ) === 'always' ) { |
331 | // Check if user has email. Don't bother sending email if they don't have it configured |
332 | if ( $user->canReceiveEmail() ) { |
333 | $jobs[] = [ |
334 | $currentWikiId, 'jobEmail', $notifyUser->sendTranslationNotificationEmail( $user ) |
335 | ]; |
336 | } |
337 | } |
338 | } |
339 | |
340 | // Talk page in current wiki |
341 | if ( $this->userOptionsManager->getOption( $user, 'translationnotifications-cmethod-talkpage' ) ) { |
342 | $jobs[] = [ |
343 | $currentWikiId, |
344 | 'jobTalkPage', |
345 | $notifyUser->leaveUserMessage( $user, 'talkpageHere' ) |
346 | ]; |
347 | } |
348 | |
349 | // Talk page in another wiki |
350 | if ( $this->userOptionsManager->getOption( $user, 'translationnotifications-cmethod-talkpage-elsewhere' ) ) { |
351 | $wiki = $this->userOptionsManager->getOption( |
352 | $user, |
353 | 'translationnotifications-cmethod-talkpage-elsewhere-loc' |
354 | ) ?? ''; |
355 | |
356 | // T342903: WikiId is sometimes empty for some users. Don't create jobs for these users. |
357 | if ( $wiki === '' ) { |
358 | $usersWithEmptyWikiId[] = $user->getId(); |
359 | } else { |
360 | $jobs[] = [ |
361 | $wiki, |
362 | 'jobTalkPageOther', |
363 | $notifyUser->leaveUserMessage( $user, 'talkpageInOtherWiki' ) |
364 | ]; |
365 | } |
366 | } |
367 | |
368 | return $jobs; |
369 | } |
370 | |
371 | /** Add jobs for a user to the list of all jobs, also updates the stats. */ |
372 | private function addUserJobsToList( array $userJobs, array &$jobList, array &$stats ): void { |
373 | if ( !count( $userJobs ) ) { |
374 | $stats[ 'jobNoPref' ]++; |
375 | } |
376 | |
377 | foreach ( $userJobs as $userJob ) { |
378 | [ $wikiId, $jobType, $job ] = $userJob; |
379 | $stats[ $jobType ]++; |
380 | if ( $job === null ) { |
381 | continue; |
382 | } |
383 | |
384 | $jobList[ $wikiId ] ??= []; |
385 | $jobList[ $wikiId ][] = $job; |
386 | } |
387 | } |
388 | |
389 | private function getCurrentTotalJobs( array $stats, bool $withSkipped = false ): int { |
390 | $total = $stats[ 'jobTalkPageOther' ] + $stats[ 'jobTalkPage' ] |
391 | + $stats[ 'jobEmail' ]; |
392 | |
393 | if ( $withSkipped ) { |
394 | $total += $stats[ 'jobEmailDisabled' ] + $stats[ 'jobNoPref' ]; |
395 | $total += $stats[ 'unsubscribed' ]; |
396 | } |
397 | |
398 | return $total; |
399 | } |
400 | } |