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 ApiBase;
6use ApiPageSet;
7use ApiQuery;
8use ApiQueryGeneratorBase;
9use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader;
10use GrowthExperiments\NewcomerTasks\ImageRecommendationFilter;
11use GrowthExperiments\NewcomerTasks\LinkRecommendationFilter;
12use GrowthExperiments\NewcomerTasks\ProtectionFilter;
13use GrowthExperiments\NewcomerTasks\Task\TaskSet;
14use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters;
15use GrowthExperiments\NewcomerTasks\TaskSuggester\NewcomerTasksCacheRefreshJob;
16use GrowthExperiments\NewcomerTasks\TaskSuggester\SearchStrategy\SearchStrategy;
17use GrowthExperiments\NewcomerTasks\TaskSuggester\TaskSuggesterFactory;
18use GrowthExperiments\NewcomerTasks\TaskType\TaskType;
19use GrowthExperiments\NewcomerTasks\Topic\Topic;
20use JobQueueGroup;
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    /**
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}