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