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