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