Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.50% covered (success)
90.50%
219 / 242
68.75% covered (warning)
68.75%
11 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialConstraintReport
90.50% covered (success)
90.50%
219 / 242
68.75% covered (warning)
68.75%
11 / 16
31.83
0.00% covered (danger)
0.00%
0 / 1
 factory
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 getModules
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
82.05% covered (warning)
82.05%
32 / 39
0.00% covered (danger)
0.00%
0 / 1
7.28
 buildEntityIdForm
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
1.00
 buildNotice
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getExplanationText
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 buildResultTable
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
 appendToResultTable
94.59% covered (success)
94.59%
35 / 37
0.00% covered (danger)
0.00%
0 / 1
3.00
 buildResultHeader
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 buildSummary
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 formatStatus
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
2
 getClaimLink
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getClaimUrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare( strict_types = 1 );
4
5namespace WikibaseQuality\ConstraintReport\Specials;
6
7use HtmlArmor;
8use HTMLForm;
9use IBufferingStatsdDataFactory;
10use InvalidArgumentException;
11use MediaWiki\Config\Config;
12use MediaWiki\Html\Html;
13use MediaWiki\SpecialPage\SpecialPage;
14use OOUI\IconWidget;
15use OOUI\LabelWidget;
16use UnexpectedValueException;
17use Wikibase\DataModel\Entity\EntityId;
18use Wikibase\DataModel\Entity\EntityIdParser;
19use Wikibase\DataModel\Entity\EntityIdParsingException;
20use Wikibase\DataModel\Entity\ItemId;
21use Wikibase\DataModel\Entity\NumericPropertyId;
22use Wikibase\DataModel\Services\EntityId\EntityIdFormatter;
23use Wikibase\DataModel\Services\Lookup\EntityLookup;
24use Wikibase\Lib\LanguageFallbackChainFactory;
25use Wikibase\Lib\Store\EntityTitleLookup;
26use Wikibase\Repo\EntityIdLabelFormatterFactory;
27use Wikibase\View\EntityIdFormatterFactory;
28use WikibaseQuality\ConstraintReport\ConstraintCheck\DelegatingConstraintChecker;
29use WikibaseQuality\ConstraintReport\ConstraintCheck\Message\ViolationMessageRenderer;
30use WikibaseQuality\ConstraintReport\ConstraintCheck\Message\ViolationMessageRendererFactory;
31use WikibaseQuality\ConstraintReport\ConstraintCheck\Result\CheckResult;
32use WikibaseQuality\ConstraintReport\Html\HtmlTableBuilder;
33use WikibaseQuality\ConstraintReport\Html\HtmlTableCellBuilder;
34use WikibaseQuality\ConstraintReport\Html\HtmlTableHeaderBuilder;
35
36/**
37 * Special page that displays all constraints that are defined on an Entity with additional information
38 * (whether it complied or was a violation, which parameters the constraint has etc.).
39 *
40 * @author BP2014N1
41 * @license GPL-2.0-or-later
42 */
43class SpecialConstraintReport extends SpecialPage {
44
45    private EntityIdParser $entityIdParser;
46    private EntityLookup $entityLookup;
47    private EntityTitleLookup $entityTitleLookup;
48    private EntityIdFormatter $entityIdLabelFormatter;
49    private EntityIdFormatter $entityIdLinkFormatter;
50    private DelegatingConstraintChecker $constraintChecker;
51    private ViolationMessageRenderer $violationMessageRenderer;
52    private Config $config;
53    private IBufferingStatsdDataFactory $dataFactory;
54
55    public static function factory(
56        Config $config,
57        IBufferingStatsdDataFactory $dataFactory,
58        EntityIdFormatterFactory $entityIdHtmlLinkFormatterFactory,
59        EntityIdLabelFormatterFactory $entityIdLabelFormatterFactory,
60        EntityIdParser $entityIdParser,
61        EntityTitleLookup $entityTitleLookup,
62        LanguageFallbackChainFactory $languageFallbackChainFactory,
63        EntityLookup $entityLookup,
64        DelegatingConstraintChecker $delegatingConstraintChecker,
65        ViolationMessageRendererFactory $violationMessageRendererFactory
66    ): self {
67        return new self(
68            $entityLookup,
69            $entityTitleLookup,
70            $entityIdLabelFormatterFactory,
71            $entityIdHtmlLinkFormatterFactory,
72            $entityIdParser,
73            $languageFallbackChainFactory,
74            $delegatingConstraintChecker,
75            $violationMessageRendererFactory,
76            $config,
77            $dataFactory
78        );
79    }
80
81    public function __construct(
82        EntityLookup $entityLookup,
83        EntityTitleLookup $entityTitleLookup,
84        EntityIdLabelFormatterFactory $entityIdLabelFormatterFactory,
85        EntityIdFormatterFactory $entityIdHtmlLinkFormatterFactory,
86        EntityIdParser $entityIdParser,
87        LanguageFallbackChainFactory $languageFallbackChainFactory,
88        DelegatingConstraintChecker $constraintChecker,
89        ViolationMessageRendererFactory $violationMessageRendererFactory,
90        Config $config,
91        IBufferingStatsdDataFactory $dataFactory
92    ) {
93        parent::__construct( 'ConstraintReport' );
94
95        $this->entityLookup = $entityLookup;
96        $this->entityTitleLookup = $entityTitleLookup;
97        $this->entityIdParser = $entityIdParser;
98
99        $language = $this->getLanguage();
100
101        $this->entityIdLabelFormatter = $entityIdLabelFormatterFactory->getEntityIdFormatter(
102            $language
103        );
104
105        $this->entityIdLinkFormatter = $entityIdHtmlLinkFormatterFactory->getEntityIdFormatter(
106            $language
107        );
108
109        $this->constraintChecker = $constraintChecker;
110
111        $this->violationMessageRenderer = $violationMessageRendererFactory->getViolationMessageRenderer(
112            $language,
113            $languageFallbackChainFactory->newFromLanguage( $language ),
114            $this->getContext()
115        );
116
117        $this->config = $config;
118        $this->dataFactory = $dataFactory;
119    }
120
121    /**
122     * Returns array of modules that should be added
123     *
124     * @return string[]
125     */
126    private function getModules(): array {
127        return [
128            'SpecialConstraintReportPage',
129            'wikibase.quality.constraints.icon',
130            'wikibase.alltargets',
131        ];
132    }
133
134    /**
135     * @see SpecialPage::getGroupName
136     *
137     * @return string
138     */
139    protected function getGroupName() {
140        return 'wikibase';
141    }
142
143    /**
144     * @inheritDoc
145     */
146    public function getDescription() {
147        return $this->msg( 'wbqc-constraintreport' );
148    }
149
150    /**
151     * @see SpecialPage::execute
152     *
153     * @param string|null $subPage
154     *
155     * @throws InvalidArgumentException
156     * @throws EntityIdParsingException
157     * @throws UnexpectedValueException
158     */
159    public function execute( $subPage ) {
160        $out = $this->getOutput();
161
162        $postRequest = $this->getContext()->getRequest()->getVal( 'entityid' );
163        if ( $postRequest ) {
164            $out->redirect( $this->getPageTitle( strtoupper( $postRequest ) )->getLocalURL() );
165            return;
166        }
167
168        $out->enableOOUI();
169        $out->addModules( $this->getModules() );
170
171        $this->setHeaders();
172
173        $out->addHTML( $this->getExplanationText() );
174        $this->buildEntityIdForm();
175
176        if ( !$subPage ) {
177            return;
178        }
179
180        if ( !is_string( $subPage ) ) {
181            throw new InvalidArgumentException( '$subPage must be string.' );
182        }
183
184        try {
185            $entityId = $this->entityIdParser->parse( $subPage );
186        } catch ( EntityIdParsingException $e ) {
187            $out->addHTML(
188                $this->buildNotice( 'wbqc-constraintreport-invalid-entity-id', true )
189            );
190            return;
191        }
192
193        if ( !$this->entityLookup->hasEntity( $entityId ) ) {
194            $out->addHTML(
195                $this->buildNotice( 'wbqc-constraintreport-not-existent-entity', true )
196            );
197            return;
198        }
199
200        $this->dataFactory->increment(
201            'wikibase.quality.constraints.specials.specialConstraintReport.executeCheck'
202        );
203        $results = $this->constraintChecker->checkAgainstConstraintsOnEntityId( $entityId );
204
205        if ( $results !== [] ) {
206            $out->addHTML(
207                $this->buildResultHeader( $entityId )
208                . $this->buildSummary( $results )
209                . $this->buildResultTable( $entityId, $results )
210            );
211        } else {
212            $out->addHTML(
213                $this->buildResultHeader( $entityId )
214                . $this->buildNotice( 'wbqc-constraintreport-empty-result' )
215            );
216        }
217    }
218
219    /**
220     * Builds html form for entity id input
221     */
222    private function buildEntityIdForm(): void {
223        $formDescriptor = [
224            'entityid' => [
225                'class' => 'HTMLTextField',
226                'section' => 'section',
227                'name' => 'entityid',
228                'label-message' => 'wbqc-constraintreport-form-entityid-label',
229                'cssclass' => 'wbqc-constraintreport-form-entity-id',
230                'placeholder' => $this->msg( 'wbqc-constraintreport-form-entityid-placeholder' )->escaped(),
231            ],
232        ];
233        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext(), 'wbqc-constraintreport-form' );
234        $htmlForm->setSubmitText( $this->msg( 'wbqc-constraintreport-form-submit-label' )->escaped() );
235        $htmlForm->setSubmitCallback( static function () {
236            return false;
237        } );
238        $htmlForm->setMethod( 'post' );
239        $htmlForm->show();
240    }
241
242    /**
243     * Builds notice with given message. Optionally notice can be handles as error by settings $error to true
244     *
245     * @param string $messageKey
246     * @param bool $error
247     *
248     * @throws InvalidArgumentException
249     *
250     * @return string HTML
251     */
252    private function buildNotice( string $messageKey, bool $error = false ): string {
253        $cssClasses = 'wbqc-constraintreport-notice';
254        if ( $error ) {
255            $cssClasses .= ' wbqc-constraintreport-notice-error';
256        }
257
258        return Html::rawElement(
259                'p',
260                [
261                    'class' => $cssClasses,
262                ],
263                $this->msg( $messageKey )->escaped()
264            );
265    }
266
267    /**
268     * @return string HTML
269     */
270    private function getExplanationText(): string {
271        return Html::rawElement(
272            'div',
273            [ 'class' => 'wbqc-explanation' ],
274            Html::rawElement(
275                'p',
276                [],
277                $this->msg( 'wbqc-constraintreport-explanation-part-one' )->escaped()
278            )
279            . Html::rawElement(
280                'p',
281                [],
282                $this->msg( 'wbqc-constraintreport-explanation-part-two' )->escaped()
283            )
284        );
285    }
286
287    /**
288     * @param EntityId $entityId
289     * @param CheckResult[] $results
290     *
291     * @return string HTML
292     */
293    private function buildResultTable( EntityId $entityId, array $results ): string {
294        // Set table headers
295        $table = new HtmlTableBuilder(
296            [
297                new HtmlTableHeaderBuilder(
298                    $this->msg( 'wbqc-constraintreport-result-table-header-status' )->text(),
299                    true
300                ),
301                new HtmlTableHeaderBuilder(
302                    $this->msg( 'wbqc-constraintreport-result-table-header-property' )->text(),
303                    true
304                ),
305                new HtmlTableHeaderBuilder(
306                    $this->msg( 'wbqc-constraintreport-result-table-header-message' )->text(),
307                    true
308                ),
309                new HtmlTableHeaderBuilder(
310                    $this->msg( 'wbqc-constraintreport-result-table-header-constraint' )->text(),
311                    true
312                ),
313            ]
314        );
315
316        foreach ( $results as $result ) {
317            $table = $this->appendToResultTable( $table, $entityId, $result );
318        }
319
320        return $table->toHtml();
321    }
322
323    private function appendToResultTable(
324        HtmlTableBuilder $table,
325        EntityId $entityId,
326        CheckResult $result
327    ): HtmlTableBuilder {
328        $message = $result->getMessage();
329        if ( $message === null ) {
330            // no row for this result
331            return $table;
332        }
333
334        // Status column
335        $statusColumn = $this->formatStatus( $result->getStatus() );
336
337        // Property column
338        $propertyId = new NumericPropertyId( $result->getContextCursor()->getSnakPropertyId() );
339        $propertyColumn = $this->getClaimLink(
340            $entityId,
341            $propertyId,
342            $this->entityIdLabelFormatter->formatEntityId( $propertyId )
343        );
344
345        // Message column
346        $messageColumn = $this->violationMessageRenderer->render( $message );
347
348        // Constraint column
349        $constraintTypeItemId = $result->getConstraint()->getConstraintTypeItemId();
350        try {
351            $constraintTypeLabel = $this->entityIdLabelFormatter->formatEntityId( new ItemId( $constraintTypeItemId ) );
352        } catch ( InvalidArgumentException $e ) {
353            $constraintTypeLabel = htmlspecialchars( $constraintTypeItemId );
354        }
355        $constraintColumn = $this->getClaimLink(
356            $propertyId,
357            new NumericPropertyId( $this->config->get( 'WBQualityConstraintsPropertyConstraintId' ) ),
358            $constraintTypeLabel
359        );
360
361        // Append cells
362        $table->appendRow(
363            [
364                new HtmlTableCellBuilder(
365                    new HtmlArmor( $statusColumn )
366                ),
367                new HtmlTableCellBuilder(
368                    new HtmlArmor( $propertyColumn )
369                ),
370                new HtmlTableCellBuilder(
371                    new HtmlArmor( $messageColumn )
372                ),
373                new HtmlTableCellBuilder(
374                    new HtmlArmor( $constraintColumn )
375                ),
376            ]
377        );
378
379        return $table;
380    }
381
382    /**
383     * Returns html text of the result header
384     *
385     * @param EntityId $entityId
386     *
387     * @return string HTML
388     */
389    protected function buildResultHeader( EntityId $entityId ): string {
390        $entityLink = sprintf( '%s (%s)',
391                               $this->entityIdLinkFormatter->formatEntityId( $entityId ),
392                               htmlspecialchars( $entityId->getSerialization() ) );
393
394        return Html::rawElement(
395            'h3',
396            [],
397            sprintf( '%s %s', $this->msg( 'wbqc-constraintreport-result-headline' )->escaped(), $entityLink )
398        );
399    }
400
401    /**
402     * Builds summary from given results
403     *
404     * @param CheckResult[] $results
405     *
406     * @return string HTML
407     */
408    protected function buildSummary( array $results ): string {
409        $statuses = [];
410        foreach ( $results as $result ) {
411            $status = strtolower( $result->getStatus() );
412            $statuses[$status] = isset( $statuses[$status] ) ? $statuses[$status] + 1 : 1;
413        }
414
415        $statusElements = [];
416        foreach ( $statuses as $status => $count ) {
417            if ( $count > 0 ) {
418                $statusElements[] =
419                    $this->formatStatus( $status )
420                    . ': '
421                    . $count;
422            }
423        }
424
425        return Html::rawElement( 'p', [], implode( ', ', $statusElements ) );
426    }
427
428    /**
429     * Formats given status to html
430     *
431     * @param string $status
432     *
433     * @throws InvalidArgumentException
434     *
435     * @return string HTML
436     */
437    private function formatStatus( string $status ): string {
438        $messageName = "wbqc-constraintreport-status-" . strtolower( $status );
439        $statusIcons = [
440            CheckResult::STATUS_SUGGESTION => [
441                'icon' => 'suggestion-constraint-violation',
442            ],
443            CheckResult::STATUS_WARNING => [
444                'icon' => 'non-mandatory-constraint-violation',
445            ],
446            CheckResult::STATUS_VIOLATION => [
447                'icon' => 'mandatory-constraint-violation',
448            ],
449            CheckResult::STATUS_BAD_PARAMETERS => [
450                'icon' => 'alert',
451                'flags' => 'warning',
452            ],
453        ];
454
455        if ( array_key_exists( $status, $statusIcons ) ) {
456            $iconWidget = new IconWidget( $statusIcons[$status] );
457            $iconHtml = $iconWidget->toString() . ' ';
458        } else {
459            $iconHtml = '';
460        }
461
462        $labelWidget = new LabelWidget( [
463            'label' => $this->msg( $messageName )->text(),
464        ] );
465        $labelHtml = $labelWidget->toString();
466
467        $formattedStatus =
468            Html::rawElement(
469                'span',
470                [
471                    'class' => 'wbqc-status wbqc-status-' . $status,
472                ],
473                $iconHtml . $labelHtml
474            );
475
476        return $formattedStatus;
477    }
478
479    /**
480     * Returns html link to given entity with anchor to specified property.
481     *
482     * @param EntityId $entityId
483     * @param NumericPropertyId $propertyId
484     * @param string $text HTML
485     *
486     * @return string HTML
487     */
488    private function getClaimLink(
489        EntityId $entityId,
490        NumericPropertyId $propertyId,
491        string $text
492    ): string {
493        return Html::rawElement(
494            'a',
495            [
496                'href' => $this->getClaimUrl( $entityId, $propertyId ),
497                'target' => '_blank',
498            ],
499            $text
500        );
501    }
502
503    /**
504     * Returns url of given entity with anchor to specified property.
505     */
506    private function getClaimUrl(
507        EntityId $entityId,
508        NumericPropertyId $propertyId
509    ): string {
510        $title = $this->entityTitleLookup->getTitleForId( $entityId );
511        $entityUrl = sprintf( '%s#%s', $title->getLocalURL(), $propertyId->getSerialization() );
512
513        return $entityUrl;
514    }
515
516}