Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.55% covered (danger)
21.55%
25 / 116
21.43% covered (danger)
21.43%
3 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
MentorStatusManager
21.55% covered (danger)
21.55%
25 / 116
21.43% covered (danger)
21.43%
3 / 14
464.50
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 canChangeStatus
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 makeAwayReasonCacheKey
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 invalidateAwayReasonCache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAwayReason
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 getAwayReasonUncached
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 getMentorStatus
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getMentorBackTimestamp
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getMentorBackTimestampInternal
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 parseBackTimestamp
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getAwayMentors
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 markMentorAsAway
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 markMentorAsAwayTimestamp
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 markMentorAsActive
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace GrowthExperiments\MentorDashboard\MentorTools;
4
5use MediaWiki\User\Options\UserOptionsManager;
6use MediaWiki\User\UserFactory;
7use MediaWiki\User\UserIdentity;
8use MediaWiki\User\UserIdentityLookup;
9use StatusValue;
10use Wikimedia\LightweightObjectStore\ExpirationAwareness;
11use Wikimedia\ObjectCache\HashBagOStuff;
12use Wikimedia\Rdbms\DBAccessObjectUtils;
13use Wikimedia\Rdbms\IConnectionProvider;
14use Wikimedia\Rdbms\IDBAccessObject;
15use Wikimedia\Timestamp\ConvertibleTimestamp;
16
17class MentorStatusManager {
18
19    /** @var int Also hardcoded in AwaySettingsDialog.js */
20    private const MAX_BACK_IN_DAYS = 365;
21
22    /** @var string Mentor status */
23    public const STATUS_ACTIVE = 'active';
24    /** @var string Mentor status */
25    public const STATUS_AWAY = 'away';
26
27    /** @var string[] List of MentorStatusManager::STATUS_* constants */
28    public const STATUSES = [
29        self::STATUS_ACTIVE,
30        self::STATUS_AWAY
31    ];
32
33    /** @var string */
34    public const AWAY_BECAUSE_TIMESTAMP = 'timestamp';
35    /** @var string */
36    public const AWAY_BECAUSE_BLOCK = 'block';
37    /** @var string */
38    public const AWAY_BECAUSE_LOCK = 'lock';
39
40    /** @var string Preference key to store mentor's away timestamp */
41    public const MENTOR_AWAY_TIMESTAMP_PREF = 'growthexperiments-mentor-away-timestamp';
42
43    /** @var int Number of seconds in a day */
44    private const SECONDS_DAY = 86400;
45
46    /** @var UserOptionsManager */
47    private $userOptionsManager;
48
49    /** @var UserIdentityLookup */
50    private $userIdentityLookup;
51
52    /** @var UserFactory */
53    private $userFactory;
54
55    private IConnectionProvider $connectionProvider;
56
57    private HashBagOStuff $inprocessCache;
58
59    /**
60     * @param UserOptionsManager $userOptionsManager
61     * @param UserIdentityLookup $userIdentityLookup
62     * @param UserFactory $userFactory
63     * @param IConnectionProvider $connectionProvider
64     */
65    public function __construct(
66        UserOptionsManager $userOptionsManager,
67        UserIdentityLookup $userIdentityLookup,
68        UserFactory $userFactory,
69        IConnectionProvider $connectionProvider
70    ) {
71        $this->userOptionsManager = $userOptionsManager;
72        $this->userIdentityLookup = $userIdentityLookup;
73        $this->userFactory = $userFactory;
74        $this->connectionProvider = $connectionProvider;
75        $this->inprocessCache = new HashBagOStuff();
76    }
77
78    /**
79     * Can user change their status?
80     *
81     * @param UserIdentity $mentor
82     * @param int $flags bitfield consisting of IDBAccessObject::READ_* constants
83     * @return StatusValue
84     */
85    public function canChangeStatus( UserIdentity $mentor, int $flags = 0 ): StatusValue {
86        $awayReason = $this->getAwayReason( $mentor, $flags );
87        switch ( $awayReason ) {
88            case self::AWAY_BECAUSE_BLOCK:
89                return StatusValue::newFatal(
90                    'growthexperiments-mentor-dashboard-mentor-tools-mentor-status-error-cannot-be-changed-block'
91                );
92            case self::AWAY_BECAUSE_LOCK:
93                return StatusValue::newFatal(
94                    'growthexperiments-mentor-dashboard-mentor-tools-mentor-status-error-cannot-be-changed-lock',
95                    $mentor->getName()
96                );
97            default:
98                return StatusValue::newGood();
99        }
100    }
101
102    /**
103     * @param UserIdentity $mentor
104     * @return string
105     */
106    private function makeAwayReasonCacheKey( UserIdentity $mentor ): string {
107        return $this->inprocessCache->makeKey(
108            'GrowthExperiments', __CLASS__, 'awayReason',
109            $mentor->getId()
110        );
111    }
112
113    /**
114     * @param UserIdentity $mentor
115     */
116    private function invalidateAwayReasonCache( UserIdentity $mentor ): void {
117        $this->inprocessCache->delete( $this->makeAwayReasonCacheKey( $mentor ) );
118    }
119
120    /**
121     * Why is the user away?
122     *
123     * @param UserIdentity $mentor
124     * @param int $flags bitfield consisting of IDBAccessObject::READ_* constants
125     * @return string|null Away reason (AWAY_* constant) or null if mentor is not away
126     */
127    public function getAwayReason( UserIdentity $mentor, int $flags = 0 ): ?string {
128        if ( DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST ) ) {
129            $this->invalidateAwayReasonCache( $mentor );
130        }
131
132        return $this->inprocessCache->getWithSetCallback(
133            $this->makeAwayReasonCacheKey( $mentor ),
134            ExpirationAwareness::TTL_INDEFINITE,
135            function () use ( $mentor, $flags ) {
136                return $this->getAwayReasonUncached( $mentor, $flags );
137            }
138        );
139    }
140
141    /**
142     * Why is the user away?
143     *
144     * This bypasses caching.
145     *
146     * @param UserIdentity $mentor
147     * @param int $flags bitfield consisting of IDBAccessObject::READ_* constants
148     * @return string|null Away reason (AWAY_* constant) or null if mentor is not away
149     */
150    private function getAwayReasonUncached( UserIdentity $mentor, int $flags = 0 ): ?string {
151        // NOTE: (b)lock checking must be first. This is to make canChangeStatus() work for mentors
152        // who are blocked _and_ (manually) away.
153        $block = $this->userFactory->newFromUserIdentity( $mentor )
154            ->getBlock( $flags );
155        if ( $block !== null && $block->isSitewide() ) {
156            return self::AWAY_BECAUSE_BLOCK;
157        }
158
159        if ( $this->userFactory->newFromUserIdentity( $mentor )->isLocked() ) {
160            return self::AWAY_BECAUSE_LOCK;
161        }
162
163        if ( $this->getMentorBackTimestampInternal( $mentor, $flags ) !== null ) {
164            return self::AWAY_BECAUSE_TIMESTAMP;
165        }
166
167        // user is not away
168        return null;
169    }
170
171    /**
172     * Get mentor's current status
173     *
174     * @param UserIdentity $mentor
175     * @param int $flags bitfield; consists of IDBAccessObject::READ_* constants
176     * @return string one of MentorStatusManager::STATUS_* constants
177     */
178    public function getMentorStatus( UserIdentity $mentor, int $flags = 0 ): string {
179        return $this->getAwayReason( $mentor, $flags ) === null
180            ? self::STATUS_ACTIVE
181            : self::STATUS_AWAY;
182    }
183
184    /**
185     * @param UserIdentity $mentor
186     * @param int $flags bitfield; consists of IDBAccessObject::READ_* constants
187     * @return string|null Null if expiry is not set (mentor's current status does not expire)
188     */
189    public function getMentorBackTimestamp( UserIdentity $mentor, int $flags = 0 ): ?string {
190        if ( $this->getAwayReason( $mentor, $flags ) !== self::AWAY_BECAUSE_TIMESTAMP ) {
191            // mentor is either not away at all, or is away permanently
192            return null;
193        }
194        return $this->getMentorBackTimestampInternal( $mentor, $flags );
195    }
196
197    /**
198     * Get mentor's back timestamp from their user preferences
199     *
200     * Back date returned by this method only applies if
201     * getAwayReason() is AWAY_BECAUSE_TIMESTAMP.
202     *
203     * @param UserIdentity $mentor
204     * @param int $flags
205     * @return string|null
206     */
207    private function getMentorBackTimestampInternal( UserIdentity $mentor, int $flags = 0 ): ?string {
208        return $this->parseBackTimestamp( $this->userOptionsManager->getOption(
209            $mentor,
210            self::MENTOR_AWAY_TIMESTAMP_PREF,
211            null,
212            false,
213            $flags
214        ) );
215    }
216
217    /**
218     * @param string|null $rawTs
219     * @return string|null
220     */
221    private function parseBackTimestamp( ?string $rawTs ): ?string {
222        if (
223            $rawTs === null ||
224            (int)ConvertibleTimestamp::convert( TS_UNIX, $rawTs ) < (int)wfTimestamp( TS_UNIX )
225        ) {
226            return null;
227        }
228
229        return $rawTs;
230    }
231
232    /**
233     * Get mentors marked as away
234     *
235     * @param int $flags bitfield; consists of IDBAccessObject::READ_* constants
236     * @return UserIdentity[]
237     */
238    public function getAwayMentors( int $flags = 0 ): array {
239        $db = DBAccessObjectUtils::getDBFromRecency( $this->connectionProvider, $flags );
240
241        // This should be okay, as up_property is an index, and we won't
242        // get a lot of rows to process.
243        $awayMentorIds = $db->newSelectQueryBuilder()
244            ->select( 'up_user' )
245            ->from( 'user_properties' )
246            ->where( [
247                'up_property' => self::MENTOR_AWAY_TIMESTAMP_PREF,
248                $db->expr( 'up_value', '!=', null ),
249                $db->expr( 'up_value', '>', $db->timestamp() )
250            ] )
251            ->recency( $flags )
252            ->caller( __METHOD__ )
253            ->fetchFieldValues();
254
255        if ( $awayMentorIds === [] ) {
256            return [];
257        }
258
259        return iterator_to_array(
260            $this->userIdentityLookup
261                ->newSelectQueryBuilder()
262                ->whereUserIds( $awayMentorIds )
263                ->caller( __METHOD__ )
264                ->fetchUserIdentities()
265        );
266    }
267
268    /**
269     * Mark a mentor as away
270     *
271     * @param UserIdentity $mentor
272     * @param int $backInDays Length of mentor's wiki-vacation in days
273     * @return StatusValue
274     */
275    public function markMentorAsAway( UserIdentity $mentor, int $backInDays ): StatusValue {
276        return $this->markMentorAsAwayTimestamp(
277            $mentor,
278            ConvertibleTimestamp::convert(
279                TS_MW,
280                (int)wfTimestamp( TS_UNIX ) + self::SECONDS_DAY * $backInDays
281            )
282        );
283    }
284
285    /**
286     * Mark a mentor as away
287     *
288     * @param UserIdentity $mentor
289     * @param string $timestamp When will the mentor be back?
290     * @return StatusValue
291     */
292    public function markMentorAsAwayTimestamp(
293        UserIdentity $mentor,
294        string $timestamp
295    ): StatusValue {
296        $canChangeStatus = $this->canChangeStatus( $mentor );
297        if ( !$canChangeStatus->isOK() ) {
298            return $canChangeStatus;
299        }
300
301        if (
302            (
303                (int)ConvertibleTimestamp::convert( TS_UNIX, $timestamp ) -
304                (int)ConvertibleTimestamp::now( TS_UNIX )
305            ) > self::MAX_BACK_IN_DAYS * self::SECONDS_DAY
306        ) {
307            return StatusValue::newFatal(
308                'growthexperiments-mentor-dashboard-mentor-tools-away-dialog-error-toohigh',
309                self::MAX_BACK_IN_DAYS
310            );
311        }
312
313        $this->userOptionsManager->setOption(
314            $mentor,
315            self::MENTOR_AWAY_TIMESTAMP_PREF,
316            ConvertibleTimestamp::convert(
317                TS_MW,
318                $timestamp
319            )
320        );
321        $this->userOptionsManager->saveOptions( $mentor );
322        $this->invalidateAwayReasonCache( $mentor );
323        return StatusValue::newGood();
324    }
325
326    /**
327     * Mark a mentor as active
328     *
329     * @param UserIdentity $mentor
330     * @return StatusValue
331     */
332    public function markMentorAsActive( UserIdentity $mentor ): StatusValue {
333        $canChangeStatus = $this->canChangeStatus( $mentor );
334        if ( !$canChangeStatus->isOK() ) {
335            return $canChangeStatus;
336        }
337
338        $this->userOptionsManager->setOption(
339            $mentor,
340            self::MENTOR_AWAY_TIMESTAMP_PREF,
341            null
342        );
343        $this->userOptionsManager->saveOptions( $mentor );
344        $this->invalidateAwayReasonCache( $mentor );
345        return StatusValue::newGood();
346    }
347}