Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
34.92% covered (danger)
34.92%
22 / 63
43.75% covered (danger)
43.75%
7 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
NewcomerTasksUserOptionsLookup
34.92% covered (danger)
34.92%
22 / 63
43.75% covered (danger)
43.75%
7 / 16
295.88
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getTaskTypeFilter
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getTopics
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTopicsMatchMode
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getTopicFilterWithoutFallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 areLinkRecommendationsEnabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 areImageRecommendationsEnabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 areSectionImageRecommendationsEnabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 filterTaskTypes
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultTaskTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getConversionMap
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 convertTaskTypes
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 filterNonExistentTaskTypes
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getJsonListOption
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getStringOption
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 shouldUserSeeAllTaskTypes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace GrowthExperiments\NewcomerTasks;
4
5use GrowthExperiments\ExperimentUserManager;
6use GrowthExperiments\HomepageModules\SuggestedEdits;
7use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader;
8use GrowthExperiments\NewcomerTasks\TaskSuggester\SearchStrategy\SearchStrategy;
9use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskTypeHandler;
10use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskTypeHandler;
11use GrowthExperiments\NewcomerTasks\TaskType\SectionImageRecommendationTaskTypeHandler;
12use MediaWiki\Config\Config;
13use MediaWiki\User\Options\UserOptionsLookup;
14use MediaWiki\User\UserIdentity;
15
16/**
17 * Retrieves user settings related to newcomer tasks.
18 */
19class NewcomerTasksUserOptionsLookup {
20
21    /**
22     * This property isn't used, but we want to preserve the ability to run A/B tests where
23     * user options depend on the user's experiment group.
24     * @var ExperimentUserManager
25     */
26    private $experimentUserManager;
27
28    /** @var UserOptionsLookup */
29    private $userOptionsLookup;
30
31    /** @var Config */
32    private $config;
33
34    /** @var ConfigurationLoader */
35    private $configurationLoader;
36
37    /**
38     * @param ExperimentUserManager $experimentUserManager
39     * @param UserOptionsLookup $userOptionsLookup
40     * @param Config $config
41     * @param ConfigurationLoader $configurationLoader
42     */
43    public function __construct(
44        ExperimentUserManager $experimentUserManager,
45        UserOptionsLookup $userOptionsLookup,
46        Config $config,
47        ConfigurationLoader $configurationLoader
48    ) {
49        $this->experimentUserManager = $experimentUserManager;
50        $this->userOptionsLookup = $userOptionsLookup;
51        $this->config = $config;
52        $this->configurationLoader = $configurationLoader;
53    }
54
55    /**
56     * Get user's task types given their preferences and community configuration.
57     * @param UserIdentity $user
58     * @return string[] A list of task type IDs, or the default task types when the user
59     * has no preference set.
60     * @see \GrowthExperiments\NewcomerTasks\TaskType\TaskType::getId()
61     */
62    public function getTaskTypeFilter( UserIdentity $user ): array {
63        $taskTypes = $this->getJsonListOption( $user, SuggestedEdits::TASKTYPES_PREF );
64        // Filter out invalid task types for the user and use defaults based on user options.
65        if ( !$taskTypes ) {
66            $taskTypes = $this->getDefaultTaskTypes( $user );
67        }
68        return $this->filterNonExistentTaskTypes( $this->convertTaskTypes( $taskTypes, $user ) );
69    }
70
71    /**
72     * Get the given user's topic preferences.
73     * @param UserIdentity $user
74     * @return string[] A list of topic IDs, or an empty array when the user has
75     *   no preference set (This is method is meant to be compatible with TaskSuggester
76     *   which takes an empty array as "no filtering".)
77     * @see \GrowthExperiments\NewcomerTasks\Topic\Topic::getId()
78     */
79    public function getTopics( UserIdentity $user ): array {
80        return $this->getTopicFilterWithoutFallback( $user ) ?? [];
81    }
82
83    /**
84     * Get the given user's topic mode preference.
85     * @param UserIdentity $user
86     * @return string A string representing the topic match mode.
87     * One of ( 'AND', 'OR').
88     * @see SearchStrategy::TOPIC_MATCH_MODES
89     */
90    public function getTopicsMatchMode( UserIdentity $user ): string {
91        $matchMode = $this->getStringOption( $user, SuggestedEdits::TOPICS_MATCH_MODE_PREF ) ??
92            SearchStrategy::TOPIC_MATCH_MODE_OR;
93        if ( $matchMode === SearchStrategy::TOPIC_MATCH_MODE_AND &&
94            !$this->config->get( 'GETopicsMatchModeEnabled' ) ) {
95            $matchMode = SearchStrategy::TOPIC_MATCH_MODE_OR;
96        }
97        return $matchMode;
98    }
99
100    /**
101     * Get the given user's topic preferences without a fallback to empty array.
102     * @param UserIdentity $user
103     * @return string[]|null A list of topic IDs, an empty array when the user has
104     *   no preference set, or null if preference wasn't set or is invalid.
105     */
106    public function getTopicFilterWithoutFallback( UserIdentity $user ): ?array {
107        return $this->getJsonListOption( $user, SuggestedEdits::getTopicFiltersPref( $this->config ) );
108    }
109
110    /**
111     * Check if link recommendations are enabled. When true, the link-recommendation task type
112     * should be made available to the user and the links task type hidden.
113     * @return bool
114     */
115    public function areLinkRecommendationsEnabled(): bool {
116        return $this->config->get( 'GENewcomerTasksLinkRecommendationsEnabled' )
117               && $this->config->get( 'GELinkRecommendationsFrontendEnabled' )
118               && array_key_exists( LinkRecommendationTaskTypeHandler::TASK_TYPE_ID,
119                   $this->configurationLoader->getTaskTypes() );
120    }
121
122    /**
123     * Check if image recommendations are enabled. When true, the image-recommendation task type
124     * should be made available to the user.
125     * @return bool
126     */
127    public function areImageRecommendationsEnabled(): bool {
128        return $this->config->get( 'GENewcomerTasksImageRecommendationsEnabled' )
129            && array_key_exists( ImageRecommendationTaskTypeHandler::TASK_TYPE_ID,
130                $this->configurationLoader->getTaskTypes() );
131    }
132
133    /**
134     * Check if section-level image recommendations are enabled. When true, the
135     * section-image-recommendation task type should be made available to the user.
136     * @param UserIdentity $user
137     * @return bool
138     */
139    public function areSectionImageRecommendationsEnabled( UserIdentity $user ): bool {
140        return $this->config->get( 'GENewcomerTasksSectionImageRecommendationsEnabled' )
141            && array_key_exists( SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID,
142                $this->configurationLoader->getTaskTypes() );
143    }
144
145    /**
146     * Remove task types which the user is not supposed to see, given the link recommendation
147     * configuration and community configuration.
148     * @param string[] $taskTypes Task types IDs.
149     * @param UserIdentity $user
150     * @return string[] Filtered task types IDs. Array keys are not preserved.
151     */
152    public function filterTaskTypes( array $taskTypes, UserIdentity $user ): array {
153        $conversionMap = $this->getConversionMap( $user );
154        $taskTypes = array_filter( $taskTypes,
155            fn ( $taskTypeId ) => !array_key_exists( $taskTypeId, $conversionMap )
156        );
157        return $this->filterNonExistentTaskTypes( $taskTypes );
158    }
159
160    /**
161     * Get default task types when the user has no stored preference.
162     * @param UserIdentity $user
163     * @return string[]
164     */
165    private function getDefaultTaskTypes( UserIdentity $user ): array {
166        // This doesn't do anything useful right now, but we want to preserve the ability
167        // to determine the default task types dynamically for A/B testing.
168        return SuggestedEdits::DEFAULT_TASK_TYPES;
169    }
170
171    /**
172     * Get mapping of task types which the user is not supposed to see to a similar task type
173     * or false (meaning nothing should be shown instead).
174     * Identical to TaskTypesAbFilter.getConversionMap().
175     * @param UserIdentity $user
176     * @return array A map of old task type ID => new task type ID or false.
177     * @phan-return array<string,string|false>
178     */
179    private function getConversionMap( UserIdentity $user ): array {
180        $map = [];
181        if ( $this->areLinkRecommendationsEnabled() ) {
182            $map += [ 'links' => LinkRecommendationTaskTypeHandler::TASK_TYPE_ID ];
183        } else {
184            $map += [ LinkRecommendationTaskTypeHandler::TASK_TYPE_ID => 'links' ];
185        }
186        if ( !$this->areImageRecommendationsEnabled() ) {
187            $map += [ ImageRecommendationTaskTypeHandler::TASK_TYPE_ID => false ];
188        }
189        if ( !$this->areSectionImageRecommendationsEnabled( $user ) ) {
190            $map += [ SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID => false ];
191        }
192        return $map;
193    }
194
195    /**
196     * Convert task types which the user is not supposed to see, given the link recommendation
197     * configuration, to the closest task type available to them.
198     * @param string[] $taskTypes Task types IDs.
199     * @param UserIdentity $user
200     * @return string[] Converted task types IDs. Array keys are not preserved.
201     */
202    private function convertTaskTypes( array $taskTypes, UserIdentity $user ): array {
203        $map = $this->getConversionMap( $user );
204        $taskTypes = array_map( static function ( string $taskType ) use ( $map ) {
205            return $map[$taskType] ?? $taskType;
206        }, $taskTypes );
207        return array_unique( array_filter( $taskTypes ) );
208    }
209
210    /**
211     * Remove task types which have been disabled via community configuration.
212     * @param string[] $taskTypesToFilter Task types IDs.
213     * @return string[] Filtered task types IDs.
214     */
215    private function filterNonExistentTaskTypes( array $taskTypesToFilter ) {
216        $allTaskTypes = $this->configurationLoader->getTaskTypes();
217        return array_values( array_filter( $taskTypesToFilter,
218            static function ( $taskTypeId ) use ( $allTaskTypes ) {
219                return array_key_exists( $taskTypeId, $allTaskTypes );
220            }
221        ) );
222    }
223
224    /**
225     * Read a user preference that is a list of strings.
226     * @param UserIdentity $user
227     * @param string $pref
228     * @return array|null User preferences as a list of strings, or null of the preference was
229     *   missing or invalid.
230     */
231    private function getJsonListOption( UserIdentity $user, string $pref ) {
232        $stored = $this->userOptionsLookup->getOption( $user, $pref );
233        if ( $stored ) {
234            $stored = json_decode( $stored, true );
235        }
236        // sanity check
237        if ( !is_array( $stored ) || array_filter( $stored, 'is_string' ) !== $stored ) {
238            return null;
239        }
240        return $stored;
241    }
242
243    /**
244     * Read a user preference that is a string.
245     * @param UserIdentity $user
246     * @param string $pref
247     * @return string|null User preference as a string, or null if the preference is invalid
248     */
249    private function getStringOption( UserIdentity $user, string $pref ) {
250        $stored = $this->userOptionsLookup->getOption( $user, $pref );
251
252        if ( !is_string( $stored ) ) {
253            return null;
254        }
255        return $stored;
256    }
257
258    /**
259     * Check if the user is an internal pseudo-user who should see all task types.
260     * @param UserIdentity $user
261     * @return bool
262     */
263    private function shouldUserSeeAllTaskTypes( UserIdentity $user ) {
264        return $user->getId() === 0 && $user->getName() === SuggestionsInfo::USER;
265    }
266
267}