Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 237 |
|
0.00% |
0 / 16 |
CRAP | |
0.00% |
0 / 1 |
ValidationStatistics | |
0.00% |
0 / 237 |
|
0.00% |
0 / 16 |
1482 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 149 |
|
0.00% |
0 / 1 |
420 | |||
readyForQuery | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
getEditorCount | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
getReviewerCount | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
getStats | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getMeanReviewWaitAnon | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getMedianReviewWaitAnon | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getMeanPendingWait | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getTotalPages | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getReviewedPages | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getSyncedPages | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getReviewPercentilesAnon | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getLastUpdate | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getTopReviewers | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
6 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | use MediaWiki\Html\Html; |
4 | use MediaWiki\MediaWikiServices; |
5 | use MediaWiki\SpecialPage\IncludableSpecialPage; |
6 | use MediaWiki\SpecialPage\SpecialPage; |
7 | use Wikimedia\Rdbms\SelectQueryBuilder; |
8 | |
9 | class 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 | } |