Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
21.55% |
25 / 116 |
|
21.43% |
3 / 14 |
CRAP | |
0.00% |
0 / 1 |
MentorStatusManager | |
21.55% |
25 / 116 |
|
21.43% |
3 / 14 |
464.50 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
canChangeStatus | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
makeAwayReasonCacheKey | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
invalidateAwayReasonCache | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getAwayReason | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
2.01 | |||
getAwayReasonUncached | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
getMentorStatus | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getMentorBackTimestamp | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getMentorBackTimestampInternal | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
parseBackTimestamp | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
getAwayMentors | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
6 | |||
markMentorAsAway | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
markMentorAsAwayTimestamp | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
12 | |||
markMentorAsActive | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\MentorDashboard\MentorTools; |
4 | |
5 | use MediaWiki\User\Options\UserOptionsManager; |
6 | use MediaWiki\User\UserFactory; |
7 | use MediaWiki\User\UserIdentity; |
8 | use MediaWiki\User\UserIdentityLookup; |
9 | use StatusValue; |
10 | use Wikimedia\LightweightObjectStore\ExpirationAwareness; |
11 | use Wikimedia\ObjectCache\HashBagOStuff; |
12 | use Wikimedia\Rdbms\DBAccessObjectUtils; |
13 | use Wikimedia\Rdbms\IConnectionProvider; |
14 | use Wikimedia\Rdbms\IDBAccessObject; |
15 | use Wikimedia\Timestamp\ConvertibleTimestamp; |
16 | |
17 | class 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 | } |