Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
48.39% |
406 / 839 |
|
22.22% |
10 / 45 |
CRAP | |
0.00% |
0 / 1 |
| Campaign | |
48.39% |
406 / 839 |
|
22.22% |
10 / 45 |
3501.24 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| getId | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| getName | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| getStartTime | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| getEndTime | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| getPriority | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| isEnabled | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| isLocked | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| isArchived | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| isGeotargeted | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| getBuckets | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| loadBasicSettings | |
0.00% |
0 / 40 |
|
0.00% |
0 / 1 |
20 | |||
| campaignExists | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| getActiveCampaignsAndBanners | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
20 | |||
| getCampaignSettings | |
97.73% |
43 / 44 |
|
0.00% |
0 / 1 |
4 | |||
| getHistoricalCampaigns | |
68.92% |
51 / 74 |
|
0.00% |
0 / 1 |
9.92 | |||
| getCampaignMixins | |
36.92% |
24 / 65 |
|
0.00% |
0 / 1 |
89.53 | |||
| updateCampaignMixins | |
0.00% |
0 / 61 |
|
0.00% |
0 / 1 |
56 | |||
| addCampaign | |
85.06% |
74 / 87 |
|
0.00% |
0 / 1 |
12.48 | |||
| removeCampaign | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
3.03 | |||
| removeCampaignByName | |
100.00% |
35 / 35 |
|
100.00% |
1 / 1 |
1 | |||
| addTemplateTo | |
96.00% |
24 / 25 |
|
0.00% |
0 / 1 |
2 | |||
| removeTemplateFor | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| getNoticeId | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| getNoticeProjects | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
| getNoticeLanguages | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
| getNoticeCountries | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
| getNoticeRegions | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
| getTitleForURL | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getQueryForURL | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| getCanonicalURL | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| updateNoticeDate | |
88.24% |
15 / 17 |
|
0.00% |
0 / 1 |
4.03 | |||
| setBooleanCampaignSetting | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 | |||
| setNumericCampaignSetting | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
56 | |||
| updateWeight | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
| updateBucket | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
| updateProjects | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
20 | |||
| updateProjectLanguages | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
20 | |||
| updateCountries | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
20 | |||
| updateRegions | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
20 | |||
| processAfterCampaignChange | |
91.89% |
34 / 37 |
|
0.00% |
0 / 1 |
6.02 | |||
| processSettingsForHook | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
4.00 | |||
| settingNameIsValid | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setType | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
| campaignLogs | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
56 | |||
| 1 | <?php |
| 2 | |
| 3 | use MediaWiki\Json\FormatJson; |
| 4 | use MediaWiki\MediaWikiServices; |
| 5 | use MediaWiki\SpecialPage\SpecialPage; |
| 6 | use MediaWiki\Title\Title; |
| 7 | use MediaWiki\User\User; |
| 8 | use MediaWiki\Utils\MWTimestamp; |
| 9 | use Wikimedia\Rdbms\IReadableDatabase; |
| 10 | use Wikimedia\Rdbms\SelectQueryBuilder; |
| 11 | |
| 12 | class 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 | } |