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