Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
45.65% covered (danger)
45.65%
21 / 46
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
PraiseworthyConditionsLookup
45.65% covered (danger)
45.65%
21 / 46
28.57% covered (danger)
28.57%
2 / 7
70.01
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 buildDatePeriod
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getEditsInDatePeriod
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 wasMenteePraised
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 isMenteeSkipped
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 canUserBePraised
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 isMenteePraiseworthyForMentor
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
1<?php
2
3namespace GrowthExperiments\MentorDashboard\PersonalizedPraise;
4
5use DateInterval;
6use DatePeriod;
7use DateTime;
8use GrowthExperiments\HomepageHooks;
9use GrowthExperiments\Mentorship\MentorManager;
10use GrowthExperiments\UserImpact\UserImpact;
11use MediaWiki\User\Options\UserOptionsLookup;
12use MediaWiki\User\UserFactory;
13use MediaWiki\User\UserIdentity;
14use Wikimedia\Timestamp\ConvertibleTimestamp;
15
16/**
17 * Service to look up the conditions for mentee being praiseworthy
18 */
19class PraiseworthyConditionsLookup {
20
21    private PersonalizedPraiseSettings $settings;
22    private UserOptionsLookup $userOptionsLookup;
23    private UserFactory $userFactory;
24    private MentorManager $mentorManager;
25
26    /** @var string */
27    public const WAS_PRAISED_PREF = 'growthexperiments-mentorship-was-praised';
28
29    /** @var string */
30    public const SKIPPED_UNTIL_PREF = 'growthexperiments-personalized-praise-skipped-until';
31
32    /** @var int Number of days skipped mentees cannot be suggested for */
33    public const SKIP_MENTEES_FOR_DAYS = 10;
34
35    /**
36     * @param PersonalizedPraiseSettings $settings
37     * @param UserOptionsLookup $userOptionsLookup
38     * @param UserFactory $userFactory
39     * @param MentorManager $mentorManager
40     */
41    public function __construct(
42        PersonalizedPraiseSettings $settings,
43        UserOptionsLookup $userOptionsLookup,
44        UserFactory $userFactory,
45        MentorManager $mentorManager
46    ) {
47        $this->settings = $settings;
48        $this->userOptionsLookup = $userOptionsLookup;
49        $this->userFactory = $userFactory;
50        $this->mentorManager = $mentorManager;
51    }
52
53    /**
54     * Build a DatePeriod going from today to today minus $days days
55     *
56     * @param int $days
57     * @return DatePeriod
58     */
59    private function buildDatePeriod( int $days ): DatePeriod {
60        $daysAgoUnix = ( new ConvertibleTimestamp() )->sub( 'P' . $days . 'D' )->getTimestamp( TS_UNIX );
61        $tomorrowUnix = ( new ConvertibleTimestamp() )->add( 'P1D' )->getTimestamp( TS_UNIX );
62        return new DatePeriod(
63            new DateTime( '@' . $daysAgoUnix ),
64            new DateInterval( 'P1D' ),
65            new DateTime( '@' . $tomorrowUnix )
66        );
67    }
68
69    /**
70     * Find how many edits the mentee made in a DatePeriod
71     *
72     * @param UserImpact $menteeImpact
73     * @param DatePeriod $datePeriod
74     * @return int
75     */
76    private function getEditsInDatePeriod(
77        UserImpact $menteeImpact,
78        DatePeriod $datePeriod
79    ): int {
80        $editCountByDay = $menteeImpact->getEditCountByDay();
81
82        $res = 0;
83        foreach ( $datePeriod as $day ) {
84            $res += $editCountByDay[$day->format( 'Y-m-d' )] ?? 0;
85        }
86
87        return $res;
88    }
89
90    /**
91     * Was the mentee ever praised by their mentor?
92     *
93     * @param UserIdentity $mentee
94     * @return bool
95     */
96    private function wasMenteePraised( UserIdentity $mentee ): bool {
97        return $this->userOptionsLookup->getBoolOption(
98            $mentee,
99            self::WAS_PRAISED_PREF
100        );
101    }
102
103    /**
104     * Is the mentee currently skipped?
105     *
106     * @param UserIdentity $mentee
107     * @return bool
108     */
109    private function isMenteeSkipped( UserIdentity $mentee ): bool {
110        $skippedUntilRaw = $this->userOptionsLookup->getOption(
111            $mentee,
112            self::SKIPPED_UNTIL_PREF
113        );
114        if ( $skippedUntilRaw === null ) {
115            return false;
116        }
117
118        $noPraiseUntilUnix = ( new ConvertibleTimestamp( $skippedUntilRaw ) )->getTimestamp( TS_UNIX );
119        $nowUnix = ( new ConvertibleTimestamp() )->getTimestamp( TS_UNIX );
120        return $nowUnix < $noPraiseUntilUnix;
121    }
122
123    /**
124     * Is the user able to be praised by a mentor?
125     *
126     * Users can receive praise if ALL of the following conditions are true:
127     *     * has mentorship module enabled
128     *     * was never praised in the past
129     *     * either was not skipped at all or was skipped at least SKIP_MENTEES_FOR_DAYS days ago
130     *
131     * Use this method if you want to know whether an user can theoretically appear in the
132     * Personalized praise module. If you want to know whether they should be included in the
133     * module, call isMenteePraiseworthyForMentor instead.
134     *
135     * @param UserIdentity $mentee
136     * @return bool
137     */
138    public function canUserBePraised( UserIdentity $mentee ): bool {
139        // NOTE: This does not use HomepageHooks::isHomepageEnabled, because it accesses the global
140        // state. Personalized praise is not directly tied to Homepage, so relying on the
141        // preference value alone is sufficient.
142
143        $menteeUser = $this->userFactory->newFromUserIdentity( $mentee );
144        return $this->userOptionsLookup->getBoolOption( $mentee, HomepageHooks::HOMEPAGE_PREF_ENABLE ) &&
145            $this->mentorManager->getMentorshipStateForUser( $mentee ) === MentorManager::MENTORSHIP_ENABLED &&
146            $menteeUser->isNamed() &&
147            $menteeUser->getBlock() === null &&
148            !$this->wasMenteePraised( $mentee ) &&
149            !$this->isMenteeSkipped( $mentee );
150    }
151
152    /**
153     * Is the mentee praiseworthy for a given mentor?
154     *
155     * @param UserImpact $menteeImpact
156     * @param UserIdentity $mentor
157     * @return bool
158     */
159    public function isMenteePraiseworthyForMentor(
160        UserImpact $menteeImpact,
161        UserIdentity $mentor
162    ): bool {
163        if ( !$this->canUserBePraised( $menteeImpact->getUser() ) ) {
164            return false;
165        }
166
167        $conditions = $this->settings->getPraiseworthyConditions( $mentor );
168
169        if ( $menteeImpact->getTotalEditsCount() >= $conditions->getMaxEdits() ) {
170            return false;
171        }
172        if (
173            $conditions->getMaxReverts() !== null &&
174            $menteeImpact->getRevertedEditCount() > $conditions->getMaxReverts() ) {
175            return false;
176        }
177
178        $datePeriod = $this->buildDatePeriod( $conditions->getDays() );
179        return $this->getEditsInDatePeriod( $menteeImpact, $datePeriod ) >= $conditions->getMinEdits();
180    }
181}