Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
48.39% covered (danger)
48.39%
406 / 839
22.22% covered (danger)
22.22%
10 / 45
CRAP
0.00% covered (danger)
0.00%
0 / 1
Campaign
48.39% covered (danger)
48.39%
406 / 839
22.22% covered (danger)
22.22%
10 / 45
3501.24
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getId
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getName
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getStartTime
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getEndTime
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getPriority
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isEnabled
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isLocked
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isArchived
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isGeotargeted
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getBuckets
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 loadBasicSettings
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
20
 campaignExists
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getActiveCampaignsAndBanners
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
20
 getCampaignSettings
97.73% covered (success)
97.73%
43 / 44
0.00% covered (danger)
0.00%
0 / 1
4
 getHistoricalCampaigns
68.92% covered (warning)
68.92%
51 / 74
0.00% covered (danger)
0.00%
0 / 1
9.92
 getCampaignMixins
36.92% covered (danger)
36.92%
24 / 65
0.00% covered (danger)
0.00%
0 / 1
89.53
 updateCampaignMixins
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
56
 addCampaign
85.06% covered (warning)
85.06%
74 / 87
0.00% covered (danger)
0.00%
0 / 1
12.48
 removeCampaign
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
3.03
 removeCampaignByName
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
1
 addTemplateTo
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
2
 removeTemplateFor
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getNoticeId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getNoticeProjects
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 getNoticeLanguages
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 getNoticeCountries
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 getNoticeRegions
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 getTitleForURL
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQueryForURL
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getCanonicalURL
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 updateNoticeDate
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
4.03
 setBooleanCampaignSetting
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 setNumericCampaignSetting
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 updateWeight
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 updateBucket
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 updateProjects
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 updateProjectLanguages
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 updateCountries
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 updateRegions
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 processAfterCampaignChange
91.89% covered (success)
91.89%
34 / 37
0.00% covered (danger)
0.00%
0 / 1
6.02
 processSettingsForHook
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 settingNameIsValid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setType
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 campaignLogs
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2
3use MediaWiki\Json\FormatJson;
4use MediaWiki\MediaWikiServices;
5use MediaWiki\SpecialPage\SpecialPage;
6use MediaWiki\Title\Title;
7use MediaWiki\User\User;
8use MediaWiki\Utils\MWTimestamp;
9use Wikimedia\Rdbms\IReadableDatabase;
10use Wikimedia\Rdbms\SelectQueryBuilder;
11
12class Campaign {
13
14    /** @var int|null */
15    private $id = null;
16    /** @var string|null */
17    private $name = null;
18
19    /** @var MWTimestamp Start datetime of campaign */
20    private $start = null;
21
22    /** @var MWTimestamp End datetime of campaign */
23    private $end = null;
24
25    /** @var int Priority level of the campaign, higher is more important */
26    private $priority = null;
27
28    /** @var bool True if the campaign is enabled for showing */
29    private $enabled = null;
30
31    /** @var bool True if the campaign is currently non editable */
32    private $locked = null;
33
34    /** @var bool True if the campaign has been moved to the archive */
35    private $archived = null;
36
37    /** @var bool True if there is geo-targeting data for ths campaign */
38    private $geotargeted = null;
39
40    /** @var int The number of buckets in this campaign */
41    private $buckets = null;
42
43    /** @var int */
44    private $throttle = null;
45
46    /**
47     * Construct a lazily loaded CentralNotice campaign object
48     *
49     * @param string|int $campaignIdentifier Either an ID or name for the campaign
50     */
51    public function __construct( $campaignIdentifier ) {
52        if ( is_int( $campaignIdentifier ) ) {
53            $this->id = $campaignIdentifier;
54        } else {
55            $this->name = $campaignIdentifier;
56        }
57    }
58
59    /**
60     * Get the unique numerical ID for this campaign
61     *
62     * @throws CampaignExistenceException If lazy loading failed.
63     * @return int
64     */
65    public function getId() {
66        if ( $this->id === null ) {
67            $this->loadBasicSettings();
68        }
69
70        return $this->id;
71    }
72
73    /**
74     * Get the unique name for this campaign
75     *
76     * @throws CampaignExistenceException If lazy loading failed.
77     * @return string
78     */
79    public function getName() {
80        if ( $this->name === null ) {
81            $this->loadBasicSettings();
82        }
83
84        return $this->name;
85    }
86
87    /**
88     * Get the start time for the campaign. Only applicable if the campaign is enabled.
89     *
90     * @throws CampaignExistenceException If lazy loading failed.
91     * @return MWTimestamp
92     */
93    public function getStartTime() {
94        if ( $this->start === null ) {
95            $this->loadBasicSettings();
96        }
97
98        return $this->start;
99    }
100
101    /**
102     * Get the end time for the campaign. Only applicable if the campaign is enabled.
103     *
104     * @throws CampaignExistenceException If lazy loading failed.
105     * @return MWTimestamp
106     */
107    public function getEndTime() {
108        if ( $this->end === null ) {
109            $this->loadBasicSettings();
110        }
111
112        return $this->end;
113    }
114
115    /**
116     * Get the priority level for this campaign. The larger this is the higher the priority is.
117     *
118     * @throws CampaignExistenceException If lazy loading failed.
119     * @return int
120     */
121    public function getPriority() {
122        if ( $this->priority === null ) {
123            $this->loadBasicSettings();
124        }
125
126        return $this->priority;
127    }
128
129    /**
130     * Returns the enabled/disabled status of the campaign.
131     *
132     * If a campaign is enabled it is eligible to be shown to users.
133     *
134     * @throws CampaignExistenceException If lazy loading failed.
135     * @return bool
136     */
137    public function isEnabled() {
138        if ( $this->enabled === null ) {
139            $this->loadBasicSettings();
140        }
141
142        return $this->enabled;
143    }
144
145    /**
146     * Returns the locked/unlocked status of the campaign. A locked campaign is not able to be
147     * edited until unlocked.
148     *
149     * @throws CampaignExistenceException If lazy loading failed.
150     * @return bool
151     */
152    public function isLocked() {
153        if ( $this->locked === null ) {
154            $this->loadBasicSettings();
155        }
156
157        return $this->locked;
158    }
159
160    /**
161     * Returns the archival status of the campaign. An archived campaign is not allowed to be
162     * edited.
163     *
164     * @throws CampaignExistenceException If lazy loading failed.
165     * @return bool
166     */
167    public function isArchived() {
168        if ( $this->archived === null ) {
169            $this->loadBasicSettings();
170        }
171
172        return $this->archived;
173    }
174
175    /**
176     * Returned the geotargeted status of this campaign. Will be true if GeoIP information should
177     * be used to determine user eligibility.
178     *
179     * @throws CampaignExistenceException If lazy loading failed.
180     * @return bool
181     */
182    public function isGeotargeted() {
183        if ( $this->geotargeted === null ) {
184            $this->loadBasicSettings();
185        }
186
187        return $this->geotargeted;
188    }
189
190    /**
191     * Get the number of buckets in this campaign.
192     *
193     * @throws CampaignExistenceException If lazy loading failed.
194     * @return int
195     */
196    public function getBuckets() {
197        if ( $this->buckets === null ) {
198            $this->loadBasicSettings();
199        }
200
201        return $this->buckets;
202    }
203
204    /**
205     * Load basic campaign settings from the database table cn_notices
206     *
207     * @throws CampaignExistenceException If the campaign doesn't exist
208     */
209    private function loadBasicSettings() {
210        $db = CNDatabase::getReplicaDb();
211
212        // What selector are we using?
213        if ( $this->id !== null ) {
214            $selector = [ 'not_id' => $this->id ];
215        } elseif ( $this->name !== null ) {
216            $selector = [ 'not_name' => $this->name ];
217        } else {
218            throw new CampaignExistenceException( "No valid database key available for campaign." );
219        }
220
221        // Get campaign info from database
222        $row = $db->newSelectQueryBuilder()
223            ->select( [
224                'not_id',
225                'not_name',
226                'not_start',
227                'not_end',
228                'not_enabled',
229                'not_preferred',
230                'not_locked',
231                'not_archived',
232                'not_geo',
233                'not_buckets',
234                'not_throttle',
235            ] )
236            ->from( 'cn_notices' )
237            ->where( $selector )
238            ->caller( __METHOD__ )
239            ->fetchRow();
240        if ( $row ) {
241            $this->id = $row->not_id;
242            $this->name = $row->not_name;
243            $this->start = new MWTimestamp( $row->not_start );
244            $this->end = new MWTimestamp( $row->not_end );
245            $this->enabled = (bool)$row->not_enabled;
246            $this->priority = (int)$row->not_preferred;
247            $this->locked = (bool)$row->not_locked;
248            $this->archived = (bool)$row->not_archived;
249            $this->geotargeted = (bool)$row->not_geo;
250            $this->buckets = (int)$row->not_buckets;
251            $this->throttle = (int)$row->not_throttle;
252        } else {
253            throw new CampaignExistenceException(
254                "Campaign could not be retrieved from database " .
255                    "with id '{$this->id}' or name '{$this->name}'"
256            );
257        }
258    }
259
260    /**
261     * See if a given campaign exists in the database
262     *
263     * @param string $campaignName
264     * @param bool $fromPrimary
265     *
266     * @return bool
267     */
268    public static function campaignExists( $campaignName, $fromPrimary = false ) {
269        $db = $fromPrimary ? CNDatabase::getPrimaryDb() : CNDatabase::getReplicaDb();
270
271        return (bool)$db->newSelectQueryBuilder()
272            ->select( 'not_name' )
273            ->from( 'cn_notices' )
274            ->where( [ 'not_name' => $campaignName ] )
275            ->caller( __METHOD__ )
276            ->fetchField();
277    }
278
279    /**
280     * Get a list of active/active-and-future campaigns and associated banners.
281     *
282     * @param bool $includeFuture Include campaigns that haven't started yet, too.
283     *
284     * @return array An array of campaigns, whose elements are arrays with campaign name,
285     * an array of associated banners, and campaign start and end times.
286     */
287    public static function getActiveCampaignsAndBanners( $includeFuture = false ) {
288        $dbr = CNDatabase::getReplicaDb();
289        $time = $dbr->timestamp();
290
291        $conds = [
292            $dbr->expr( 'notices.not_end', '>=', $dbr->timestamp( $time ) ),
293            'notices.not_enabled' => 1,
294            'notices.not_archived' => 0
295        ];
296
297        if ( !$includeFuture ) {
298            $conds[] = $dbr->expr( 'notices.not_start', '<=', $dbr->timestamp( $time ) );
299        }
300
301        // Query campaigns and banners at once
302        $dbRows = $dbr->newSelectQueryBuilder()
303            ->select( [
304                'notices.not_id',
305                'notices.not_name',
306                'notices.not_start',
307                'notices.not_end',
308                'templates.tmp_name'
309            ] )
310            ->from( 'cn_notices', 'notices' )
311            ->leftJoin( 'cn_assignments', 'assignments', 'notices.not_id = assignments.not_id' )
312            ->leftJoin( 'cn_templates', 'templates', 'assignments.tmp_id = templates.tmp_id' )
313            ->where( $conds )
314            ->caller( __METHOD__ )
315            ->fetchResultSet();
316
317        $campaigns = [];
318
319        foreach ( $dbRows as $dbRow ) {
320            $campaignId = $dbRow->not_id;
321
322            // The first time we see any campaign, create the corresponding outer K/V
323            // entry. Note that these keys don't make it into data structure we return.
324            $campaigns[$campaignId] ??= [
325                'name' => $dbRow->not_name,
326                'start' => $dbRow->not_start,
327                'end' => $dbRow->not_end,
328            ];
329
330            $bannerName = $dbRow->tmp_name;
331            // Automagically PHP creates the inner array as needed
332            if ( $bannerName ) {
333                $campaigns[$campaignId]['banners'][] = $bannerName;
334            }
335        }
336
337        return array_values( $campaigns );
338    }
339
340    /**
341     * Return settings for a campaign
342     *
343     * @param string $campaignName The name of the campaign
344     * @param bool $fromPrimary Whether to use the primary database or the replica database
345     *
346     * @return array|bool an array of settings or false if the campaign does not exist
347     */
348    public static function getCampaignSettings( $campaignName, $fromPrimary = false ) {
349        $db = $fromPrimary ? CNDatabase::getPrimaryDb() : CNDatabase::getReplicaDb();
350
351        // Get campaign info from database
352        $row = $db->newSelectQueryBuilder()
353            ->select( [
354                'not_id',
355                'not_start',
356                'not_end',
357                'not_enabled',
358                'not_preferred',
359                'not_locked',
360                'not_archived',
361                'not_geo',
362                'not_buckets',
363                'not_throttle',
364                'not_type',
365            ] )
366            ->from( 'cn_notices' )
367            ->where( [ 'not_name' => $campaignName ] )
368            ->caller( __METHOD__ )
369            ->fetchRow();
370        if ( !$row ) {
371            return false;
372        }
373
374        $banners = [];
375        // All we want are the banner names, weights, and buckets
376        foreach ( Banner::getCampaignBanners( $row->not_id, $fromPrimary ) as $banner ) {
377            $outKey = $banner['name'];
378            $banners[$outKey]['weight'] = $banner['weight'];
379            $banners[$outKey]['bucket'] = $banner['bucket'];
380        }
381
382        return [
383            'start'     => $row->not_start,
384            'end'       => $row->not_end,
385            'enabled'   => $row->not_enabled,
386            'preferred' => $row->not_preferred,
387            'locked'    => $row->not_locked,
388            'archived'  => $row->not_archived,
389            'geo'       => $row->not_geo,
390            'buckets'   => $row->not_buckets,
391            'throttle'  => $row->not_throttle,
392            'type'      => $row->not_type,
393            'projects'  => implode( ", ", self::getNoticeProjects( $campaignName, $fromPrimary ) ),
394            'languages' => implode( ", ", self::getNoticeLanguages( $campaignName, $fromPrimary ) ),
395            'countries' => implode( ", ", self::getNoticeCountries( $campaignName, $fromPrimary ) ),
396            'regions'   => implode( ", ", self::getNoticeRegions( $campaignName, $fromPrimary ) ),
397            // Encode into a JSON string for storage
398            'banners'   => FormatJson::encode( $banners ),
399            'mixins'    => FormatJson::encode( self::getCampaignMixins( $campaignName, true, $fromPrimary ) ),
400        ];
401    }
402
403    /**
404     * Get all campaign configurations as of timestamp $ts
405     *
406     * @todo Only used in tests
407     *
408     * @param int $ts
409     * @return array of settings structs having the following properties:
410     *     id
411     *     name
412     *     enabled
413     *     projects: array of sister project names
414     *     languages: array of language codes
415     *     countries: array of country codes
416     *     regions: array of region codes
417     *     preferred: campaign priority
418     *     geo: is geolocated?
419     *     buckets: number of buckets
420     *     banners: array of banner objects, as returned by getHistoricalBanner,
421     *       plus the following information from the parent campaign:
422     *         campaign: name of the campaign
423     *         campaign_z_index
424     *         campaign_num_buckets
425     *         campaign_throttle
426     */
427    public static function getHistoricalCampaigns( $ts ) {
428        $dbr = CNDatabase::getReplicaDb();
429
430        $logIds = $dbr->newSelectQueryBuilder()
431            ->select( [
432                "log_id" => "MAX(notlog_id)",
433            ] )
434            ->from( 'cn_notice_log' )
435            ->where( [
436                $dbr->expr( 'notlog_timestamp', '<=', $dbr->timestamp( $ts ) )
437            ] )
438            ->groupBy( 'notlog_not_id' )
439            ->caller( __METHOD__ )
440            ->fetchFieldValues();
441
442        $campaigns = [];
443        foreach ( $logIds as $logId ) {
444            $campaignRow = $dbr->newSelectQueryBuilder()
445                ->select( [
446                    "id" => "notlog_not_id",
447                    "name" => "notlog_not_name",
448                    "enabled" => "notlog_end_enabled",
449                    "projects" => "notlog_end_projects",
450                    "languages" => "notlog_end_languages",
451                    "countries" => "notlog_end_countries",
452                    "regions" => "notlog_end_regions",
453                    "preferred" => "notlog_end_preferred",
454                    "geotargeted" => "notlog_end_geo",
455                    "banners" => "notlog_end_banners",
456                    "bucket_count" => "notlog_end_buckets",
457                    "throttle" => "notlog_end_throttle",
458                ] )
459                ->from( 'cn_notice_log' )
460                ->where( [
461                    'notlog_id' => $logId,
462                    $dbr->expr( 'notlog_end_start', '<=', $dbr->timestamp( $ts ) ),
463                    $dbr->expr( 'notlog_end_end', '>=', $dbr->timestamp( $ts ) ),
464                    'notlog_end_enabled' => 1,
465                ] )
466                ->caller( __METHOD__ )
467                ->fetchRow();
468
469            if ( !$campaignRow ) {
470                continue;
471            }
472            $campaign = (array)$campaignRow;
473            $campaign['projects'] = explode( ", ", $campaign['projects'] );
474            $campaign['languages'] = explode( ", ", $campaign['languages'] );
475            $campaign['countries'] = explode( ", ", $campaign['countries'] );
476            $campaign['regions'] = explode( ", ", $campaign['regions'] );
477            if ( $campaign['banners'] === null ) {
478                $campaign['banners'] = [];
479            } else {
480                $campaign['banners'] = FormatJson::decode( $campaign['banners'], true );
481                if ( !is_array( current( $campaign['banners'] ) ) ) {
482                    // Old log format; only had weight
483                    foreach ( $campaign['banners'] as $key => &$value ) {
484                        $value = [
485                            'weight' => $value,
486                            'bucket' => 0
487                        ];
488                    }
489                }
490            }
491
492            // Fix for legacy logs before bucketing
493            $campaign['bucket_count'] ??= 1;
494
495            foreach ( $campaign['banners'] as $name => &$banner ) {
496                $historical_banner = Banner::getHistoricalBanner( $name, $ts );
497
498                if ( $historical_banner === null ) {
499                    // FIXME: crazy hacks
500                    $historical_banner = Banner::getBannerSettings( $name );
501                    $historical_banner['label'] = wfMessage( 'centralnotice-damaged-log', $name )->text();
502                    $historical_banner['display_anon'] = $historical_banner['anon'];
503                    $historical_banner['display_account'] = $historical_banner['account'];
504                    $historical_banner['devices'] = [ 'desktop' ];
505                }
506                $banner['name'] = $name;
507                $banner['label'] = $name;
508
509                $campaign_info = [
510                    'campaign' => $campaign['name'],
511                    'campaign_z_index' => $campaign['preferred'],
512                    'campaign_num_buckets' => $campaign['bucket_count'],
513                    'campaign_throttle' => $campaign['throttle'],
514                ];
515
516                $banner = array_merge( $banner, $campaign_info, $historical_banner );
517            }
518
519            $campaigns[] = $campaign;
520        }
521        return $campaigns;
522    }
523
524    /**
525     * Retrieve campaign mixins settings for this campaign.
526     *
527     * If $compact is true, retrieve only enabled mixins, and return a compact
528     * data structure in which keys are mixin names and values are parameter
529     * settings.
530     *
531     * If $compact is false, mixins that were once enabled for this campaign but
532     * are now disabled will be included, showing their last parameter settings.
533     * The data structure will be an array whose keys are mixin names and whose
534     * values are arrays with 'enabled' and 'parameters' keys. Note that mixins
535     * that were never enabled for this campaign will be omitted.
536     *
537     * @param string $campaignName
538     * @param bool $compact
539     * @param bool $fromPrimary
540     *
541     * @return array
542     */
543    public static function getCampaignMixins( $campaignName, $compact = false, $fromPrimary = false ) {
544        global $wgCentralNoticeCampaignMixins;
545
546        $db = $fromPrimary ? CNDatabase::getPrimaryDb() : CNDatabase::getReplicaDb();
547
548        // Prepare query conditions
549        $conds = [ 'notices.not_name' => $campaignName ];
550        if ( $compact ) {
551            $conds['notice_mixins.nmxn_enabled'] = 1;
552        }
553
554        $dbRows = $db->newSelectQueryBuilder()
555            ->select( [
556                'notice_mixins.nmxn_mixin_name',
557                'notice_mixins.nmxn_enabled',
558                'notice_mixin_params.nmxnp_param_name',
559                'notice_mixin_params.nmxnp_param_value'
560            ] )
561            ->from( 'cn_notices', 'notices' )
562            ->join( 'cn_notice_mixins', 'notice_mixins', 'notices.not_id = notice_mixins.nmxn_not_id' )
563            ->leftJoin( 'cn_notice_mixin_params', 'notice_mixin_params',
564                'notice_mixins.nmxn_id = notice_mixin_params.nmxnp_notice_mixin_id' )
565            ->where( $conds )
566            ->caller( __METHOD__ )
567            ->fetchResultSet();
568
569        // Build up the results
570        // We expect a row for every parameter name-value pair for every mixin,
571        // and maybe some with null name-value pairs (for mixins with no
572        // parameters).
573        $campaignMixins = [];
574        foreach ( $dbRows as $dbRow ) {
575            $mixinName = $dbRow->nmxn_mixin_name;
576
577            // A mixin may have been removed from the code but may still
578            // leave stuff in the database. In that case, skip it!
579            if ( !isset( $wgCentralNoticeCampaignMixins[$mixinName] ) ) {
580                continue;
581            }
582
583            // First time we have a result row for this mixin?
584            if ( !isset( $campaignMixins[$mixinName] ) ) {
585                // Data structure depends on $compact
586                if ( $compact ) {
587                    $campaignMixins[$mixinName] = [];
588
589                } else {
590                    $campaignMixins[$mixinName] = [
591                        'enabled' => (bool)$dbRow->nmxn_enabled,
592                        'parameters' => []
593                    ];
594                }
595            }
596
597            // If there are mixin params in this row, add them in
598            if ( $dbRow->nmxnp_param_name !== null ) {
599                $paramName = $dbRow->nmxnp_param_name;
600                $mixinDef = $wgCentralNoticeCampaignMixins[$mixinName];
601
602                // Handle mixin parameters being removed, too
603                if ( !isset( $mixinDef['parameters'][$paramName] ) ) {
604                    continue;
605                }
606
607                $paramType = $mixinDef['parameters'][$paramName]['type'];
608
609                switch ( $paramType ) {
610                    case 'string':
611                        $paramVal = $dbRow->nmxnp_param_value;
612                        break;
613
614                    case 'integer':
615                        $paramVal = intval( $dbRow->nmxnp_param_value );
616                        break;
617
618                    case 'float':
619                        $paramVal = floatval( $dbRow->nmxnp_param_value );
620                        break;
621
622                    case 'boolean':
623                        $paramVal = ( $dbRow->nmxnp_param_value === 'true' );
624                        break;
625
626                    case 'json':
627                        $paramVal = json_decode( $dbRow->nmxnp_param_value );
628
629                        if ( $paramVal === null ) {
630                            wfLogWarning( 'Couldn\'t decode json param ' . $paramName
631                                . ' for mixin ' . $mixinName . ' in campaign ' .
632                                $campaignName . '.' );
633
634                            // In this case, it's fine to emit a null value for the
635                            // parameter. Both Admin UI and subscribing client-side
636                            // code should handle it gracefully and warn in the console.
637                            // TODO Handle this better, server-side.
638                        }
639
640                        break;
641
642                    default:
643                        throw new DomainException(
644                            'Unknown parameter type ' . $paramType );
645                }
646
647                // Again, data structure depends on $compact
648                if ( $compact ) {
649                    $campaignMixins[$mixinName][$paramName] = $paramVal;
650                } else {
651                    $campaignMixins[$mixinName]['parameters'][$paramName] = $paramVal;
652                }
653            }
654        }
655
656        // Ensure consistent ordering, since it's needed for
657        // CNChoiceDataResourceLoaderModule (which gets this data via
658        // ChoiceDataProvider) for consistent RL module hashes.
659
660        array_walk( $campaignMixins, static function ( &$campaignMixin ) {
661            ksort( $campaignMixin );
662        } );
663
664        ksort( $campaignMixins );
665
666        return $campaignMixins;
667    }
668
669    /**
670     * Update enabled or disabled status and parameters for a campaign mixin,
671     * for a given campaign.
672     *
673     * @param string $campaignName
674     * @param string $mixinName
675     * @param bool $enable
676     * @param array|null $params For mixins with no parameters, set to an empty array.
677     */
678    public static function updateCampaignMixins(
679        $campaignName, $mixinName, $enable, $params = null
680    ) {
681        global $wgCentralNoticeCampaignMixins;
682
683        // TODO Error handling!
684
685        $dbw = CNDatabase::getPrimaryDb();
686
687        // Get the campaign ID
688        // Note: the need to fetch the ID here highlights the need for some
689        // kind of ORM.
690        $noticeId = self::getNoticeId( $campaignName, $dbw );
691
692        if ( $enable ) {
693            if ( $params === null ) {
694                throw new InvalidArgumentException( 'Paremeters info required to enable mixin ' .
695                    $mixinName . ' for campaign ' . $campaignName );
696            }
697
698            $dbw->newInsertQueryBuilder()
699                ->insertInto( 'cn_notice_mixins' )
700                ->row( [
701                    'nmxn_not_id' => $noticeId,
702                    'nmxn_mixin_name' => $mixinName,
703                    'nmxn_enabled' => 1
704                ] )
705                ->onDuplicateKeyUpdate()
706                ->uniqueIndexFields( [ 'nmxn_not_id', 'nmxn_mixin_name' ] )
707                ->set( [
708                    'nmxn_enabled' => 1
709                ] )
710                ->caller( __METHOD__ )
711                ->execute();
712
713            $noticeMixinId = $dbw->newSelectQueryBuilder()
714                ->select( 'nmxn_id' )
715                ->from( 'cn_notice_mixins' )
716                ->where( [
717                    'nmxn_not_id' => $noticeId,
718                    'nmxn_mixin_name' => $mixinName
719                ] )
720                ->caller( __METHOD__ )
721                ->fetchField();
722
723            foreach ( $params as $paramName => $paramVal ) {
724                $mixinDef = $wgCentralNoticeCampaignMixins[$mixinName];
725
726                // Handle an undefined parameter. Not likely to happen, maybe
727                // in the middle of a deploy that removes a parameter.
728                if ( !isset( $mixinDef['parameters'][$paramName] ) ) {
729                    wfLogWarning( 'No definition found for the parameter '
730                        . $paramName . ' for the campaign mixn ' .
731                        $mixinName . '.' );
732
733                    continue;
734                }
735
736                // Munge boolean params for database storage. (Other types
737                // should end up as strings, which will be fine.)
738                if ( $mixinDef['parameters'][$paramName]['type'] === 'boolean' ) {
739                    $paramVal = ( $paramVal ? 'true' : 'false' );
740                }
741
742                $dbw->newInsertQueryBuilder()
743                    ->insertInto( 'cn_notice_mixin_params' )
744                    ->row( [
745                        'nmxnp_notice_mixin_id' => $noticeMixinId,
746                        'nmxnp_param_name' => $paramName,
747                        'nmxnp_param_value' => $paramVal
748                    ] )
749                    ->onDuplicateKeyUpdate()
750                    ->uniqueIndexFields( [ 'nmxnp_notice_mixin_id', 'nmxnp_param_name' ] )
751                    ->set( [
752                        'nmxnp_param_value' => $paramVal
753                    ] )
754                    ->caller( __METHOD__ )
755                    ->execute();
756            }
757
758        } else {
759
760            // When we disable a mixin, just set enabled to false; since we keep
761            // the old parameter values in case the mixin is re-enabled, we also
762            // keep the row in this table, since the id is used in the param
763            // table.
764            $dbw->newUpdateQueryBuilder()
765                ->update( 'cn_notice_mixins' )
766                ->set( [ 'nmxn_enabled' => 0 ] )
767                ->where( [
768                    'nmxn_not_id' => $noticeId,
769                    'nmxn_mixin_name' => $mixinName
770                ] )
771                ->caller( __METHOD__ )
772                ->execute();
773        }
774    }
775
776    /**
777     * Add a new campaign to the database
778     *
779     * @param string $noticeName Name of the campaign
780     * @param bool $enabled Boolean setting, true or false
781     * @param string $startTs Campaign start in UTC
782     * @param array $projects Targeted project types (wikipedia, wikibooks, etc.)
783     * @param array $project_languages Targeted project languages (en, de, etc.)
784     * @param bool $geotargeted Boolean setting, true or false
785     * @param array $geo_countries Targeted countries
786     * @param array $geo_regions Targeted regions in format CountryCode_RegionCode
787     * @param int $throttle limit allocations, 0 - 100
788     * @param int $priority priority level, LOW_PRIORITY - EMERGENCY_PRIORITY
789     * @param User $user User adding the campaign
790     * @param string|null $type Type of campaign
791     * @param string|null $summary Change summary provided by the user
792     * @return int|string noticeId on success, or message key for error
793     */
794    public static function addCampaign( $noticeName, $enabled, $startTs, $projects,
795        $project_languages, $geotargeted, $geo_countries, $geo_regions, $throttle,
796        $priority, $user, $type, $summary = null
797    ) {
798        $noticeName = trim( $noticeName );
799        if ( self::campaignExists( $noticeName, true ) ) {
800            return 'centralnotice-notice-exists';
801        } elseif ( !$projects ) {
802            return 'centralnotice-no-project';
803        } elseif ( !$project_languages ) {
804            return 'centralnotice-no-language';
805        }
806
807        $dbw = CNDatabase::getPrimaryDb();
808        $dbw->startAtomic( __METHOD__ );
809
810        $endTime = strtotime( '+1 hour', (int)wfTimestamp( TS_UNIX, $startTs ) );
811        $endTs = wfTimestamp( TS_MW, $endTime );
812
813        $dbw->newInsertQueryBuilder()
814            ->insertInto( 'cn_notices' )
815            ->row( [
816                'not_name'      => $noticeName,
817                'not_enabled'   => (int)$enabled,
818                'not_start'     => $dbw->timestamp( $startTs ),
819                'not_end'       => $dbw->timestamp( $endTs ),
820                'not_geo'       => (int)$geotargeted,
821                'not_throttle'  => $throttle,
822                'not_preferred' => $priority,
823                'not_type'      => $type
824            ] )
825            ->caller( __METHOD__ )
826            ->execute();
827        $not_id = $dbw->insertId();
828
829        if ( $not_id ) {
830            // Do multi-row insert for campaign projects
831            $insertArray = [];
832            foreach ( $projects as $project ) {
833                $insertArray[] = [ 'np_notice_id' => $not_id, 'np_project' => $project ];
834            }
835            $dbw->newInsertQueryBuilder()
836                ->insertInto( 'cn_notice_projects' )
837                ->ignore()
838                ->rows( $insertArray )
839                ->caller( __METHOD__ )
840                ->execute();
841
842            // Do multi-row insert for campaign languages
843            $insertArray = [];
844            foreach ( $project_languages as $code ) {
845                $insertArray[] = [ 'nl_notice_id' => $not_id, 'nl_language' => $code ];
846            }
847            $dbw->newInsertQueryBuilder()
848                ->insertInto( 'cn_notice_languages' )
849                ->ignore()
850                ->rows( $insertArray )
851                ->caller( __METHOD__ )
852                ->execute();
853
854            if ( $geotargeted ) {
855
856                // Do multi-row insert for campaign countries
857                if ( $geo_countries ) {
858                    $insertArray = [];
859                    foreach ( $geo_countries as $code ) {
860                        $insertArray[] = [ 'nc_notice_id' => $not_id, 'nc_country' => $code ];
861                    }
862                    $dbw->newInsertQueryBuilder()
863                        ->insertInto( 'cn_notice_countries' )
864                        ->ignore()
865                        ->rows( $insertArray )
866                        ->caller( __METHOD__ )
867                        ->execute();
868                }
869
870                // Do multi-row insert for campaign regions
871                if ( $geo_regions ) {
872                    $insertArray = [];
873                    foreach ( $geo_regions as $code ) {
874                        $insertArray[] = [ 'nr_notice_id' => $not_id, 'nr_region' => $code ];
875                    }
876                    $dbw->newInsertQueryBuilder()
877                        ->insertInto( 'cn_notice_regions' )
878                        ->ignore()
879                        ->rows( $insertArray )
880                        ->caller( __METHOD__ )
881                        ->execute();
882                }
883
884            }
885
886            $dbw->endAtomic( __METHOD__ );
887
888            // Log the creation of the campaign
889            $beginSettings = [];
890            $endSettings = [
891                'projects'  => implode( ", ", $projects ),
892                'languages' => implode( ", ", $project_languages ),
893                'countries' => implode( ", ", $geo_countries ),
894                'regions'   => implode( ", ", $geo_regions ),
895                'start'     => $dbw->timestamp( $startTs ),
896                'end'       => $dbw->timestamp( $endTs ),
897                'enabled'   => (int)$enabled,
898                'preferred' => 0,
899                'locked'    => 0,
900                'archived'  => 0,
901                'geo'       => (int)$geotargeted,
902                'throttle'  => $throttle,
903                'type'      => $type
904            ];
905            self::processAfterCampaignChange( 'created', $not_id, $noticeName, $user,
906                $beginSettings, $endSettings, $summary );
907
908            return $not_id;
909        }
910
911        throw new RuntimeException( 'insertId() did not return a value.' );
912    }
913
914    /**
915     * Remove a campaign from the database
916     *
917     * @todo Only used in tests
918     *
919     * @param string $campaignName Name of the campaign
920     * @param User $user User removing the campaign
921     *
922     * @return bool|string True on success, string with message key for error
923     */
924    public static function removeCampaign( $campaignName, $user ) {
925        $dbr = CNDatabase::getReplicaDb();
926
927        $locked = $dbr->newSelectQueryBuilder()
928            ->select( 'not_locked' )
929            ->from( 'cn_notices' )
930            ->where( [ 'not_name' => $campaignName ] )
931            ->caller( __METHOD__ )
932            ->fetchField();
933        if ( $locked === false ) {
934            return 'centralnotice-remove-notice-doesnt-exist';
935        } elseif ( $locked ) {
936            return 'centralnotice-notice-is-locked';
937        }
938
939        self::removeCampaignByName( $campaignName, $user );
940        return true;
941    }
942
943    /**
944     * @param string $campaignName
945     * @param User $user
946     */
947    private static function removeCampaignByName( $campaignName, $user ) {
948        $dbw = CNDatabase::getPrimaryDb();
949
950        // Log the removal of the campaign
951        $campaignId = self::getNoticeId( $campaignName, $dbw );
952        self::processAfterCampaignChange( 'removed', $campaignId, $campaignName, $user );
953
954        $dbw->startAtomic( __METHOD__ );
955        $dbw->newDeleteQueryBuilder()
956            ->deleteFrom( 'cn_assignments' )
957            ->where( [ 'not_id' => $campaignId ] )
958            ->caller( __METHOD__ )
959            ->execute();
960        $dbw->newDeleteQueryBuilder()
961            ->deleteFrom( 'cn_notices' )
962            ->where( [ 'not_name' => $campaignName ] )
963            ->caller( __METHOD__ )
964            ->execute();
965        $dbw->newDeleteQueryBuilder()
966            ->deleteFrom( 'cn_notice_languages' )
967            ->where( [ 'nl_notice_id' => $campaignId ] )
968            ->caller( __METHOD__ )
969            ->execute();
970        $dbw->newDeleteQueryBuilder()
971            ->deleteFrom( 'cn_notice_projects' )
972            ->where( [ 'np_notice_id' => $campaignId ] )
973            ->caller( __METHOD__ )
974            ->execute();
975        $dbw->newDeleteQueryBuilder()
976            ->deleteFrom( 'cn_notice_countries' )
977            ->where( [ 'nc_notice_id' => $campaignId ] )
978            ->caller( __METHOD__ )
979            ->execute();
980        $dbw->newDeleteQueryBuilder()
981            ->deleteFrom( 'cn_notice_regions' )
982            ->where( [ 'nr_notice_id' => $campaignId ] )
983            ->caller( __METHOD__ )
984            ->execute();
985        $dbw->endAtomic( __METHOD__ );
986    }
987
988    /**
989     * Assign a banner to a campaign at a certain weight
990     *
991     * @param string $noticeName
992     * @param string $templateName
993     * @param int $weight
994     * @param int $bucket
995     * @return bool|string True on success, string with message key for error
996     */
997    public static function addTemplateTo( $noticeName, $templateName, $weight, $bucket = 0 ) {
998        $dbw = CNDatabase::getPrimaryDb();
999
1000        $noticeId = self::getNoticeId( $noticeName, $dbw );
1001        $templateId = Banner::fromName( $templateName )->getId();
1002        $exists = (bool)$dbw->newSelectQueryBuilder()
1003            ->select( 'asn_id' )
1004            ->from( 'cn_assignments' )
1005            ->where( [
1006                'tmp_id' => $templateId,
1007                'not_id' => $noticeId
1008            ] )
1009            ->caller( __METHOD__ )
1010            ->fetchField();
1011
1012        if ( $exists ) {
1013            return 'centralnotice-template-already-exists';
1014        }
1015
1016        $dbw->newInsertQueryBuilder()
1017            ->insertInto( 'cn_assignments' )
1018            ->row( [
1019                'tmp_id'     => $templateId,
1020                'tmp_weight' => $weight,
1021                'not_id'     => $noticeId,
1022                'asn_bucket' => $bucket,
1023            ] )
1024            ->caller( __METHOD__ )
1025            ->execute();
1026
1027        return true;
1028    }
1029
1030    /**
1031     * Remove a banner assignment from a campaign
1032     *
1033     * @param string $noticeName
1034     * @param string $templateName
1035     */
1036    public static function removeTemplateFor( $noticeName, $templateName ) {
1037        $dbw = CNDatabase::getPrimaryDb();
1038        $noticeId = self::getNoticeId( $noticeName, $dbw );
1039        $templateId = Banner::fromName( $templateName )->getId();
1040        $dbw->newDeleteQueryBuilder()
1041            ->deleteFrom( 'cn_assignments' )
1042            ->where( [ 'tmp_id' => $templateId, 'not_id' => $noticeId ] )
1043            ->caller( __METHOD__ )
1044            ->execute();
1045    }
1046
1047    /**
1048     * Lookup the ID for a campaign based on the campaign name
1049     *
1050     * @param string $noticeName
1051     * @param IReadableDatabase $db
1052     * @return int|null
1053     */
1054    public static function getNoticeId( $noticeName, IReadableDatabase $db ) {
1055        return (int)$db->newSelectQueryBuilder()
1056            ->select( 'not_id' )
1057            ->from( 'cn_notices' )
1058            ->where( [ 'not_name' => $noticeName ] )
1059            ->caller( __METHOD__ )
1060            ->fetchField() ?: null;
1061    }
1062
1063    /**
1064     * @param string $noticeName
1065     * @param bool $fromPrimary
1066     * @return string[]
1067     */
1068    public static function getNoticeProjects( $noticeName, $fromPrimary = false ) {
1069        $db = $fromPrimary ? CNDatabase::getPrimaryDb() : CNDatabase::getReplicaDb();
1070        $noticeId = self::getNoticeId( $noticeName, $db );
1071        $projects = [];
1072        if ( $noticeId ) {
1073            $projects = $db->newSelectQueryBuilder()
1074                ->select( 'np_project' )
1075                ->from( 'cn_notice_projects' )
1076                ->where( [ 'np_notice_id' => $noticeId ] )
1077                ->caller( __METHOD__ )
1078                ->fetchFieldValues();
1079        }
1080        sort( $projects );
1081        return $projects;
1082    }
1083
1084    /**
1085     * @param string $noticeName
1086     * @param bool $fromPrimary
1087     *
1088     * @return string[]
1089     */
1090    public static function getNoticeLanguages( $noticeName, $fromPrimary = false ) {
1091        $db = $fromPrimary ? CNDatabase::getPrimaryDb() : CNDatabase::getReplicaDb();
1092        $noticeId = self::getNoticeId( $noticeName, $db );
1093        $languages = [];
1094        if ( $noticeId ) {
1095            $languages = $db->newSelectQueryBuilder()
1096                ->select( 'nl_language' )
1097                ->from( 'cn_notice_languages' )
1098                ->where( [ 'nl_notice_id' => $noticeId ] )
1099                ->caller( __METHOD__ )
1100                ->fetchFieldValues();
1101        }
1102        sort( $languages );
1103        return $languages;
1104    }
1105
1106    /**
1107     * @param string $noticeName
1108     * @param bool $fromPrimary
1109     *
1110     * @return string[]
1111     */
1112    public static function getNoticeCountries( $noticeName, $fromPrimary = false ) {
1113        $db = $fromPrimary ? CNDatabase::getPrimaryDb() : CNDatabase::getReplicaDb();
1114        $noticeId = self::getNoticeId( $noticeName, $db );
1115        $countries = [];
1116        if ( $noticeId ) {
1117            $countries = $db->newSelectQueryBuilder()
1118                ->select( 'nc_country' )
1119                ->from( 'cn_notice_countries' )
1120                ->where( [ 'nc_notice_id' => $noticeId ] )
1121                ->caller( __METHOD__ )
1122                ->fetchFieldValues();
1123        }
1124        sort( $countries );
1125        return $countries;
1126    }
1127
1128    /**
1129     * @param string $noticeName
1130     * @param bool $fromPrimary
1131     *
1132     * @return string[]
1133     */
1134    public static function getNoticeRegions( $noticeName, $fromPrimary = false ) {
1135        $db = $fromPrimary ? CNDatabase::getPrimaryDb() : CNDatabase::getReplicaDb();
1136        $noticeId = self::getNoticeId( $noticeName, $db );
1137        $regions = [];
1138        if ( $noticeId ) {
1139            $regions = $db->newSelectQueryBuilder()
1140                ->select( 'nr_region' )
1141                ->from( 'cn_notice_regions' )
1142                ->where( [ 'nr_notice_id' => $noticeId ] )
1143                ->caller( __METHOD__ )
1144                ->fetchFieldValues();
1145        }
1146        sort( $regions );
1147        return $regions;
1148    }
1149
1150    /**
1151     * Returns a Title object to use in obtaining the URL of a campaign.
1152     * @return Title
1153     */
1154    public static function getTitleForURL() {
1155        return SpecialPage::getTitleFor( 'CentralNotice' );
1156    }
1157
1158    /**
1159     * Returns an array with key/value pairs for a query string, to use in obtaining the
1160     * URL of the campaign with the specified name.
1161     *
1162     * @param string $campaignName
1163     * @return string[]
1164     */
1165    public static function getQueryForURL( $campaignName ) {
1166        return [
1167            'subaction' => 'noticeDetail',
1168            'notice' => $campaignName
1169        ];
1170    }
1171
1172    /**
1173     * Returns the canonical URL for campaign with the specified name (as returned by
1174     * Title::getCanonicalURL()).
1175     *
1176     * Usage note: This method should be considered part of CentralNotice's public API.
1177     * It's called from outside the extension in EventBus::onCentralNoticeCampaignChange().
1178     *
1179     * @param string $campaignName
1180     * @return string
1181     */
1182    public static function getCanonicalURL( $campaignName ) {
1183        return self::getTitleForURL()->getCanonicalURL(
1184            self::getQueryForURL( $campaignName ) );
1185    }
1186
1187    /**
1188     * @param string $noticeName
1189     * @param string $start Date
1190     * @param string $end Date
1191     * @return bool|string True on success, string with message key for error
1192     */
1193    public static function updateNoticeDate( $noticeName, $start, $end ) {
1194        // Start/end don't line up
1195        if ( $start > $end || $end < $start ) {
1196            return 'centralnotice-invalid-date-range';
1197        }
1198
1199        // Invalid campaign name
1200        if ( !self::campaignExists( $noticeName, true ) ) {
1201            return 'centralnotice-notice-doesnt-exist';
1202        }
1203
1204        $dbw = CNDatabase::getPrimaryDb();
1205
1206        // Overlap over a date within the same project and language
1207        $startDate = $dbw->timestamp( $start );
1208        $endDate = $dbw->timestamp( $end );
1209
1210        $dbw->newUpdateQueryBuilder()
1211            ->update( 'cn_notices' )
1212            ->set( [
1213                'not_start' => $startDate,
1214                'not_end'   => $endDate
1215            ] )
1216            ->where( [ 'not_name' => $noticeName ] )
1217            ->caller( __METHOD__ )
1218            ->execute();
1219
1220        return true;
1221    }
1222
1223    /**
1224     * Update a boolean setting on a campaign
1225     *
1226     * @param string $noticeName Name of the campaign
1227     * @param string $settingName Name of a boolean setting (enabled, locked, or geo)
1228     * @param bool $settingValue Value to use for the setting, true or false
1229     */
1230    public static function setBooleanCampaignSetting( $noticeName, $settingName, $settingValue ) {
1231        if ( !self::campaignExists( $noticeName, true ) ) {
1232            // Exit quietly since campaign may have been deleted at the same time.
1233            return;
1234        }
1235
1236        $settingName = strtolower( $settingName );
1237        if ( !self::settingNameIsValid( $settingName ) ) {
1238            throw new InvalidArgumentException( "Invalid setting name" );
1239        }
1240        $dbw = CNDatabase::getPrimaryDb();
1241        $dbw->newUpdateQueryBuilder()
1242            ->update( 'cn_notices' )
1243            ->set( [ 'not_' . $settingName => (int)$settingValue ] )
1244            ->where( [ 'not_name' => $noticeName ] )
1245            ->caller( __METHOD__ )
1246            ->execute();
1247    }
1248
1249    /**
1250     * Updates a numeric setting on a campaign
1251     *
1252     * @param string $noticeName Name of the campaign
1253     * @param string $settingName Name of a numeric setting (preferred)
1254     * @param int $settingValue Value to use
1255     * @param int $max The max that the value can take, default 1
1256     * @param int $min The min that the value can take, default 0
1257     * @throws InvalidArgumentException|RangeException
1258     */
1259    public static function setNumericCampaignSetting(
1260        $noticeName, $settingName, $settingValue, $max = 1, $min = 0
1261    ) {
1262        if ( $max <= $min ) {
1263            throw new RangeException( 'Max must be greater than min.' );
1264        }
1265
1266        if ( !is_numeric( $settingValue ) ) {
1267            throw new InvalidArgumentException( 'Setting value must be numeric.' );
1268        }
1269
1270        if ( $settingValue > $max ) {
1271            $settingValue = $max;
1272        }
1273
1274        if ( $settingValue < $min ) {
1275            $settingValue = $min;
1276        }
1277
1278        if ( !self::campaignExists( $noticeName, true ) ) {
1279            // Exit quietly since campaign may have been deleted at the same time.
1280            return;
1281        }
1282
1283        $settingName = strtolower( $settingName );
1284        if ( !self::settingNameIsValid( $settingName ) ) {
1285            throw new InvalidArgumentException( "Invalid setting name" );
1286        }
1287        $dbw = CNDatabase::getPrimaryDb();
1288        $dbw->newUpdateQueryBuilder()
1289            ->update( 'cn_notices' )
1290            ->set( [ 'not_' . $settingName => $settingValue ] )
1291            ->where( [ 'not_name' => $noticeName ] )
1292            ->caller( __METHOD__ )
1293            ->execute();
1294    }
1295
1296    /**
1297     * Updates the weight of a banner in a campaign.
1298     *
1299     * @param string $noticeName Name of the campaign to update
1300     * @param int $templateId ID of the banner in the campaign
1301     * @param int $weight New banner weight
1302     */
1303    public static function updateWeight( $noticeName, $templateId, $weight ) {
1304        $dbw = CNDatabase::getPrimaryDb();
1305        $noticeId = self::getNoticeId( $noticeName, $dbw );
1306        $dbw->newUpdateQueryBuilder()
1307            ->update( 'cn_assignments' )
1308            ->set( [ 'tmp_weight' => $weight ] )
1309            ->where( [
1310                'tmp_id' => $templateId,
1311                'not_id' => $noticeId
1312            ] )
1313            ->caller( __METHOD__ )
1314            ->execute();
1315    }
1316
1317    /**
1318     * Updates the bucket of a banner in a campaign. Buckets alter what is shown to the end user
1319     * which can affect the relative weight of the banner in a campaign.
1320     *
1321     * @param string $noticeName Name of the campaign to update
1322     * @param int $templateId ID of the banner in the campaign
1323     * @param int $bucket New bucket number
1324     */
1325    public static function updateBucket( $noticeName, $templateId, $bucket ) {
1326        $dbw = CNDatabase::getPrimaryDb();
1327        $noticeId = self::getNoticeId( $noticeName, $dbw );
1328        $dbw->newUpdateQueryBuilder()
1329            ->update( 'cn_assignments' )
1330            ->set( [ 'asn_bucket' => $bucket ] )
1331            ->where( [
1332                'tmp_id' => $templateId,
1333                'not_id' => $noticeId
1334            ] )
1335            ->caller( __METHOD__ )
1336            ->execute();
1337    }
1338
1339    /**
1340     * @param string $notice
1341     * @param string[] $newProjects
1342     */
1343    public static function updateProjects( $notice, $newProjects ) {
1344        $dbw = CNDatabase::getPrimaryDb();
1345        $dbw->startAtomic( __METHOD__ );
1346
1347        // Get the previously assigned projects
1348        $oldProjects = self::getNoticeProjects( $notice, true );
1349
1350        // Get the notice id
1351        $noticeId = self::getNoticeId( $notice, $dbw );
1352
1353        // Add newly assigned projects
1354        $addProjects = array_diff( $newProjects, $oldProjects );
1355        $insertArray = [];
1356        foreach ( $addProjects as $project ) {
1357            $insertArray[] = [ 'np_notice_id' => $noticeId, 'np_project' => $project ];
1358        }
1359        if ( $insertArray ) {
1360            $dbw->newInsertQueryBuilder()
1361                ->insertInto( 'cn_notice_projects' )
1362                ->ignore()
1363                ->rows( $insertArray )
1364                ->caller( __METHOD__ )
1365                ->execute();
1366        }
1367
1368        // Remove disassociated projects
1369        $removeProjects = array_diff( $oldProjects, $newProjects );
1370        if ( $removeProjects ) {
1371            $dbw->newDeleteQueryBuilder()
1372                ->deleteFrom( 'cn_notice_projects' )
1373                ->where( [ 'np_notice_id' => $noticeId, 'np_project' => $removeProjects ] )
1374                ->caller( __METHOD__ )
1375                ->execute();
1376        }
1377
1378        $dbw->endAtomic( __METHOD__ );
1379    }
1380
1381    /**
1382     * @param string $notice
1383     * @param string[] $newLanguages
1384     */
1385    public static function updateProjectLanguages( $notice, $newLanguages ) {
1386        $dbw = CNDatabase::getPrimaryDb();
1387        $dbw->startAtomic( __METHOD__ );
1388
1389        // Get the previously assigned languages
1390        $oldLanguages = self::getNoticeLanguages( $notice, true );
1391
1392        // Get the notice id
1393        $noticeId = self::getNoticeId( $notice, $dbw );
1394
1395        // Add newly assigned languages
1396        $addLanguages = array_diff( $newLanguages, $oldLanguages );
1397        $insertArray = [];
1398        foreach ( $addLanguages as $code ) {
1399            $insertArray[] = [ 'nl_notice_id' => $noticeId, 'nl_language' => $code ];
1400        }
1401        if ( $insertArray ) {
1402            $dbw->newInsertQueryBuilder()
1403                ->insertInto( 'cn_notice_languages' )
1404                ->ignore()
1405                ->rows( $insertArray )
1406                ->caller( __METHOD__ )
1407                ->execute();
1408        }
1409
1410        // Remove disassociated languages
1411        $removeLanguages = array_diff( $oldLanguages, $newLanguages );
1412        if ( $removeLanguages ) {
1413            $dbw->newDeleteQueryBuilder()
1414                ->deleteFrom( 'cn_notice_languages' )
1415                ->where( [ 'nl_notice_id' => $noticeId, 'nl_language' => $removeLanguages ] )
1416                ->caller( __METHOD__ )
1417                ->execute();
1418        }
1419
1420        $dbw->endAtomic( __METHOD__ );
1421    }
1422
1423    /**
1424     * Update countries targeted for a campaign
1425     * @param string $notice
1426     * @param array $newCountries
1427     */
1428    public static function updateCountries( $notice, $newCountries ) {
1429        $dbw = CNDatabase::getPrimaryDb();
1430        $dbw->startAtomic( __METHOD__ );
1431
1432        // Get the previously assigned countries
1433        $oldCountries = self::getNoticeCountries( $notice, true );
1434
1435        // Get the notice id
1436        $noticeId = self::getNoticeId( $notice, $dbw );
1437
1438        // Add newly assigned countries
1439        $addCountries = array_diff( $newCountries, $oldCountries );
1440        $insertArray = [];
1441        foreach ( $addCountries as $code ) {
1442            $insertArray[] = [ 'nc_notice_id' => $noticeId, 'nc_country' => $code ];
1443        }
1444        if ( $insertArray ) {
1445            $dbw->newInsertQueryBuilder()
1446                ->insertInto( 'cn_notice_countries' )
1447                ->ignore()
1448                ->rows( $insertArray )
1449                ->caller( __METHOD__ )
1450                ->execute();
1451        }
1452
1453        // Remove disassociated countries
1454        $removeCountries = array_diff( $oldCountries, $newCountries );
1455        if ( $removeCountries ) {
1456            $dbw->newDeleteQueryBuilder()
1457                ->deleteFrom( 'cn_notice_countries' )
1458                ->where( [ 'nc_notice_id' => $noticeId, 'nc_country' => $removeCountries ] )
1459                ->caller( __METHOD__ )
1460                ->execute();
1461        }
1462
1463        $dbw->endAtomic( __METHOD__ );
1464    }
1465
1466    /**
1467     * Update regions targeted for a campaign
1468     * @param string $notice
1469     * @param array $newRegions in format CountryCode_RegionCode
1470     */
1471    public static function updateRegions( $notice, $newRegions ) {
1472        $dbw = CNDatabase::getPrimaryDb();
1473        $dbw->startAtomic( __METHOD__ );
1474
1475        // Get the previously assigned regions
1476        $oldRegions = self::getNoticeRegions( $notice, true );
1477
1478        // Get the notice id
1479        $noticeId = self::getNoticeId( $notice, $dbw );
1480
1481        // Add newly assigned regions
1482        $addRegions = array_diff( $newRegions, $oldRegions );
1483        $insertArray = [];
1484        foreach ( $addRegions as $code ) {
1485            $insertArray[] = [ 'nr_notice_id' => $noticeId, 'nr_region' => $code ];
1486        }
1487        if ( $insertArray ) {
1488            $dbw->newInsertQueryBuilder()
1489                ->insertInto( 'cn_notice_regions' )
1490                ->ignore()
1491                ->rows( $insertArray )
1492                ->caller( __METHOD__ )
1493                ->execute();
1494        }
1495
1496        // Remove disassociated regions
1497        $removeRegions = array_diff( $oldRegions, $newRegions );
1498        if ( $removeRegions ) {
1499            $dbw->newDeleteQueryBuilder()
1500                ->deleteFrom( 'cn_notice_regions' )
1501                ->where( [ 'nr_notice_id' => $noticeId, 'nr_region' => $removeRegions ] )
1502                ->caller( __METHOD__ )
1503                ->execute();
1504        }
1505
1506        $dbw->endAtomic( __METHOD__ );
1507    }
1508
1509    /**
1510     * Log any changes related to a campaign
1511     *
1512     * @param string $action 'created', 'modified', or 'removed'
1513     * @param int $campaignId ID of the campaign
1514     * @param string $campaignName Name of the campaign
1515     * @param User $user User causing the change
1516     * @param array $beginSettings array of campaign settings before changes (optional).
1517     *   If provided, it should include at least start, end, enabled and archived.
1518     * @param array $endSettings array of campaign settings after changes (optional).
1519     *   If provided, it should include at least start, end, enabled and archived.
1520     * @param string|null $summary Change summary provided by the user
1521     */
1522    public static function processAfterCampaignChange(
1523        $action, $campaignId, $campaignName, $user, $beginSettings = [],
1524        $endSettings = [], $summary = null
1525    ) {
1526        ChoiceDataProvider::invalidateCache();
1527
1528        // Summary shouldn't actually come in null, but just in case...
1529        $summary ??= '';
1530
1531        $dbw = CNDatabase::getPrimaryDb();
1532        $time = $dbw->timestamp();
1533
1534        ( new CentralNoticeHookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
1535            ->onCentralNoticeCampaignChange(
1536                $action,
1537                $time,
1538                $campaignName,
1539                $user,
1540                self::processSettingsForHook( $beginSettings ),
1541                self::processSettingsForHook( $endSettings ),
1542                $summary
1543            );
1544
1545        // Only log the change if it is done by an actual user (rather than a testing script)
1546        if ( !$user->isNamed() ) {
1547            return;
1548        }
1549
1550        $log = [
1551            'notlog_timestamp' => $time,
1552            'notlog_user_id'   => $user->getId(),
1553            'notlog_action'    => $action,
1554            'notlog_not_id'    => $campaignId,
1555            'notlog_not_name'  => $campaignName,
1556            'notlog_comment'   => $summary,
1557        ];
1558
1559        foreach ( $beginSettings as $key => $value ) {
1560            if ( !self::settingNameIsValid( $key ) ) {
1561                throw new InvalidArgumentException( "Invalid setting name" );
1562            }
1563            $log[ 'notlog_begin_' . $key ] = $value;
1564        }
1565
1566        foreach ( $endSettings as $key => $value ) {
1567            if ( !self::settingNameIsValid( $key ) ) {
1568                throw new InvalidArgumentException( "Invalid setting name" );
1569            }
1570            $log[ 'notlog_end_' . $key ] = $value;
1571        }
1572
1573        $dbw->newInsertQueryBuilder()
1574            ->insertInto( 'cn_notice_log' )
1575            ->row( $log )
1576            ->caller( __METHOD__ )
1577            ->execute();
1578    }
1579
1580    /**
1581     * Prepare campaign settings to be sent to the CampaignChange hook. This is necessary
1582     * since the settings provided to processAfterCampaignChange() are in a format
1583     * that is appropriate for the cn_notice_log table, but not for the hook.
1584     *
1585     * @param array $settings
1586     * @return array|null
1587     */
1588    private static function processSettingsForHook( $settings ) {
1589        if ( !$settings ) {
1590            return null;
1591        }
1592
1593        if ( isset( $settings[ 'banners' ] ) ) {
1594            $banners = json_decode( $settings[ 'banners' ] );
1595
1596            // This should never happen, since the string should just have been json-encoded
1597            // in getCampaignSettings().
1598            if ( $banners === null ) {
1599                throw new UnexpectedValueException( 'Json decoding error for banner settings' );
1600            }
1601
1602            // Names of banners are object properties
1603            $banners = array_keys( (array)$banners );
1604
1605        } else {
1606            $banners = [];
1607        }
1608
1609        return [
1610            'start' => $settings[ 'start' ],
1611            'end' => $settings[ 'end' ],
1612            'enabled' => (bool)$settings[ 'enabled' ],
1613            'archived' => (bool)$settings[ 'archived' ],
1614            'banners' => $banners,
1615        ];
1616    }
1617
1618    /**
1619     * Check that a string is a valid setting name.
1620     * @param string $settingName
1621     * @return bool
1622     */
1623    private static function settingNameIsValid( $settingName ): bool {
1624        return (bool)preg_match( '/^[a-z_]*$/', $settingName );
1625    }
1626
1627    /**
1628     * @param string $campaignName
1629     * @param string|null $type
1630     */
1631    public static function setType( $campaignName, $type ) {
1632        // Following pattern from setNumericalCampaignSettings() and exiting with no
1633        // error if the campaign doesn't exist. TODO Is this right?
1634        if ( !self::campaignExists( $campaignName, true ) ) {
1635            return;
1636        }
1637
1638        $dbw = CNDatabase::getPrimaryDb();
1639        $dbw->newUpdateQueryBuilder()
1640            ->update( 'cn_notices' )
1641            ->set( [ 'not_type' => $type ] )
1642            ->where( [ 'not_name' => $campaignName ] )
1643            ->caller( __METHOD__ )
1644            ->execute();
1645    }
1646
1647    /**
1648     * @param string|null|false $campaign
1649     * @param string|null|false $username
1650     * @param string|null|false $start
1651     * @param string|null|false $end
1652     * @param int $limit
1653     * @param int $offset
1654     * @return array[]
1655     */
1656    public static function campaignLogs(
1657        $campaign = false, $username = false, $start = false, $end = false, $limit = 50, $offset = 0
1658    ) {
1659        $dbr = CNDatabase::getReplicaDb();
1660        $conds = [];
1661        if ( $start ) {
1662            $conds[] = $dbr->expr( 'notlog_timestamp', '>=', $start );
1663        }
1664        if ( $end ) {
1665            $conds[] = $dbr->expr( 'notlog_timestamp', '<', $end );
1666        }
1667        if ( $campaign ) {
1668            // This used to be a LIKE, but that was undocumented,
1669            // and filters prevented the % and \ character from being
1670            // used. The one character _ wildcard could have been used
1671            // from the api, but that was completely undocumented.
1672            // This was sketchy security wise, so the LIKE was removed.
1673            $conds["notlog_not_name"] = $campaign;
1674        }
1675        if ( $username ) {
1676            $user = User::newFromName( $username );
1677            if ( $user ) {
1678                $conds["notlog_user_id"] = $user->getId();
1679            }
1680        }
1681
1682        $res = $dbr->newSelectQueryBuilder()
1683            ->select( '*' )
1684            ->from( 'cn_notice_log' )
1685            ->where( $conds )
1686            ->orderBy( 'notlog_timestamp', SelectQueryBuilder::SORT_DESC )
1687            ->limit( $limit )
1688            ->offset( $offset )
1689            ->caller( __METHOD__ )
1690            ->fetchResultSet();
1691        $logs = [];
1692        foreach ( $res as $row ) {
1693            $entry = new CampaignLog( $row );
1694            $logs[] = array_merge( get_object_vars( $entry ), $entry->changes() );
1695        }
1696        return $logs;
1697    }
1698}