Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
13.46% |
14 / 104 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
PraiseworthyMenteeSuggester | |
13.46% |
14 / 104 |
|
0.00% |
0 / 12 |
430.05 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
getUserImpactsForActiveMentees | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
6 | |||
getPraiseworthyMenteesForMentorUncached | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
2.01 | |||
makeCacheKeyForMentor | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getScopedLock | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
refreshPraiseworthyMenteesForMentor | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
getPraiseworthyMenteesForMentor | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
3.02 | |||
isMenteeMarkedAsPraiseworthy | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
markMenteeAsPraiseworthy | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
20 | |||
removeMenteeFromSuggestions | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
20 | |||
markMenteeAsPraised | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
markMenteeAsSkipped | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\MentorDashboard\PersonalizedPraise; |
4 | |
5 | use GrowthExperiments\EventLogging\PersonalizedPraiseLogger; |
6 | use GrowthExperiments\Mentorship\Store\MentorStore; |
7 | use GrowthExperiments\UserImpact\DatabaseUserImpactStore; |
8 | use GrowthExperiments\UserImpact\UserImpact; |
9 | use GrowthExperiments\UserImpact\UserImpactLookup; |
10 | use MediaWiki\User\Options\UserOptionsManager; |
11 | use MediaWiki\User\UserIdentity; |
12 | use Psr\Log\LoggerAwareTrait; |
13 | use Wikimedia\LightweightObjectStore\ExpirationAwareness; |
14 | use Wikimedia\ObjectCache\BagOStuff; |
15 | use Wikimedia\ScopedCallback; |
16 | use Wikimedia\Timestamp\ConvertibleTimestamp; |
17 | |
18 | class PraiseworthyMenteeSuggester { |
19 | use LoggerAwareTrait; |
20 | |
21 | private const EXPIRATION_TTL = ExpirationAwareness::TTL_DAY; |
22 | |
23 | private BagOStuff $globalCache; |
24 | private UserOptionsManager $userOptionsManager; |
25 | private PraiseworthyConditionsLookup $praiseworthyConditionsLookup; |
26 | private PersonalizedPraiseNotificationsDispatcher $notificationsDispatcher; |
27 | private PersonalizedPraiseLogger $eventLogger; |
28 | private MentorStore $mentorStore; |
29 | private UserImpactLookup $userImpactLookup; |
30 | |
31 | /** |
32 | * @param BagOStuff $globalCache |
33 | * @param UserOptionsManager $userOptionsManager |
34 | * @param PraiseworthyConditionsLookup $praiseworthyConditionsLookup |
35 | * @param PersonalizedPraiseNotificationsDispatcher $notificationsDispatcher |
36 | * @param PersonalizedPraiseLogger $personalizedPraiseLogger |
37 | * @param MentorStore $mentorStore |
38 | * @param UserImpactLookup $userImpactLookup |
39 | */ |
40 | public function __construct( |
41 | BagOStuff $globalCache, |
42 | UserOptionsManager $userOptionsManager, |
43 | PraiseworthyConditionsLookup $praiseworthyConditionsLookup, |
44 | PersonalizedPraiseNotificationsDispatcher $notificationsDispatcher, |
45 | PersonalizedPraiseLogger $personalizedPraiseLogger, |
46 | MentorStore $mentorStore, |
47 | UserImpactLookup $userImpactLookup |
48 | ) { |
49 | $this->globalCache = $globalCache; |
50 | $this->userOptionsManager = $userOptionsManager; |
51 | $this->praiseworthyConditionsLookup = $praiseworthyConditionsLookup; |
52 | $this->notificationsDispatcher = $notificationsDispatcher; |
53 | $this->eventLogger = $personalizedPraiseLogger; |
54 | $this->mentorStore = $mentorStore; |
55 | $this->userImpactLookup = $userImpactLookup; |
56 | } |
57 | |
58 | /** |
59 | * Get array of user impacts for all active mentees assigned to $mentor |
60 | * |
61 | * @param UserIdentity $mentor |
62 | * @return UserImpact[] |
63 | */ |
64 | private function getUserImpactsForActiveMentees( UserIdentity $mentor ): array { |
65 | $mentees = $this->mentorStore->getMenteesByMentor( |
66 | $mentor, MentorStore::ROLE_PRIMARY, |
67 | false, false |
68 | ); |
69 | |
70 | if ( $this->userImpactLookup instanceof DatabaseUserImpactStore ) { |
71 | $userIds = array_map( static function ( UserIdentity $mentee ) { |
72 | return $mentee->getId(); |
73 | }, $mentees ); |
74 | return $this->userImpactLookup->batchGetUserImpact( $userIds ); |
75 | } else { |
76 | // There is no batching in other implementations than DatabaseUserImpactStore; fetch |
77 | // user impacts one by one. |
78 | $this->logger->error( |
79 | __METHOD__ . ' does not have DatabaseUserImpactStore injected, possible ' . |
80 | 'performance impact.' |
81 | ); |
82 | return array_map( function ( UserIdentity $mentee ) { |
83 | return $this->userImpactLookup->getUserImpact( $mentee ); |
84 | }, $mentees ); |
85 | } |
86 | } |
87 | |
88 | /** |
89 | * Get list of praiseworthy mentees with no caching |
90 | * |
91 | * This will iterate through all recently active mentees assigned to the |
92 | * mentor in question. |
93 | * |
94 | * @param UserIdentity $mentor |
95 | * @return UserImpact[] |
96 | */ |
97 | public function getPraiseworthyMenteesForMentorUncached( UserIdentity $mentor ): array { |
98 | $impacts = $this->getUserImpactsForActiveMentees( $mentor ); |
99 | return array_filter( $impacts, function ( ?UserImpact $impact ) use ( $mentor ) { |
100 | if ( $impact === null ) { |
101 | return false; |
102 | } |
103 | |
104 | return $this->praiseworthyConditionsLookup->isMenteePraiseworthyForMentor( |
105 | $impact, $mentor |
106 | ); |
107 | } ); |
108 | } |
109 | |
110 | /** |
111 | * @param UserIdentity $mentor |
112 | * @return string |
113 | */ |
114 | private function makeCacheKeyForMentor( UserIdentity $mentor ): string { |
115 | return $this->globalCache->makeKey( |
116 | 'GrowthExperiments', 'PraiseworthyMenteeSuggester', |
117 | UserImpact::VERSION, 'getPraiseworthyMentees', $mentor->getId() |
118 | ); |
119 | } |
120 | |
121 | /** |
122 | * Acquire a lock via BagOStuff |
123 | * |
124 | * If a lock cannot be acquired, an error is logged. |
125 | * |
126 | * @param string $key |
127 | * @param string $caller |
128 | * @return ScopedCallback|null |
129 | * @see BagOStuff::getScopedLock() |
130 | */ |
131 | private function getScopedLock( string $key, string $caller ): ?ScopedCallback { |
132 | $lock = $this->globalCache->getScopedLock( $key ); |
133 | if ( !$lock ) { |
134 | $this->logger->error( |
135 | $caller . ' failed to acquire a lock for cache of praiseworthy mentees' |
136 | ); |
137 | } |
138 | return $lock; |
139 | } |
140 | |
141 | /** |
142 | * Refresh the mentor's cache of their praiseworthy mentees |
143 | * |
144 | * @param UserIdentity $mentor |
145 | */ |
146 | public function refreshPraiseworthyMenteesForMentor( UserIdentity $mentor ): void { |
147 | $key = $this->makeCacheKeyForMentor( $mentor ); |
148 | $lock = $this->getScopedLock( $key, __METHOD__ ); |
149 | if ( !$lock ) { |
150 | return; |
151 | } |
152 | |
153 | $praiseworthyMentees = $this->getPraiseworthyMenteesForMentorUncached( $mentor ); |
154 | $this->globalCache->set( |
155 | $key, |
156 | $praiseworthyMentees, |
157 | self::EXPIRATION_TTL |
158 | ); |
159 | |
160 | $wasNotified = $this->notificationsDispatcher->maybeNotifyAboutPendingMentees( $mentor ); |
161 | foreach ( $praiseworthyMentees as $impact ) { |
162 | $this->eventLogger->logSuggested( $mentor, $impact->getUser(), $wasNotified ); |
163 | } |
164 | } |
165 | |
166 | /** |
167 | * Get cached list of praiseworthy mentees for mentor |
168 | * |
169 | * @param UserIdentity $mentor |
170 | * @return UserImpact[] ID => UserImpact mapping |
171 | */ |
172 | public function getPraiseworthyMenteesForMentor( UserIdentity $mentor ): array { |
173 | $key = $this->makeCacheKeyForMentor( $mentor ); |
174 | $lock = $this->getScopedLock( $key, __METHOD__ ); |
175 | if ( !$lock ) { |
176 | return []; |
177 | } |
178 | |
179 | $res = $this->globalCache->get( $key ); |
180 | if ( !$res ) { |
181 | return []; |
182 | } |
183 | return $res; |
184 | } |
185 | |
186 | /** |
187 | * @param UserIdentity $mentee |
188 | * @param UserIdentity $mentor |
189 | * @return bool |
190 | */ |
191 | public function isMenteeMarkedAsPraiseworthy( UserIdentity $mentee, UserIdentity $mentor ): bool { |
192 | $praiseworthyIds = array_keys( $this->getPraiseworthyMenteesForMentor( $mentor ) ); |
193 | return in_array( $mentee->getId(), $praiseworthyIds ); |
194 | } |
195 | |
196 | /** |
197 | * Mark a mentee as praiseworthy |
198 | * |
199 | * Caller is responsible for checking whether mentee is praiseworthy or not; |
200 | * this can be done by calling PraiseworthyConditionsLookup::isMenteePraiseworthyForMentor. |
201 | * |
202 | * @param UserImpact $menteeImpact |
203 | * @param UserIdentity $mentorUser |
204 | */ |
205 | public function markMenteeAsPraiseworthy( |
206 | UserImpact $menteeImpact, |
207 | UserIdentity $mentorUser |
208 | ): void { |
209 | if ( $this->isMenteeMarkedAsPraiseworthy( $menteeImpact->getUser(), $mentorUser ) ) { |
210 | // already done |
211 | return; |
212 | } |
213 | |
214 | $key = $this->makeCacheKeyForMentor( $mentorUser ); |
215 | $lock = $this->getScopedLock( $key, __METHOD__ ); |
216 | if ( !$lock ) { |
217 | return; |
218 | } |
219 | |
220 | $data = $this->globalCache->get( $key ); |
221 | if ( !$data ) { |
222 | $data = []; |
223 | } |
224 | $data[$menteeImpact->getUser()->getId()] = $menteeImpact; |
225 | $this->globalCache->set( $key, $data, self::EXPIRATION_TTL ); |
226 | |
227 | $this->notificationsDispatcher->onMenteeSuggested( $mentorUser, $menteeImpact->getUser() ); |
228 | } |
229 | |
230 | /** |
231 | * Remove a mentee from the list of praiseworthy mentees |
232 | * |
233 | * @param UserIdentity $mentee |
234 | */ |
235 | private function removeMenteeFromSuggestions( UserIdentity $mentee ): void { |
236 | $mentor = $this->mentorStore->loadMentorUser( $mentee, MentorStore::ROLE_PRIMARY ); |
237 | if ( !$mentor ) { |
238 | return; |
239 | } |
240 | |
241 | if ( $this->isMenteeMarkedAsPraiseworthy( $mentee, $mentor ) ) { |
242 | $this->globalCache->merge( |
243 | $this->makeCacheKeyForMentor( $mentor ), |
244 | static function ( $cache, $key, $value ) use ( $mentee ) { |
245 | if ( array_key_exists( $mentee->getId(), $value ) ) { |
246 | unset( $value[$mentee->getId()] ); |
247 | } |
248 | return $value; |
249 | } |
250 | ); |
251 | } |
252 | } |
253 | |
254 | /** |
255 | * Mark a mentee as already praised |
256 | * |
257 | * @param UserIdentity $mentee |
258 | */ |
259 | public function markMenteeAsPraised( UserIdentity $mentee ): void { |
260 | $this->userOptionsManager->setOption( |
261 | $mentee, |
262 | PraiseworthyConditionsLookup::WAS_PRAISED_PREF, |
263 | true |
264 | ); |
265 | $this->userOptionsManager->saveOptions( $mentee ); |
266 | |
267 | $this->removeMenteeFromSuggestions( $mentee ); |
268 | } |
269 | |
270 | /** |
271 | * Mark a mentee as skipped |
272 | * |
273 | * The mentee will not be re-suggested for PraiseworthyConditionsLookup::SKIP_MENTEES_FOR_DAYS |
274 | * days. |
275 | * |
276 | * @param UserIdentity $mentee |
277 | */ |
278 | public function markMenteeAsSkipped( UserIdentity $mentee ): void { |
279 | $this->userOptionsManager->setOption( |
280 | $mentee, |
281 | PraiseworthyConditionsLookup::SKIPPED_UNTIL_PREF, |
282 | ( new ConvertibleTimestamp() ) |
283 | ->add( 'P' . PraiseworthyConditionsLookup::SKIP_MENTEES_FOR_DAYS . 'D' ) |
284 | ->getTimestamp( TS_MW ) |
285 | ); |
286 | $this->userOptionsManager->saveOptions( $mentee ); |
287 | |
288 | $this->removeMenteeFromSuggestions( $mentee ); |
289 | } |
290 | } |