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'; |
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 | } |