Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
56.43% covered (warning)
56.43%
79 / 140
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangeMentor
56.43% covered (warning)
56.43%
79 / 140
50.00% covered (danger)
50.00%
4 / 8
92.85
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
2
 wasMentorChanged
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 log
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
42
 validate
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
4
 notify
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
20
 isMentorshipEnabledForUser
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 execute
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
7
 getMenteeUser
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace GrowthExperiments\Mentorship;
4
5use GrowthExperiments\HelpPanel;
6use GrowthExperiments\HomepageHooks;
7use GrowthExperiments\Mentorship\Store\MentorStore;
8use ManualLogEntry;
9use MediaWiki\Deferred\DeferredUpdates;
10use MediaWiki\Extension\Notifications\Model\Event;
11use MediaWiki\Status\Status;
12use MediaWiki\Title\Title;
13use MediaWiki\User\User;
14use MediaWiki\User\UserFactory;
15use MediaWiki\User\UserIdentity;
16use Psr\Log\LoggerInterface;
17use Wikimedia\Rdbms\IConnectionProvider;
18
19class ChangeMentor {
20    private UserIdentity $mentee;
21    private ?UserIdentity $mentor;
22    private ?UserIdentity $newMentor;
23    private UserIdentity $performer;
24    private LoggerInterface $logger;
25    private MentorManager $mentorManager;
26    private MentorStore $mentorStore;
27    private UserFactory $userFactory;
28    private IConnectionProvider $connectionProvider;
29    private ?User $menteeUser = null;
30
31    /**
32     * @param UserIdentity $mentee Mentee's user object
33     * @param UserIdentity $performer Performer's user object
34     * @param LoggerInterface $logger
35     * @param Mentor|null $mentor Old mentor
36     * @param MentorManager $mentorManager
37     * @param MentorStore $mentorStore
38     * @param UserFactory $userFactory
39     * @param IConnectionProvider $connectionProvider
40     */
41    public function __construct(
42        UserIdentity $mentee,
43        UserIdentity $performer,
44        LoggerInterface $logger,
45        ?Mentor $mentor,
46        MentorManager $mentorManager,
47        MentorStore $mentorStore,
48        UserFactory $userFactory,
49        IConnectionProvider $connectionProvider
50    ) {
51        $this->logger = $logger;
52
53        $this->performer = $performer;
54        $this->mentee = $mentee;
55        $this->mentorManager = $mentorManager;
56        $this->mentorStore = $mentorStore;
57        $this->userFactory = $userFactory;
58        $this->connectionProvider = $connectionProvider;
59        $this->mentor = $mentor ? $mentor->getUserIdentity() : null;
60    }
61
62    /**
63     * Was mentee's mentor already changed?
64     *
65     * @return bool
66     */
67    public function wasMentorChanged(): bool {
68        $res = $this->connectionProvider->getReplicaDatabase()->newSelectQueryBuilder()
69            ->select( [ 'log_page' ] )
70            ->from( 'logging' )
71            ->where( [
72                'log_type' => 'growthexperiments',
73                'log_namespace' => NS_USER,
74                'log_title' => Title::makeTitle( NS_USER, $this->mentee->getName() )->getDbKey()
75            ] )
76            ->caller( __METHOD__ )
77            ->fetchRow();
78        return (bool)$res;
79    }
80
81    /**
82     * Log mentor change
83     *
84     * @param string $reason Reason for the change
85     * @param bool $forceBot Whether to mark this log entry as bot-made
86     */
87    protected function log( string $reason, bool $forceBot ) {
88        $this->logger->debug(
89            'Logging mentor change for {mentee} from {oldMentor} to {newMentor} by {performer}', [
90                'mentee' => $this->mentee,
91                'oldMentor' => $this->mentor,
92                'newMentor' => $this->newMentor,
93                'performer' => $this->performer,
94            ] );
95
96        if ( $this->performer->getId() === $this->newMentor->getId() ) {
97            $primaryLogtype = 'claimmentee';
98        } else {
99            $primaryLogtype = 'setmentor';
100        }
101        $logEntry = new ManualLogEntry(
102            'growthexperiments',
103            $this->mentor ?
104                $primaryLogtype :
105                "$primaryLogtype-no-previous-mentor"
106        );
107        $logEntry->setPerformer( $this->performer );
108        $logEntry->setTarget( $this->getMenteeUser()->getUserPage() );
109        $logEntry->setComment( $reason );
110        if ( $forceBot ) {
111            // Don't spam RecentChanges with bulk changes (T304428)
112            $logEntry->setForceBotFlag( true );
113        }
114        $parameters = [];
115        if ( $this->mentor ) {
116            // $this->mentor is null when no mentor existed previously
117            $parameters['4::previous-mentor'] = $this->mentor->getName();
118        }
119        if ( $this->performer->getId() !== $this->newMentor->getId() ) {
120            $parameters['5::new-mentor'] = $this->newMentor->getName();
121        }
122        $logEntry->setParameters( $parameters );
123        $logid = $logEntry->insert();
124        $logEntry->publish( $logid );
125    }
126
127    /**
128     * Verify the mentor change is possible
129     *
130     * @return Status
131     */
132    private function validate(): Status {
133        $this->logger->debug(
134            'Validating mentor change for {mentee} from {oldMentor} to {newMentor}', [
135                'mentee' => $this->mentee,
136                'oldMentor' => $this->mentor,
137                'newMentor' => $this->newMentor
138            ] );
139        $status = Status::newGood();
140
141        if ( !$this->getMenteeUser()->isNamed() ) {
142            $this->logger->info(
143                'Mentor change for {mentee} from {oldMentor} to {newMentor}'
144                . ' did not succeed, because the mentee doesn\'t exist', [
145                    'mentee' => $this->mentee,
146                    'oldMentor' => $this->mentor,
147                    'newMentor' => $this->newMentor
148                ] );
149            $status->fatal( 'growthexperiments-homepage-claimmentee-no-user' );
150            return $status;
151        }
152        if ( $this->mentor && $this->mentor->equals( $this->newMentor ) ) {
153            $this->logger->info(
154                'Mentor change for {mentee} from {oldMentor} to {newMentor}'
155                . ' did not succeed, because the old and new mentor are equal', [
156                    'mentee' => $this->mentee,
157                    'oldMentor' => $this->mentor,
158                    'newMentor' => $this->newMentor
159                ] );
160            $status->fatal(
161                'growthexperiments-homepage-claimmentee-already-mentor',
162                $this->mentee->getName(),
163                $this->performer->getName()
164            );
165            return $status;
166        }
167
168        return $status;
169    }
170
171    /**
172     * Notify mentee about the mentor change
173     *
174     * @param string $reason Reason for the change
175     */
176    protected function notify( string $reason ) {
177        if ( \ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
178            DeferredUpdates::addCallableUpdate( function () use ( $reason ) {
179                $this->logger->debug( 'Notify {mentee} about mentor change done by {performer}', [
180                    'mentee' => $this->mentee,
181                    'performer' => $this->performer
182                ] );
183                Event::create( [
184                    'type' => 'mentor-changed',
185                    'title' => $this->getMenteeUser()->getUserPage(),
186                    'extra' => [
187                        'mentee' => $this->mentee->getId(),
188                        'oldMentor' => $this->mentor->getName(),
189                    ],
190                    'agent' => $this->newMentor,
191                ] );
192
193                if (
194                    $this->performer->equals( $this->newMentor ) &&
195                    $this->mentor !== null
196                ) {
197                    // mentee was claimed, notify old mentor as well
198                    Event::create( [
199                        'type' => 'mentee-claimed',
200                        'title' => $this->getMenteeUser()->getUserPage(),
201                        'extra' => [
202                            'mentor' => $this->mentor->getId(),
203                            'reason' => $reason
204                        ],
205                        'agent' => $this->performer
206                    ] );
207                }
208            } );
209        }
210    }
211
212    /**
213     * Does user have mentorship-consuming features enabled?
214     *
215     * This is used to skip notifications about mentorship changes when the user doesn't actually
216     * have relevant features enabled.
217     *
218     * @note This is a separate method to make unit testing possible
219     * (HomepageHooks::isHomepageEnabled and HelpPanel::shouldShowHelpPanelToUser both use global
220     * state)
221     * @param UserIdentity $user
222     * @return bool
223     */
224    protected function isMentorshipEnabledForUser( UserIdentity $user ): bool {
225        return HomepageHooks::isHomepageEnabled( $user )
226            || HelpPanel::shouldShowHelpPanelToUser( $user );
227    }
228
229    /**
230     * Change mentor
231     *
232     * This sets the new primary mentor in the database and adds a log under Special:Log. In most
233     * cases, it also notifies the mentee about the mentor change. Notification is only
234     * sent if all of the following conditions are true:
235     *
236     *     1) Mentee has access to Special:Homepage or Help panel
237     *     2) Mentee has mentorship enabled (MENTORSHIP_ENABLED)
238     *
239     * If mentee's mentorship state is MENTORSHIP_DISABLED, access to mentorship is enabled by
240     * this method, except when $bulkChange is true, but a notification is not sent.
241     *
242     * @param UserIdentity $newMentor New mentor to assign
243     * @param string $reason Reason for the change
244     * @param bool $bulkChange Is this a part of a bulk mentor reassignment (used by
245     * ReassignMentees class)
246     * @return Status
247     */
248    public function execute(
249        UserIdentity $newMentor,
250        string $reason,
251        bool $bulkChange = false
252    ): Status {
253        // Ensure mentor/mentee relationship is dropped if the mentee is opted out from mentorship (T354259)
254        if ( $this->mentorManager->getMentorshipStateForUser( $this->mentee ) ===
255            MentorManager::MENTORSHIP_OPTED_OUT ) {
256            $this->logger->info(
257                'ChangeMentor dropped mentee relationship for {user} '
258                . 'because the user is opted out of mentorship',
259                [ 'user' => $this->mentee->getName() ]
260            );
261            $this->mentorStore->dropMenteeRelationship( $this->mentee );
262
263            // Pretend the action failed, which is likely better than pretending it succeeded
264            // (leaving the on-wiki user wondering "why is there no log in
265            // Special:Log/growthexperiments for the reassignment"). We might've performed
266            // an internal cleanup by dropping the relationship, but from the on-wiki users point
267            // of view, the mentor change failed.
268            return Status::newFatal(
269                'growthexperiments-homepage-claimmentee-opt-out',
270                $this->mentee->getName()
271            );
272        }
273
274        $this->newMentor = $newMentor;
275        $status = $this->validate();
276        if ( !$status->isOK() ) {
277            return $status;
278        }
279
280        $this->mentorStore->setMentorForUser( $this->mentee, $this->newMentor, MentorStore::ROLE_PRIMARY );
281        $this->log( $reason, $bulkChange );
282
283        if ( $this->isMentorshipEnabledForUser( $this->mentee ) ) {
284            $mentorshipState = $this->mentorManager->getMentorshipStateForUser( $this->mentee );
285
286            if ( $mentorshipState === MentorManager::MENTORSHIP_ENABLED ) {
287                $this->notify( $reason );
288            }
289
290            if ( !$bulkChange && $mentorshipState === MentorManager::MENTORSHIP_DISABLED ) {
291                // For non-bulk changes when MENTORSHIP_DISABLED (=GrowthExperiments decided not
292                // to include the mentorship module), set the state to MENTORSHIP_ENABLED to ensure
293                // the user can benefit from mentorship (T327206).
294                // NOTE: Do not enable for MENTORSHIP_OPTOUT, as that means "I'm not interested
295                // in being mentored at all" (as an explicit user choice).
296                // NOTE: Call to notify() is intentionally above this condition. For users who
297                // didn't have mentorship access before, notification "Your mentor has changed"
298                // would be confusing (T330035).
299
300                $this->mentorManager->setMentorshipStateForUser(
301                    $this->mentee,
302                    MentorManager::MENTORSHIP_ENABLED
303                );
304            }
305        }
306
307        return $status;
308    }
309
310    private function getMenteeUser(): User {
311        if ( !$this->menteeUser ) {
312            $this->menteeUser = $this->userFactory->newFromUserIdentity( $this->mentee );
313        }
314        return $this->menteeUser;
315    }
316}