Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 105 |
|
0.00% |
0 / 4 |
CRAP | |
0.00% |
0 / 1 |
AddLinkSubmissionHandler | |
0.00% |
0 / 105 |
|
0.00% |
0 / 4 |
600 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
validate | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
handle | |
0.00% |
0 / 69 |
|
0.00% |
0 / 1 |
210 | |||
normalizeTargets | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
30 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\NewcomerTasks\AddLink; |
4 | |
5 | use GrowthExperiments\NewcomerTasks\AbstractSubmissionHandler; |
6 | use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader; |
7 | use GrowthExperiments\NewcomerTasks\NewcomerTasksUserOptionsLookup; |
8 | use GrowthExperiments\NewcomerTasks\SubmissionHandler; |
9 | use GrowthExperiments\NewcomerTasks\Task\TaskSet; |
10 | use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters; |
11 | use GrowthExperiments\NewcomerTasks\TaskSuggester\TaskSuggesterFactory; |
12 | use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskType; |
13 | use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskTypeHandler; |
14 | use GrowthExperiments\NewcomerTasks\TaskType\TaskType; |
15 | use MediaWiki\Cache\LinkBatchFactory; |
16 | use MediaWiki\Linker\LinkTarget; |
17 | use MediaWiki\Message\Message; |
18 | use MediaWiki\Page\ProperPageIdentity; |
19 | use MediaWiki\Title\MalformedTitleException; |
20 | use MediaWiki\Title\TitleFactory; |
21 | use MediaWiki\User\UserIdentity; |
22 | use MediaWiki\User\UserIdentityUtils; |
23 | use Psr\Log\LoggerInterface; |
24 | use StatusValue; |
25 | use UnexpectedValueException; |
26 | use Wikimedia\Rdbms\DBReadOnlyError; |
27 | use Wikimedia\Rdbms\IDBAccessObject; |
28 | |
29 | /** |
30 | * Record the user's decision on the recommendations for a given page. |
31 | */ |
32 | class AddLinkSubmissionHandler extends AbstractSubmissionHandler implements SubmissionHandler { |
33 | |
34 | /** @var LinkRecommendationHelper */ |
35 | private $linkRecommendationHelper; |
36 | /** @var LinkSubmissionRecorder */ |
37 | private $addLinkSubmissionRecorder; |
38 | /** @var TitleFactory */ |
39 | private $titleFactory; |
40 | /** @var UserIdentityUtils */ |
41 | private $userIdentityUtils; |
42 | /** @var LinkRecommendationStore */ |
43 | private $linkRecommendationStore; |
44 | /** @var LinkBatchFactory */ |
45 | private $linkBatchFactory; |
46 | /** @var TaskSuggesterFactory */ |
47 | private $taskSuggesterFactory; |
48 | /** @var NewcomerTasksUserOptionsLookup */ |
49 | private $newcomerTasksUserOptionsLookup; |
50 | /** @var ConfigurationLoader */ |
51 | private $configurationLoader; |
52 | /** @var LoggerInterface */ |
53 | private $logger; |
54 | |
55 | /** |
56 | * @param LinkRecommendationHelper $linkRecommendationHelper |
57 | * @param LinkRecommendationStore $linkRecommendationStore |
58 | * @param LinkSubmissionRecorder $addLinkSubmissionRecorder |
59 | * @param LinkBatchFactory $linkBatchFactory |
60 | * @param TitleFactory $titleFactory |
61 | * @param UserIdentityUtils $userIdentityUtils |
62 | * @param TaskSuggesterFactory $taskSuggesterFactory |
63 | * @param NewcomerTasksUserOptionsLookup $newcomerTasksUserOptionsLookup |
64 | * @param ConfigurationLoader $configurationLoader |
65 | * @param LoggerInterface $logger |
66 | */ |
67 | public function __construct( |
68 | LinkRecommendationHelper $linkRecommendationHelper, |
69 | LinkRecommendationStore $linkRecommendationStore, |
70 | LinkSubmissionRecorder $addLinkSubmissionRecorder, |
71 | LinkBatchFactory $linkBatchFactory, |
72 | TitleFactory $titleFactory, |
73 | UserIdentityUtils $userIdentityUtils, |
74 | TaskSuggesterFactory $taskSuggesterFactory, |
75 | NewcomerTasksUserOptionsLookup $newcomerTasksUserOptionsLookup, |
76 | ConfigurationLoader $configurationLoader, |
77 | LoggerInterface $logger |
78 | ) { |
79 | $this->linkRecommendationHelper = $linkRecommendationHelper; |
80 | $this->addLinkSubmissionRecorder = $addLinkSubmissionRecorder; |
81 | $this->titleFactory = $titleFactory; |
82 | $this->linkRecommendationStore = $linkRecommendationStore; |
83 | $this->linkBatchFactory = $linkBatchFactory; |
84 | $this->userIdentityUtils = $userIdentityUtils; |
85 | $this->taskSuggesterFactory = $taskSuggesterFactory; |
86 | $this->newcomerTasksUserOptionsLookup = $newcomerTasksUserOptionsLookup; |
87 | $this->configurationLoader = $configurationLoader; |
88 | $this->logger = $logger; |
89 | } |
90 | |
91 | /** @inheritDoc */ |
92 | public function validate( |
93 | TaskType $taskType, ProperPageIdentity $page, UserIdentity $user, ?int $baseRevId, array $data |
94 | ): StatusValue { |
95 | $title = $this->titleFactory->castFromPageIdentity( $page ); |
96 | if ( !$title ) { |
97 | // Not really possible but makes phan happy. |
98 | throw new UnexpectedValueException( 'Invalid title: ' |
99 | . $page->getNamespace() . ':' . $page->getDBkey() ); |
100 | } |
101 | if ( !$this->linkRecommendationStore->getByLinkTarget( $title, IDBAccessObject::READ_LATEST ) ) { |
102 | // There's no link recommendation data stored for this page, so it must have been |
103 | // removed from the database during the time the user had the UI open. Don't allow |
104 | // the save to continue. |
105 | return StatusValue::newGood()->error( 'growthexperiments-addlink-notinstore', |
106 | $title->getPrefixedText() ); |
107 | } |
108 | $userErrorMessage = self::getUserErrorMessage( $this->userIdentityUtils, $user ); |
109 | if ( $userErrorMessage ) { |
110 | return StatusValue::newGood()->error( $userErrorMessage ); |
111 | } |
112 | return StatusValue::newGood(); |
113 | } |
114 | |
115 | /** |
116 | * @inheritDoc |
117 | * @throws MalformedTitleException |
118 | */ |
119 | public function handle( |
120 | TaskType $taskType, ProperPageIdentity $page, UserIdentity $user, ?int $baseRevId, ?int $editRevId, array $data |
121 | ): StatusValue { |
122 | // The latest revision is the saved edit, so we need to find the link recommendation based on the base |
123 | // revision ID. |
124 | $linkRecommendation = $baseRevId ? $this->linkRecommendationStore->getByRevId( |
125 | $baseRevId, |
126 | IDBAccessObject::READ_LATEST |
127 | ) : null; |
128 | $title = $this->titleFactory->castFromPageIdentity( $page ); |
129 | if ( !$title ) { |
130 | // This should never happen, it's here to make Phan happy. |
131 | return StatusValue::newFatal( 'invalidtitle' ); |
132 | } |
133 | if ( !$linkRecommendation ) { |
134 | $this->logger->warning( 'Unable to find link recommendation for title {title} ' . |
135 | 'with getByRevId(), using base revision ID of {baseRevId} and edit revision ID of ' . |
136 | '{editRevId}. Additional data: {data}', [ |
137 | 'title' => $title->getPrefixedDBkey(), |
138 | 'baseRevId' => $baseRevId, |
139 | 'editRevId' => $editRevId, |
140 | 'data' => json_encode( $data ) |
141 | ] ); |
142 | // Try to find the find the link recommendation based on the link target. |
143 | $linkRecommendation = $this->linkRecommendationStore->getByLinkTarget( |
144 | $title, |
145 | IDBAccessObject::READ_LATEST, |
146 | true |
147 | ); |
148 | } |
149 | |
150 | if ( !$linkRecommendation ) { |
151 | $this->logger->error( 'Unable to find link recommendation for title {title} ' . |
152 | 'with getByLinkTarget using base revision ID of {baseRevId} and edit revision ID of ' . |
153 | '{editRevId}. Additional data: {data}', [ |
154 | 'title' => $title->getPrefixedDBkey(), |
155 | 'baseRevId' => $baseRevId, |
156 | 'editRevId' => $editRevId, |
157 | 'data' => json_encode( $data ) |
158 | ] ); |
159 | return StatusValue::newFatal( 'growthexperiments-addlink-handler-notfound' ); |
160 | } |
161 | $links = $this->normalizeTargets( $linkRecommendation->getLinks() ); |
162 | |
163 | $acceptedTargets = $this->normalizeTargets( $data['acceptedTargets'] ?: [] ); |
164 | $rejectedTargets = $this->normalizeTargets( $data['rejectedTargets'] ?: [] ); |
165 | $skippedTargets = $this->normalizeTargets( $data['skippedTargets'] ?: [] ); |
166 | |
167 | $allTargets = array_merge( $acceptedTargets, $rejectedTargets, $skippedTargets ); |
168 | $unexpectedTargets = array_diff( $allTargets, $links ); |
169 | if ( $unexpectedTargets ) { |
170 | return StatusValue::newFatal( 'growthexperiments-addlink-handler-wrongtargets', |
171 | Message::listParam( $unexpectedTargets, 'comma' ) ); |
172 | } |
173 | |
174 | $warnings = []; |
175 | $taskSet = $this->taskSuggesterFactory->create()->suggest( |
176 | $user, |
177 | new TaskSetFilters( |
178 | $this->newcomerTasksUserOptionsLookup->getTaskTypeFilter( $user ), |
179 | $this->newcomerTasksUserOptionsLookup->getTopics( $user ), |
180 | $this->newcomerTasksUserOptionsLookup->getTopicsMatchMode( $user ) |
181 | ) |
182 | ); |
183 | if ( $taskSet instanceof TaskSet ) { |
184 | $qualityGateConfig = $taskSet->getQualityGateConfig(); |
185 | if ( $taskType instanceof LinkRecommendationTaskType |
186 | && isset( $qualityGateConfig[LinkRecommendationTaskTypeHandler::TASK_TYPE_ID]['dailyCount'] ) |
187 | && $qualityGateConfig[LinkRecommendationTaskTypeHandler::TASK_TYPE_ID]['dailyCount'] |
188 | >= $taskType->getMaxTasksPerDay() - 1 |
189 | ) { |
190 | $warnings['gelinkrecommendationdailytasksexceeded'] = true; |
191 | } |
192 | } |
193 | |
194 | try { |
195 | $this->linkRecommendationHelper->deleteLinkRecommendation( |
196 | $page, |
197 | // FIXME T283606: In theory if $editRevId is set (this is a real edit, not a null edit that |
198 | // happens when the user accepted nothing), we can leave search index updates to the |
199 | // SearchDataForIndex hook. In practice that does not work because we delete the DB row |
200 | // here so the hook logic will assume there's nothing to do. Might want to improve that |
201 | // in the future. |
202 | true |
203 | ); |
204 | $status = $this->addLinkSubmissionRecorder->record( $user, $linkRecommendation, $acceptedTargets, |
205 | $rejectedTargets, $skippedTargets, $editRevId ); |
206 | $status->merge( |
207 | StatusValue::newGood( [ 'warnings' => $warnings, 'logId' => $status->getValue() ] ), |
208 | true |
209 | ); |
210 | } catch ( DBReadOnlyError $e ) { |
211 | $status = StatusValue::newFatal( 'readonly' ); |
212 | } |
213 | return $status; |
214 | } |
215 | |
216 | /** |
217 | * Normalize link targets into prefixed dbkey format |
218 | * @param array<int,string|LinkTarget|LinkRecommendationLink> $targets |
219 | * @return string[] |
220 | * @throws MalformedTitleException |
221 | */ |
222 | private function normalizeTargets( array $targets ): array { |
223 | $linkBatch = $this->linkBatchFactory->newLinkBatch(); |
224 | $normalized = []; |
225 | $linkTargets = []; |
226 | foreach ( $targets as $target ) { |
227 | if ( $target instanceof LinkRecommendationLink ) { |
228 | $target = $target->getLinkTarget(); |
229 | } |
230 | if ( !$target instanceof LinkTarget ) { |
231 | $target = $this->titleFactory->newFromTextThrow( $target ); |
232 | } |
233 | $linkTarget = $this->titleFactory->newFromLinkTarget( $target ); |
234 | $linkTargets[] = $linkTarget; |
235 | $linkBatch->addObj( $linkTarget ); |
236 | } |
237 | $linkBatch->execute(); |
238 | foreach ( $linkTargets as $target ) { |
239 | $normalized[] = $target->getPrefixedDBkey(); |
240 | } |
241 | return $normalized; |
242 | } |
243 | |
244 | } |