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