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';
101        /** @var ImageRecommendationBaseTaskType $taskType */
102
103        $userErrorMessage = self::getUserErrorMessage( $this->userIdentityUtils, $user );
104        if ( $userErrorMessage ) {
105            return StatusValue::newGood()->error( $userErrorMessage );
106        }
107
108        return $this->parseData( $taskType, $data );
109    }
110
111    /** @inheritDoc */
112    public function handle(
113        TaskType $taskType, ProperPageIdentity $page, UserIdentity $user, ?int $baseRevId, ?int $editRevId, array $data
114    ): StatusValue {
115        Assert::parameterType( ImageRecommendationBaseTaskType::class, $taskType, '$taskType' );
116        '@phan-var ImageRecommendationBaseTaskType $taskType';
117        /** @var ImageRecommendationBaseTaskType $taskType */
118
119        $status = $this->parseData( $taskType, $data );
120        if ( !$status->isGood() ) {
121            return $status;
122        }
123        [ $accepted, $reasons, $filename, $sectionTitle, $sectionNumber ] = $status->getValue();
124
125        // Remove this image from being recommended in the future, unless it was rejected with
126        // one of the "not sure" options.
127        // NOTE: This has to check $accepted, because $reasons will be empty for accepted
128        // suggested edits. Accepted edits need to be invalidated to account for possible
129        // reverts, see T350598 for more details.
130        if ( $accepted || array_diff( $reasons, self::REJECTION_REASONS_UNDECIDED ) ) {
131            $this->invalidateRecommendation(
132                $taskType,
133                $page,
134                $user->getId(),
135                $accepted,
136                $filename,
137                $sectionTitle,
138                $sectionNumber,
139                $reasons
140            );
141        }
142
143        $warnings = [];
144        $taskSuggester = $this->taskSuggesterFactory->create();
145        $taskSet = $taskSuggester->suggest(
146            $user,
147            new TaskSetFilters(
148                $this->newcomerTasksUserOptionsLookup->getTaskTypeFilter( $user ),
149                $this->newcomerTasksUserOptionsLookup->getTopics( $user ),
150                $this->newcomerTasksUserOptionsLookup->getTopicsMatchMode( $user )
151            )
152        );
153        if ( $taskSet instanceof TaskSet ) {
154            $qualityGateConfig = $taskSet->getQualityGateConfig();
155            if ( $taskType instanceof ImageRecommendationBaseTaskType
156                && isset( $qualityGateConfig[$taskType->getId()]['dailyCount'] )
157                && $qualityGateConfig[$taskType->getId()]['dailyCount']
158                    >= $taskType->getMaxTasksPerDay() - 1
159            ) {
160                if ( $taskType instanceof ImageRecommendationTaskType ) {
161                    $warnings['geimagerecommendationdailytasksexceeded'] = true;
162                } elseif ( $taskType instanceof SectionImageRecommendationTaskType ) {
163                    $warnings['gesectionimagerecommendationdailytasksexceeded'] = true;
164                }
165            }
166            // Reduce the likelihood that the user encounters the task they were undecided about again.
167            if ( in_array( $accepted, self::REJECTION_REASONS_UNDECIDED ) ) {
168                // Refresh the user's TaskSet cache in a deferred update, since this can be kind of slow.
169                DeferredUpdates::addCallableUpdate( static function () use ( $taskSuggester, $user, $taskSet ) {
170                    $taskSuggester->suggest(
171                        $user,
172                        $taskSet->getFilters(),
173                        null,
174                        null,
175                        [ 'resetCache' => true ]
176                    );
177                } );
178            }
179        }
180
181        $subType = self::LOG_SUBTYPES[ $taskType->getId() ];
182        $logEntry = new ManualLogEntry( 'growthexperiments', $subType );
183        $logEntry->setTarget( $page );
184        $logEntry->setPerformer( $user );
185        $logEntry->setParameters( [
186            '4::section' => $sectionTitle,
187            'accepted' => $accepted,
188        ] );
189        if ( $editRevId ) {
190            // This has the side effect of the log entry getting tagged with all the change tags
191            // the revision is getting tagged with. Overall, still preferable - the log entry is
192            // not published to recent changes so its tags don't matter much.
193            $logEntry->setAssociatedRevId( $editRevId );
194        }
195        $logId = $logEntry->insert();
196        // Do not publish to recent changes, it would be pointless as this action cannot
197        // be inspected or patrolled.
198        $logEntry->publish( $logId, 'udp' );
199
200        return StatusValue::newGood( [ 'logId' => $logId, 'warnings' => $warnings ] );
201    }
202
203    /**
204     * Validate and parse Add Image data submitted through the VE save API.
205     * @param ImageRecommendationBaseTaskType $taskType
206     * @param array $data
207     * @return StatusValue A status with [ $accepted, $reasons ] on success:
208     *   - $accepted (bool): true if the image was accepted, false if it was rejected
209     *   - $reasons (string[]): list of rejection reasons.
210     *   - $filename (string) The filename of the image suggestion
211     */
212    private function parseData( ImageRecommendationBaseTaskType $taskType, array $data ): StatusValue {
213        if ( !array_key_exists( 'accepted', $data ) ) {
214            return StatusValue::newGood()
215                ->error( 'apierror-growthexperiments-addimage-handler-accepted-missing' );
216        } elseif ( !is_bool( $data['accepted'] ) ) {
217            return StatusValue::newGood()->error(
218                'apierror-growthexperiments-addimage-handler-accepted-wrongtype',
219                gettype( $data['accepted'] )
220            );
221        }
222
223        if ( !array_key_exists( 'reasons', $data ) ) {
224            return StatusValue::newGood()
225                ->error( 'apierror-growthexperiments-addimage-handler-reason-missing' );
226        } elseif ( !is_array( $data['reasons'] ) ) {
227            return StatusValue::newGood()->error(
228                'apierror-growthexperiments-addimage-handler-reason-wrongtype',
229                gettype( $data['reasons'] )
230            );
231        }
232        foreach ( $data['reasons'] as $reason ) {
233            if ( !is_string( $reason ) ) {
234                return StatusValue::newGood()->error(
235                    'apierror-growthexperiments-addimage-handler-reason-invaliditem',
236                    '[' . gettype( $reason ) . ']',
237                    Message::listParam( self::REJECTION_REASONS, 'comma' )
238                );
239            } elseif ( !in_array( $reason, self::REJECTION_REASONS, true ) ) {
240                return StatusValue::newGood()->error(
241                    'apierror-growthexperiments-addimage-handler-reason-invaliditem',
242                    $reason,
243                    Message::listParam( self::REJECTION_REASONS, 'comma' )
244                );
245            }
246        }
247
248        $recommendationAccepted = $data[ 'accepted' ] ?? false;
249        if ( $recommendationAccepted ) {
250            $minCaptionLength = $taskType->getMinimumCaptionCharacterLength();
251            if ( strlen( trim( $data['caption'] ) ) < $minCaptionLength ) {
252                return StatusValue::newGood()->error(
253                    'growthexperiments-addimage-caption-warning-tooshort',
254                    $minCaptionLength
255                );
256            }
257        }
258
259        // sectionTitle and sectionNumber are present in addimage and addsection image recommendation
260        // data. Values will be null for addimage submissions.
261        // See AddImageArticleTarget.prototype.invalidateRecommendation
262        if ( !array_key_exists( 'sectionTitle', $data ) ) {
263            return StatusValue::newGood()
264                ->error( 'apierror-growthexperiments-addimage-handler-section-title-missing' );
265        }
266        if ( !array_key_exists( 'sectionNumber', $data ) ) {
267            return StatusValue::newGood()
268                ->error( 'apierror-growthexperiments-addimage-handler-section-number-missing' );
269        }
270        if ( $taskType instanceof ImageRecommendationTaskType ) {
271            if ( $data['sectionTitle'] !== null ) {
272                return StatusValue::newGood()->error(
273                    'apierror-growthexperiments-addimage-handler-section-title-wrongtype',
274                    gettype( $data['sectionTitle'] )
275                );
276            }
277            if ( $data['sectionNumber'] !== null ) {
278                return StatusValue::newGood()->error(
279                    'apierror-growthexperiments-addimage-handler-section-number-wrongtype',
280                    gettype( $data['sectionTitle'] )
281                );
282            }
283        }
284        if ( $taskType instanceof SectionImageRecommendationTaskType ) {
285            if ( !is_string( $data['sectionTitle'] ) ) {
286                return StatusValue::newGood()->error(
287                    'apierror-growthexperiments-addsectionimage-handler-section-title-wrongtype',
288                    gettype( $data['sectionTitle'] )
289                );
290            }
291            if ( !is_int( $data['sectionNumber'] ) ) {
292                return StatusValue::newGood()->error(
293                    'apierror-growthexperiments-addsectionimage-handler-section-number-wrongtype',
294                    gettype( $data['sectionTitle'] )
295                );
296            }
297        }
298
299        return StatusValue::newGood( [
300            $data['accepted'],
301            array_values( $data['reasons'] ),
302            $data['filename'],
303            $data['sectionTitle'],
304            $data['sectionNumber'],
305        ] );
306    }
307
308    /**
309     * Invalidate the recommendation for the specified page.
310     *
311     * This method will:
312     * - Reset the "hasrecommendation:image" weighted tag for the article, so the article is no longer returned in
313     *   search results for image suggestions.
314     * - Add the article to a short-lived cache, which ImageRecommendationFilter consults to decide if the article
315     *   should appear in the user's suggested edits queue on Special:Homepage or via the growthtasks API.
316     * - Generate and send an event to EventGate to the image-suggestion-feedback stream.
317     *
318     * @param ImageRecommendationBaseTaskType $taskType
319     * @param ProperPageIdentity $page
320     * @param int $userId
321     * @param null|bool $accepted True if accepted, false if rejected, null if invalidating for
322     * other reasons (e.g. image exists on page when user visits it)
323     * @param string $filename Unprefixed filename.
324     * @param string|null $sectionTitle Title of the section the suggestion is for
325     * @param int|null $sectionNumber Number of the section the suggestion is for
326     * @param string[] $rejectionReasons Reasons for rejecting the image.
327     * @throws \Exception
328     * @see ApiInvalidateImageRecommendation::execute
329     */
330    public function invalidateRecommendation(
331        ImageRecommendationBaseTaskType $taskType,
332        ProperPageIdentity $page,
333        int $userId,
334        ?bool $accepted,
335        string $filename,
336        ?string $sectionTitle,
337        ?int $sectionNumber,
338        array $rejectionReasons = []
339    ) {
340        if ( $taskType->getId() === ImageRecommendationTaskTypeHandler::TASK_TYPE_ID ) {
341            ( $this->weightedTagsUpdaterProvider )()->resetWeightedTags(
342                $page, [ ImageRecommendationTaskTypeHandler::WEIGHTED_TAG_PREFIX ]
343            );
344        } elseif ( $taskType->getId() === SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID ) {
345            ( $this->weightedTagsUpdaterProvider )()->resetWeightedTags(
346                $page, [
347                    SectionImageRecommendationTaskTypeHandler::WEIGHTED_TAG_PREFIX,
348                    ImageRecommendationTaskTypeHandler::WEIGHTED_TAG_PREFIX
349                ]
350            );
351        }
352        // Mark the task as "invalid" in a temporary cache, until the weighted tags in the search
353        // index are updated.
354        $this->cache->set(
355            ImageRecommendationFilter::makeKey(
356                $this->cache,
357                $taskType->getId(),
358                $page->getDBkey()
359            ),
360            true,
361            $this->cache::TTL_MINUTE * 10
362        );
363
364        if ( $this->eventGateImageFeedbackUpdater ) {
365            $this->eventGateImageFeedbackUpdater->update(
366                $page->getId(),
367                $userId,
368                $accepted,
369                $filename,
370                $sectionTitle,
371                $sectionNumber,
372                $rejectionReasons
373            );
374        }
375    }
376
377}