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