Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.20% covered (success)
93.20%
137 / 147
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiQueryGrowthTasks
93.20% covered (success)
93.20%
137 / 147
77.78% covered (warning)
77.78%
7 / 9
24.18
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 executeGenerator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 run
94.59% covered (success)
94.59%
70 / 74
0.00% covered (danger)
0.00%
0 / 1
14.03
 isInternal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedParams
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
1
 getTopics
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpUrls
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace GrowthExperiments\Api;
4
5use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader;
6use GrowthExperiments\NewcomerTasks\ImageRecommendationFilter;
7use GrowthExperiments\NewcomerTasks\LinkRecommendationFilter;
8use GrowthExperiments\NewcomerTasks\ProtectionFilter;
9use GrowthExperiments\NewcomerTasks\Task\TaskSet;
10use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters;
11use GrowthExperiments\NewcomerTasks\TaskSuggester\NewcomerTasksCacheRefreshJob;
12use GrowthExperiments\NewcomerTasks\TaskSuggester\SearchStrategy\SearchStrategy;
13use GrowthExperiments\NewcomerTasks\TaskSuggester\TaskSuggesterFactory;
14use GrowthExperiments\NewcomerTasks\TaskType\TaskType;
15use GrowthExperiments\NewcomerTasks\Topic\Topic;
16use JobQueueGroup;
17use MediaWiki\Api\ApiBase;
18use MediaWiki\Api\ApiPageSet;
19use MediaWiki\Api\ApiQuery;
20use MediaWiki\Api\ApiQueryGeneratorBase;
21use MediaWiki\Title\Title;
22use StatusValue;
23use Wikimedia\ParamValidator\ParamValidator;
24use 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 */
30class 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}