Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
62.99% covered (warning)
62.99%
80 / 127
20.00% covered (danger)
20.00%
2 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
MentorPageMentorManager
62.99% covered (warning)
62.99%
80 / 127
20.00% covered (danger)
20.00%
2 / 10
106.39
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getMentorForUserIfExists
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 getMentorForUser
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
13.27
 getRandomAutoAssignedMentorForUserAndRole
28.57% covered (danger)
28.57%
4 / 14
0.00% covered (danger)
0.00%
0 / 1
9.83
 getMentorForUserSafe
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 getEffectiveMentorForUser
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getEffectiveMentorForUserSafe
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getRandomAutoAssignedMentor
69.23% covered (warning)
69.23%
27 / 39
0.00% covered (danger)
0.00%
0 / 1
5.73
 getMentorshipStateForUser
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 setMentorshipStateForUser
64.29% covered (warning)
64.29%
9 / 14
0.00% covered (danger)
0.00%
0 / 1
4.73
1<?php
2
3namespace GrowthExperiments\Mentorship;
4
5use GrowthExperiments\MentorDashboard\MentorTools\MentorStatusManager;
6use GrowthExperiments\Mentorship\Provider\MentorProvider;
7use GrowthExperiments\Mentorship\Store\MentorStore;
8use GrowthExperiments\WikiConfigException;
9use InvalidArgumentException;
10use MediaWiki\User\Options\UserOptionsLookup;
11use MediaWiki\User\Options\UserOptionsManager;
12use MediaWiki\User\UserIdentity;
13use MediaWiki\User\UserIdentityLookup;
14use Psr\Log\LoggerAwareInterface;
15use Psr\Log\LoggerAwareTrait;
16use Psr\Log\NullLogger;
17use Wikimedia\Rdbms\DBReadOnlyError;
18
19class 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}