Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
40.91% covered (danger)
40.91%
72 / 176
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
AddImageSubmissionHandler
40.91% covered (danger)
40.91%
72 / 176
20.00% covered (danger)
20.00%
1 / 5
319.47
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 / 34
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace GrowthExperiments\NewcomerTasks\AddImage;
4
5use CirrusSearch\CirrusSearch;
6use GrowthExperiments\NewcomerTasks\AbstractSubmissionHandler;
7use GrowthExperiments\NewcomerTasks\AddImage\EventBus\EventGateImageSuggestionFeedbackUpdater;
8use GrowthExperiments\NewcomerTasks\ImageRecommendationFilter;
9use GrowthExperiments\NewcomerTasks\NewcomerTasksUserOptionsLookup;
10use GrowthExperiments\NewcomerTasks\SubmissionHandler;
11use GrowthExperiments\NewcomerTasks\Task\TaskSet;
12use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters;
13use GrowthExperiments\NewcomerTasks\TaskSuggester\TaskSuggesterFactory;
14use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationBaseTaskType;
15use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskType;
16use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskTypeHandler;
17use GrowthExperiments\NewcomerTasks\TaskType\SectionImageRecommendationTaskType;
18use GrowthExperiments\NewcomerTasks\TaskType\SectionImageRecommendationTaskTypeHandler;
19use GrowthExperiments\NewcomerTasks\TaskType\TaskType;
20use ManualLogEntry;
21use MediaWiki\Deferred\DeferredUpdates;
22use MediaWiki\Page\ProperPageIdentity;
23use MediaWiki\User\UserIdentity;
24use MediaWiki\User\UserIdentityUtils;
25use Message;
26use StatusValue;
27use WANObjectCache;
28use Wikimedia\Assert\Assert;
29
30/**
31 * Record the user's decision on the recommendations for a given page.
32 * Creates a Special:Log entry and handles updating the search index.
33 */
34class AddImageSubmissionHandler extends AbstractSubmissionHandler implements SubmissionHandler {
35
36    /**
37     * List of valid reasons for rejecting an image. Keep in sync with
38     * RecommendedImageRejectionDialog.rejectionReasons.
39     */
40    public const REJECTION_REASONS = [
41        'notrelevant',
42        'sectionnotappropriate',
43        'noinfo',
44        'offensive',
45        'lowquality',
46        'unfamiliar',
47        'foreignlanguage',
48        'other'
49    ];
50    /**
51     * Rejection reasons which means the user is undecided (as opposed thinking the image is bad).
52     * Should be a subset of REJECTION_REASONS.
53     */
54    private const REJECTION_REASONS_UNDECIDED = [ 'unfamiliar', 'foreignlanguage' ];
55
56    private const LOG_SUBTYPES = [
57        ImageRecommendationTaskTypeHandler::TASK_TYPE_ID => 'addimage',
58        SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID => 'addsectionimage',
59    ];
60
61    /** @var callable */
62    private $cirrusSearchFactory;
63
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 $cirrusSearchFactory A factory method returning a CirrusSearch instance.
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 $cirrusSearchFactory,
81        TaskSuggesterFactory $taskSuggesterFactory,
82        NewcomerTasksUserOptionsLookup $newcomerTasksUserOptionsLookup,
83        WANObjectCache $cache,
84        UserIdentityUtils $userIdentityUtils,
85        ?EventGateImageSuggestionFeedbackUpdater $eventGateImageFeedbackUpdater
86    ) {
87        $this->cirrusSearchFactory = $cirrusSearchFactory;
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        }
165
166        $subType = self::LOG_SUBTYPES[ $taskType->getId() ];
167        $logEntry = new ManualLogEntry( 'growthexperiments', $subType );
168        $logEntry->setTarget( $page );
169        $logEntry->setPerformer( $user );
170        $logEntry->setParameters( [
171            '4::section' => $sectionTitle,
172            'accepted' => $accepted,
173        ] );
174        if ( $editRevId ) {
175            // This has the side effect of the log entry getting tagged with all the change tags
176            // the revision is getting tagged with. Overall, still preferable - the log entry is
177            // not published to recent changes so its tags don't matter much.
178            $logEntry->setAssociatedRevId( $editRevId );
179        }
180        $logId = $logEntry->insert();
181        // Do not publish to recent changes, it would be pointless as this action cannot
182        // be inspected or patrolled.
183        $logEntry->publish( $logId, 'udp' );
184
185        // Reduce the likelihood that the user encounters the task they were undecided about again.
186        if ( in_array( $accepted, self::REJECTION_REASONS_UNDECIDED ) ) {
187            // Refresh the user's TaskSet cache in a deferred update, since this can be kind of slow.
188            DeferredUpdates::addCallableUpdate( static function () use ( $taskSuggester, $user, $taskSet ) {
189                $taskSuggester->suggest(
190                    $user,
191                    $taskSet->getFilters(),
192                    null,
193                    null,
194                    [ 'resetCache' => true ]
195                );
196            } );
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        /** @var CirrusSearch $cirrusSearch */
339        $cirrusSearch = ( $this->cirrusSearchFactory )();
340        if ( $taskType->getId() === ImageRecommendationTaskTypeHandler::TASK_TYPE_ID ) {
341            $cirrusSearch->resetWeightedTags(
342                $page,
343                ImageRecommendationTaskTypeHandler::WEIGHTED_TAG_PREFIX
344            );
345        } elseif ( $taskType->getId() === SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID ) {
346            $cirrusSearch->resetWeightedTags(
347                $page,
348                SectionImageRecommendationTaskTypeHandler::WEIGHTED_TAG_PREFIX
349            );
350            $cirrusSearch->resetWeightedTags(
351                $page,
352                ImageRecommendationTaskTypeHandler::WEIGHTED_TAG_PREFIX
353            );
354        }
355        // Mark the task as "invalid" in a temporary cache, until the weighted tags in the search
356        // index are updated.
357        $this->cache->set(
358            ImageRecommendationFilter::makeKey(
359                $this->cache,
360                $taskType->getId(),
361                $page->getDBkey()
362            ),
363            true,
364            $this->cache::TTL_MINUTE * 10
365        );
366
367        if ( $this->eventGateImageFeedbackUpdater ) {
368            $this->eventGateImageFeedbackUpdater->update(
369                $page->getId(),
370                $userId,
371                $accepted,
372                $filename,
373                $sectionTitle,
374                $sectionNumber,
375                $rejectionReasons
376            );
377        }
378    }
379
380}