Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImpactHooks
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 5
462
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 onPageSaveComplete
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 onManualLogEntryBeforePublish
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 userIsInImpactDataCohort
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 refreshUserImpactDataInDeferredUpdate
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace GrowthExperiments;
4
5use DateTime;
6use GrowthExperiments\UserImpact\RefreshUserImpactJob;
7use GrowthExperiments\UserImpact\UserImpactFormatter;
8use GrowthExperiments\UserImpact\UserImpactLookup;
9use GrowthExperiments\UserImpact\UserImpactStore;
10use IDBAccessObject;
11use JobQueueGroup;
12use MediaWiki\Config\Config;
13use MediaWiki\Deferred\DeferredUpdates;
14use MediaWiki\Hook\ManualLogEntryBeforePublishHook;
15use MediaWiki\Storage\Hook\PageSaveCompleteHook;
16use MediaWiki\User\Options\UserOptionsLookup;
17use MediaWiki\User\UserEditTracker;
18use MediaWiki\User\UserFactory;
19use MediaWiki\User\UserIdentity;
20use MediaWiki\User\UserIdentityUtils;
21use MediaWiki\Utils\MWTimestamp;
22use Wikimedia\LightweightObjectStore\ExpirationAwareness;
23use Wikimedia\Timestamp\ConvertibleTimestamp;
24
25class ImpactHooks implements
26    PageSaveCompleteHook,
27    ManualLogEntryBeforePublishHook
28{
29
30    private Config $config;
31    private UserImpactLookup $userImpactLookup;
32    private UserImpactStore $userImpactStore;
33    private UserOptionsLookup $userOptionsLookup;
34    private UserFactory $userFactory;
35    private UserEditTracker $userEditTracker;
36    private UserImpactFormatter $userImpactFormatter;
37    private JobQueueGroup $jobQueueGroup;
38    private UserIdentityUtils $userIdentityUtils;
39
40    /**
41     * @param Config $config
42     * @param UserImpactLookup $userImpactLookup
43     * @param UserImpactStore $userImpactStore
44     * @param UserImpactFormatter $userImpactFormatter
45     * @param UserOptionsLookup $userOptionsLookup
46     * @param UserFactory $userFactory
47     * @param UserEditTracker $userEditTracker
48     * @param JobQueueGroup $jobQueueGroup
49     * @param UserIdentityUtils $userIdentityUtils
50     */
51    public function __construct(
52        Config $config,
53        UserImpactLookup $userImpactLookup,
54        UserImpactStore $userImpactStore,
55        UserImpactFormatter $userImpactFormatter,
56        UserOptionsLookup $userOptionsLookup,
57        UserFactory $userFactory,
58        UserEditTracker $userEditTracker,
59        JobQueueGroup $jobQueueGroup,
60        UserIdentityUtils $userIdentityUtils
61    ) {
62        $this->config = $config;
63        $this->userImpactLookup = $userImpactLookup;
64        $this->userImpactStore = $userImpactStore;
65        $this->userImpactFormatter = $userImpactFormatter;
66        $this->userOptionsLookup = $userOptionsLookup;
67        $this->userFactory = $userFactory;
68        $this->userEditTracker = $userEditTracker;
69        $this->jobQueueGroup = $jobQueueGroup;
70        $this->userIdentityUtils = $userIdentityUtils;
71    }
72
73    /** @inheritDoc */
74    public function onPageSaveComplete( $wikiPage, $user, $summary, $flags, $revisionRecord, $editResult ) {
75        if ( !$this->config->get( 'GEUseNewImpactModule' ) ) {
76            return;
77        }
78        // Refresh the user's impact after they've made an edit.
79        if ( $this->userIsInImpactDataCohort( $user ) &&
80            $user->equals( $revisionRecord->getUser() )
81        ) {
82            $this->refreshUserImpactDataInDeferredUpdate( $user );
83
84        }
85    }
86
87    /** @inheritDoc */
88    public function onManualLogEntryBeforePublish( $logEntry ): void {
89        if ( !$this->config->get( 'GEUseNewImpactModule' ) ) {
90            return;
91        }
92        if ( $logEntry->getType() === 'thanks' && $logEntry->getSubtype() === 'thank' ) {
93            $recipientUserPage = $logEntry->getTarget();
94            $user = $this->userFactory->newFromName( $recipientUserPage->getDBkey() );
95            if ( $user instanceof UserIdentity && $this->userIsInImpactDataCohort( $user ) ) {
96                $this->refreshUserImpactDataInDeferredUpdate( $user );
97            }
98        }
99    }
100
101    /**
102     * Account is considered to be in the Impact module data cohort if:
103     * - is registered, AND
104     * - has homepage preference enabled, AND
105     * - has edited, AND
106     * - created in the last year OR edited within the last 7 days
107     * @param UserIdentity $userIdentity
108     * @return bool
109     */
110    private function userIsInImpactDataCohort( UserIdentity $userIdentity ): bool {
111        if ( !$this->userIdentityUtils->isNamed( $userIdentity ) ) {
112            return false;
113        }
114        if ( !$this->userOptionsLookup->getBoolOption( $userIdentity, HomepageHooks::HOMEPAGE_PREF_ENABLE ) ) {
115            return false;
116        }
117        $lastEditTimestamp = $this->userEditTracker->getLatestEditTimestamp(
118            $userIdentity,
119            IDBAccessObject::READ_LATEST
120        );
121        if ( !$lastEditTimestamp ) {
122            return false;
123        }
124
125        $registrationCutoff = new DateTime( 'now - 1year' );
126        $user = $this->userFactory->newFromUserIdentity( $userIdentity );
127        $registrationTimestamp = wfTimestamp( TS_UNIX, $user->getRegistration() );
128
129        $lastEditTimestamp = MWTimestamp::getInstance( $lastEditTimestamp );
130        $lastEditAge = $lastEditTimestamp->diff( new ConvertibleTimestamp( new DateTime( 'now - 1week' ) ) );
131        if ( !$lastEditAge ) {
132            return false;
133        }
134
135        return $registrationTimestamp >= $registrationCutoff->getTimestamp() || $lastEditAge->days <= 7;
136    }
137
138    /**
139     * Refresh the user impact data after a thanks is received or the user makes an edit.
140     *
141     * @param UserIdentity $userIdentity
142     * @return void
143     */
144    private function refreshUserImpactDataInDeferredUpdate( UserIdentity $userIdentity ): void {
145        DeferredUpdates::addCallableUpdate( function () use ( $userIdentity ) {
146            // In a job queue context, we have more flexibility for ensuring that the user's page view data is
147            // reasonably complete, because we're not blocking a web request. In the web context, we will make
148            // a maximum of 5 requests to the AQS service to fetch page views for the user's "top articles".
149            // If the user has a cached impact, get the top viewed articles from that and place define them
150            // as the priority articles for fetching page view data. If somehow those articles are in fact not
151            // the top viewed articles, the refreshUserImpactJob will fix that when it runs.
152            $cachedImpact = $this->userImpactStore->getExpensiveUserImpact( $userIdentity );
153            $priorityArticles = [];
154            if ( $cachedImpact ) {
155                $sortedAndFilteredData = $this->userImpactFormatter->sortAndFilter( $cachedImpact->jsonSerialize() );
156                if ( count( $sortedAndFilteredData['topViewedArticles'] ) ) {
157                    // We need an array of titles, where the keys contain the titles in DBKey format.
158                    $priorityArticles = $sortedAndFilteredData['topViewedArticles'];
159                }
160            }
161            $impact = $this->userImpactLookup->getExpensiveUserImpact(
162                $userIdentity,
163                IDBAccessObject::READ_LATEST,
164                $priorityArticles
165            );
166            if ( $impact ) {
167                $this->userImpactStore->setUserImpact( $impact );
168                // Also enqueue a job, so that we can get accurate page view data for users who aren't in
169                // the filters defined for the refreshUserImpact.php cron job.
170                $this->jobQueueGroup->push( new RefreshUserImpactJob( [
171                    'impactDataBatch' => [ $userIdentity->getId() => json_encode( $impact ) ],
172                    // We want to regenerate the page view data, so set staleBefore that's
173                    // guaranteed to result in cache invalidation
174                    'staleBefore' => MWTimestamp::time() + ExpirationAwareness::TTL_SECOND
175                ] ) );
176            }
177        } );
178    }
179}