docbrown/app/models/billboard.rb
Ben Halpern f4c48fc83b
Add new article billboard locations (#20878)
* Initial work

* Add new billboard locations

* Clean up formatting
2024-04-22 13:58:39 -04:00

322 lines
13 KiB
Ruby

class Billboard < ApplicationRecord
include Taggable
acts_as_taggable_on :tags
resourcify
belongs_to :creator, class_name: "User", optional: true
belongs_to :audience_segment, optional: true
belongs_to :page, optional: true
ALLOWED_PLACEMENT_AREAS = %w[sidebar_left
sidebar_left_2
sidebar_right
sidebar_right_second
sidebar_right_third
feed_first
feed_second
feed_third
home_hero
page_fixed_bottom
post_fixed_bottom
post_body_bottom
post_sidebar
post_comments
post_comments_mid
digest_first
digest_second].freeze
ALLOWED_PLACEMENT_AREAS_HUMAN_READABLE = ["Sidebar Left (First Position)",
"Sidebar Left (Second Position)",
"Sidebar Right (Home first position)",
"Sidebar Right (Home second position)",
"Sidebar Right (Home third position)",
"Home Feed First",
"Home Feed Second",
"Home Feed Third",
"Home Hero",
"Fixed Bottom (Page)",
"Fixed Bottom (Individual Post)",
"Below the post body",
"Sidebar Right (Individual Post)",
"Below the comment section",
"Midway through the comment section",
"Digest Email First",
"Digest Email Second"].freeze
HOME_FEED_PLACEMENTS = %w[feed_first feed_second feed_third].freeze
MAX_TAG_LIST_SIZE = 25
POST_WIDTH = 775
SIDEBAR_WIDTH = 350
LOW_IMPRESSION_COUNT = 1_000
RANDOM_RANGE_MAX_FALLBACK = 5
NEW_AND_PRIORITY_RANGE_MAX_FALLBACK = 35
attribute :target_geolocations, :geolocation_array
enum display_to: { all: 0, logged_in: 1, logged_out: 2 }, _prefix: true
enum type_of: { in_house: 0, community: 1, external: 2 }
enum render_mode: { forem_markdown: 0, raw: 1 }
enum template: { authorship_box: 0, plain: 1 }
enum :special_behavior, { nothing: 0, delayed: 1 }
belongs_to :organization, optional: true
has_many :billboard_events, foreign_key: :display_ad_id, inverse_of: :billboard, dependent: :destroy
validates :placement_area, presence: true,
inclusion: { in: ALLOWED_PLACEMENT_AREAS }
validates :body_markdown, presence: true
validates :organization, presence: true, if: :community?
validates :weight, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10_000 }
validates :audience_segment_type,
inclusion: { in: AudienceSegment.type_ofs },
allow_blank: true
validate :valid_audience_segment_match,
:validate_in_house_hero_ads,
:valid_manual_audience_segment,
:validate_tag,
:validate_geolocations
before_save :process_markdown
after_save :generate_billboard_name
after_save :refresh_audience_segment, if: :should_refresh_audience_segment?
scope :approved_and_published, -> { where(approved: true, published: true) }
scope :search_ads, lambda { |term|
where "name ILIKE :search OR processed_html ILIKE :search OR placement_area ILIKE :search",
search: "%#{term}%"
}
scope :seldom_seen, ->(area) { where("impressions_count < ?", low_impression_count(area)).or(where(priority: true)) }
self.table_name = "display_ads"
def self.for_display(area:, user_signed_in:, user_id: nil, article: nil, user_tags: nil,
location: nil, cookies_allowed: false, page_id: nil)
permit_adjacent = article ? article.permit_adjacent_sponsors? : true
billboards_for_display = Billboards::FilteredAdsQuery.call(
billboards: self,
area: area,
user_signed_in: user_signed_in,
article_id: article&.id,
article_tags: article&.cached_tag_list_array || [],
organization_id: article&.organization_id,
permit_adjacent_sponsors: permit_adjacent,
user_id: user_id,
page_id: page_id,
user_tags: user_tags,
location: location,
cookies_allowed: cookies_allowed,
)
case rand(99) # output integer from 0-99
when (0..random_range_max(area)) # smallest range, 5%
# We are always showing more of the good stuff — but we are also always testing the system to give any a chance to
# rise to the top. 5 out of every 100 times we show an ad (5%), it is totally random. This gives "not yet
# evaluated" stuff a chance to get some engagement and start showing up more. If it doesn't get engagement, it
# stays in this area.
billboards_for_display.sample
when (random_range_max(area)..new_and_priority_range_max(area)) # medium range, 30%
# Here we sample from only billboards with fewer than 1000 impressions (with a fallback
# if there are none of those, causing an extra query, but that shouldn't happen very often).
relation = billboards_for_display.seldom_seen(area)
weighted_random_selection(relation, article&.id) || billboards_for_display.sample
else # large range, 65%
# Ads that get engagement have a higher "success rate", and among this category, we sample from the top 15 that
# meet that criteria. Within those 15 top "success rates" likely to be clicked, there is a weighting towards the
# top ranked outcome as well, and a steady decline over the next 15 — that's because it's not "Here are the top 15
# pick one randomly", it is actually "Let's cut off the query at a random limit between 1 and 15 and sample from
# that". So basically the "limit" logic will result in 15 sets, and then we sample randomly from there. The
# "first ranked" ad will show up in all 15 sets, where as 15 will only show in 1 of the 15.
billboards_for_display.limit(rand(1..15)).sample
end
end
def self.weighted_random_selection(relation, target_article_id = nil)
base_query = relation.to_sql
random_val = rand.to_f
if FeatureFlag.enabled?(:article_id_adjusted_weight)
condition = target_article_id.blank? ? "FALSE" : "#{target_article_id} = ANY(preferred_article_ids)"
query = <<-SQL
WITH base AS (#{base_query}),
weighted AS (
SELECT *,
CASE
WHEN #{condition} THEN weight * 10
ELSE weight
END AS adjusted_weight,
SUM(CASE
WHEN #{condition} THEN weight * 10
ELSE weight
END) OVER () AS total_weight,
SUM(CASE
WHEN #{condition} THEN weight * 10
ELSE weight
END) OVER (ORDER BY id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_weight
FROM base
)
SELECT *, running_weight, ? * total_weight AS random_value FROM weighted
WHERE running_weight >= ? * total_weight
ORDER BY running_weight ASC
LIMIT 1
SQL
else
query = <<-SQL
WITH base AS (#{base_query}),
weighted AS (
SELECT *, weight,
SUM(weight) OVER () AS total_weight,
SUM(weight) OVER (ORDER BY id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_weight
FROM base
)
SELECT *, running_weight, ? * total_weight AS random_value FROM weighted
WHERE running_weight >= ? * total_weight
ORDER BY running_weight ASC
LIMIT 1
SQL
end
relation.find_by_sql([query, random_val, random_val]).first
end
# Temporary ENV configs, to eventually be replaced by permanent configurations
# once we determine what the appropriate long-term config approach is.
def self.low_impression_count(placement_area)
ApplicationConfig["LOW_IMPRESSION_COUNT_FOR_#{placement_area.upcase}"] ||
ApplicationConfig["LOW_IMPRESSION_COUNT"] ||
LOW_IMPRESSION_COUNT
end
def self.random_range_max(placement_area)
selected_number = ApplicationConfig["SELDOM_SEEN_MIN_FOR_#{placement_area.upcase}"] ||
ApplicationConfig["SELDOM_SEEN_MIN"] ||
RANDOM_RANGE_MAX_FALLBACK
selected_number.to_i
end
def self.new_and_priority_range_max(placement_area)
selected_number = ApplicationConfig["SELDOM_SEEN_MAX_FOR_#{placement_area.upcase}"] ||
ApplicationConfig["SELDOM_SEEN_MAX"] ||
NEW_AND_PRIORITY_RANGE_MAX_FALLBACK
selected_number.to_i
end
def human_readable_placement_area
ALLOWED_PLACEMENT_AREAS_HUMAN_READABLE[ALLOWED_PLACEMENT_AREAS.find_index(placement_area)]
end
def validate_tag
# check there are not too many tags
return errors.add(:tag_list, I18n.t("models.article.too_many_tags")) if tag_list.size > MAX_TAG_LIST_SIZE
validate_tag_name(tag_list)
end
def validate_geolocations
target_geolocations.each do |geo|
unless geo.valid?(:targeting)
errors.add(:target_geolocations, I18n.t("models.billboard.invalid_location", location: geo.to_iso3166))
end
end
end
def validate_in_house_hero_ads
return unless placement_area == "home_hero" && type_of != "in_house"
errors.add(:type_of, "must be in_house if billboard is a Home Hero")
end
def audience_segment_type
@audience_segment_type ||= audience_segment&.type_of
end
def audience_segment_type=(type)
errors.delete(:audience_segment_type)
@audience_segment_type = type
end
# This needs to correspond with Rails built-in method signature
# rubocop:disable Style/OptionHash
def as_json(options = {})
overrides = {
"audience_segment_type" => audience_segment_type,
"tag_list" => cached_tag_list,
"exclude_article_ids" => exclude_article_ids.join(","),
"target_geolocations" => target_geolocations.map(&:to_iso3166)
}
super(options.merge(except: %i[tags tag_list target_geolocations])).merge(overrides)
end
# rubocop:enable Style/OptionHash
# exclude_article_ids and preferred_article_ids are integer arrays, web inputs are comma-separated strings
# ActiveRecord normalizes these in a bad way, so we are intervening
def exclude_article_ids=(input)
adjusted_input = input.is_a?(String) ? input.split(",") : input
adjusted_input = adjusted_input&.filter_map { |value| value.presence&.to_i }
write_attribute :exclude_article_ids, (adjusted_input || [])
end
def preferred_article_ids=(input)
adjusted_input = input.is_a?(String) ? input.split(",") : input
adjusted_input = adjusted_input&.filter_map { |value| value.presence&.to_i }
write_attribute :preferred_article_ids, (adjusted_input || [])
end
private
def generate_billboard_name
return unless name.nil?
self.name = "Billboard #{id}"
save!
end
def process_markdown
return unless body_markdown_changed?
if render_mode == "forem_markdown"
extracted_process_markdown
else # raw
self.processed_html = Html::Parser.new(body_markdown)
.prefix_all_images(width: 880, quality: 100, synchronous_detail_detection: true).html
end
end
def extracted_process_markdown
renderer = ContentRenderer.new(body_markdown || "", source: self)
self.processed_html = renderer.process(prefix_images_options: { width: prefix_width,
quality: 100,
synchronous_detail_detection: true }).processed_html
self.processed_html = processed_html.delete("\n")
end
def prefix_width
placement_area.include?("sidebar") ? SIDEBAR_WIDTH : POST_WIDTH
end
def refresh_audience_segment
AudienceSegmentRefreshWorker.perform_async(audience_segment_id)
end
def should_refresh_audience_segment?
change_relevant_to_audience = saved_change_to_approved? ||
saved_change_to_published? ||
saved_change_to_audience_segment_id?
change_relevant_to_audience &&
audience_segment &&
audience_segment.updated_at < 1.day.ago
end
def valid_audience_segment_match
return if audience_segment.blank? || audience_segment_type.blank?
errors.add(:audience_segment_type) if audience_segment.type_of.to_s != audience_segment_type.to_s
end
def valid_manual_audience_segment
return if audience_segment_type != "manual"
errors.add(:audience_segment_type) if audience_segment.blank?
end
end