docbrown/app/models/display_ad.rb
Ridhwana ccacf0bc3a
Refactor the home feed + add user tag filtering on in-feed billboards (#19600)
* feat: fix of there is no article

* feat: set the feed order in a data structure rather than in a view

* feat: update yarn.lock

* feat: push the items to the array

* feat: update feed to only show billboards if we have the correct length of items in the feed

* fix: organizedFeedItems.legth

* feat: rename 'featured' to 'image'

* feat: rename 'featured' to 'image'

* feat: rename 'featured' to 'image'

* feat: add some utilities for the feed that we can use in the FeedTest

* feat/WIP: the setup for the feed tests and a first working test

* test: imageItem

* fix: the podcasts can be passed through as an array of objects in the feedItems so that they can be grouped in one card

* feat: remove podcastEpisode state and the attribute in the object

* fix: check that the items exist before trying to slice them

* feat: setup the userdata and the podcast data in the test

* whoops - commit the podcast episodes

* feat: write soem more tests for the feed and including the podcasts

* feat: add some more tests

* feat: add more billboards tests to chcek the order of stuff

* feat: set the timeframe not empty

* feat: update the logic for organizaed feed by inserting the last on first

* doc: jsdoc for functions

* refactor: break the code up into smaller functions

* refactor: make the code more readable and easier to follow

* refactor: pull function out into a utlity

* feat: add specs to utility

* feat: chcek if pinned post

* test the latest timeframe correctly

* chore: update var name

* move the podcast items out of the object clause

* feat: update text

* feat: add a pack file that duplicates initializeDisplayAdVisibility

* feat: create callbacks that will help us to determine when the feed has been rendered so that we can observe the dsplay ads accordingly

* chore: rename to billboards instead of display ad

* feat: abstract out a function that will work for any tagged resource and also limit the article tags within a scope of the article_id

* feat: update the spec for the previous changes

* feat: write tests for the user_tags

* feat: add user_tags to for_display adn the query

* feat: add user_tags to the endpoint where we query the tags to send it through to for_display

* feat: pass through the suer tags to the async request

* feat: update the user_tags fiter query + tests

* feat: update the admin view to show targeted tags on the new feed option billboards too

* feat: add tests for targeted tags field on admin

* refactor: consolidate all of the options into one

* feat: move comment close to attribute

* feat: use a helper method

* feat: include helper on ads query

* feat: move the query from the frontend to live on the backend

* feat: first pass at some error handling whilst maintaining the order of the items

* test: for feed error

* feat: abstract out some code

* update the feed items for errors

* chore: comment

* feat: update the variable names

* feat: update honeybadger message

* feat: update the name of the variable

* refactor: do not conflate the duty of the untagged_ads

* refactor: rename the variables + add note for clarity

* fix: update var
2023-07-06 16:47:08 +02:00

234 lines
9.2 KiB
Ruby

class DisplayAd < ApplicationRecord
include Taggable
acts_as_taggable_on :tags
resourcify
belongs_to :creator, class_name: "User", optional: true
belongs_to :audience_segment, optional: true
# rubocop:disable Layout/LineLength
ALLOWED_PLACEMENT_AREAS = %w[sidebar_left sidebar_left_2 sidebar_right feed_first feed_second feed_third home_hero post_sidebar post_comments].freeze
# rubocop:enable Layout/LineLength
ALLOWED_PLACEMENT_AREAS_HUMAN_READABLE = ["Sidebar Left (First Position)",
"Sidebar Left (Second Position)",
"Sidebar Right (Home)",
"Home Feed First",
"Home Feed Second",
"Home Feed Third",
"Home Hero",
"Sidebar Right (Individual Post)",
"Below the comment section"].freeze
HOME_FEED_PLACEMENTS = %w[feed_first feed_second feed_third].freeze
MAX_TAG_LIST_SIZE = 10
POST_WIDTH = 775
SIDEBAR_WIDTH = 350
LOW_IMPRESSION_COUNT = 1_000
RANDOM_RANGE_MAX_FALLBACK = 5
NEW_AND_PRIORITY_RANGE_MAX_FALLBACK = 35
enum display_to: { all: 0, logged_in: 1, logged_out: 2 }, _prefix: true
enum type_of: { in_house: 0, community: 1, external: 2 }
belongs_to :organization, optional: true
has_many :display_ad_events, dependent: :destroy
validates :placement_area, presence: true,
inclusion: { in: ALLOWED_PLACEMENT_AREAS }
validates :body_markdown, presence: true
validates :organization, presence: true, if: :community?
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
before_save :process_markdown
after_save :generate_display_ad_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)) }
def self.for_display(area:, user_signed_in:, user_id: nil, article: nil, user_tags: nil)
permit_adjacent = article ? article.permit_adjacent_sponsors? : true
ads_for_display = DisplayAds::FilteredAdsQuery.call(
display_ads: 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,
user_tags: user_tags,
)
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.
ads_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).
ads_for_display.seldom_seen(area).sample || ads_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.
ads_for_display.limit(rand(1..15)).sample
end
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_in_house_hero_ads
return unless placement_area == "home_hero" && type_of != "in_house"
errors.add(:type_of, "must be in_house if display ad 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(",")
}
super(options.merge(except: %i[tags tag_list])).merge(overrides)
end
# rubocop:enable Style/OptionHash
# exclude_article_ids is an integer array, 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
# 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)
ApplicationConfig["SELDOM_SEEN_MIN_FOR_#{placement_area.upcase}"] ||
ApplicationConfig["SELDOM_SEEN_MIN"] ||
RANDOM_RANGE_MAX_FALLBACK
end
def self.new_and_priority_range_max(placement_area)
ApplicationConfig["SELDOM_SEEN_MAX_FOR_#{placement_area.upcase}"]||
ApplicationConfig["SELDOM_SEEN_MAX"] ||
NEW_AND_PRIORITY_RANGE_MAX_FALLBACK
end
private
def generate_display_ad_name
return unless name.nil?
self.name = "Display Ad #{id}"
save!
end
def process_markdown
return unless body_markdown_changed?
if FeatureFlag.enabled?(:consistent_rendering)
extracted_process_markdown
else
original_process_markdown
end
end
def extracted_process_markdown
renderer = ContentRenderer.new(body_markdown || "", source: self)
self.processed_html = renderer.process(prefix_images_options: { width: prefix_width,
synchronous_detail_detection: true }).processed_html
self.processed_html = processed_html.delete("\n")
end
def original_process_markdown
renderer = Redcarpet::Render::HTMLRouge.new(hard_wrap: true, filter_html: false)
markdown = Redcarpet::Markdown.new(renderer, Constants::Redcarpet::CONFIG)
initial_html = markdown.render(body_markdown)
stripped_html = ActionController::Base.helpers.sanitize initial_html,
tags: MarkdownProcessor::AllowedTags::DISPLAY_AD,
attributes: MarkdownProcessor::AllowedAttributes::DISPLAY_AD
html = stripped_html.delete("\n")
self.processed_html = Html::Parser.new(html)
.prefix_all_images(width: prefix_width, synchronous_detail_detection: true).html
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