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