Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
62.99% |
80 / 127 |
|
20.00% |
2 / 10 |
CRAP | |
0.00% |
0 / 1 |
MentorPageMentorManager | |
62.99% |
80 / 127 |
|
20.00% |
2 / 10 |
106.39 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
getMentorForUserIfExists | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
4.03 | |||
getMentorForUser | |
62.50% |
10 / 16 |
|
0.00% |
0 / 1 |
13.27 | |||
getRandomAutoAssignedMentorForUserAndRole | |
28.57% |
4 / 14 |
|
0.00% |
0 / 1 |
9.83 | |||
getMentorForUserSafe | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
getEffectiveMentorForUser | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
getEffectiveMentorForUserSafe | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
getRandomAutoAssignedMentor | |
69.23% |
27 / 39 |
|
0.00% |
0 / 1 |
5.73 | |||
getMentorshipStateForUser | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
2 | |||
setMentorshipStateForUser | |
64.29% |
9 / 14 |
|
0.00% |
0 / 1 |
4.73 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\Mentorship; |
4 | |
5 | use GrowthExperiments\MentorDashboard\MentorTools\MentorStatusManager; |
6 | use GrowthExperiments\Mentorship\Provider\MentorProvider; |
7 | use GrowthExperiments\Mentorship\Store\MentorStore; |
8 | use GrowthExperiments\WikiConfigException; |
9 | use InvalidArgumentException; |
10 | use MediaWiki\User\Options\UserOptionsLookup; |
11 | use MediaWiki\User\Options\UserOptionsManager; |
12 | use MediaWiki\User\UserIdentity; |
13 | use MediaWiki\User\UserIdentityLookup; |
14 | use Psr\Log\LoggerAwareInterface; |
15 | use Psr\Log\LoggerAwareTrait; |
16 | use Psr\Log\NullLogger; |
17 | use Wikimedia\Rdbms\DBReadOnlyError; |
18 | |
19 | class MentorPageMentorManager extends MentorManager implements LoggerAwareInterface { |
20 | use LoggerAwareTrait; |
21 | |
22 | public const MENTORSHIP_ENABLED_PREF = 'growthexperiments-homepage-mentorship-enabled'; |
23 | |
24 | private MentorStore $mentorStore; |
25 | private MentorStatusManager $mentorStatusManager; |
26 | private MentorProvider $mentorProvider; |
27 | private UserIdentityLookup $userIdentityLookup; |
28 | private UserOptionsLookup $userOptionsLookup; |
29 | private UserOptionsManager $userOptionsManager; |
30 | private bool $wasPosted; |
31 | |
32 | /** |
33 | * @param MentorStore $mentorStore |
34 | * @param MentorStatusManager $mentorStatusManager |
35 | * @param MentorProvider $mentorProvider |
36 | * @param UserIdentityLookup $userIdentityLookup |
37 | * @param UserOptionsLookup $userOptionsLookup |
38 | * @param UserOptionsManager $userOptionsManager |
39 | * @param bool $wasPosted Is this a POST request? |
40 | */ |
41 | public function __construct( |
42 | MentorStore $mentorStore, |
43 | MentorStatusManager $mentorStatusManager, |
44 | MentorProvider $mentorProvider, |
45 | UserIdentityLookup $userIdentityLookup, |
46 | UserOptionsLookup $userOptionsLookup, |
47 | UserOptionsManager $userOptionsManager, |
48 | $wasPosted |
49 | ) { |
50 | $this->mentorStore = $mentorStore; |
51 | $this->mentorStatusManager = $mentorStatusManager; |
52 | $this->mentorProvider = $mentorProvider; |
53 | $this->userIdentityLookup = $userIdentityLookup; |
54 | $this->userOptionsLookup = $userOptionsLookup; |
55 | $this->userOptionsManager = $userOptionsManager; |
56 | $this->wasPosted = $wasPosted; |
57 | |
58 | $this->setLogger( new NullLogger() ); |
59 | } |
60 | |
61 | /** @inheritDoc */ |
62 | public function getMentorForUserIfExists( |
63 | UserIdentity $user, |
64 | string $role = MentorStore::ROLE_PRIMARY |
65 | ): ?Mentor { |
66 | $mentorUser = $this->mentorStore->loadMentorUser( $user, $role ); |
67 | |
68 | if ( $this->getMentorshipStateForUser( $user ) === self::MENTORSHIP_OPTED_OUT ) { |
69 | if ( !$mentorUser ) { |
70 | // The user is opted out, but the database contains a mentor anyway. Drop it from |
71 | // the DB as well. This has to happen to ensure false mentor/mentee assignment is |
72 | // not visible in dumps etc. |
73 | $this->mentorStore->dropMenteeRelationship( $user ); |
74 | } |
75 | |
76 | // Never offer any mentor to users who opted out of mentorship (T351415) |
77 | return null; |
78 | } |
79 | |
80 | if ( !$mentorUser ) { |
81 | return null; |
82 | } |
83 | |
84 | return $this->mentorProvider->newMentorFromUserIdentity( $mentorUser, $user ); |
85 | } |
86 | |
87 | /** @inheritDoc */ |
88 | public function getMentorForUser( |
89 | UserIdentity $user, |
90 | string $role = MentorStore::ROLE_PRIMARY |
91 | ): Mentor { |
92 | $mentorUser = $this->mentorStore->loadMentorUser( $user, $role ); |
93 | |
94 | if ( $this->getMentorshipStateForUser( $user ) === self::MENTORSHIP_OPTED_OUT ) { |
95 | if ( !$mentorUser ) { |
96 | // The user is opted out, but the database contains a mentor anyway. Drop it from |
97 | // the DB as well. This has to happen to ensure false mentor/mentee assignment is |
98 | // not visible in dumps etc. |
99 | $this->mentorStore->dropMenteeRelationship( $user ); |
100 | } |
101 | |
102 | // Never offer any mentor to users who opted out of mentorship (T351415) |
103 | throw new WikiConfigException( 'Mentorship: Mentee opted out' ); |
104 | } |
105 | |
106 | if ( |
107 | $role === MentorStore::ROLE_BACKUP && |
108 | $mentorUser !== null |
109 | ) { |
110 | // Only use the saved backup mentor if they're still eligible to be a backup mentor. |
111 | // Ignore the current backup mentor relationship if any of the following applies: |
112 | // a) the backup mentor is away |
113 | // b) the backup mentor is no longer a mentor |
114 | if ( |
115 | $this->mentorStatusManager->getMentorStatus( $mentorUser ) === MentorStatusManager::STATUS_AWAY || |
116 | !$this->mentorProvider->isMentor( $mentorUser ) |
117 | ) { |
118 | // Drop the relationship. We do not need to remember the user and exclude later |
119 | // in getRandomAutoAssignedMentorForUserAndRole – that method will ensure only an |
120 | // eligible backup user is generated. |
121 | $mentorUser = null; |
122 | } |
123 | } |
124 | |
125 | if ( !$mentorUser ) { |
126 | $mentorUser = $this->getRandomAutoAssignedMentorForUserAndRole( $user, $role ); |
127 | if ( !$mentorUser ) { |
128 | // TODO: Remove this call (T290371) |
129 | throw new WikiConfigException( 'Mentorship: No mentor available' ); |
130 | } |
131 | $this->mentorStore->setMentorForUser( $user, $mentorUser, $role ); |
132 | } |
133 | |
134 | return $this->mentorProvider->newMentorFromUserIdentity( $mentorUser, $user ); |
135 | } |
136 | |
137 | /** |
138 | * Wrapper for getRandomAutoAssignedMentor |
139 | * |
140 | * In addition to getRandomAutoAssignedMentor, this is mentor role-aware, |
141 | * and automatically excludes the primary mentor if generating a mentor |
142 | * for a non-primary role. |
143 | * |
144 | * If $role is ROLE_BACKUP, it also makes sure to not generate a mentor that's away. |
145 | * |
146 | * @param UserIdentity $mentee |
147 | * @param string $role One of MentorStore::ROLE_* roles |
148 | * @return UserIdentity|null Mentor that can be assigned to the mentee |
149 | * @throws WikiConfigException if mentor list configuration is invalid |
150 | */ |
151 | private function getRandomAutoAssignedMentorForUserAndRole( |
152 | UserIdentity $mentee, |
153 | string $role |
154 | ): ?UserIdentity { |
155 | $excludedUsers = []; |
156 | if ( $role !== MentorStore::ROLE_PRIMARY ) { |
157 | $primaryMentor = $this->mentorStore->loadMentorUser( |
158 | $mentee, |
159 | MentorStore::ROLE_PRIMARY |
160 | ); |
161 | if ( $primaryMentor ) { |
162 | $excludedUsers[] = $primaryMentor; |
163 | } |
164 | } |
165 | if ( $role === MentorStore::ROLE_BACKUP ) { |
166 | $excludedUsers = array_merge( |
167 | $excludedUsers, |
168 | $this->mentorStatusManager->getAwayMentors() |
169 | ); |
170 | } |
171 | |
172 | return $this->getRandomAutoAssignedMentor( $mentee, $excludedUsers ); |
173 | } |
174 | |
175 | /** @inheritDoc */ |
176 | public function getMentorForUserSafe( |
177 | UserIdentity $user, |
178 | string $role = MentorStore::ROLE_PRIMARY |
179 | ): ?Mentor { |
180 | try { |
181 | return $this->getMentorForUser( $user, $role ); |
182 | } catch ( WikiConfigException $e ) { |
183 | // WikiConfigException is thrown when no mentor is available |
184 | // Do not log, as not-yet-developed wikis may have |
185 | // zero mentors for long period of time (T274035) |
186 | } catch ( DBReadOnlyError $e ) { |
187 | // @phan-suppress-previous-line PhanPluginDuplicateCatchStatementBody |
188 | // Just pretend the user doesn't have a mentor. It will be set later, and often |
189 | // this call is made in the context of something not specifically mentorship- |
190 | // related, such as the homepage, so it's better than erroring out. |
191 | } |
192 | return null; |
193 | } |
194 | |
195 | /** @inheritDoc */ |
196 | public function getEffectiveMentorForUser( UserIdentity $menteeUser ): Mentor { |
197 | $primaryMentor = $this->getMentorForUser( $menteeUser, MentorStore::ROLE_PRIMARY ); |
198 | if ( |
199 | $this->mentorStatusManager |
200 | ->getMentorStatus( $primaryMentor->getUserIdentity() ) === MentorStatusManager::STATUS_ACTIVE |
201 | ) { |
202 | return $primaryMentor; |
203 | } else { |
204 | return $this->getMentorForUser( $menteeUser, MentorStore::ROLE_BACKUP ); |
205 | } |
206 | } |
207 | |
208 | /** @inheritDoc */ |
209 | public function getEffectiveMentorForUserSafe( UserIdentity $menteeUser ): ?Mentor { |
210 | $primaryMentor = $this->getMentorForUserSafe( $menteeUser, MentorStore::ROLE_PRIMARY ); |
211 | if ( $primaryMentor === null ) { |
212 | // If primary mentor cannot be assigned, there's zero chance to successfully assign any |
213 | // mentor. |
214 | return null; |
215 | } |
216 | |
217 | if ( $this->mentorStatusManager->getMentorStatus( $primaryMentor->getUserIdentity() ) === |
218 | MentorStatusManager::STATUS_ACTIVE ) { |
219 | return $primaryMentor; |
220 | } else { |
221 | return $this->getMentorForUserSafe( $menteeUser, MentorStore::ROLE_BACKUP ); |
222 | } |
223 | } |
224 | |
225 | /** |
226 | * @inheritDoc |
227 | */ |
228 | public function getRandomAutoAssignedMentor( |
229 | UserIdentity $mentee, array $excluded = [] |
230 | ): ?UserIdentity { |
231 | $autoAssignedMentors = $this->mentorProvider->getWeightedAutoAssignedMentors(); |
232 | if ( count( $autoAssignedMentors ) === 0 ) { |
233 | $this->logger->debug( |
234 | 'Mentorship: No mentor available for user {user}', |
235 | [ |
236 | 'user' => $mentee->getName() |
237 | ] |
238 | ); |
239 | return null; |
240 | } |
241 | $autoAssignedMentors = array_values( array_diff( $autoAssignedMentors, |
242 | array_map( static function ( UserIdentity $excludedUser ) { |
243 | return $excludedUser->getName(); |
244 | }, $excluded ) |
245 | ) ); |
246 | if ( count( $autoAssignedMentors ) === 0 ) { |
247 | $this->logger->debug( |
248 | 'Mentorship: No mentor available for {user} but excluded users', |
249 | [ |
250 | 'user' => $mentee->getName() |
251 | ] |
252 | ); |
253 | return null; |
254 | } |
255 | $autoAssignedMentors = array_values( array_diff( $autoAssignedMentors, [ $mentee->getName() ] ) ); |
256 | if ( count( $autoAssignedMentors ) === 0 ) { |
257 | $this->logger->debug( |
258 | 'Mentorship: No mentor available for {user} but themselves', |
259 | [ |
260 | 'user' => $mentee->getName() |
261 | ] |
262 | ); |
263 | return null; |
264 | } |
265 | |
266 | $selectedMentorName = $autoAssignedMentors[ rand( 0, count( $autoAssignedMentors ) - 1 ) ]; |
267 | $result = $this->userIdentityLookup->getUserIdentityByName( $selectedMentorName ); |
268 | if ( $result === null ) { |
269 | throw new WikiConfigException( |
270 | 'Mentorship: Mentor {user} does not have a valid username', |
271 | [ 'user' => $selectedMentorName ] |
272 | ); |
273 | } |
274 | |
275 | return $result; |
276 | } |
277 | |
278 | /** |
279 | * @inheritDoc |
280 | */ |
281 | public function getMentorshipStateForUser( UserIdentity $user ): int { |
282 | $state = $this->userOptionsLookup->getIntOption( $user, self::MENTORSHIP_ENABLED_PREF ); |
283 | if ( !in_array( $state, self::MENTORSHIP_STATES ) ) { |
284 | // default to MENTORSHIP_DISABLED and log an error |
285 | $this->logger->error( |
286 | 'User {user} has invalid value of {property} user property', |
287 | [ |
288 | 'user' => $user->getName(), |
289 | 'property' => self::MENTORSHIP_ENABLED_PREF, |
290 | 'impact' => 'defaulting to MENTORSHIP_DISABLED' |
291 | ] |
292 | ); |
293 | return self::MENTORSHIP_DISABLED; |
294 | } |
295 | |
296 | return $state; |
297 | } |
298 | |
299 | /** |
300 | * @inheritDoc |
301 | */ |
302 | public function setMentorshipStateForUser( UserIdentity $user, int $state ): void { |
303 | if ( !in_array( $state, self::MENTORSHIP_STATES ) ) { |
304 | throw new InvalidArgumentException( |
305 | 'Invalid value of $state passed to ' . __METHOD__ |
306 | ); |
307 | } |
308 | |
309 | $this->userOptionsManager->setOption( |
310 | $user, |
311 | self::MENTORSHIP_ENABLED_PREF, |
312 | $state |
313 | ); |
314 | $this->userOptionsManager->saveOptions( $user ); |
315 | |
316 | if ( $state === self::MENTORSHIP_OPTED_OUT ) { |
317 | // user opted out, drop mentor/mentee relationship |
318 | $this->mentorStore->dropMenteeRelationship( $user ); |
319 | } elseif ( $state === self::MENTORSHIP_ENABLED ) { |
320 | // The user has opted-in to mentorship. Calling getMentorForUserSafe will |
321 | // persist the newly assigned mentor(s) in the MentorStore. |
322 | $this->getMentorForUserSafe( $user ); |
323 | } |
324 | } |
325 | } |