Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 229
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
ValidationStatistics
0.00% covered (danger)
0.00%
0 / 229
0.00% covered (danger)
0.00%
0 / 16
1406
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
 execute
0.00% covered (danger)
0.00%
0 / 148
0.00% covered (danger)
0.00%
0 / 1
420
 readyForQuery
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getEditorCount
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getReviewerCount
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getStats
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getMeanReviewWaitAnon
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getMedianReviewWaitAnon
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getMeanPendingWait
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getTotalPages
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getReviewedPages
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getSyncedPages
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getReviewPercentilesAnon
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getLastUpdate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getTopReviewers
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
6
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3use MediaWiki\Html\Html;
4use MediaWiki\Language\Language;
5use MediaWiki\SpecialPage\IncludableSpecialPage;
6use MediaWiki\SpecialPage\SpecialPage;
7use MediaWiki\User\ActorStore;
8use Wikimedia\ObjectCache\WANObjectCache;
9use Wikimedia\Rdbms\IConnectionProvider;
10use Wikimedia\Rdbms\SelectQueryBuilder;
11
12class ValidationStatistics extends IncludableSpecialPage {
13    /** @var array|null */
14    private $latestData = null;
15
16    public function __construct(
17        private readonly ActorStore $actorStore,
18        private readonly IConnectionProvider $dbProvider,
19        private readonly Language $contLang,
20        private readonly WANObjectCache $cache,
21    ) {
22        parent::__construct( 'ValidationStatistics' );
23    }
24
25    /**
26     * @inheritDoc
27     */
28    public function execute( $par ) {
29        $flaggedRevsProtection = $this->getConfig()->get( 'FlaggedRevsProtection' );
30
31        $out = $this->getOutput();
32        $lang = $this->getLanguage();
33
34        $this->setHeaders();
35        $this->addHelpLink( 'Help:Extension:FlaggedRevs' );
36        $ec = $this->getEditorCount();
37        $rc = $this->getReviewerCount();
38        $mt = $this->getMeanReviewWaitAnon();
39        $mdt = $this->getMedianReviewWaitAnon();
40        $pt = $this->getMeanPendingWait();
41        $pData = $this->getReviewPercentilesAnon();
42        $timestamp = $this->getLastUpdate();
43
44        $out->addWikiMsg( 'validationstatistics-users',
45            $lang->formatNum( $ec ), $lang->formatNum( $rc )
46        );
47        # Most of the output depends on background queries
48        if ( !$this->readyForQuery() ) {
49            return;
50        }
51
52        # Is there a review time table available?
53        if ( count( $pData ) ) {
54            $headerRows = '';
55            $dataRows = '';
56            foreach ( $pData as $percentile => $perValue ) {
57                $headerRows .= "<th>P<sub>" . intval( $percentile ) . "</sub></th>";
58                $dataRows .= '<td>' .
59                    htmlspecialchars( $lang->formatTimePeriod( $perValue, [ 'avoid' => 'avoidminutes' ] ) ) .
60                    '</td>';
61            }
62            $css = 'wikitable flaggedrevs_stats_table';
63            $reviewChart = "<table class='$css' style='white-space: nowrap;'>\n";
64            $reviewChart .= "<tr style='text-align: center;'>$headerRows</tr>\n";
65            $reviewChart .= "<tr style='text-align: center;'>$dataRows</tr>\n";
66            $reviewChart .= "</table>\n";
67        } else {
68            $reviewChart = '';
69        }
70
71        if ( $timestamp != '-' ) {
72            # Show "last updated"...
73            $out->addWikiMsg( 'validationstatistics-lastupdate',
74                $lang->date( $timestamp, true ),
75                $lang->time( $timestamp, true )
76            );
77        }
78        $out->addHTML( '<hr/>' );
79        # Show pending time stats...
80        $out->addWikiMsg( 'validationstatistics-pndtime',
81            $lang->formatTimePeriod( $pt, [ 'avoid' => 'avoidminutes' ] ) );
82        # Show review time stats...
83        if ( !FlaggedRevs::useOnlyIfProtected() ) {
84            $out->addWikiMsg( 'validationstatistics-revtime',
85                $lang->formatTimePeriod( $mt, [ 'avoid' => 'avoidminutes' ] ),
86                $lang->formatTimePeriod( $mdt, [ 'avoid' => 'avoidminutes' ] ),
87                $reviewChart
88            );
89        }
90        # Show per-namespace stats table...
91        $out->addWikiMsg( 'validationstatistics-table' );
92        $out->addHTML(
93            Html::openElement( 'table', [ 'class' => 'wikitable flaggedrevs_stats_table' ] )
94        );
95        $out->addHTML( "<tr>\n" );
96        // Headings (for a positive grep result):
97        // validationstatistics-ns, validationstatistics-total, validationstatistics-stable,
98        // validationstatistics-latest, validationstatistics-synced, validationstatistics-old,
99        // validationstatistics-unreviewed
100        $msgs = [ 'ns', 'total', 'stable', 'latest', 'synced', 'old' ]; // our headings
101        if ( !$flaggedRevsProtection ) {
102            $msgs[] = 'unreviewed';
103        }
104        foreach ( $msgs as $msg ) {
105            $out->addHTML( '<th>' .
106                $this->msg( "validationstatistics-$msg" )->parse() . '</th>' );
107        }
108        $out->addHTML( "</tr>\n" );
109        $namespaces = FlaggedRevs::getReviewNamespaces();
110        foreach ( $namespaces as $namespace ) {
111            $total = $this->getTotalPages( $namespace );
112            $reviewed = $this->getReviewedPages( $namespace );
113            $synced = $this->getSyncedPages( $namespace );
114            if ( $total === '-' || $reviewed === '-' || $synced === '-' ) {
115                continue; // NS added to config recently?
116            }
117
118            $NsText = $this->contLang->getFormattedNsText( $namespace );
119            $NsText = $NsText ?: $this->msg( 'blanknamespace' )->text();
120
121            $percRev = intval( $total ) == 0
122                ? '-' // devision by zero
123                : $this->msg( 'parentheses',
124                    $this->msg( 'percent' )
125                        ->numParams( sprintf(
126                            '%4.2f',
127                            100 * intval( $reviewed ) / intval( $total )
128                        ) )->escaped()
129                )->escaped();
130            $percLatest = intval( $total ) == 0
131                ? '-' // devision by zero
132                : $this->msg( 'parentheses',
133                    $this->msg( 'percent' )
134                        ->numParams( sprintf( '%4.2f', 100 * intval( $synced ) / intval( $total )
135                        ) )->escaped()
136                )->escaped();
137            $percSynced = intval( $reviewed ) == 0
138                ? '-' // devision by zero
139                : $this->msg( 'percent' )
140                    ->numParams( sprintf( '%4.2f', 100 * intval( $synced ) / intval( $reviewed ) ) )
141                    ->escaped();
142            $outdated = intval( $reviewed ) - intval( $synced );
143            $outdated = $lang->formatNum( max( 0, $outdated ) ); // lag between queries
144            $unreviewed = intval( $total ) - intval( $reviewed );
145            $unreviewed = $lang->formatNum( max( 0, $unreviewed ) ); // lag between queries
146
147            $linkRenderer = $this->getLinkRenderer();
148            $out->addHTML(
149                "<tr style='text-align: center;'>
150                    <td>" .
151                        htmlspecialchars( $NsText ) .
152                    "</td>
153                    <td>" .
154                        htmlspecialchars( $lang->formatNum( $total ) ) .
155                    "</td>
156                    <td>" .
157                        htmlspecialchars( $lang->formatNum( $reviewed ) .
158                            $this->contLang->getDirMark() ) . " <i>$percRev</i>
159                    </td>
160                    <td>" .
161                        htmlspecialchars( $lang->formatNum( $synced ) .
162                            $this->contLang->getDirMark() ) . " <i>$percLatest</i>
163                    </td>
164                    <td>" .
165                        $percSynced .
166                    "</td>
167                    <td>" .
168                        $linkRenderer->makeKnownLink(
169                            SpecialPage::getTitleFor( 'PendingChanges' ),
170                            $outdated,
171                            [],
172                            [ 'namespace' => $namespace ]
173                        ) .
174                    "</td>"
175            );
176            if ( !$flaggedRevsProtection ) {
177                $out->addHTML( "
178                    <td>" .
179                        $linkRenderer->makeKnownLink(
180                            SpecialPage::getTitleFor( 'UnreviewedPages' ),
181                            $unreviewed,
182                            [],
183                            [ 'namespace' => $namespace ]
184                        ) .
185                    "</td>"
186                );
187            }
188            $out->addHTML( "
189                </tr>"
190            );
191        }
192        $out->addHTML( Html::closeElement( 'table' ) );
193        # Is there a top X user list? If so, then show it...
194        $data = $this->getTopReviewers();
195        if ( is_array( $data ) && count( $data ) ) {
196            $out->addWikiMsg( 'validationstatistics-utable',
197                $lang->formatNum( 5 ),
198                $lang->formatNum( 1 )
199            );
200            $css = 'wikitable flaggedrevs_stats_table';
201            $reviewChart = "<table class='$css' style='white-space: nowrap;'>\n";
202            $reviewChart .= '<tr><th>' . $this->msg( 'validationstatistics-user' )->escaped() .
203                '</th><th>' . $this->msg( 'validationstatistics-reviews' )->escaped() . '</th></tr>';
204            foreach ( $data as [ $user, $reviews ] ) {
205                $reviewChart .= '<tr><td>' . htmlspecialchars( $user->getName() ) .
206                    '</td><td>' . htmlspecialchars( $lang->formatNum( $reviews ) ) . '</td></tr>';
207            }
208            $reviewChart .= "</table>\n";
209            $out->addHTML( $reviewChart );
210        }
211    }
212
213    /**
214     * @return bool
215     */
216    private function readyForQuery() {
217        $dbr = $this->dbProvider->getReplicaDatabase( false, 'vslow' );
218        return $dbr->newSelectQueryBuilder()
219                ->select( '1' )
220                ->from( 'flaggedrevs_statistics' )
221                ->caller( __METHOD__ )
222                ->fetchField();
223    }
224
225    /**
226     * @return int
227     */
228    private function getEditorCount() {
229        $dbr = $this->dbProvider->getReplicaDatabase();
230
231        return (int)$dbr->newSelectQueryBuilder()
232            ->select( 'COUNT(*)' )
233            ->from( 'user_groups' )
234            ->where( [
235                'ug_group' => 'editor',
236                $dbr->expr( 'ug_expiry', '=', null )->or( 'ug_expiry', '>=', $dbr->timestamp() ),
237            ] )
238            ->caller( __METHOD__ )
239            ->fetchField();
240    }
241
242    /**
243     * @return int
244     */
245    private function getReviewerCount() {
246        $dbr = $this->dbProvider->getReplicaDatabase();
247
248        return (int)$dbr->newSelectQueryBuilder()
249            ->select( 'COUNT(*)' )
250            ->from( 'user_groups' )
251            ->where( [
252                'ug_group' => 'reviewer',
253                $dbr->expr( 'ug_expiry', '=', null )->or( 'ug_expiry', '>=', $dbr->timestamp() ),
254            ] )
255            ->caller( __METHOD__ )
256            ->fetchField();
257    }
258
259    /**
260     * @return array
261     */
262    private function getStats() {
263        if ( $this->latestData === null ) {
264            $this->latestData = FlaggedRevsStats::getStats();
265        }
266        return $this->latestData;
267    }
268
269    /**
270     * @return int|string
271     */
272    private function getMeanReviewWaitAnon() {
273        $stats = $this->getStats();
274        return $stats['reviewLag-anon-average'];
275    }
276
277    /**
278     * @return int|string
279     */
280    private function getMedianReviewWaitAnon() {
281        $stats = $this->getStats();
282        return $stats['reviewLag-anon-median'];
283    }
284
285    /**
286     * @return int|string
287     */
288    private function getMeanPendingWait() {
289        $stats = $this->getStats();
290        return $stats['pendingLag-average'];
291    }
292
293    /**
294     * @param int $ns
295     * @return int|string
296     */
297    private function getTotalPages( $ns ) {
298        $stats = $this->getStats();
299        return $stats['totalPages-NS'][$ns] ?? '-';
300    }
301
302    /**
303     * @param int $ns
304     * @return int|string
305     */
306    private function getReviewedPages( $ns ) {
307        $stats = $this->getStats();
308        return $stats['reviewedPages-NS'][$ns] ?? '-';
309    }
310
311    /**
312     * @param int $ns
313     * @return int|string
314     */
315    private function getSyncedPages( $ns ) {
316        $stats = $this->getStats();
317        return $stats['syncedPages-NS'][$ns] ?? '-';
318    }
319
320    /**
321     * @return int[]
322     */
323    private function getReviewPercentilesAnon() {
324        $stats = $this->getStats();
325        return $stats['reviewLag-anon-percentile'];
326    }
327
328    /**
329     * @return string
330     */
331    private function getLastUpdate() {
332        $stats = $this->getStats();
333        return $stats['statTimestamp'];
334    }
335
336    /**
337     * Get top X reviewers in the last Y hours
338     * @return array[] array of tuples ( UserIdentity $user, int $reviews )
339     */
340    private function getTopReviewers() {
341        $fname = __METHOD__;
342
343        return $this->cache->getWithSetCallback(
344            $this->cache->makeKey( 'flaggedrevs', 'reviewTopUsers' ),
345            WANObjectCache::TTL_HOUR,
346            function () use ( $fname ) {
347                $dbr = $this->dbProvider->getReplicaDatabase( false, 'vslow' );
348                $limit = 5;
349                $seconds = 3600;
350                $cutoff = $dbr->timestamp( time() - $seconds );
351                $res = $dbr->newSelectQueryBuilder()
352                    ->select( [ 'actor_id', 'actor_name', 'actor_user', 'reviews' => 'COUNT(*)' ] )
353                    ->from( 'logging' )
354                    ->join( 'actor', null, 'actor_id=log_actor' )
355                    ->where( [
356                        'log_type' => 'review', // page reviews
357                        // manual approvals (filter on log_action)
358                        'log_action' => [ 'approve', 'approve2', 'approve-i', 'approve2-i' ],
359                        $dbr->expr( 'log_timestamp', '>=', $cutoff ) // last hour
360                    ] )
361                    ->groupBy( 'actor_user' )
362                    ->orderBy( 'reviews', SelectQueryBuilder::SORT_DESC )
363                    ->limit( $limit )
364                    ->caller( $fname )
365                    ->fetchResultSet();
366
367                $data = [];
368                foreach ( $res as $row ) {
369                    $data[] = [ $this->actorStore->newActorFromRow( $row ), (int)$row->reviews ];
370                }
371                return $data;
372            },
373            [
374                'lockTSE' => 300,
375                'staleTTL' => WANObjectCache::TTL_MINUTE,
376                'version' => 2,
377            ]
378        );
379    }
380
381    /**
382     * @return string
383     */
384    protected function getGroupName() {
385        return 'quality';
386    }
387}