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