Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 54 |
|
0.00% |
0 / 5 |
CRAP | |
0.00% |
0 / 1 |
ImpactHooks | |
0.00% |
0 / 54 |
|
0.00% |
0 / 5 |
380 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
onPageSaveComplete | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
onManualLogEntryBeforePublish | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
30 | |||
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 JobQueueGroup; |
11 | use MediaWiki\Config\Config; |
12 | use MediaWiki\Deferred\DeferredUpdates; |
13 | use MediaWiki\Hook\ManualLogEntryBeforePublishHook; |
14 | use MediaWiki\Storage\Hook\PageSaveCompleteHook; |
15 | use MediaWiki\User\Options\UserOptionsLookup; |
16 | use MediaWiki\User\UserEditTracker; |
17 | use MediaWiki\User\UserFactory; |
18 | use MediaWiki\User\UserIdentity; |
19 | use MediaWiki\User\UserIdentityUtils; |
20 | use MediaWiki\Utils\MWTimestamp; |
21 | use Wikimedia\LightweightObjectStore\ExpirationAwareness; |
22 | use Wikimedia\Rdbms\IDBAccessObject; |
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 | // 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 | } |