Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 232
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
TranslationNotificationsSubmitJob
0.00% covered (danger)
0.00%
0 / 232
0.00% covered (danger)
0.00%
0 / 8
1190
0.00% covered (danger)
0.00%
0 / 1
 newJob
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 run
0.00% covered (danger)
0.00%
0 / 146
0.00% covered (danger)
0.00%
0 / 1
182
 getSourceLanguage
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 fetchTranslators
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 getJobsForUser
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
72
 addUserJobsToList
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getCurrentTotalJobs
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\TranslationNotifications\Jobs;
5
6use Exception;
7use IJobSpecification;
8use JobSpecification;
9use ManualLogEntry;
10use MediaWiki\Config\Config;
11use MediaWiki\Extension\Translate\PageTranslation\TranslatablePage;
12use MediaWiki\Extension\TranslationNotifications\Utilities\LanguageSet;
13use MediaWiki\Extension\TranslationNotifications\Utilities\TranslationNotifyUser;
14use MediaWiki\JobQueue\JobQueueGroupFactory;
15use MediaWiki\Languages\LanguageFactory;
16use MediaWiki\Languages\LanguageNameUtils;
17use MediaWiki\Title\Title;
18use MediaWiki\User\Options\UserOptionsManager;
19use MediaWiki\User\User;
20use MediaWiki\User\UserFactory;
21use MediaWiki\WikiMap\WikiMap;
22use Wikimedia\Rdbms\IConnectionProvider;
23use Wikimedia\Rdbms\IExpression;
24use Wikimedia\Rdbms\IResultWrapper;
25use 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 */
33class 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}