Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.90% covered (warning)
89.90%
267 / 297
55.00% covered (warning)
55.00%
11 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialManageMentors
89.90% covered (warning)
89.90%
267 / 297
55.00% covered (warning)
55.00%
11 / 20
60.35
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
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isIncludable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renderInReadOnlyMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canManageMentors
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getDescription
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastActiveTimestamp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeUserLink
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 formatWeight
30.77% covered (danger)
30.77%
4 / 13
0.00% covered (danger)
0.00%
0 / 1
17.95
 formatStatus
31.58% covered (danger)
31.58%
6 / 19
0.00% covered (danger)
0.00%
0 / 1
22.70
 getMentorAsHtmlRow
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
2
 getMentorsTableBody
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 getMentorsTable
100.00% covered (success)
100.00%
67 / 67
100.00% covered (success)
100.00%
1 / 1
3
 getFormByAction
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
5
 parseSubpage
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 handleAction
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
6.01
 displayMentorshipWarningMessage
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 makePreHTML
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
3
 makeHeadlineElement
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 execute
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
5.01
1<?php
2
3namespace GrowthExperiments\Specials;
4
5use GrowthExperiments\MentorDashboard\MentorTools\IMentorWeights;
6use GrowthExperiments\MentorDashboard\MentorTools\MentorStatusManager;
7use GrowthExperiments\Mentorship\Mentor;
8use GrowthExperiments\Mentorship\MentorRemover;
9use GrowthExperiments\Mentorship\Provider\IMentorWriter;
10use GrowthExperiments\Mentorship\Provider\MentorProvider;
11use GrowthExperiments\Specials\Forms\ManageMentorsAbstractForm;
12use GrowthExperiments\Specials\Forms\ManageMentorsAddMentor;
13use GrowthExperiments\Specials\Forms\ManageMentorsEditMentor;
14use GrowthExperiments\Specials\Forms\ManageMentorsRemoveMentor;
15use GrowthExperiments\Util;
16use LogicException;
17use MediaWiki\Config\Config;
18use MediaWiki\Html\Html;
19use MediaWiki\Linker\Linker;
20use MediaWiki\SpecialPage\SpecialPage;
21use MediaWiki\User\UserEditTracker;
22use MediaWiki\User\UserIdentity;
23use MediaWiki\User\UserIdentityLookup;
24use MediaWiki\Utils\MWTimestamp;
25use OOUI\ButtonWidget;
26use PermissionsError;
27use Wikimedia\Timestamp\ConvertibleTimestamp;
28
29class SpecialManageMentors extends SpecialPage {
30
31    private UserIdentityLookup $userIdentityLookup;
32    private UserEditTracker $userEditTracker;
33    private MentorProvider $mentorProvider;
34    private IMentorWriter $mentorWriter;
35    private MentorStatusManager $mentorStatusManager;
36    private MentorRemover $mentorRemover;
37    private Config $wikiConfig;
38
39    /**
40     * @param UserIdentityLookup $userIdentityLookup
41     * @param UserEditTracker $userEditTracker
42     * @param MentorProvider $mentorProvider
43     * @param IMentorWriter $mentorWriter
44     * @param MentorStatusManager $mentorStatusManager
45     * @param MentorRemover $mentorRemover
46     * @param Config $wikiConfig
47     */
48    public function __construct(
49        UserIdentityLookup $userIdentityLookup,
50        UserEditTracker $userEditTracker,
51        MentorProvider $mentorProvider,
52        IMentorWriter $mentorWriter,
53        MentorStatusManager $mentorStatusManager,
54        MentorRemover $mentorRemover,
55        Config $wikiConfig
56    ) {
57        parent::__construct( 'ManageMentors' );
58
59        $this->userIdentityLookup = $userIdentityLookup;
60        $this->userEditTracker = $userEditTracker;
61        $this->mentorProvider = $mentorProvider;
62        $this->mentorWriter = $mentorWriter;
63        $this->mentorStatusManager = $mentorStatusManager;
64        $this->mentorRemover = $mentorRemover;
65        $this->wikiConfig = $wikiConfig;
66    }
67
68    /** @inheritDoc */
69    protected function getGroupName() {
70        return 'growth-tools';
71    }
72
73    /**
74     * @inheritDoc
75     */
76    public function isIncludable() {
77        return true;
78    }
79
80    /**
81     * @return bool
82     */
83    private function renderInReadOnlyMode(): bool {
84        return $this->including() ?? false;
85    }
86
87    /**
88     * Can manage mentors?
89     * @return bool
90     */
91    private function canManageMentors(): bool {
92        return !$this->renderInReadOnlyMode() &&
93            ManageMentorsAbstractForm::canManageMentors( $this->getAuthority() );
94    }
95
96    /**
97     * @inheritDoc
98     */
99    public function getDescription() {
100        return $this->msg( 'growthexperiments-manage-mentors-title' );
101    }
102
103    /**
104     * @param UserIdentity $user
105     * @return MWTimestamp
106     */
107    private function getLastActiveTimestamp( UserIdentity $user ): MWTimestamp {
108        return new MWTimestamp( $this->userEditTracker->getLatestEditTimestamp( $user ) );
109    }
110
111    private function makeUserLink( UserIdentity $user ) {
112        return Linker::userLink(
113            $user->getId(),
114            $user->getName()
115        ) . Linker::userToolLinks( $user->getId(), $user->getName() );
116    }
117
118    /**
119     * @param Mentor $mentor
120     * @return array{0:string,1:int}
121     */
122    private function formatWeight( Mentor $mentor ): array {
123        switch ( $mentor->getWeight() ) {
124            case IMentorWeights::WEIGHT_NONE:
125                $msgKey = 'growthexperiments-mentor-dashboard-mentor-tools-mentor-weight-none';
126                break;
127            case IMentorWeights::WEIGHT_LOW:
128                $msgKey = 'growthexperiments-mentor-dashboard-mentor-tools-mentor-weight-low';
129                break;
130            case IMentorWeights::WEIGHT_NORMAL:
131                $msgKey = 'growthexperiments-mentor-dashboard-mentor-tools-mentor-weight-medium';
132                break;
133            case IMentorWeights::WEIGHT_HIGH:
134                $msgKey = 'growthexperiments-mentor-dashboard-mentor-tools-mentor-weight-high';
135                break;
136            default:
137                throw new LogicException(
138                    'Weight ' . $mentor->getWeight() . ' is not supported'
139                );
140        }
141        return [ $this->msg( $msgKey )->text(), $mentor->getWeight() ];
142    }
143
144    /**
145     * @param Mentor $mentor
146     * @return array{0:string,1:int}
147     */
148    private function formatStatus( Mentor $mentor ): array {
149        $reason = $this->mentorStatusManager->getAwayReason( $mentor->getUserIdentity() );
150        switch ( $reason ) {
151            case MentorStatusManager::AWAY_BECAUSE_BLOCK:
152            case MentorStatusManager::AWAY_BECAUSE_LOCK:
153                return [
154                    // FIXME use better custom message
155                    $this->msg( 'blockedtitle' )->text(),
156                    // XXX: is this the maximum on the frontend?
157                    PHP_INT_MAX
158                ];
159            case MentorStatusManager::AWAY_BECAUSE_TIMESTAMP:
160                $ts = $this->mentorStatusManager->getMentorBackTimestamp( $mentor->getUserIdentity() );
161                if ( $ts !== null ) {
162                    return [
163                        $this->msg( 'growthexperiments-manage-mentors-status-away-until' )
164                            ->params( $this->getLanguage()->userDate( $ts, $this->getUser() ) )
165                            ->text(),
166                        (int)ConvertibleTimestamp::convert( TS_UNIX, $ts )
167                    ];
168                }
169                // if the reason is a timestamp, but we've got no timestamp, just pretend they are active
170                // hence no break here
171            case null:
172                return [
173                    $this->msg( 'growthexperiments-mentor-dashboard-mentor-tools-mentor-status-active' )
174                        ->text(),
175                    -1
176                ];
177            default:
178                throw new LogicException( "Reason for absence \"$reason\" is not supported" );
179        }
180    }
181
182    /**
183     * @param Mentor $mentor
184     * @param int $i
185     * @return string
186     */
187    private function getMentorAsHtmlRow( Mentor $mentor, int $i ): string {
188        [ $weightText, $weightRank ] = $this->formatWeight( $mentor );
189        [ $statusText, $statusRank ] = $this->formatStatus( $mentor );
190        $ts = $this->getLastActiveTimestamp( $mentor->getUserIdentity() );
191
192        $items = [
193            Html::element( 'td', [], (string)$i ),
194            Html::rawElement(
195                'td',
196                [ 'data-sort-value' => $mentor->getUserIdentity()->getName() ],
197                $this->makeUserLink( $mentor->getUserIdentity() )
198            ),
199            Html::element(
200                'td',
201                [ 'data-sort-value' => $ts->getTimestamp( TS_UNIX ) ],
202                $this->getContext()->getLanguage()->userTimeAndDate( $ts, $this->getUser() )
203            ),
204            Html::element( 'td', [ 'data-sort-value' => $weightRank ], $weightText ),
205            Html::element( 'td', [ 'data-sort-value' => $statusRank ], $statusText ),
206            Html::element( 'td', [], $mentor->getIntroText() ),
207        ];
208        if ( $this->canManageMentors() ) {
209            $items[] = Html::rawElement( 'td', [], new ButtonWidget( [
210                'label' => $this->msg( 'growthexperiments-manage-mentors-edit' )->text(),
211                'href' => SpecialPage::getTitleFor(
212                    'ManageMentors',
213                    'edit-mentor/' . $mentor->getUserIdentity()->getId()
214                )->getLocalURL(),
215                'flags' => [ 'primary', 'progressive' ],
216            ] ) );
217            $items[] = Html::rawElement( 'td', [], new ButtonWidget( [
218                'label' => $this->msg( 'growthexperiments-manage-mentors-remove-mentor' )->text(),
219                'href' => SpecialPage::getTitleFor(
220                    'ManageMentors',
221                    'remove-mentor/' . $mentor->getUserIdentity()->getId()
222                )->getLocalURL(),
223                'flags' => [ 'primary', 'destructive' ]
224            ] ) );
225        }
226
227        return Html::rawElement(
228            'tr',
229            [],
230            implode( "\n", $items )
231        );
232    }
233
234    /**
235     * @param string[] $mentorNames
236     * @return string
237     */
238    private function getMentorsTableBody( array $mentorNames ): string {
239        // sort mentors alphabetically
240        sort( $mentorNames );
241
242        $mentorsHtml = [];
243        $i = 1;
244        foreach ( $mentorNames as $mentorName ) {
245            $mentorUser = $this->userIdentityLookup->getUserIdentityByName( $mentorName );
246            if ( !$mentorUser ) {
247                // TODO: Log an error?
248                continue;
249            }
250
251            $mentorsHtml[] = $this->getMentorAsHtmlRow(
252                $this->mentorProvider->newMentorFromUserIdentity( $mentorUser ),
253                $i
254            );
255            $i++;
256        }
257
258        return implode( "\n", $mentorsHtml );
259    }
260
261    /**
262     * @param string[] $mentorNames
263     * @return string
264     */
265    private function getMentorsTable( array $mentorNames ): string {
266        if ( $mentorNames === [] ) {
267            return Html::element(
268                'p',
269                [],
270                $this->msg( 'growthexperiments-manage-mentors-none' )->text()
271            );
272        }
273
274        $headerItems = [
275            Html::element( 'th', [], '#' ),
276            Html::element(
277                'th',
278                [],
279                $this->msg( 'growthexperiments-manage-mentors-username' )->text()
280            ),
281            Html::element(
282                'th',
283                // unix timestamp
284                [ 'data-sort-type' => 'number' ],
285                $this->msg( 'growthexperiments-manage-mentors-last-active' )->text()
286            ),
287            Html::element(
288                'th',
289                [ 'data-sort-type' => 'number' ],
290                $this->msg( 'growthexperiments-manage-mentors-weight' )->text()
291            ),
292            Html::element(
293                'th',
294                [ 'data-sort-type' => 'number' ],
295                $this->msg( 'growthexperiments-manage-mentors-status' )->text()
296            ),
297            Html::element(
298                'th',
299                [ 'class' => 'unsortable' ],
300                $this->msg( 'growthexperiments-manage-mentors-intro-msg' )->text()
301            ),
302        ];
303
304        if ( $this->canManageMentors() ) {
305            $headerItems[] = Html::element(
306                'th',
307                [ 'class' => 'unsortable' ],
308                $this->msg( 'growthexperiments-manage-mentors-edit' )->text()
309            );
310            $headerItems[] = Html::element(
311                'th',
312                [ 'class' => 'unsortable' ],
313                $this->msg( 'growthexperiments-manage-mentors-remove-mentor' )->text()
314            );
315        }
316
317        return Html::rawElement(
318            'table',
319            [
320                'class' => 'wikitable sortable'
321            ],
322            implode( "\n", [
323                Html::rawElement(
324                    'thead',
325                    [],
326                    Html::rawElement(
327                        'tr',
328                        [],
329                        implode( "\n", $headerItems )
330                    )
331                ),
332                Html::rawElement(
333                    'tbody',
334                    [],
335                    $this->getMentorsTableBody( $mentorNames )
336                )
337            ] )
338        );
339    }
340
341    /**
342     * @param string $action
343     * @param UserIdentity|null $mentorUser
344     * @return ManageMentorsAbstractForm|null
345     */
346    private function getFormByAction( string $action, ?UserIdentity $mentorUser ): ?ManageMentorsAbstractForm {
347        switch ( $action ) {
348            case 'remove-mentor':
349                return new ManageMentorsRemoveMentor(
350                    $this->mentorRemover,
351                    $mentorUser,
352                    $this->getContext()
353                );
354            case 'add-mentor':
355                return new ManageMentorsAddMentor(
356                    $this->userIdentityLookup,
357                    $this->mentorProvider,
358                    $this->mentorWriter,
359                    $this->getContext()
360                );
361            case 'edit-mentor':
362                return new ManageMentorsEditMentor(
363                    $this->mentorProvider,
364                    $this->mentorWriter,
365                    $this->mentorStatusManager,
366                    $mentorUser,
367                    $this->getContext()
368                );
369            default:
370                return null;
371        }
372    }
373
374    private function parseSubpage( ?string $par ): ?array {
375        if ( !$par ) {
376            return null;
377        }
378
379        $explodeResult = explode( '/', $par, 2 );
380        if ( count( $explodeResult ) === 2 ) {
381            [ $action, $data ] = $explodeResult;
382        } else {
383            [ $action, $data ] = [ $explodeResult[0], null ];
384        }
385        $mentorUserId = (int)$data;
386        if ( !$mentorUserId ) {
387            return [ $action, null ];
388        }
389
390        return [
391            $action,
392            $this->userIdentityLookup->getUserIdentityByUserId( $mentorUserId )
393        ];
394    }
395
396    private function handleAction( ?string $par ): bool {
397        [ $action, $mentorUser ] = $this->parseSubpage( $par );
398
399        if ( !$action ) {
400            return false;
401        }
402
403        if ( !$this->canManageMentors() ) {
404            throw new PermissionsError( 'managementors' );
405        }
406
407        if ( $mentorUser === null && $action !== 'add-mentor' ) {
408            // All forms besides add-mentor require a valid $mentorUser
409            $this->getOutput()->addHTML( Html::element(
410                'p',
411                [ 'class' => 'error' ],
412                $this->msg(
413                    'growthexperiments-manage-mentors-error-no-such-user',
414                )->text()
415            ) );
416            return true;
417        }
418
419        $form = $this->getFormByAction( $action, $mentorUser );
420        if ( !$form ) {
421            return false;
422        }
423
424        $form->show();
425        return true;
426    }
427
428    /**
429     * Display the mentorship warning message if Mentorship is not enabled.
430     * @return string Display HTML for the warning message.
431     */
432    private function displayMentorshipWarningMessage(): string {
433        $configPage = Util::useCommunityConfiguration()
434            ? SpecialPage::getTitleFor( 'CommunityConfiguration', 'Mentorship' )->getPrefixedText()
435            : SpecialPage::getTitleFor( 'EditGrowthConfig' )->getPrefixedText();
436
437        return Html::warningBox(
438            $this->msg( 'growthexperiments-mentor-dashboard-mentorship-disabled-with-link' )
439                ->params( $configPage )
440                ->parse(),
441            'ext-growthExperiments-message--warning'
442        );
443    }
444
445    /**
446     * @return string
447     */
448    private function makePreHTML(): string {
449        if ( $this->including() ) {
450            // included version should only include the table
451            return '';
452        }
453
454        $howToChangeMessageKey = $this->canManageMentors()
455            ? 'growthexperiments-manage-mentors-pretext-privileged'
456            : 'growthexperiments-manage-mentors-pretext-regular';
457
458        return Html::rawElement(
459            'div',
460            [],
461            implode( "\n", [
462                Html::rawElement(
463                    'p',
464                    [],
465                    implode( "\n", [
466                        $this->msg( 'growthexperiments-manage-mentors-pretext-purpose' )->parse(),
467                        $this->msg( $howToChangeMessageKey )->parse(),
468                        $this->msg( 'growthexperiments-manage-mentors-pretext-stored-at' )
469                            ->params( $this->getConfig()->get( 'GEStructuredMentorList' ) )
470                            ->parse(),
471                    ] )
472                ),
473                Html::rawElement(
474                    'p',
475                    [],
476                    $this->msg( 'growthexperiments-manage-mentors-pretext-to-enroll' )->parse()
477                )
478            ] )
479        );
480    }
481
482    /**
483     * @param string $text
484     * @return string
485     */
486    private function makeHeadlineElement( string $text ): string {
487        return Html::element(
488            $this->including() ? 'h3' : 'h2',
489            [],
490            $text
491        );
492    }
493
494    /**
495     * @inheritDoc
496     */
497    public function execute( $subPage ) {
498        parent::execute( $subPage );
499        if ( $this->handleAction( $subPage ) ) {
500            return;
501        }
502
503        $out = $this->getOutput();
504        // We only need OOUI (ButtonWidget) when can manage mentors
505        // Avoid access to the global context when transcluding (T346760)
506        if ( $this->canManageMentors() ) {
507            $out->enableOOUI();
508        }
509
510        if ( !$this->wikiConfig->get( 'GEMentorshipEnabled' ) ) {
511            $out->addModuleStyles( [ 'codex-styles' ] );
512            $out->addHTML( $this->displayMentorshipWarningMessage() );
513        }
514
515        $out->addHTML( implode( "\n", [
516            $this->makePreHTML(),
517            $this->makeHeadlineElement( $this->msg( 'growthexperiments-manage-mentors-auto-assigned' )->text() ),
518            Html::element( 'p', [],
519                $this->msg( 'growthexperiments-manage-mentors-auto-assigned-text' )->text()
520            ),
521            $this->getMentorsTable( $this->mentorProvider->getAutoAssignedMentors() ),
522            $this->makeHeadlineElement( $this->msg( 'growthexperiments-manage-mentors-manually-assigned' )->text() ),
523            Html::element( 'p', [],
524                $this->msg( 'growthexperiments-manage-mentors-manually-assigned-text' )->text()
525            ),
526            $this->getMentorsTable( $this->mentorProvider->getManuallyAssignedMentors() ),
527            $this->canManageMentors() ? new ButtonWidget( [
528                'label' => $this->msg( 'growhtexperiments-manage-mentors-add-mentor' )->text(),
529                'href' => SpecialPage::getTitleFor(
530                    'ManageMentors',
531                    'add-mentor'
532                )->getLocalURL(),
533                'flags' => [ 'primary', 'progressive' ],
534            ] ) : '',
535        ] ) );
536    }
537}