Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
93.20% |
137 / 147 |
|
77.78% |
7 / 9 |
CRAP | |
0.00% |
0 / 1 |
ApiQueryGrowthTasks | |
93.20% |
137 / 147 |
|
77.78% |
7 / 9 |
24.18 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
execute | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
executeGenerator | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
run | |
94.59% |
70 / 74 |
|
0.00% |
0 / 1 |
14.03 | |||
isInternal | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getAllowedParams | |
100.00% |
50 / 50 |
|
100.00% |
1 / 1 |
1 | |||
getTopics | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
getExamplesMessages | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
getHelpUrls | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\Api; |
4 | |
5 | use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader; |
6 | use GrowthExperiments\NewcomerTasks\ImageRecommendationFilter; |
7 | use GrowthExperiments\NewcomerTasks\LinkRecommendationFilter; |
8 | use GrowthExperiments\NewcomerTasks\ProtectionFilter; |
9 | use GrowthExperiments\NewcomerTasks\Task\TaskSet; |
10 | use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters; |
11 | use GrowthExperiments\NewcomerTasks\TaskSuggester\NewcomerTasksCacheRefreshJob; |
12 | use GrowthExperiments\NewcomerTasks\TaskSuggester\SearchStrategy\SearchStrategy; |
13 | use GrowthExperiments\NewcomerTasks\TaskSuggester\TaskSuggesterFactory; |
14 | use GrowthExperiments\NewcomerTasks\TaskType\TaskType; |
15 | use GrowthExperiments\NewcomerTasks\Topic\Topic; |
16 | use JobQueueGroup; |
17 | use MediaWiki\Api\ApiBase; |
18 | use MediaWiki\Api\ApiPageSet; |
19 | use MediaWiki\Api\ApiQuery; |
20 | use MediaWiki\Api\ApiQueryGeneratorBase; |
21 | use MediaWiki\Title\Title; |
22 | use StatusValue; |
23 | use Wikimedia\ParamValidator\ParamValidator; |
24 | use Wikimedia\ParamValidator\TypeDef\IntegerDef; |
25 | |
26 | /** |
27 | * API endpoint for Newcomer Tasks feature. |
28 | * @see https://www.mediawiki.org/wiki/Growth/Personalized_first_day/Newcomer_tasks |
29 | */ |
30 | class ApiQueryGrowthTasks extends ApiQueryGeneratorBase { |
31 | |
32 | private TaskSuggesterFactory $taskSuggesterFactory; |
33 | private ConfigurationLoader $configurationLoader; |
34 | private LinkRecommendationFilter $linkRecommendationFilter; |
35 | private ImageRecommendationFilter $imageRecommendationFilter; |
36 | private ProtectionFilter $protectionFilter; |
37 | private JobQueueGroup $jobQueueGroup; |
38 | |
39 | public function __construct( |
40 | ApiQuery $queryModule, |
41 | string $moduleName, |
42 | JobQueueGroup $jobQueueGroup, |
43 | TaskSuggesterFactory $taskSuggesterFactory, |
44 | ConfigurationLoader $configurationLoader, |
45 | LinkRecommendationFilter $linkRecommendationFilter, |
46 | ImageRecommendationFilter $imageRecommendationFilter, |
47 | ProtectionFilter $protectionFilter |
48 | ) { |
49 | parent::__construct( $queryModule, $moduleName, 'gt' ); |
50 | $this->taskSuggesterFactory = $taskSuggesterFactory; |
51 | $this->configurationLoader = $configurationLoader; |
52 | $this->linkRecommendationFilter = $linkRecommendationFilter; |
53 | $this->imageRecommendationFilter = $imageRecommendationFilter; |
54 | $this->protectionFilter = $protectionFilter; |
55 | $this->jobQueueGroup = $jobQueueGroup; |
56 | } |
57 | |
58 | /** @inheritDoc */ |
59 | public function execute() { |
60 | $this->run(); |
61 | } |
62 | |
63 | /** @inheritDoc */ |
64 | public function executeGenerator( $resultPageSet ) { |
65 | $this->run( $resultPageSet ); |
66 | } |
67 | |
68 | /** |
69 | * @param ApiPageSet|null $resultPageSet |
70 | */ |
71 | protected function run( ?ApiPageSet $resultPageSet = null ) { |
72 | $user = $this->getUser(); |
73 | if ( !$user->isNamed() ) { |
74 | $this->dieWithError( 'apierror-mustbeloggedin-generic' ); |
75 | } |
76 | $params = $this->extractRequestParams(); |
77 | $taskTypes = $params['tasktypes']; |
78 | $topics = $params['topics']; |
79 | $topicsMode = $params['topicsmode']; |
80 | $limit = $params['limit']; |
81 | $offset = $params['offset']; |
82 | $debug = $params['debug']; |
83 | $excludePageIds = $params['excludepageids'] ?? []; |
84 | |
85 | $taskSuggester = $this->taskSuggesterFactory->create(); |
86 | $taskSetFilters = new TaskSetFilters( $taskTypes, $topics, $topicsMode ); |
87 | |
88 | /** @var TaskSet $tasks */ |
89 | $tasks = $taskSuggester->suggest( |
90 | $user, |
91 | $taskSetFilters, |
92 | $limit, |
93 | $offset, |
94 | [ |
95 | 'debug' => $debug, |
96 | 'excludePageIds' => $excludePageIds, |
97 | // Don't use the cache if exclude page IDs has been provided; |
98 | // the page IDs are supplied if we are attempting to load more |
99 | // tasks into the queue in the front end. |
100 | 'useCache' => !$excludePageIds, |
101 | ] |
102 | ); |
103 | if ( $tasks instanceof StatusValue ) { |
104 | $this->dieStatus( $tasks ); |
105 | } |
106 | |
107 | $tasks = $this->linkRecommendationFilter->filter( $tasks ); |
108 | $tasks = $this->imageRecommendationFilter->filter( $tasks ); |
109 | $tasks = $this->protectionFilter->filter( $tasks ); |
110 | |
111 | $result = $this->getResult(); |
112 | $basePath = [ 'query', $this->getModuleName() ]; |
113 | $titles = []; |
114 | $fits = true; |
115 | $i = 0; |
116 | // TODO: Consider grouping the data by "type" so on the client-side one could |
117 | // access result.data.copyedit rather an iterating over everything. |
118 | '@phan-var TaskSet $tasks'; |
119 | foreach ( $tasks as $i => $task ) { |
120 | $title = Title::newFromLinkTarget( $task->getTitle() ); |
121 | $extraData = [ |
122 | 'tasktype' => $task->getTaskType()->getId(), |
123 | 'difficulty' => $task->getTaskType()->getDifficulty(), |
124 | 'order' => $i, |
125 | 'qualityGateIds' => $task->getTaskType()->getQualityGateIds(), |
126 | 'qualityGateConfig' => $tasks->getQualityGateConfig(), |
127 | 'token' => $task->getToken() |
128 | ]; |
129 | if ( $task->getTopics() ) { |
130 | foreach ( $task->getTopicScores() as $id => $score ) { |
131 | // Handling associative arrays is annoying in JS; return the data as |
132 | // a list of (topic ID, score) pairs instead. |
133 | $extraData['topics'][] = [ $id, $score ]; |
134 | } |
135 | } |
136 | |
137 | if ( $resultPageSet ) { |
138 | $titles[] = $title; |
139 | $resultPageSet->setGeneratorData( $title, $extraData ); |
140 | } else { |
141 | $fits = $result->addValue( array_merge( $basePath, [ 'suggestions' ] ), null, [ |
142 | 'title' => $title->getPrefixedText(), |
143 | ] + $extraData ); |
144 | if ( !$fits ) { |
145 | // Could not add to ApiResult due to hitting response size limits. |
146 | break; |
147 | } |
148 | } |
149 | } |
150 | // If we aborted because of $fits, $i is the 0-based index (relative to $offset) of which |
151 | // item we need to continue with in the next request, so we need to start with $offset + $i. |
152 | // If we finished (reached $limit) then $i points to the last task we successfully added. |
153 | if ( !$fits || $tasks->getTotalCount() > $offset + $i + 1 ) { |
154 | // $i is 0-based and will point to the first record not added, so the offset must be one larger. |
155 | $this->setContinueEnumParameter( 'offset', $offset + $i + (int)$fits ); |
156 | } |
157 | |
158 | if ( $resultPageSet ) { |
159 | $resultPageSet->populateFromTitles( $titles ); |
160 | $result->addValue( $this->getModuleName(), 'totalCount', $tasks->getTotalCount() ); |
161 | $result->addValue( $this->getModuleName(), 'qualityGateConfig', $tasks->getQualityGateConfig() ); |
162 | if ( $debug ) { |
163 | $result->addValue( $this->getModuleName(), 'debug', $tasks->getDebugData() ); |
164 | } |
165 | } else { |
166 | $result->addValue( $basePath, 'totalCount', $tasks->getTotalCount() ); |
167 | $result->addValue( $basePath, 'qualityGateConfig', $tasks->getQualityGateConfig() ); |
168 | $result->addIndexedTagName( array_merge( $basePath, [ 'suggestions' ] ), 'suggestion' ); |
169 | if ( $debug ) { |
170 | $result->addValue( $basePath, 'debug', $tasks->getDebugData() ); |
171 | } |
172 | } |
173 | if ( !$excludePageIds ) { |
174 | // Refresh the cached suggestions via the job queue when the user hasn't asked to exclude |
175 | // page IDs. This makes the API endpoint behave in the same way as SuggestedEdits.php on |
176 | // Special:Homepage. If we don't do this, then repeat queries to this API endpoint with the same user |
177 | // ID and without `pageids` set will result in returning the same cached task set. |
178 | $this->jobQueueGroup->lazyPush( |
179 | new NewcomerTasksCacheRefreshJob( [ |
180 | 'userId' => $user->getId() |
181 | ] ) |
182 | ); |
183 | } |
184 | } |
185 | |
186 | /** @inheritDoc */ |
187 | public function isInternal() { |
188 | return true; |
189 | } |
190 | |
191 | /** @inheritDoc */ |
192 | protected function getAllowedParams() { |
193 | $taskTypes = $this->configurationLoader->getTaskTypes(); |
194 | $topics = $this->getTopics(); |
195 | // Ensure valid values, tasks/topics might be empty during tests. |
196 | $taskLimit = max( count( $taskTypes ), 1 ); |
197 | $topicsLimit = max( count( $topics ), 1 ); |
198 | |
199 | return [ |
200 | 'tasktypes' => [ |
201 | ParamValidator::PARAM_TYPE => array_keys( $taskTypes ), |
202 | ParamValidator::PARAM_ISMULTI => true, |
203 | ParamValidator::PARAM_ISMULTI_LIMIT1 => $taskLimit, |
204 | ParamValidator::PARAM_ISMULTI_LIMIT2 => $taskLimit, |
205 | ParamValidator::PARAM_DEFAULT => [], |
206 | ApiBase::PARAM_HELP_MSG_PER_VALUE => array_map( function ( TaskType $taskType ) { |
207 | return $taskType->getName( $this->getContext() ); |
208 | }, $taskTypes ), |
209 | ], |
210 | 'topics' => [ |
211 | ParamValidator::PARAM_TYPE => array_keys( $topics ), |
212 | ParamValidator::PARAM_ISMULTI => true, |
213 | ParamValidator::PARAM_ISMULTI_LIMIT1 => $topicsLimit, |
214 | ParamValidator::PARAM_ISMULTI_LIMIT2 => $topicsLimit, |
215 | ParamValidator::PARAM_DEFAULT => [], |
216 | ApiBase::PARAM_HELP_MSG_PER_VALUE => array_map( function ( Topic $topic ) { |
217 | return $topic->getName( $this->getContext() ); |
218 | }, $topics ), |
219 | ], |
220 | 'topicsmode' => [ |
221 | ParamValidator::PARAM_TYPE => SearchStrategy::TOPIC_MATCH_MODES, |
222 | ], |
223 | 'limit' => [ |
224 | ParamValidator::PARAM_TYPE => 'limit', |
225 | IntegerDef::PARAM_MAX => 250, |
226 | IntegerDef::PARAM_MAX2 => 250, |
227 | ], |
228 | 'offset' => [ |
229 | ParamValidator::PARAM_TYPE => 'integer', |
230 | IntegerDef::PARAM_MIN => 1, |
231 | ApiBase::PARAM_RANGE_ENFORCE => true, |
232 | ApiBase::PARAM_HELP_MSG => 'api-help-param-continue', |
233 | ], |
234 | 'debug' => [ |
235 | ParamValidator::PARAM_TYPE => 'boolean', |
236 | ParamValidator::PARAM_DEFAULT => false, |
237 | ], |
238 | 'excludepageids' => [ |
239 | ParamValidator::PARAM_TYPE => 'integer', |
240 | ParamValidator::PARAM_ISMULTI => true, |
241 | ParamValidator::PARAM_ISMULTI_LIMIT1 => 1000, |
242 | ParamValidator::PARAM_ISMULTI_LIMIT2 => 1000, |
243 | ] |
244 | ]; |
245 | } |
246 | |
247 | /** |
248 | * @return Topic[] Array of topic id => topic |
249 | */ |
250 | protected function getTopics() { |
251 | $topics = $this->configurationLoader->loadTopics(); |
252 | if ( $topics instanceof StatusValue ) { |
253 | return []; |
254 | } |
255 | return array_combine( array_map( static function ( Topic $topic ) { |
256 | return $topic->getId(); |
257 | }, $topics ), $topics ) ?: []; |
258 | } |
259 | |
260 | /** @inheritDoc */ |
261 | protected function getExamplesMessages() { |
262 | $p = $this->getModulePrefix(); |
263 | return [ |
264 | "action=query&list=growthtasks&{$p}tasktypes=copyedit" => 'apihelp-query+growthtasks-example-1', |
265 | "action=query&generator=growthtasks&g{$p}limit=max&prop=info|revision" |
266 | => 'apihelp-query+growthtasks-example-2', |
267 | ]; |
268 | } |
269 | |
270 | /** @inheritDoc */ |
271 | public function getHelpUrls() { |
272 | return 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:GrowthExperiments#API'; |
273 | } |
274 | |
275 | } |