Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
59.49% covered (warning)
59.49%
47 / 79
26.67% covered (danger)
26.67%
4 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
MenteeOverviewDataFilter
59.49% covered (warning)
59.49%
47 / 79
26.67% covered (danger)
26.67%
4 / 15
110.83
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 limit
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 offset
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 prefix
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 minEdits
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 maxEdits
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 activeDaysAgo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 onlyIds
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 sort
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 doSortByTimestamp
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 doSortByNumber
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 doSort
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getTotalRows
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 filterInternal
86.96% covered (warning)
86.96%
20 / 23
0.00% covered (danger)
0.00%
0 / 1
12.32
 filter
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace GrowthExperiments\MentorDashboard\MenteeOverview;
4
5use LogicException;
6use MediaWiki\Utils\MWTimestamp;
7use Wikimedia\Assert\Assert;
8use 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 */
15class 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}