Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
41.86% covered (danger)
41.86%
72 / 172
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
AddImageSubmissionHandler
41.86% covered (danger)
41.86%
72 / 172
20.00% covered (danger)
20.00%
1 / 5
306.04
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 validate
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 handle
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
156
 parseData
86.11% covered (warning)
86.11%
62 / 72
0.00% covered (danger)
0.00%
0 / 1
18.87
 invalidateRecommendation
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace GrowthExperiments\NewcomerTasks\AddImage;
4
5use GrowthExperiments\NewcomerTasks\AbstractSubmissionHandler;
6use GrowthExperiments\NewcomerTasks\AddImage\EventBus\EventGateImageSuggestionFeedbackUpdater;
7use GrowthExperiments\NewcomerTasks\ImageRecommendationFilter;
8use GrowthExperiments\NewcomerTasks\NewcomerTasksUserOptionsLookup;
9use GrowthExperiments\NewcomerTasks\SubmissionHandler;
10use GrowthExperiments\NewcomerTasks\Task\TaskSet;
11use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters;
12use GrowthExperiments\NewcomerTasks\TaskSuggester\TaskSuggesterFactory;
13use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationBaseTaskType;
14use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskType;
15use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskTypeHandler;
16use GrowthExperiments\NewcomerTasks\TaskType\SectionImageRecommendationTaskType;
17use GrowthExperiments\NewcomerTasks\TaskType\SectionImageRecommendationTaskTypeHandler;
18use GrowthExperiments\NewcomerTasks\TaskType\TaskType;
19use ManualLogEntry;
20use MediaWiki\Deferred\DeferredUpdates;
21use MediaWiki\Message\Message;
22use MediaWiki\Page\ProperPageIdentity;
23use MediaWiki\User\UserIdentity;
24use MediaWiki\User\UserIdentityUtils;
25use StatusValue;
26use Wikimedia\Assert\Assert;
27use Wikimedia\ObjectCache\WANObjectCache;
28
29/**
30 * Record the user's decision on the recommendations for a given page.
31 * Creates a Special:Log entry and handles updating the search index.
32 */
33class AddImageSubmissionHandler extends AbstractSubmissionHandler implements SubmissionHandler {
34
35    /**
36     * List of valid reasons for rejecting an image. Keep in sync with
37     * RecommendedImageRejectionDialog.rejectionReasons.
38     */
39    public const REJECTION_REASONS = [
40        'notrelevant',
41        'sectionnotappropriate',
42        'noinfo',
43        'offensive',
44        'lowquality',
45        'unfamiliar',
46        'foreignlanguage',
47        'other'
48    ];
49    /**
50     * Rejection reasons which means the user is undecided (as opposed thinking the image is bad).
51     * Should be a subset of REJECTION_REASONS.
52     */
53    private const REJECTION_REASONS_UNDECIDED = [ 'unfamiliar', 'foreignlanguage' ];
54
55    private const LOG_SUBTYPES = [
56        ImageRecommendationTaskTypeHandler::TASK_TYPE_ID => 'addimage',
57        SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID => 'addsectionimage',
58    ];
59
60    /**
61     * @var callable returning {@link \CirrusSearch\WeightedTagsUpdater}
62     */
63    private $weightedTagsUpdaterProvider;
64    private TaskSuggesterFactory $taskSuggesterFactory;
65    private NewcomerTasksUserOptionsLookup $newcomerTasksUserOptionsLookup;
66    private WANObjectCache $cache;
67    private UserIdentityUtils $userIdentityUtils;
68
69    private ?EventGateImageSuggestionFeedbackUpdater $eventGateImageFeedbackUpdater;
70
71    /**
72     * @param callable(): \CirrusSearch\WeightedTagsUpdater $weightedTagsUpdaterProvider
73     * @param TaskSuggesterFactory $taskSuggesterFactory
74     * @param NewcomerTasksUserOptionsLookup $newcomerTasksUserOptionsLookup
75     * @param WANObjectCache $cache
76     * @param UserIdentityUtils $userIdentityUtils
77     * @param EventGateImageSuggestionFeedbackUpdater|null $eventGateImageFeedbackUpdater
78     */
79    public function __construct(
80        callable $weightedTagsUpdaterProvider,
81        TaskSuggesterFactory $taskSuggesterFactory,
82        NewcomerTasksUserOptionsLookup $newcomerTasksUserOptionsLookup,
83        WANObjectCache $cache,
84        UserIdentityUtils $userIdentityUtils,
85        ?EventGateImageSuggestionFeedbackUpdater $eventGateImageFeedbackUpdater
86    ) {
87        $this->weightedTagsUpdaterProvider = $weightedTagsUpdaterProvider;
88        $this->taskSuggesterFactory = $taskSuggesterFactory;
89        $this->newcomerTasksUserOptionsLookup = $newcomerTasksUserOptionsLookup;
90        $this->cache = $cache;
91        $this->userIdentityUtils = $userIdentityUtils;
92        $this->eventGateImageFeedbackUpdater = $eventGateImageFeedbackUpdater;
93    }
94
95    /** @inheritDoc */
96    public function validate(
97        TaskType $taskType, ProperPageIdentity $page, UserIdentity $user, ?int $baseRevId, array $data
98    ): StatusValue {
99        Assert::parameterType( ImageRecommendationBaseTaskType::class, $taskType, '$taskType' );
100        '@phan-var ImageRecommendationBaseTaskType $taskType';/** @var ImageRecommendationBaseTaskType $taskType */
101
102        $userErrorMessage = self::getUserErrorMessage( $this->userIdentityUtils, $user );
103        if ( $userErrorMessage ) {
104            return StatusValue::newGood()->error( $userErrorMessage );
105        }
106
107        return $this->parseData( $taskType, $data );
108    }
109
110    /** @inheritDoc */
111    public function handle(
112        TaskType $taskType, ProperPageIdentity $page, UserIdentity $user, ?int $baseRevId, ?int $editRevId, array $data
113    ): StatusValue {
114        Assert::parameterType( ImageRecommendationBaseTaskType::class, $taskType, '$taskType' );
115        '@phan-var ImageRecommendationBaseTaskType $taskType';/** @var ImageRecommendationBaseTaskType $taskType */
116
117        $status = $this->parseData( $taskType, $data );
118        if ( !$status->isGood() ) {
119            return $status;
120        }
121        [ $accepted, $reasons, $filename, $sectionTitle, $sectionNumber ] = $status->getValue();
122
123        // Remove this image from being recommended in the future, unless it was rejected with
124        // one of the "not sure" options.
125        // NOTE: This has to check $accepted, because $reasons will be empty for accepted
126        // suggested edits. Accepted edits need to be invalidated to account for possible
127        // reverts, see T350598 for more details.
128        if ( $accepted || array_diff( $reasons, self::REJECTION_REASONS_UNDECIDED ) ) {
129            $this->invalidateRecommendation(
130                $taskType,
131                $page,
132                $user->getId(),
133                $accepted,
134                $filename,
135                $sectionTitle,
136                $sectionNumber,
137                $reasons
138            );
139        }
140
141        $warnings = [];
142        $taskSuggester = $this->taskSuggesterFactory->create();
143        $taskSet = $taskSuggester->suggest(
144            $user,
145            new TaskSetFilters(
146                $this->newcomerTasksUserOptionsLookup->getTaskTypeFilter( $user ),
147                $this->newcomerTasksUserOptionsLookup->getTopics( $user ),
148                $this->newcomerTasksUserOptionsLookup->getTopicsMatchMode( $user )
149            )
150        );
151        if ( $taskSet instanceof TaskSet ) {
152            $qualityGateConfig = $taskSet->getQualityGateConfig();
153            if ( $taskType instanceof ImageRecommendationBaseTaskType
154                && isset( $qualityGateConfig[$taskType->getId()]['dailyCount'] )
155                && $qualityGateConfig[$taskType->getId()]['dailyCount']
156                    >= $taskType->getMaxTasksPerDay() - 1
157            ) {
158                if ( $taskType instanceof ImageRecommendationTaskType ) {
159                    $warnings['geimagerecommendationdailytasksexceeded'] = true;
160                } elseif ( $taskType instanceof SectionImageRecommendationTaskType ) {
161                    $warnings['gesectionimagerecommendationdailytasksexceeded'] = true;
162                }
163            }
164            // Reduce the likelihood that the user encounters the task they were undecided about again.
165            if ( in_array( $accepted, self::REJECTION_REASONS_UNDECIDED ) ) {
166                // Refresh the user's TaskSet cache in a deferred update, since this can be kind of slow.
167                DeferredUpdates::addCallableUpdate( static function () use ( $taskSuggester, $user, $taskSet ) {
168                    $taskSuggester->suggest(
169                        $user,
170                        $taskSet->getFilters(),
171                        null,
172                        null,
173                        [ 'resetCache' => true ]
174                    );
175                } );
176            }
177        }
178
179        $subType = self::LOG_SUBTYPES[ $taskType->getId() ];
180        $logEntry = new ManualLogEntry( 'growthexperiments', $subType );
181        $logEntry->setTarget( $page );
182        $logEntry->setPerformer( $user );
183        $logEntry->setParameters( [
184            '4::section' => $sectionTitle,
185            'accepted' => $accepted,
186        ] );
187        if ( $editRevId ) {
188            // This has the side effect of the log entry getting tagged with all the change tags
189            // the revision is getting tagged with. Overall, still preferable - the log entry is
190            // not published to recent changes so its tags don't matter much.
191            $logEntry->setAssociatedRevId( $editRevId );
192        }
193        $logId = $logEntry->insert();
194        // Do not publish to recent changes, it would be pointless as this action cannot
195        // be inspected or patrolled.
196        $logEntry->publish( $logId, 'udp' );
197
198        return StatusValue::newGood( [ 'logId' => $logId, 'warnings' => $warnings ] );
199    }
200
201    /**
202     * Validate and parse Add Image data submitted through the VE save API.
203     * @param ImageRecommendationBaseTaskType $taskType
204     * @param array $data
205     * @return StatusValue A status with [ $accepted, $reasons ] on success:
206     *   - $accepted (bool): true if the image was accepted, false if it was rejected
207     *   - $reasons (string[]): list of rejection reasons.
208     *   - $filename (string) The filename of the image suggestion
209     */
210    private function parseData( ImageRecommendationBaseTaskType $taskType, array $data ): StatusValue {
211        if ( !array_key_exists( 'accepted', $data ) ) {
212            return StatusValue::newGood()
213                ->error( 'apierror-growthexperiments-addimage-handler-accepted-missing' );
214        } elseif ( !is_bool( $data['accepted'] ) ) {
215            return StatusValue::newGood()->error(
216                'apierror-growthexperiments-addimage-handler-accepted-wrongtype',
217                gettype( $data['accepted'] )
218            );
219        }
220
221        if ( !array_key_exists( 'reasons', $data ) ) {
222            return StatusValue::newGood()
223                ->error( 'apierror-growthexperiments-addimage-handler-reason-missing' );
224        } elseif ( !is_array( $data['reasons'] ) ) {
225            return StatusValue::newGood()->error(
226                'apierror-growthexperiments-addimage-handler-reason-wrongtype',
227                gettype( $data['reasons'] )
228            );
229        }
230        foreach ( $data['reasons'] as $reason ) {
231            if ( !is_string( $reason ) ) {
232                return StatusValue::newGood()->error(
233                    'apierror-growthexperiments-addimage-handler-reason-invaliditem',
234                    '[' . gettype( $reason ) . ']',
235                    Message::listParam( self::REJECTION_REASONS, 'comma' )
236                );
237            } elseif ( !in_array( $reason, self::REJECTION_REASONS, true ) ) {
238                return StatusValue::newGood()->error(
239                    'apierror-growthexperiments-addimage-handler-reason-invaliditem',
240                    $reason,
241                    Message::listParam( self::REJECTION_REASONS, 'comma' )
242                );
243            }
244        }
245
246        $recommendationAccepted = $data[ 'accepted' ] ?? false;
247        if ( $recommendationAccepted ) {
248            $minCaptionLength = $taskType->getMinimumCaptionCharacterLength();
249            if ( strlen( trim( $data['caption'] ) ) < $minCaptionLength ) {
250                return StatusValue::newGood()->error(
251                    'growthexperiments-addimage-caption-warning-tooshort',
252                    $minCaptionLength
253                );
254            }
255        }
256
257        // sectionTitle and sectionNumber are present in addimage and addsection image recommendation
258        // data. Values will be null for addimage submissions.
259        // See AddImageArticleTarget.prototype.invalidateRecommendation
260        if ( !array_key_exists( 'sectionTitle', $data ) ) {
261            return StatusValue::newGood()
262                ->error( 'apierror-growthexperiments-addimage-handler-section-title-missing' );
263        }
264        if ( !array_key_exists( 'sectionNumber', $data ) ) {
265            return StatusValue::newGood()
266                ->error( 'apierror-growthexperiments-addimage-handler-section-number-missing' );
267        }
268        if ( $taskType instanceof ImageRecommendationTaskType ) {
269            if ( $data['sectionTitle'] !== null ) {
270                return StatusValue::newGood()->error(
271                    'apierror-growthexperiments-addimage-handler-section-title-wrongtype',
272                    gettype( $data['sectionTitle'] )
273                );
274            }
275            if ( $data['sectionNumber'] !== null ) {
276                return StatusValue::newGood()->error(
277                    'apierror-growthexperiments-addimage-handler-section-number-wrongtype',
278                    gettype( $data['sectionTitle'] )
279                );
280            }
281        }
282        if ( $taskType instanceof SectionImageRecommendationTaskType ) {
283            if ( !is_string( $data['sectionTitle'] ) ) {
284                return StatusValue::newGood()->error(
285                    'apierror-growthexperiments-addsectionimage-handler-section-title-wrongtype',
286                    gettype( $data['sectionTitle'] )
287                );
288            }
289            if ( !is_int( $data['sectionNumber'] ) ) {
290                return StatusValue::newGood()->error(
291                    'apierror-growthexperiments-addsectionimage-handler-section-number-wrongtype',
292                    gettype( $data['sectionTitle'] )
293                );
294            }
295        }
296
297        return StatusValue::newGood( [
298            $data['accepted'],
299            array_values( $data['reasons'] ),
300            $data['filename'],
301            $data['sectionTitle'],
302            $data['sectionNumber'],
303        ] );
304    }
305
306    /**
307     * Invalidate the recommendation for the specified page.
308     *
309     * This method will:
310     * - Reset the "hasrecommendation:image" weighted tag for the article, so the article is no longer returned in
311     *   search results for image suggestions.
312     * - Add the article to a short-lived cache, which ImageRecommendationFilter consults to decide if the article
313     *   should appear in the user's suggested edits queue on Special:Homepage or via the growthtasks API.
314     * - Generate and send an event to EventGate to the image-suggestion-feedback stream.
315     *
316     * @param ImageRecommendationBaseTaskType $taskType
317     * @param ProperPageIdentity $page
318     * @param int $userId
319     * @param null|bool $accepted True if accepted, false if rejected, null if invalidating for
320     * other reasons (e.g. image exists on page when user visits it)
321     * @param string $filename Unprefixed filename.
322     * @param string|null $sectionTitle Title of the section the suggestion is for
323     * @param int|null $sectionNumber Number of the section the suggestion is for
324     * @param string[] $rejectionReasons Reasons for rejecting the image.
325     * @throws \Exception
326     * @see ApiInvalidateImageRecommendation::execute
327     */
328    public function invalidateRecommendation(
329        ImageRecommendationBaseTaskType $taskType,
330        ProperPageIdentity $page,
331        int $userId,
332        ?bool $accepted,
333        string $filename,
334        ?string $sectionTitle,
335        ?int $sectionNumber,
336        array $rejectionReasons = []
337    ) {
338        if ( $taskType->getId() === ImageRecommendationTaskTypeHandler::TASK_TYPE_ID ) {
339            ( $this->weightedTagsUpdaterProvider )()->resetWeightedTags(
340                $page, [ ImageRecommendationTaskTypeHandler::WEIGHTED_TAG_PREFIX ]
341            );
342        } elseif ( $taskType->getId() === SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID ) {
343            ( $this->weightedTagsUpdaterProvider )()->resetWeightedTags(
344                $page, [
345                    SectionImageRecommendationTaskTypeHandler::WEIGHTED_TAG_PREFIX,
346                    ImageRecommendationTaskTypeHandler::WEIGHTED_TAG_PREFIX
347                ]
348            );
349        }
350        // Mark the task as "invalid" in a temporary cache, until the weighted tags in the search
351        // index are updated.
352        $this->cache->set(
353            ImageRecommendationFilter::makeKey(
354                $this->cache,
355                $taskType->getId(),
356                $page->getDBkey()
357            ),
358            true,
359            $this->cache::TTL_MINUTE * 10
360        );
361
362        if ( $this->eventGateImageFeedbackUpdater ) {
363            $this->eventGateImageFeedbackUpdater->update(
364                $page->getId(),
365                $userId,
366                $accepted,
367                $filename,
368                $sectionTitle,
369                $sectionNumber,
370                $rejectionReasons
371            );
372        }
373    }
374
375}