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