Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
41.86% |
72 / 172 |
|
20.00% |
1 / 5 |
CRAP | |
0.00% |
0 / 1 |
AddImageSubmissionHandler | |
41.86% |
72 / 172 |
|
20.00% |
1 / 5 |
306.04 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
validate | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
handle | |
0.00% |
0 / 59 |
|
0.00% |
0 / 1 |
156 | |||
parseData | |
86.11% |
62 / 72 |
|
0.00% |
0 / 1 |
18.87 | |||
invalidateRecommendation | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\NewcomerTasks\AddImage; |
4 | |
5 | use GrowthExperiments\NewcomerTasks\AbstractSubmissionHandler; |
6 | use GrowthExperiments\NewcomerTasks\AddImage\EventBus\EventGateImageSuggestionFeedbackUpdater; |
7 | use GrowthExperiments\NewcomerTasks\ImageRecommendationFilter; |
8 | use GrowthExperiments\NewcomerTasks\NewcomerTasksUserOptionsLookup; |
9 | use GrowthExperiments\NewcomerTasks\SubmissionHandler; |
10 | use GrowthExperiments\NewcomerTasks\Task\TaskSet; |
11 | use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters; |
12 | use GrowthExperiments\NewcomerTasks\TaskSuggester\TaskSuggesterFactory; |
13 | use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationBaseTaskType; |
14 | use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskType; |
15 | use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskTypeHandler; |
16 | use GrowthExperiments\NewcomerTasks\TaskType\SectionImageRecommendationTaskType; |
17 | use GrowthExperiments\NewcomerTasks\TaskType\SectionImageRecommendationTaskTypeHandler; |
18 | use GrowthExperiments\NewcomerTasks\TaskType\TaskType; |
19 | use ManualLogEntry; |
20 | use MediaWiki\Deferred\DeferredUpdates; |
21 | use MediaWiki\Message\Message; |
22 | use MediaWiki\Page\ProperPageIdentity; |
23 | use MediaWiki\User\UserIdentity; |
24 | use MediaWiki\User\UserIdentityUtils; |
25 | use StatusValue; |
26 | use Wikimedia\Assert\Assert; |
27 | use 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 | */ |
33 | class 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 | } |