Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
23.30% covered (danger)
23.30%
72 / 309
15.79% covered (danger)
15.79%
3 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
UncachedMenteeOverviewDataProvider
23.30% covered (danger)
23.30%
72 / 309
15.79% covered (danger)
15.79%
3 / 19
1087.57
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getReadConnection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 resetService
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getProfilingInfo
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 storeProfilingData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getIds
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getLockedMenteesIds
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 getFilteredMenteesForMentor
0.00% covered (danger)
0.00%
0 / 78
0.00% covered (danger)
0.00%
0 / 1
42
 getLastEditTimestampForUsersInternal
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
6
 getLastEditTimestampForUsers
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 filterMenteesByLastEdit
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 getFormattedDataForMentor
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
42
 getUsernames
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getRevertedEditsForUsers
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getQuestionsAskedForUsers
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getTaggedEditsForUsers
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
30
 getRegistrationTimestampForUsers
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getBlocksForUsers
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
4
 getEditCountsForUsers
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace GrowthExperiments\MentorDashboard\MenteeOverview;
4
5use ChangeTags;
6use GrowthExperiments\HomepageHooks;
7use GrowthExperiments\HomepageModules\Mentorship;
8use GrowthExperiments\Mentorship\MentorPageMentorManager;
9use GrowthExperiments\Mentorship\Store\MentorStore;
10use MediaWiki\Extension\CentralAuth\CentralAuthServices;
11use MediaWiki\Registration\ExtensionRegistry;
12use MediaWiki\Storage\NameTableAccessException;
13use MediaWiki\Storage\NameTableStore;
14use MediaWiki\User\ActorMigration;
15use MediaWiki\User\TempUser\TempUserConfig;
16use MediaWiki\User\UserIdentity;
17use MediaWiki\User\UserIdentityLookup;
18use Psr\Log\LoggerAwareTrait;
19use Psr\Log\NullLogger;
20use Wikimedia\Rdbms\IConnectionProvider;
21use Wikimedia\Rdbms\IExpression;
22use Wikimedia\Rdbms\IReadableDatabase;
23use Wikimedia\Timestamp\ConvertibleTimestamp;
24
25/**
26 * Provides data about interesting mentees mentored by a particular mentor
27 *
28 * WARNING: This class implements no caching, and it may be only used from CLI
29 * scripts or jobs.
30 */
31class UncachedMenteeOverviewDataProvider implements MenteeOverviewDataProvider {
32    use LoggerAwareTrait;
33
34    /** @var int Number of seconds in a day */
35    private const SECONDS_DAY = 86400;
36
37    private MentorStore $mentorStore;
38
39    private NameTableStore $changeTagDefStore;
40
41    private ActorMigration $actorMigration;
42
43    private UserIdentityLookup $userIdentityLookup;
44    private TempUserConfig $tempUserConfig;
45
46    private IConnectionProvider $mainConnProvider;
47
48    /** @var array Cache used by getLastEditTimestampForUsers */
49    private $lastTimestampCache = [];
50
51    /**
52     * @var array Profiling information
53     *
54     * Stored by storeProfilingData, can be printed from
55     * updateMenteeData.php maintenance script.
56     */
57    private $profilingInfo = [];
58
59    /**
60     * @param MentorStore $mentorStore
61     * @param NameTableStore $changeTagDefStore
62     * @param ActorMigration $actorMigration
63     * @param UserIdentityLookup $userIdentityLookup
64     * @param TempUserConfig $tempUserConfig
65     * @param IConnectionProvider $mainConnProvider
66     */
67    public function __construct(
68        MentorStore $mentorStore,
69        NameTableStore $changeTagDefStore,
70        ActorMigration $actorMigration,
71        UserIdentityLookup $userIdentityLookup,
72        TempUserConfig $tempUserConfig,
73        IConnectionProvider $mainConnProvider
74    ) {
75        $this->setLogger( new NullLogger() );
76
77        $this->mentorStore = $mentorStore;
78        $this->changeTagDefStore = $changeTagDefStore;
79        $this->actorMigration = $actorMigration;
80        $this->userIdentityLookup = $userIdentityLookup;
81        $this->tempUserConfig = $tempUserConfig;
82        $this->mainConnProvider = $mainConnProvider;
83    }
84
85    private function getReadConnection(): IReadableDatabase {
86        return $this->mainConnProvider->getReplicaDatabase( false, 'vslow' );
87    }
88
89    /**
90     * Do stuff that needs to happen before calculating the data
91     */
92    private function resetService(): void {
93        $this->lastTimestampCache = [];
94        $this->profilingInfo = [];
95    }
96
97    /**
98     * Get profiling information
99     *
100     * @internal Only use from updateMenteeData.php
101     * @return array
102     */
103    public function getProfilingInfo(): array {
104        return $this->profilingInfo;
105    }
106
107    /**
108     * @param string $section
109     * @param float $seconds
110     */
111    private function storeProfilingData( string $section, float $seconds ): void {
112        $this->profilingInfo[$section] = $seconds;
113    }
114
115    /**
116     * @param UserIdentity[] $users
117     * @return int[]
118     */
119    private function getIds( array $users ): array {
120        return array_map( static function ( $user ) {
121            return $user->getId();
122        }, $users );
123    }
124
125    /**
126     * Return local user IDs of all globally locked mentees
127     *
128     * If CentralAuth is not installed, this returns an empty array (as
129     * locking is not possible in that case).
130     *
131     * If CentralAuth is available, CentralAuthServices::getGlobalUserSelectQueryBuilderFactory
132     * is used to get locked mentees only. The select query builder does not impose any explicit
133     * limit on number of users that can be processed at once. UncachedMenteeOverviewDataProvider
134     * runs certain queries similar to the CentralAuth extension, which run fine with the current
135     * number of mentees (as of March 2022, the upper bound is 42,716).
136     *
137     * @param UserIdentity[] $mentees
138     * @return int[]
139     */
140    private function getLockedMenteesIds( array $mentees ): array {
141        if (
142            !ExtensionRegistry::getInstance()->isLoaded( 'CentralAuth' ) ||
143            $mentees === []
144        ) {
145            return [];
146        }
147
148        if (
149            !method_exists( CentralAuthServices::class, 'getGlobalUserSelectQueryBuilderFactory' )
150        ) {
151            $this->logger->error(
152                'Old version of CentralAuth found, CentralAuthServices::getGlobalUserSelectQueryBuilderFactory' .
153                ' was not found'
154            );
155            return [];
156        }
157
158        $userIdentities = CentralAuthServices::getGlobalUserSelectQueryBuilderFactory()
159            ->newGlobalUserSelectQueryBuilder()
160            ->whereUserNames( array_map( static function ( UserIdentity $user ) {
161                return $user->getName();
162            }, $mentees ) )
163            ->whereLocked( true )
164            ->caller( __METHOD__ )
165            ->fetchLocalUserIdentitites();
166
167        return array_map( static function ( UserIdentity $user ) {
168            return $user->getId();
169        }, iterator_to_array( $userIdentities ) );
170    }
171
172    /**
173     * Filter mentees according to business rules
174     *
175     * Only mentees that meet all of the following conditions
176     * should be considered:
177     *  * user is not a bot
178     *  * user is not a temporary account (safety check should temp users end up as mentees)
179     *  * user is not indefinitely blocked
180     *  * user is not globally locked (via CentralAuth's implementation)
181     *  * user registered less than 2 weeks ago OR made at least one edit in the last 6 months
182     *
183     * @param UserIdentity $mentor
184     * @return int[] User IDs of the mentees
185     */
186    private function getFilteredMenteesForMentor( UserIdentity $mentor ): array {
187        $startTime = microtime( true );
188
189        $mentees = $this->mentorStore->getMenteesByMentor( $mentor, MentorStore::ROLE_PRIMARY );
190        $menteeIds = array_diff(
191            $this->getIds( $mentees ),
192            $this->getLockedMenteesIds( $mentees )
193        );
194
195        if ( $menteeIds === [] ) {
196            return [];
197        }
198
199        $dbr = $this->getReadConnection();
200
201        $queryBuilder = $dbr->newSelectQueryBuilder()
202            ->select( [
203                'user_id',
204                'has_edits' => 'user_editcount > 0'
205            ] )
206            ->from( 'user' )
207            ->where( [
208                // filter to mentees only
209                'user_id' => $menteeIds,
210
211                // ensure mentees have homepage enabled
212                'user_id IN (' . $dbr->newSelectQueryBuilder()
213                    ->select( 'up_user' )
214                    ->from( 'user_properties' )
215                    ->where( [
216                        'up_property' => HomepageHooks::HOMEPAGE_PREF_ENABLE,
217                        'up_value' => '1',
218                    ] )
219                    ->getSQL() .
220                ')',
221
222                // ensure mentees do not have mentorship disabled
223                'user_id NOT IN (' . $dbr->newSelectQueryBuilder()
224                    ->select( 'up_user' )
225                    ->from( 'user_properties' )
226                    ->where( [
227                        'up_property' => MentorPageMentorManager::MENTORSHIP_ENABLED_PREF,
228                        // sanity check, should never match (1 is the default value)
229                        $dbr->expr( 'up_value', '!=', '1' ),
230                    ] )
231                    ->getSQL() .
232                ')',
233
234                // user is not a bot,
235                'user_id NOT IN (' . $dbr->newSelectQueryBuilder()
236                    ->select( 'ug_user' )
237                    ->from( 'user_groups' )
238                    ->where( [ 'ug_group' => 'bot' ] )
239                    ->getSQL() .
240                ')',
241
242                // only users who either made an edit or registered less than 2 weeks ago
243                $dbr->expr( 'user_editcount', '>', 0 )
244                    ->or( 'user_registration', '>', $dbr->timestamp(
245                        (int)wfTimestamp( TS_UNIX ) - 2 * 7 * self::SECONDS_DAY
246                    ) ),
247            ] )
248            ->caller( __METHOD__ );
249
250        // Exclude users which are indefinitely blocked
251        $queryBuilder->andWhere(
252            'user_id NOT IN (' . $dbr->newSelectQueryBuilder()
253                ->select( 'bt_user' )
254                ->from( 'block' )
255                ->join( 'block_target', null, [
256                    'bt_id=bl_target'
257                ] )
258                ->where( [
259                    'bl_expiry' => $dbr->getInfinity(),
260                    // not an IP block
261                    $dbr->expr( 'bt_user', '!=', null )
262                ] )
263                ->getSQL() .
264            ')'
265        );
266
267        // exclude temporary accounts, if enabled (T341389)
268        if ( $this->tempUserConfig->isKnown() ) {
269            foreach ( $this->tempUserConfig->getMatchPatterns() as $pattern ) {
270                $queryBuilder->andWhere(
271                    $dbr->expr( 'user_name', IExpression::NOT_LIKE, $pattern->toLikeValue( $dbr ) )
272                );
273            }
274        }
275
276        $res = $queryBuilder->fetchResultSet();
277
278        $editingUsers = [];
279        $notEditingUsers = [];
280        foreach ( $res as $row ) {
281            if ( $row->has_edits ) {
282                $editingUsers[] = (int)$row->user_id;
283            } else {
284                $notEditingUsers[] = (int)$row->user_id;
285            }
286        }
287
288        $this->storeProfilingData( 'filtermentees', microtime( true ) - $startTime );
289
290        return array_merge(
291            $notEditingUsers,
292            $this->filterMenteesByLastEdit( $editingUsers )
293        );
294    }
295
296    /**
297     * @param int[] $userIds
298     * @return array
299     */
300    private function getLastEditTimestampForUsersInternal( array $userIds ): array {
301        $startTime = microtime( true );
302
303        $rows = $this->getReadConnection()->newSelectQueryBuilder()
304            ->select( [
305                'actor_user',
306                'last_edit' => 'MAX(rev_timestamp)'
307            ] )
308            ->from( 'revision' )
309            ->join( 'actor', null, 'rev_actor = actor_id' )
310            ->where( [
311                'actor_user' => $userIds,
312            ] )
313            ->caller( __METHOD__ )
314            ->groupBy( 'actor_user' )
315            ->fetchResultSet();
316        $res = [];
317        foreach ( $rows as $row ) {
318            $res[$row->actor_user] = $row->last_edit;
319        }
320
321        $this->storeProfilingData(
322            'edittimestampinternal',
323            microtime( true ) - $startTime
324        );
325        return $res;
326    }
327
328    /**
329     * @param int[] $userIds
330     * @return array
331     */
332    private function getLastEditTimestampForUsers( array $userIds ): array {
333        if ( $userIds === [] ) {
334            return [];
335        }
336
337        $data = array_intersect_key( $this->lastTimestampCache, array_fill_keys( $userIds, true ) );
338        $notInCache = array_diff( $userIds, array_keys( $this->lastTimestampCache ) );
339        if ( $notInCache ) {
340            $new = $this->getLastEditTimestampForUsersInternal( $notInCache );
341            $data += $new;
342            $this->lastTimestampCache += $new;
343        }
344        return $data;
345    }
346
347    /**
348     * Filter provided user IDs to IDs of users who edited up to 6 months ago
349     *
350     * @param array $allUserIds
351     * @return int[]
352     */
353    private function filterMenteesByLastEdit( array $allUserIds ): array {
354        if ( $allUserIds === [] ) {
355            return [];
356        }
357
358        $allLastEdits = $this->getLastEditTimestampForUsers( $allUserIds );
359        $userIds = [];
360        foreach ( $allLastEdits as $userId => $lastEdit ) {
361            $secondsSinceLastEdit = (int)wfTimestamp( TS_UNIX ) -
362                (int)ConvertibleTimestamp::convert(
363                    TS_UNIX,
364                    $lastEdit
365                );
366            if ( $secondsSinceLastEdit <= self::SECONDS_DAY * 6 * 30 ) {
367                $userIds[] = $userId;
368            }
369        }
370        return $userIds;
371    }
372
373    /**
374     * Calculates data for a given mentor's mentees
375     *
376     * @param UserIdentity $mentor
377     * @return array
378     */
379    public function getFormattedDataForMentor( UserIdentity $mentor ): array {
380        $this->resetService();
381
382        $userIds = $this->getFilteredMenteesForMentor( $mentor );
383        if ( $userIds === [] ) {
384            return [];
385        }
386
387        $mainData = [
388            'username' => $this->getUsernames( $userIds ),
389            'reverted' => $this->getRevertedEditsForUsers( $userIds ),
390            'questions' => $this->getQuestionsAskedForUsers( $userIds ),
391            'editcount' => $this->getEditCountsForUsers( $userIds ),
392            'registration' => $this->getRegistrationTimestampForUsers( $userIds ),
393            'last_edit' => $this->getLastEditTimestampForUsers( $userIds ),
394            'blocks' => $this->getBlocksForUsers( $userIds ),
395        ];
396
397        $res = [];
398        foreach ( $mainData as $key => $data ) {
399            foreach ( $data as $userId => $value ) {
400                $res[$userId][$key] = $value;
401            }
402        }
403        foreach ( $res as $userId => $userData ) {
404            $res[$userId]['last_active'] = $userData['last_edit'] ?? $userData['registration'] ?? null;
405            if ( $res[$userId]['last_active'] === null ) {
406                $this->logger->error(
407                    __METHOD__ . ': Registration and last_edit timestamps not found for user ID {userId}',
408                    [
409                        'userId' => $userId,
410                        'exception' => new \RuntimeException
411                    ]
412                );
413            }
414        }
415        return $res;
416    }
417
418    /**
419     * @param int[] $userIds
420     * @return array
421     */
422    private function getUsernames( array $userIds ): array {
423        $startTime = microtime( true );
424
425        $rows = $this->getReadConnection()->newSelectQueryBuilder()
426            ->select( [ 'user_id', 'user_name' ] )
427            ->from( 'user' )
428            ->where( [ 'user_id' => $userIds ] )
429            ->caller( __METHOD__ )
430            ->fetchResultSet();
431        $res = [];
432        foreach ( $rows as $row ) {
433            $res[$row->user_id] = $row->user_name;
434        }
435
436        $this->storeProfilingData( 'usernames', microtime( true ) - $startTime );
437
438        return $res;
439    }
440
441    /**
442     * @param int[] $userIds
443     * @return array
444     */
445    private function getRevertedEditsForUsers( array $userIds ): array {
446        $startTime = microtime( true );
447        $res = $this->getTaggedEditsForUsers(
448            [ ChangeTags::TAG_REVERTED ],
449            $userIds
450        );
451        $this->storeProfilingData( 'reverted', microtime( true ) - $startTime );
452        return $res;
453    }
454
455    /**
456     * @param int[] $userIds
457     * @return array
458     */
459    private function getQuestionsAskedForUsers( array $userIds ): array {
460        $startTime = microtime( true );
461        $res = $this->getTaggedEditsForUsers(
462            [
463                Mentorship::MENTORSHIP_HELPPANEL_QUESTION_TAG,
464                Mentorship::MENTORSHIP_MODULE_QUESTION_TAG
465            ],
466            $userIds
467        );
468        $this->storeProfilingData( 'questions', microtime( true ) - $startTime );
469        return $res;
470    }
471
472    /**
473     * @param string[] $tags
474     * @param int[] $userIds
475     * @return int[]
476     */
477    private function getTaggedEditsForUsers( array $tags, array $userIds ) {
478        $tagIds = [];
479        foreach ( $tags as $tag ) {
480            try {
481                $tagIds[] = $this->changeTagDefStore->getId( $tag );
482            } catch ( NameTableAccessException $e ) {
483                // Skip non-existing tags gracefully
484            }
485        }
486        if ( $tagIds === [] ) {
487            return array_fill_keys( $userIds, 0 );
488        }
489
490        $dbr = $this->getReadConnection();
491        $queryInfo = $this->actorMigration->getJoin( 'rev_user' );
492        $taggedEditsSubquery = $dbr->newSelectQueryBuilder()
493            ->select( [
494                'rev_user' => $queryInfo['fields']['rev_user'],
495                'ct_rev_id'
496            ] )
497            ->from( 'change_tag' )
498            ->join( 'revision', null, 'rev_id=ct_rev_id' )
499            ->tables( $queryInfo['tables'] )
500            ->where( [
501                'actor_user' => $userIds,
502                'ct_tag_id' => $tagIds
503            ] )
504            ->joinConds( $queryInfo['joins'] )
505            ->caller( __METHOD__ );
506        $rows = $dbr->newSelectQueryBuilder()
507            ->select( [ 'user_id', 'tagged' => 'COUNT(ct_rev_id)' ] )
508            ->from( 'user' )
509            ->leftJoin( $taggedEditsSubquery, 'tagged_edits', 'rev_user=user_id' )
510            ->where( [ 'user_id' => $userIds ] )
511            ->caller( __METHOD__ )
512            ->groupBy( 'user_id' )
513            ->fetchResultSet();
514
515        $res = [];
516        foreach ( $rows as $row ) {
517            $res[$row->user_id] = (int)$row->tagged;
518        }
519        return $res;
520    }
521
522    /**
523     * @param int[] $userIds
524     * @return array
525     */
526    private function getRegistrationTimestampForUsers( array $userIds ): array {
527        $startTime = microtime( true );
528        $rows = $this->getReadConnection()->newSelectQueryBuilder()
529            ->select( [ 'user_id', 'user_registration' ] )
530            ->from( 'user' )
531            ->where( [ 'user_id' => $userIds ] )
532            ->caller( __METHOD__ )
533            ->fetchResultSet();
534        $res = [];
535        foreach ( $rows as $row ) {
536            $res[$row->user_id] = $row->user_registration;
537        }
538        $this->storeProfilingData( 'registration', microtime( true ) - $startTime );
539        return $res;
540    }
541
542    /**
543     * Get number of blocks placed against the mentees
544     *
545     * @param int[] $userIds
546     * @return int[]
547     */
548    private function getBlocksForUsers( array $userIds ): array {
549        if ( $userIds === [] ) {
550            return [];
551        }
552
553        $startTime = microtime( true );
554
555        // fetch usernames (assoc. array; username => user ID)
556        // NOTE: username has underscores, not spaces
557        $users = [];
558        $userNamesAsStrings = [];
559        '@phan-var array<string> $userNamesAsStrings';
560        $userIdentities = iterator_to_array( $this->userIdentityLookup->newSelectQueryBuilder()
561            ->whereUserIds( $userIds )
562            ->caller( __METHOD__ )
563            ->fetchUserIdentities() );
564        array_walk(
565            $userIdentities,
566            static function ( UserIdentity $value, $key ) use ( &$users, &$userNamesAsStrings ) {
567                $username = str_replace( ' ', '_', $value->getName() );
568                $userNamesAsStrings[] = $username;
569                $users[$username] = $value->getId();
570            }
571        );
572
573        $rows = $this->getReadConnection()->newSelectQueryBuilder()
574            ->select( [ 'log_title', 'blocks' => 'COUNT(log_id)' ] )
575            ->from( 'logging' )
576            ->where( [
577                'log_type' => 'block',
578                'log_action' => 'block',
579                'log_namespace' => NS_USER,
580                'log_title' => $userNamesAsStrings
581            ] )
582            ->groupBy( 'log_title' )
583            ->caller( __METHOD__ )
584            ->fetchResultSet();
585
586        $res = [];
587        foreach ( $rows as $row ) {
588            $res[$users[$row->log_title]] = (int)$row->blocks;
589        }
590
591        // fill missing IDs with zeros
592        $missingIds = array_diff( $userIds, array_keys( $res ) );
593        foreach ( $missingIds as $id ) {
594            $res[$id] = 0;
595        }
596
597        $this->storeProfilingData( 'blocks', microtime( true ) - $startTime );
598
599        return $res;
600    }
601
602    /**
603     * @param int[] $userIds
604     * @return int[]
605     */
606    private function getEditCountsForUsers( array $userIds ): array {
607        $startTime = microtime( true );
608
609        $rows = $this->getReadConnection()->newSelectQueryBuilder()
610            ->select( [ 'user_id', 'user_editcount' ] )
611            ->from( 'user' )
612            ->where( [ 'user_id' => $userIds ] )
613            ->caller( __METHOD__ )
614            ->fetchResultSet();
615        $res = [];
616        foreach ( $rows as $row ) {
617            $res[$row->user_id] = (int)$row->user_editcount;
618        }
619
620        $this->storeProfilingData( 'editcount', microtime( true ) - $startTime );
621
622        return $res;
623    }
624}