Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 58 |
|
0.00% |
0 / 5 |
CRAP | |
0.00% |
0 / 1 |
ImpactHooks | |
0.00% |
0 / 58 |
|
0.00% |
0 / 5 |
462 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
onPageSaveComplete | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
20 | |||
onManualLogEntryBeforePublish | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
42 | |||
userIsInImpactDataCohort | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
42 | |||
refreshUserImpactDataInDeferredUpdate | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments; |
4 | |
5 | use DateTime; |
6 | use GrowthExperiments\UserImpact\RefreshUserImpactJob; |
7 | use GrowthExperiments\UserImpact\UserImpactFormatter; |
8 | use GrowthExperiments\UserImpact\UserImpactLookup; |
9 | use GrowthExperiments\UserImpact\UserImpactStore; |
10 | use IDBAccessObject; |
11 | use JobQueueGroup; |
12 | use MediaWiki\Config\Config; |
13 | use MediaWiki\Deferred\DeferredUpdates; |
14 | use MediaWiki\Hook\ManualLogEntryBeforePublishHook; |
15 | use MediaWiki\Storage\Hook\PageSaveCompleteHook; |
16 | use MediaWiki\User\Options\UserOptionsLookup; |
17 | use MediaWiki\User\UserEditTracker; |
18 | use MediaWiki\User\UserFactory; |
19 | use MediaWiki\User\UserIdentity; |
20 | use MediaWiki\User\UserIdentityUtils; |
21 | use MediaWiki\Utils\MWTimestamp; |
22 | use Wikimedia\LightweightObjectStore\ExpirationAwareness; |
23 | use Wikimedia\Timestamp\ConvertibleTimestamp; |
24 | |
25 | class 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 | } |