Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
13.46% covered (danger)
13.46%
14 / 104
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
PraiseworthyMenteeSuggester
13.46% covered (danger)
13.46%
14 / 104
0.00% covered (danger)
0.00%
0 / 12
430.05
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
 getUserImpactsForActiveMentees
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getPraiseworthyMenteesForMentorUncached
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 makeCacheKeyForMentor
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getScopedLock
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 refreshPraiseworthyMenteesForMentor
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 getPraiseworthyMenteesForMentor
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 isMenteeMarkedAsPraiseworthy
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 markMenteeAsPraiseworthy
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 removeMenteeFromSuggestions
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 markMenteeAsPraised
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 markMenteeAsSkipped
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace GrowthExperiments\MentorDashboard\PersonalizedPraise;
4
5use GrowthExperiments\EventLogging\PersonalizedPraiseLogger;
6use GrowthExperiments\Mentorship\Store\MentorStore;
7use GrowthExperiments\UserImpact\DatabaseUserImpactStore;
8use GrowthExperiments\UserImpact\UserImpact;
9use GrowthExperiments\UserImpact\UserImpactLookup;
10use MediaWiki\User\Options\UserOptionsManager;
11use MediaWiki\User\UserIdentity;
12use Psr\Log\LoggerAwareTrait;
13use Wikimedia\LightweightObjectStore\ExpirationAwareness;
14use Wikimedia\ObjectCache\BagOStuff;
15use Wikimedia\ScopedCallback;
16use Wikimedia\Timestamp\ConvertibleTimestamp;
17
18class PraiseworthyMenteeSuggester {
19    use LoggerAwareTrait;
20
21    private const EXPIRATION_TTL = ExpirationAwareness::TTL_DAY;
22
23    private BagOStuff $globalCache;
24    private UserOptionsManager $userOptionsManager;
25    private PraiseworthyConditionsLookup $praiseworthyConditionsLookup;
26    private PersonalizedPraiseNotificationsDispatcher $notificationsDispatcher;
27    private PersonalizedPraiseLogger $eventLogger;
28    private MentorStore $mentorStore;
29    private UserImpactLookup $userImpactLookup;
30
31    /**
32     * @param BagOStuff $globalCache
33     * @param UserOptionsManager $userOptionsManager
34     * @param PraiseworthyConditionsLookup $praiseworthyConditionsLookup
35     * @param PersonalizedPraiseNotificationsDispatcher $notificationsDispatcher
36     * @param PersonalizedPraiseLogger $personalizedPraiseLogger
37     * @param MentorStore $mentorStore
38     * @param UserImpactLookup $userImpactLookup
39     */
40    public function __construct(
41        BagOStuff $globalCache,
42        UserOptionsManager $userOptionsManager,
43        PraiseworthyConditionsLookup $praiseworthyConditionsLookup,
44        PersonalizedPraiseNotificationsDispatcher $notificationsDispatcher,
45        PersonalizedPraiseLogger $personalizedPraiseLogger,
46        MentorStore $mentorStore,
47        UserImpactLookup $userImpactLookup
48    ) {
49        $this->globalCache = $globalCache;
50        $this->userOptionsManager = $userOptionsManager;
51        $this->praiseworthyConditionsLookup = $praiseworthyConditionsLookup;
52        $this->notificationsDispatcher = $notificationsDispatcher;
53        $this->eventLogger = $personalizedPraiseLogger;
54        $this->mentorStore = $mentorStore;
55        $this->userImpactLookup = $userImpactLookup;
56    }
57
58    /**
59     * Get array of user impacts for all active mentees assigned to $mentor
60     *
61     * @param UserIdentity $mentor
62     * @return UserImpact[]
63     */
64    private function getUserImpactsForActiveMentees( UserIdentity $mentor ): array {
65        $mentees = $this->mentorStore->getMenteesByMentor(
66            $mentor, MentorStore::ROLE_PRIMARY,
67            false, false
68        );
69
70        if ( $this->userImpactLookup instanceof DatabaseUserImpactStore ) {
71            $userIds = array_map( static function ( UserIdentity $mentee ) {
72                return $mentee->getId();
73            }, $mentees );
74            return $this->userImpactLookup->batchGetUserImpact( $userIds );
75        } else {
76            // There is no batching in other implementations than DatabaseUserImpactStore; fetch
77            // user impacts one by one.
78            $this->logger->error(
79                __METHOD__ . ' does not have DatabaseUserImpactStore injected, possible ' .
80                'performance impact.'
81            );
82            return array_map( function ( UserIdentity $mentee ) {
83                return $this->userImpactLookup->getUserImpact( $mentee );
84            }, $mentees );
85        }
86    }
87
88    /**
89     * Get list of praiseworthy mentees with no caching
90     *
91     * This will iterate through all recently active mentees assigned to the
92     * mentor in question.
93     *
94     * @param UserIdentity $mentor
95     * @return UserImpact[]
96     */
97    public function getPraiseworthyMenteesForMentorUncached( UserIdentity $mentor ): array {
98        $impacts = $this->getUserImpactsForActiveMentees( $mentor );
99        return array_filter( $impacts, function ( ?UserImpact $impact ) use ( $mentor ) {
100            if ( $impact === null ) {
101                return false;
102            }
103
104            return $this->praiseworthyConditionsLookup->isMenteePraiseworthyForMentor(
105                $impact, $mentor
106            );
107        } );
108    }
109
110    /**
111     * @param UserIdentity $mentor
112     * @return string
113     */
114    private function makeCacheKeyForMentor( UserIdentity $mentor ): string {
115        return $this->globalCache->makeKey(
116            'GrowthExperiments', 'PraiseworthyMenteeSuggester',
117            UserImpact::VERSION, 'getPraiseworthyMentees', $mentor->getId()
118        );
119    }
120
121    /**
122     * Acquire a lock via BagOStuff
123     *
124     * If a lock cannot be acquired, an error is logged.
125     *
126     * @param string $key
127     * @param string $caller
128     * @return ScopedCallback|null
129     * @see BagOStuff::getScopedLock()
130     */
131    private function getScopedLock( string $key, string $caller ): ?ScopedCallback {
132        $lock = $this->globalCache->getScopedLock( $key );
133        if ( !$lock ) {
134            $this->logger->error(
135                $caller . ' failed to acquire a lock for cache of praiseworthy mentees'
136            );
137        }
138        return $lock;
139    }
140
141    /**
142     * Refresh the mentor's cache of their praiseworthy mentees
143     *
144     * @param UserIdentity $mentor
145     */
146    public function refreshPraiseworthyMenteesForMentor( UserIdentity $mentor ): void {
147        $key = $this->makeCacheKeyForMentor( $mentor );
148        $lock = $this->getScopedLock( $key, __METHOD__ );
149        if ( !$lock ) {
150            return;
151        }
152
153        $praiseworthyMentees = $this->getPraiseworthyMenteesForMentorUncached( $mentor );
154        $this->globalCache->set(
155            $key,
156            $praiseworthyMentees,
157            self::EXPIRATION_TTL
158        );
159
160        $wasNotified = $this->notificationsDispatcher->maybeNotifyAboutPendingMentees( $mentor );
161        foreach ( $praiseworthyMentees as $impact ) {
162            $this->eventLogger->logSuggested( $mentor, $impact->getUser(), $wasNotified );
163        }
164    }
165
166    /**
167     * Get cached list of praiseworthy mentees for mentor
168     *
169     * @param UserIdentity $mentor
170     * @return UserImpact[] ID => UserImpact mapping
171     */
172    public function getPraiseworthyMenteesForMentor( UserIdentity $mentor ): array {
173        $key = $this->makeCacheKeyForMentor( $mentor );
174        $lock = $this->getScopedLock( $key, __METHOD__ );
175        if ( !$lock ) {
176            return [];
177        }
178
179        $res = $this->globalCache->get( $key );
180        if ( !$res ) {
181            return [];
182        }
183        return $res;
184    }
185
186    /**
187     * @param UserIdentity $mentee
188     * @param UserIdentity $mentor
189     * @return bool
190     */
191    public function isMenteeMarkedAsPraiseworthy( UserIdentity $mentee, UserIdentity $mentor ): bool {
192        $praiseworthyIds = array_keys( $this->getPraiseworthyMenteesForMentor( $mentor ) );
193        return in_array( $mentee->getId(), $praiseworthyIds );
194    }
195
196    /**
197     * Mark a mentee as praiseworthy
198     *
199     * Caller is responsible for checking whether mentee is praiseworthy or not;
200     * this can be done by calling PraiseworthyConditionsLookup::isMenteePraiseworthyForMentor.
201     *
202     * @param UserImpact $menteeImpact
203     * @param UserIdentity $mentorUser
204     */
205    public function markMenteeAsPraiseworthy(
206        UserImpact $menteeImpact,
207        UserIdentity $mentorUser
208    ): void {
209        if ( $this->isMenteeMarkedAsPraiseworthy( $menteeImpact->getUser(), $mentorUser ) ) {
210            // already done
211            return;
212        }
213
214        $key = $this->makeCacheKeyForMentor( $mentorUser );
215        $lock = $this->getScopedLock( $key, __METHOD__ );
216        if ( !$lock ) {
217            return;
218        }
219
220        $data = $this->globalCache->get( $key );
221        if ( !$data ) {
222            $data = [];
223        }
224        $data[$menteeImpact->getUser()->getId()] = $menteeImpact;
225        $this->globalCache->set( $key, $data, self::EXPIRATION_TTL );
226
227        $this->notificationsDispatcher->onMenteeSuggested( $mentorUser, $menteeImpact->getUser() );
228    }
229
230    /**
231     * Remove a mentee from the list of praiseworthy mentees
232     *
233     * @param UserIdentity $mentee
234     */
235    private function removeMenteeFromSuggestions( UserIdentity $mentee ): void {
236        $mentor = $this->mentorStore->loadMentorUser( $mentee, MentorStore::ROLE_PRIMARY );
237        if ( !$mentor ) {
238            return;
239        }
240
241        if ( $this->isMenteeMarkedAsPraiseworthy( $mentee, $mentor ) ) {
242            $this->globalCache->merge(
243                $this->makeCacheKeyForMentor( $mentor ),
244                static function ( $cache, $key, $value ) use ( $mentee ) {
245                    if ( array_key_exists( $mentee->getId(), $value ) ) {
246                        unset( $value[$mentee->getId()] );
247                    }
248                    return $value;
249                }
250            );
251        }
252    }
253
254    /**
255     * Mark a mentee as already praised
256     *
257     * @param UserIdentity $mentee
258     */
259    public function markMenteeAsPraised( UserIdentity $mentee ): void {
260        $this->userOptionsManager->setOption(
261            $mentee,
262            PraiseworthyConditionsLookup::WAS_PRAISED_PREF,
263            true
264        );
265        $this->userOptionsManager->saveOptions( $mentee );
266
267        $this->removeMenteeFromSuggestions( $mentee );
268    }
269
270    /**
271     * Mark a mentee as skipped
272     *
273     * The mentee will not be re-suggested for PraiseworthyConditionsLookup::SKIP_MENTEES_FOR_DAYS
274     * days.
275     *
276     * @param UserIdentity $mentee
277     */
278    public function markMenteeAsSkipped( UserIdentity $mentee ): void {
279        $this->userOptionsManager->setOption(
280            $mentee,
281            PraiseworthyConditionsLookup::SKIPPED_UNTIL_PREF,
282            ( new ConvertibleTimestamp() )
283                ->add( 'P' . PraiseworthyConditionsLookup::SKIP_MENTEES_FOR_DAYS . 'D' )
284                ->getTimestamp( TS_MW )
285        );
286        $this->userOptionsManager->saveOptions( $mentee );
287
288        $this->removeMenteeFromSuggestions( $mentee );
289    }
290}