Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 105
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
AddLinkSubmissionHandler
0.00% covered (danger)
0.00%
0 / 105
0.00% covered (danger)
0.00%
0 / 4
600
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 validate
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 handle
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 1
210
 normalizeTargets
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace GrowthExperiments\NewcomerTasks\AddLink;
4
5use GrowthExperiments\NewcomerTasks\AbstractSubmissionHandler;
6use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader;
7use GrowthExperiments\NewcomerTasks\NewcomerTasksUserOptionsLookup;
8use GrowthExperiments\NewcomerTasks\SubmissionHandler;
9use GrowthExperiments\NewcomerTasks\Task\TaskSet;
10use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters;
11use GrowthExperiments\NewcomerTasks\TaskSuggester\TaskSuggesterFactory;
12use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskType;
13use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskTypeHandler;
14use GrowthExperiments\NewcomerTasks\TaskType\TaskType;
15use MediaWiki\Cache\LinkBatchFactory;
16use MediaWiki\Linker\LinkTarget;
17use MediaWiki\Message\Message;
18use MediaWiki\Page\ProperPageIdentity;
19use MediaWiki\Title\MalformedTitleException;
20use MediaWiki\Title\TitleFactory;
21use MediaWiki\User\UserIdentity;
22use MediaWiki\User\UserIdentityUtils;
23use Psr\Log\LoggerInterface;
24use StatusValue;
25use UnexpectedValueException;
26use Wikimedia\Rdbms\DBReadOnlyError;
27use Wikimedia\Rdbms\IDBAccessObject;
28
29/**
30 * Record the user's decision on the recommendations for a given page.
31 */
32class 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}