app/models/campaign.rb
# == Schema Information
#
# Table name: campaigns
#
# id :bigint not null, primary key
# assigned_property_ids :bigint default([]), not null, is an Array
# audience_ids :bigint default([]), not null, is an Array
# core_hours_only :boolean default(FALSE)
# country_codes :string default([]), is an Array
# creative_ids :bigint default([]), not null, is an Array
# daily_budget_cents :integer default(0), not null
# daily_budget_currency :string default("USD"), not null
# ecpm_cents :integer default(0), not null
# ecpm_currency :string default("USD"), not null
# ecpm_multiplier :decimal(, ) default(1.0), not null
# end_date :date not null
# fallback :boolean default(FALSE), not null
# fixed_ecpm :boolean default(TRUE), not null
# hourly_budget_cents :integer default(0), not null
# hourly_budget_currency :string default("USD"), not null
# job_posting :boolean default(FALSE), not null
# keywords :string default([]), is an Array
# name :string not null
# negative_keywords :string default([]), is an Array
# paid_fallback :boolean default(FALSE)
# prohibited_property_ids :bigint default([]), not null, is an Array
# province_codes :string default([]), is an Array
# region_ids :bigint default([]), not null, is an Array
# start_date :date not null
# status :string not null
# total_budget_cents :integer default(0), not null
# total_budget_currency :string default("USD"), not null
# url :text not null
# weekdays_only :boolean default(FALSE)
# created_at :datetime not null
# updated_at :datetime not null
# campaign_bundle_id :bigint
# creative_id :bigint
# legacy_id :uuid
# organization_id :bigint
# user_id :bigint
#
# Indexes
#
# index_campaigns_on_assigned_property_ids (assigned_property_ids) USING gin
# index_campaigns_on_audience_ids (audience_ids) USING gin
# index_campaigns_on_campaign_bundle_id (campaign_bundle_id)
# index_campaigns_on_core_hours_only (core_hours_only)
# index_campaigns_on_country_codes (country_codes) USING gin
# index_campaigns_on_creative_id (creative_id)
# index_campaigns_on_creative_ids (creative_ids) USING gin
# index_campaigns_on_end_date (end_date)
# index_campaigns_on_job_posting (job_posting)
# index_campaigns_on_keywords (keywords) USING gin
# index_campaigns_on_name (lower((name)::text))
# index_campaigns_on_negative_keywords (negative_keywords) USING gin
# index_campaigns_on_organization_id (organization_id)
# index_campaigns_on_paid_fallback (paid_fallback)
# index_campaigns_on_prohibited_property_ids (prohibited_property_ids) USING gin
# index_campaigns_on_province_codes (province_codes) USING gin
# index_campaigns_on_region_ids (region_ids) USING gin
# index_campaigns_on_start_date (start_date)
# index_campaigns_on_status (status)
# index_campaigns_on_user_id (user_id)
# index_campaigns_on_weekdays_only (weekdays_only)
#
class Campaign < ApplicationRecord
# extends ...................................................................
# includes ..................................................................
include Campaigns::Budgetable
include Campaigns::Impressionable
include Campaigns::Operable
include Campaigns::Presentable
include Campaigns::Reportable
include Campaigns::Statusable
include Colorable
include Eventable
include Impressionable
include Keywordable
include Organizationable
include Sparklineable
include SplitTestable
include Taggable
# relationships .............................................................
belongs_to :campaign_bundle, optional: true
belongs_to :audience, optional: true
belongs_to :region, optional: true
belongs_to :creative, -> { includes :creative_images }, optional: true
belongs_to :user
has_many :pixel_conversions
# validations ...............................................................
validates :name, length: {maximum: 255, allow_blank: false}
validates :url, url: true, presence: true
validates :start_date, presence: true
validates :end_date, presence: true
validate :validate_creatives
validate :validate_active_creatives, if: :active?
validate :validate_assigned_properties, if: :sponsor?
# callbacks .................................................................
before_validation :sync_to_campaign_bundle
before_validation :sort_arrays
before_validation :sanitize_creative_ids
before_validation :assign_audiences
before_validation :assign_regions
before_save :sanitize_assigned_property_ids
before_destroy :validate_destroyable
after_save :update_campaign_bundle_dates
# scopes ....................................................................
# TODO: update standard/sponsor scopes to use arel instead of string interpolation
scope :standard, -> { where "\"campaigns\".\"creative_ids\" && ARRAY(#{Creative.standard.select(:id).to_sql})" }
scope :sponsor, -> { where "\"campaigns\".\"creative_ids\" && ARRAY(#{Creative.sponsor.select(:id).to_sql})" }
scope :fallback, -> { where fallback: true }
scope :paid_fallback, -> { where paid_fallback: true }
scope :premium, -> { where(fallback: false).where(paid_fallback: false) }
scope :job_posting, -> { where job_posting: true }
scope :starts_on_or_before, ->(date) { where arel_table[:start_date].lteq(date.to_date) }
scope :starts_on_or_after, ->(date) { where arel_table[:start_date].gteq(date.to_date) }
scope :starts_after, ->(date) { where arel_table[:start_date].gt(date.to_date) }
scope :ends_on_or_before, ->(date) { where arel_table[:end_date].lteq(date.to_date) }
scope :ends_on_or_after, ->(date) { where arel_table[:end_date].gteq(date.to_date) }
scope :ends_after, ->(date) { where arel_table[:end_date].gt(date.to_date) }
scope :unending, -> { where end_date: nil }
scope :started, -> { where arel_table[:start_date].lteq(Arel::Nodes::SqlLiteral.new("current_date")) }
scope :ended, -> { where arel_table[:end_date].lteq(Arel::Nodes::SqlLiteral.new("current_date")) }
scope :unended, -> { unending.or(where(arel_table[:end_date].gteq(Arel::Nodes::SqlLiteral.new("current_date")))) }
scope :available_on, ->(date) { starts_on_or_before(date).unending.or(starts_on_or_before(date).ends_on_or_after(date)) }
scope :search_keywords, ->(*values) { values.blank? ? all : with_any_keywords(*values) }
scope :search_country_codes, ->(*values) { values.blank? ? all : with_any_country_codes(*values) }
scope :search_province_codes, ->(*values) { values.blank? ? all : with_any_province_codes(*values) }
scope :search_fallback, ->(value) { value.blank? ? all : where(fallback: value) }
scope :search_paid_fallback, ->(value) { value.blank? ? all : where(paid_fallback: value) }
scope :search_name, ->(value) { value.blank? ? all : search_column(:name, value) }
scope :search_negative_keywords, ->(*values) { values.blank? ? all : with_any_negative(*values) }
scope :search_status, ->(*values) { values.blank? ? all : where(status: values) }
scope :search_core_hours_only, ->(value) { value.nil? ? all : where(core_hours_only: value) }
scope :search_user, ->(value) { value.blank? ? all : where(user_id: User.advertisers.search_name(value).or(User.advertisers.search_company(value))) }
scope :search_user_id, ->(value) { value.blank? ? all : where(user_id: value) }
scope :search_weekdays_only, ->(value) { value.nil? ? all : where(weekdays_only: value) }
scope :without_assigned_property_ids, -> { where assigned_property_ids: [] }
scope :with_assigned_property_id, ->(property_id) { where "\"campaigns\".\"assigned_property_ids\" @> ?::bigint[]", "{#{property_id}}" }
scope :with_any_assigned_property_ids, ->(*property_ids) { where "\"campaigns\".\"assigned_property_ids\" && ?::bigint[]", "{#{property_ids.select(&:present?).join(",")}}" }
scope :premium_with_assigned_property_id, ->(property_id) { premium.with_assigned_property_id property_id }
scope :fallback_with_assigned_property_id, ->(property_id) { fallback.with_assigned_property_id property_id }
scope :permitted_for_property_id, ->(property_id) {
subquery = Property.select(:prohibited_organization_ids).where(id: property_id)
organization_id_prohibited = Arel::Nodes::InfixOperation.new("<@", Arel::Nodes::SqlLiteral.new("ARRAY[\"campaigns\".\"organization_id\"]"), subquery.arel)
property_id_array = Arel::Nodes::SqlLiteral.new(sanitize_sql_array(["ARRAY[?::bigint]", property_id]))
campaign_id_prohibited = Arel::Nodes::InfixOperation.new("@>", arel_table[:prohibited_property_ids], property_id_array)
where.not(organization_id_prohibited).where.not(campaign_id_prohibited)
}
scope :targeted_premium_for_property, ->(property, *keywords) { targeted_premium_for_property_id property.id }
scope :targeted_premium_for_property_id, ->(property_id, *keywords) { premium.targeted_for_property_id(property_id, *keywords) }
scope :targeted_for_property_id, ->(property_id, *keywords) do
if keywords.present?
permitted_for_property_id(property_id)
.with_any_keywords(*keywords)
.without_any_negative_keywords(*keywords)
.without_assigned_property_ids
else
subquery = Property.active.select(:keywords).where(id: property_id)
keywords_overlap = Arel::Nodes::InfixOperation.new("&&", arel_table[:keywords], subquery.arel)
negative_keywords_overlap = Arel::Nodes::InfixOperation.new("&&", arel_table[:negative_keywords], subquery.arel)
permitted_for_property_id(property_id)
.where(keywords_overlap)
.where.not(negative_keywords_overlap)
.without_assigned_property_ids
end
end
scope :fallback_for_property_id, ->(property_id) do
fallback
.permitted_for_property_id(property_id)
.where.not(fallback: Property.select(:prohibit_fallback_campaigns).where(id: property_id).limit(1))
end
scope :targeted_fallback_for_property_id, ->(property_id, *keywords) do
fallback
.targeted_for_property_id(property_id, *keywords)
.where.not(fallback: Property.select(:prohibit_fallback_campaigns).where(id: property_id).limit(1))
end
scope :targeted_country_code, ->(country_code) { country_code ? with_all_country_codes(country_code) : without_country_codes }
scope :targeted_province_code, ->(province_code) { province_code ? without_province_codes.or(with_all_province_codes(province_code)) : without_province_codes }
scope :with_active_creatives, -> {
where "\"campaigns\".\"creative_ids\" && (SELECT array_agg(id) FROM \"creatives\" WHERE \"creatives\".\"status\" = 'active')::bigint[]"
}
scope :with_inactive_creatives, -> {
where "\"campaigns\".\"creative_ids\" && (SELECT array_agg(id) FROM \"creatives\" WHERE \"creatives\".\"status\" != 'active')::bigint[]"
}
scope :order_by_status, -> {
order_by = ["CASE"]
ENUMS::CAMPAIGN_STATUSES.values.each_with_index do |status, index|
order_by << "WHEN status='#{status}' THEN #{index}"
end
order_by << "END"
order(Arel.sql(order_by.join(" ")))
}
# Scopes and helpers provied by tag_columns
# SEE: https://github.com/hopsoft/tag_columns
#
# - with_country_codes
# - without_country_codes
# - with_any_country_codes
# - without_any_country_codes
# - with_all_country_codes
# - without_all_country_codes
#
# - with_creative_ids
# - without_creative_ids
# - with_any_creative_ids
# - without_any_creative_ids
# - with_all_creative_ids
# - without_all_creative_ids
#
# - with_province_codes
# - without_province_codes
# - with_any_province_codes
# - without_any_province_codes
# - with_all_province_codes
# - without_all_province_codes
#
# - with_keywords
# - without_keywords
# - with_any_keywords
# - without_any_keywords
# - with_all_keywords
# - without_all_keywords
#
# - with_negative_keywords
# - without_negative_keywords
# - with_any_negative_keywords
# - without_any_negative_keywords
# - with_all_negative_keywords
# - without_all_negative_keywords
#
# Examples
#
# irb>Campaign.with_country_codes("US", "GB")
# irb>Campaign.with_keywords("Frontend Frameworks & Tools", "Ruby")
# irb>Campaign.without_negative_keywords("Database", "Docker", "React")
# additional config (i.e. accepts_nested_attribute_for etc...) ..............
monetize :total_budget_cents, numericality: {greater_than_or_equal_to: 0}
monetize :daily_budget_cents, numericality: {greater_than_or_equal_to: 0}
monetize :hourly_budget_cents, numericality: {greater_than_or_equal_to: 0}
monetize :ecpm_cents, numericality: {greater_than_or_equal_to: 0}
tag_columns :creative_ids
tag_columns :region_ids
tag_columns :country_codes
tag_columns :province_codes
tag_columns :audience_ids
tag_columns :keywords
tag_columns :negative_keywords
acts_as_commentable
has_paper_trail on: %i[create update destroy], version_limit: nil, only: %i[
core_hours_only
country_codes
creative_id
daily_budget_cents
daily_budget_currency
ecpm_cents
ecpm_currency
end_date
keywords
name
negative_keywords
province_codes
start_date
status
total_budget_cents
total_budget_currency
url
user_id
weekdays_only
]
# class methods .............................................................
class << self
end
# public instance methods ...................................................
attr_accessor :temporary_id
def region_and_audience_pricing_strategy?
pricing_strategy == ENUMS::CAMPAIGN_PRICING_STRATEGIES::REGION_AND_AUDIENCE
end
def campaign_pricing_strategy?
pricing_strategy == ENUMS::CAMPAIGN_PRICING_STRATEGIES::CAMPAIGN
end
def pricing_strategy
return ENUMS::CAMPAIGN_PRICING_STRATEGIES::REGION_AND_AUDIENCE if campaign_bundle
return ENUMS::CAMPAIGN_PRICING_STRATEGIES::REGION_AND_AUDIENCE if start_date >= Date.parse("2020-06-01")
ENUMS::CAMPAIGN_PRICING_STRATEGIES::CAMPAIGN
end
def to_stashable_attributes
as_json.merge temporary_id: temporary_id
end
def inventory_summary
@inventory_summary ||= InventorySummary.new(self)
end
def targeting_variants
r = region_ids.present? ? regions : regions(include_fuzzy_matches: true)
a = audience_ids.present? ? audiences : audiences(include_fuzzy_matches: true)
r.to_a.product a.to_a
end
def metadata
key = "#{cache_key_with_version}/metadata"
Rails.cache.fetch key do
{
standard: standard_creatives.exists?,
sponsor: sponsor_creatives.exists?
}
end
end
def standard?
metadata[:standard]
end
def sponsor?
metadata[:sponsor]
end
def creatives
@creatives ||= Creative.where(id: creative_ids)
end
def permitted_creatives
@permitted_creatives ||= Creative.where(organization_id: organization_id)
end
def standard_creatives
@standard_creatives ||= creatives.standard
end
def sponsor_creatives
@sponsor_creatives ||= creatives.sponsor
end
def split_alternative_names
creatives.active.select(:id).map(&:split_test_name)
end
def assigner_properties
return Property.none unless fallback?
Property.with_assigned_fallback_campaign_id id
end
def assigned_properties
return Property.none if assigned_property_ids.blank?
Property.where id: assigned_property_ids
end
def prohibited_properties
return Property.none if prohibited_property_ids.blank?
Property.where id: prohibited_property_ids
end
def prohibit_property!(property_id)
ids = (prohibited_property_ids.compact << property_id.to_i).uniq.sort.compact
update(prohibited_property_ids: ids)
end
def prohibited_property?(property)
property_id = property.is_a?(Property) ? property.id : property.to_i
prohibited_property_ids.compact.include? property_id
end
def permit_property!(property_id)
ids = (prohibited_property_ids.compact - [property_id.to_i]).uniq.sort.compact
update(prohibited_property_ids: ids)
end
def fixed_ecpm?
return false unless campaign_pricing_strategy?
super
end
def adjusted_ecpm(country_code)
return ecpm if fixed_ecpm?
adjusted = ecpm * Country::UNKNOWN_CPM_MULTIPLER
country = Country.find(country_code)
if country
# DEPRECATE: delete logic for country multiplier after all campaigns with a start_date before 2019-03-07 have completed
adjusted = if start_date && start_date < Date.parse("2019-03-07")
country.ecpm base: ecpm, multiplier: :country
else
country.ecpm base: ecpm
end
end
adjusted = Monetize.parse("$0.10 USD") if adjusted.cents < 10
adjusted
end
def ecpms
countries.map do |country|
{
country_iso_code: country.iso_code,
country_name: country.name,
ecpm: adjusted_ecpm(country.iso_code)
}
end
end
# Returns a relation for properties that have rendered this campaign
# NOTE: Expects scoped daily_summaries to be pre-built by EnsureScopedDailySummariesJob
def displaying_properties(start_date = nil, end_date = nil)
subquery = daily_summaries.displayed.where(scoped_by_type: "Property")
subquery = subquery.between(start_date, end_date) if start_date
Property.where id: subquery.distinct.pluck(:scoped_by_id).map(&:to_i)
end
def matching_properties
@matching_properties ||= Property.for_campaign(self)
end
def matching_keywords(property)
keywords & property.keywords
end
def premium?
!fallback?
end
def available_on?(date)
date.to_date.between? start_date, end_date
end
def countries
Country.where iso_code: country_codes
end
def provinces
Province.where iso_code: province_codes
end
def campaign_type
return "fallback" if fallback?
"premium"
end
def to_meta_tags
{
title: name,
keywords: keywords
}
end
def audiences(include_fuzzy_matches: false)
relation = Audience.where(id: audience_ids)
relation = relation.or(Audience.with_any_keywords(*keywords)) if include_fuzzy_matches
relation
end
def regions(include_fuzzy_matches: false)
relation = Region.where(id: region_ids)
relation = relation.or(Region.with_any_country_codes(*country_codes)) if include_fuzzy_matches
relation
end
def current_competitors
return Campaign.none unless start_date
assign_keywords
assign_country_codes
relation = Campaign.sold.with_any_country_codes(*country_codes).with_any_keywords(*keywords).available_on(start_date)
relation = relation.unending.or(relation.ends_on_or_before(end_date)) if end_date
relation
end
def current_premium_competitors
return Campaign.none unless start_date
current_competitors.premium
end
def future_competitors
return Campaign.none unless start_date
assign_keywords
assign_country_codes
relation = Campaign.sold.with_any_country_codes(*country_codes).with_any_keywords(*keywords).starts_after(start_date).ends_on_or_before(start_date.advance(days: 90))
relation = relation.unending.or(relation.ends_on_or_before(end_date)) if end_date
relation
end
def future_premium_competitors
return Campaign.none unless start_date
future_competitors.premium
end
def assign_keywords(force_audience_keywords: false)
return unless audiences.exists?
original_keywords = keywords
audience_keywords = audiences.map(&:keywords).flatten.uniq
force_audience_keywords ||= audience_ids_changed? || original_keywords.blank?
self.keywords = if force_audience_keywords
audience_keywords
else
(original_keywords & audience_keywords).uniq.sort
end
end
def assign_country_codes(force_region_country_codes: false)
return unless regions.exists?
original_country_codes = country_codes
region_country_codes = regions.map(&:country_codes).flatten.uniq
force_region_country_codes ||= region_ids_changed? || original_country_codes.blank?
self.country_codes = if force_region_country_codes
region_country_codes
else
(original_country_codes & region_country_codes).uniq.sort
end
end
def update_campaign_bundle_dates
campaign_bundle&.update_dates
end
# protected instance methods ................................................
# private instance methods ..................................................
private
def assign_audiences(force: false)
self.audiences = nil if force
self.audiences ||= Audience.match(keywords)
assign_keywords
audiences
end
def assign_regions(force: false)
self.regions = nil if force
self.regions ||= Region.where(id: region_ids)
assign_country_codes
regions
end
def sanitize_creative_ids
permitted_creative_ids = permitted_creatives.distinct.pluck(:id)
self.creative_id = ([creative_id] & permitted_creative_ids).first
self.creative_ids = (creative_ids & permitted_creative_ids).compact.uniq
self.creative_ids = [creative_id].compact if creative_ids.blank?
self.creative_id = creative_ids.first unless creative_ids.include?(creative_id)
end
def sort_arrays
self.audience_ids = audience_ids&.reject(&:blank?)&.sort || []
self.region_ids = region_ids&.reject(&:blank?)&.sort || []
self.country_codes = country_codes&.reject(&:blank?)&.sort || []
self.keywords = keywords&.reject(&:blank?)&.sort || []
self.negative_keywords = negative_keywords&.reject(&:blank?)&.sort || []
self.province_codes = province_codes&.reject(&:blank?)&.sort || []
self.creative_ids = creative_ids&.reject(&:blank?)&.sort || []
end
def sanitize_assigned_property_ids
self.assigned_property_ids = assigned_property_ids.select(&:present?).uniq.sort
end
def validate_creatives
if standard_creatives.exists? && sponsor_creatives.exists?
errors.add :creatives, "cannot include both standard and sponsor types"
end
end
def validate_active_creatives
errors.add(:creatives, "must be attached") if creative_ids.blank?
errors.add(:creatives, "cannot be inactive") if creatives.select(&:active?).blank?
end
def validate_assigned_properties
return unless sponsor?
return unless active?
return if fallback?
return if paid_fallback?
conflicting_campaigns = Campaign.premium.with_any_assigned_property_ids(*assigned_property_ids).available_on(start_date)
.or(Campaign.premium.with_any_assigned_property_ids(*assigned_property_ids).available_on(end_date))
if conflicting_campaigns.exists?
conflicting_campaigns.each do |conflicting_campaign|
next if conflicting_campaign == self
conflicting_properties = Property.where(id: assigned_property_ids & conflicting_campaign.assigned_property_ids)
conflicting_properties.each do |conflicting_property|
errors.add :base, "#{conflicting_property.analytics_key} is already reserved by #{conflicting_campaign.analytics_key} from #{conflicting_campaign.start_date.iso8601} through #{conflicting_campaign.start_date.iso8601}"
end
end
end
if assigned_properties.present? && assigned_properties.map(&:restrict_to_sponsor_campaigns?).uniq != [true]
errors.add :assigned_properties, "must be set to those restricted to sponsor campaigns i.e. GitHub properties, etc..."
end
end
def validate_destroyable
return unless daily_summaries.exists? || impressions.exists?
errors.add :base, "Record has associated impressions and/or daily summaries, try archiving it instead."
throw :abort
end
def sync_to_campaign_bundle
return unless campaign_bundle
self.organization_id = campaign_bundle.organization_id
self.region_ids = campaign_bundle.region_ids
assign_country_codes
assign_keywords
end
end