Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
59.49% |
47 / 79 |
|
26.67% |
4 / 15 |
CRAP | |
0.00% |
0 / 1 |
MenteeOverviewDataFilter | |
59.49% |
47 / 79 |
|
26.67% |
4 / 15 |
110.83 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
limit | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
offset | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
prefix | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
minEdits | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
maxEdits | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
activeDaysAgo | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
onlyIds | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
sort | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
doSortByTimestamp | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
doSortByNumber | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
doSort | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
getTotalRows | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
filterInternal | |
86.96% |
20 / 23 |
|
0.00% |
0 / 1 |
12.32 | |||
filter | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\MentorDashboard\MenteeOverview; |
4 | |
5 | use LogicException; |
6 | use MediaWiki\Utils\MWTimestamp; |
7 | use Wikimedia\Assert\Assert; |
8 | use Wikimedia\Timestamp\ConvertibleTimestamp; |
9 | |
10 | /** |
11 | * Helper class to filter data about mentees |
12 | * |
13 | * This is consumed by the mentee overview module of the mentor dashboard. |
14 | */ |
15 | class MenteeOverviewDataFilter { |
16 | public const SORT_BY_NAME = 'username'; |
17 | public const SORT_BY_REVERTS = 'reverted'; |
18 | public const SORT_BY_BLOCKS = 'blocks'; |
19 | public const SORT_BY_QUESTIONS = 'questions'; |
20 | public const SORT_BY_EDITCOUNT = 'editcount'; |
21 | public const SORT_BY_TENURE = 'registration'; |
22 | public const SORT_BY_ACTIVITY = 'last_active'; |
23 | |
24 | public const SORT_ORDER_ASCENDING = 'asc'; |
25 | public const SORT_ORDER_DESCENDING = 'desc'; |
26 | |
27 | /** @var int Number of seconds in a day */ |
28 | private const SECONDS_DAY = 86400; |
29 | |
30 | private const TIMESTAMP_SORTS = [ |
31 | self::SORT_BY_TENURE, |
32 | self::SORT_BY_ACTIVITY |
33 | ]; |
34 | |
35 | private const ALL_SORTS = [ |
36 | self::SORT_BY_NAME, |
37 | self::SORT_BY_REVERTS, |
38 | self::SORT_BY_BLOCKS, |
39 | self::SORT_BY_QUESTIONS, |
40 | self::SORT_BY_EDITCOUNT, |
41 | self::SORT_BY_TENURE, |
42 | self::SORT_BY_ACTIVITY |
43 | ]; |
44 | |
45 | /** @var array */ |
46 | private $data; |
47 | |
48 | /** @var string */ |
49 | private $sortBy = self::SORT_BY_ACTIVITY; |
50 | |
51 | /** @var string */ |
52 | private $sortOrder = self::SORT_ORDER_DESCENDING; |
53 | |
54 | /** @var int */ |
55 | private $limit = 10; |
56 | |
57 | /** @var int */ |
58 | private $offset = 0; |
59 | |
60 | /** @var string */ |
61 | private $prefix = ''; |
62 | |
63 | /** @var int|null */ |
64 | private $minEdits = null; |
65 | |
66 | /** @var int|null */ |
67 | private $maxEdits = null; |
68 | |
69 | /** @var int|null */ |
70 | private $activeDaysAgo = null; |
71 | |
72 | /** @var int[]|null */ |
73 | private $onlyIds = null; |
74 | |
75 | /** @var int|null */ |
76 | private $totalRows = null; |
77 | |
78 | /** @var array|null */ |
79 | private $filteredData = null; |
80 | |
81 | /** |
82 | * @param array $data Data to filter (must come from MenteeOverviewDataProvider) |
83 | */ |
84 | public function __construct( |
85 | array $data |
86 | ) { |
87 | $this->data = $data; |
88 | } |
89 | |
90 | /** |
91 | * Set a limit (can be used for pagination) |
92 | * |
93 | * @param int $limit |
94 | * @return static |
95 | */ |
96 | public function limit( int $limit ): MenteeOverviewDataFilter { |
97 | $this->limit = $limit; |
98 | return $this; |
99 | } |
100 | |
101 | /** |
102 | * Set an offset (can be used for pagination) |
103 | * |
104 | * @param int $offset |
105 | * @return static |
106 | */ |
107 | public function offset( int $offset ): MenteeOverviewDataFilter { |
108 | $this->offset = $offset; |
109 | return $this; |
110 | } |
111 | |
112 | /** |
113 | * Only mentees matching a given prefix |
114 | * |
115 | * @param string $prefix |
116 | * @return static |
117 | */ |
118 | public function prefix( string $prefix ) { |
119 | $this->prefix = str_replace( '_', ' ', $prefix ); |
120 | return $this; |
121 | } |
122 | |
123 | /** |
124 | * Set minimum number of edits |
125 | * |
126 | * @param int|null $minEdits |
127 | * @return static |
128 | */ |
129 | public function minEdits( ?int $minEdits ): MenteeOverviewDataFilter { |
130 | $this->minEdits = $minEdits; |
131 | return $this; |
132 | } |
133 | |
134 | /** |
135 | * Set maximum number of edits |
136 | * |
137 | * @param int|null $maxEdits |
138 | * @return static |
139 | */ |
140 | public function maxEdits( ?int $maxEdits ): MenteeOverviewDataFilter { |
141 | $this->maxEdits = $maxEdits; |
142 | return $this; |
143 | } |
144 | |
145 | /** |
146 | * Filter by user's activity |
147 | * |
148 | * @param int|null $activeDaysAgo |
149 | * @return static |
150 | */ |
151 | public function activeDaysAgo( ?int $activeDaysAgo ): MenteeOverviewDataFilter { |
152 | $this->activeDaysAgo = $activeDaysAgo; |
153 | return $this; |
154 | } |
155 | |
156 | /** |
157 | * Include only users with specified IDs |
158 | * |
159 | * Used to filter by starred mentees. |
160 | * |
161 | * @param array|null $ids User IDs |
162 | * @return static |
163 | */ |
164 | public function onlyIds( ?array $ids ): MenteeOverviewDataFilter { |
165 | $this->onlyIds = $ids; |
166 | return $this; |
167 | } |
168 | |
169 | /** |
170 | * Set sorting |
171 | * |
172 | * @param string $sortBy One of MenteeOverviewDataFilter::SORT_BY_* constants |
173 | * @param string $order One of MenteeOverviewDataFilter::SORT_ORDER_* constants |
174 | * @return static |
175 | */ |
176 | public function sort( |
177 | string $sortBy = self::SORT_BY_ACTIVITY, |
178 | string $order = self::SORT_ORDER_DESCENDING |
179 | ): MenteeOverviewDataFilter { |
180 | Assert::parameter( |
181 | in_array( $sortBy, self::ALL_SORTS ), |
182 | '$sortBy', |
183 | 'must be one of the MenteeOverviewDataFilter::SORT_BY_* constants' |
184 | ); |
185 | Assert::parameter( |
186 | in_array( $order, [ self::SORT_ORDER_DESCENDING, self::SORT_ORDER_ASCENDING ] ), |
187 | '$order', |
188 | 'must be one of the MenteeOverviewDataFilter::SORT_ORDER_* constants' |
189 | ); |
190 | |
191 | $this->sortBy = $sortBy; |
192 | $this->sortOrder = $order; |
193 | return $this; |
194 | } |
195 | |
196 | /** |
197 | * Sort data using $field, interpreting it as a timestamp |
198 | * |
199 | * This does NOT check if it actually is a timestamp. Caller needs |
200 | * to do this. |
201 | * |
202 | * @param array &$data |
203 | * @param string $field |
204 | */ |
205 | private function doSortByTimestamp( array &$data, string $field ) { |
206 | usort( $data, function ( $a, $b ) use ( $field ) { |
207 | $tsA = MWTimestamp::getInstance( $a[$field] ?? false ); |
208 | $tsB = MWTimestamp::getInstance( $b[$field] ?? false ); |
209 | if ( $this->sortOrder === self::SORT_ORDER_ASCENDING ) { |
210 | return $tsA->getTimestamp() <=> $tsB->getTimestamp(); |
211 | } elseif ( $this->sortOrder === self::SORT_ORDER_DESCENDING ) { |
212 | return $tsB->getTimestamp() <=> $tsA->getTimestamp(); |
213 | } else { |
214 | throw new LogicException( 'sortOrder is not valid' ); |
215 | } |
216 | } ); |
217 | } |
218 | |
219 | /** |
220 | * Sort data using $field, interpreting it as a number |
221 | * |
222 | * This does NOT check if it actually is a number. Caller needs to |
223 | * do that. |
224 | * |
225 | * @param array &$data |
226 | * @param string $field |
227 | */ |
228 | private function doSortByNumber( array &$data, string $field ) { |
229 | usort( $data, function ( $a, $b ) use ( $field ) { |
230 | if ( $this->sortOrder === self::SORT_ORDER_ASCENDING ) { |
231 | return $a[$field] <=> $b[$field]; |
232 | } elseif ( $this->sortOrder === self::SORT_ORDER_DESCENDING ) { |
233 | return $b[$field] <=> $a[$field]; |
234 | } else { |
235 | throw new LogicException( 'sortOrder is not valid' ); |
236 | } |
237 | } ); |
238 | } |
239 | |
240 | /** |
241 | * Sort the data |
242 | * |
243 | * This function uses $this->sortBy to decide what to filter by, |
244 | * and calls doSortByTimestamp or doSortByNumber depending |
245 | * on the data type of the field. |
246 | * |
247 | * @param array &$data |
248 | */ |
249 | private function doSort( array &$data ) { |
250 | if ( in_array( $this->sortBy, self::TIMESTAMP_SORTS ) ) { |
251 | $this->doSortByTimestamp( $data, $this->sortBy ); |
252 | } else { |
253 | $this->doSortByNumber( $data, $this->sortBy ); |
254 | } |
255 | } |
256 | |
257 | /** |
258 | * How many rows would the filters return? |
259 | * |
260 | * This ignores offset and limit, and is useful |
261 | * for pagination purposes. |
262 | * |
263 | * @return int |
264 | */ |
265 | public function getTotalRows(): int { |
266 | if ( $this->totalRows !== null ) { |
267 | return $this->totalRows; |
268 | } |
269 | if ( $this->filteredData === null ) { |
270 | $this->filteredData = $this->filterInternal(); |
271 | } |
272 | $this->totalRows = count( $this->filteredData ); |
273 | return $this->totalRows; |
274 | } |
275 | |
276 | /** |
277 | * Apply filtering rules (but do not apply limit/offset) |
278 | * |
279 | * @return array |
280 | */ |
281 | private function filterInternal(): array { |
282 | // Filter the data |
283 | $prefixLen = strlen( $this->prefix ); |
284 | $filteredData = array_filter( $this->data, function ( $menteeData ) use ( $prefixLen ) { |
285 | if ( $this->prefix !== '' ) { |
286 | if ( substr( $menteeData['username'], 0, $prefixLen ) !== $this->prefix ) { |
287 | return false; |
288 | } |
289 | } |
290 | |
291 | if ( $this->onlyIds !== null && !in_array( $menteeData['user_id'], $this->onlyIds ) ) { |
292 | return false; |
293 | } |
294 | |
295 | if ( $this->minEdits !== null && $menteeData['editcount'] < $this->minEdits ) { |
296 | return false; |
297 | } |
298 | |
299 | if ( $this->maxEdits !== null && $menteeData['editcount'] > $this->maxEdits ) { |
300 | return false; |
301 | } |
302 | |
303 | if ( $this->activeDaysAgo !== null && $menteeData['last_active'] !== null ) { |
304 | $secondsSinceLastActivity = (int)wfTimestamp( TS_UNIX ) - |
305 | (int)ConvertibleTimestamp::convert( |
306 | TS_UNIX, |
307 | $menteeData['last_active'] |
308 | ); |
309 | if ( $secondsSinceLastActivity >= self::SECONDS_DAY * $this->activeDaysAgo ) { |
310 | return false; |
311 | } |
312 | } |
313 | |
314 | return true; |
315 | } ); |
316 | |
317 | // Sort the data |
318 | $this->doSort( $filteredData ); |
319 | return $filteredData; |
320 | } |
321 | |
322 | /** |
323 | * Do the filtering and return data |
324 | * |
325 | * @return array Filtered and sorted data |
326 | */ |
327 | public function filter(): array { |
328 | if ( $this->filteredData === null ) { |
329 | $this->filteredData = $this->filterInternal(); |
330 | } |
331 | |
332 | // Apply limit and offset |
333 | return array_slice( $this->filteredData, $this->offset, $this->limit, true ); |
334 | } |
335 | } |