gitcoinco/code_fund_ads

View on GitHub
app/models/property.rb

Summary

Maintainability
A
3 hrs
Test Coverage
# == Schema Information
#
# Table name: properties
#
#  id                             :bigint           not null, primary key
#  ad_template                    :string
#  ad_theme                       :string
#  assigned_fallback_campaign_ids :bigint           default([]), not null, is an Array
#  deleted_at                     :datetime
#  description                    :text
#  fallback_ad_template           :string
#  fallback_ad_theme              :string
#  keywords                       :string           default([]), not null, is an Array
#  language                       :string           not null
#  name                           :string           not null
#  prohibit_fallback_campaigns    :boolean          default(FALSE), not null
#  prohibited_organization_ids    :bigint           default([]), not null, is an Array
#  property_type                  :string           default("website"), not null
#  responsive_behavior            :string           default("none"), not null
#  restrict_to_assigner_campaigns :boolean          default(FALSE), not null
#  revenue_percentage             :decimal(, )      default(0.6), not null
#  status                         :string           not null
#  url                            :text             not null
#  created_at                     :datetime         not null
#  updated_at                     :datetime         not null
#  audience_id                    :bigint
#  legacy_id                      :uuid
#  user_id                        :bigint           not null
#
# Indexes
#
#  index_properties_on_assigned_fallback_campaign_ids  (assigned_fallback_campaign_ids) USING gin
#  index_properties_on_audience_id                     (audience_id)
#  index_properties_on_keywords                        (keywords) USING gin
#  index_properties_on_name                            (lower((name)::text))
#  index_properties_on_prohibited_organization_ids     (prohibited_organization_ids) USING gin
#  index_properties_on_property_type                   (property_type)
#  index_properties_on_status                          (status)
#  index_properties_on_user_id                         (user_id)
#

class Property < ApplicationRecord
  # extends ...................................................................

  # includes ..................................................................
  include Properties::Chartable
  include Properties::Impressionable
  include Properties::Presentable
  include Properties::Reportable
  include Eventable
  include Imageable
  include Impressionable
  include Keywordable
  include Sparklineable
  include Taggable

  # relationships .............................................................
  belongs_to :user
  belongs_to :audience, optional: true
  has_many :advertisers, through: :property_advertisers, class_name: "User", foreign_key: "advertiser_id"
  has_many :pixel_conversions
  has_many :property_advertisers, dependent: :destroy
  has_many :property_traffic_estimates, dependent: :destroy

  # validations ...............................................................
  # validates :ad_template, presence: true
  # validates :ad_theme, presence: true
  validates :language, length: {maximum: 255}, allow_blank: false
  validates :name, length: {maximum: 255}, allow_blank: false
  validates :property_type, inclusion: {in: ENUMS::PROPERTY_TYPES.values}
  validates :responsive_behavior, inclusion: {in: ENUMS::PROPERTY_RESPONSIVE_BEHAVIORS.values}
  validates :revenue_percentage, numericality: {less_than_or_equal_to: 1.0, greater_than_or_equal_to: 0}
  validates :status, inclusion: {in: ENUMS::PROPERTY_STATUSES.values}
  validates :url, url: true, presence: true

  # callbacks .................................................................
  before_validation :assign_audience
  before_save :assign_restrict_to_assigner_campaigns
  before_save :sanitize_assigned_fallback_campaign_ids
  after_save :generate_screenshot
  before_destroy :destroy_paper_trail_versions
  before_destroy :validate_destroyable

  # scopes ....................................................................
  scope :active, -> { where status: ENUMS::PROPERTY_STATUSES::ACTIVE }
  scope :archived, -> { where status: ENUMS::PROPERTY_STATUSES::ARCHIVED }
  scope :blacklisted, -> { where status: ENUMS::PROPERTY_STATUSES::BLACKLISTED }
  scope :pending, -> { where status: ENUMS::PROPERTY_STATUSES::PENDING }
  scope :rejected, -> { where status: ENUMS::PROPERTY_STATUSES::REJECTED }
  scope :website, -> { where property_type: ENUMS::PROPERTY_TYPES::WEBSITE }
  scope :search_ad_template, ->(*values) { values.blank? ? all : where(ad_template: values) }
  scope :search_keywords, ->(*values) { values.blank? ? all : with_any_keywords(*values) }
  scope :exclude_keywords, ->(*values) { values.blank? ? all : without_any_keywords(*values) }
  scope :search_language, ->(*values) { values.blank? ? all : where(language: values) }
  scope :search_name, ->(value) { value.blank? ? all : search_column(:name, value) }
  scope :search_property_type, ->(*values) { values.blank? ? all : where(property_type: values) }
  scope :search_status, ->(*values) { values.blank? ? all : where(status: values) }
  scope :search_url, ->(value) { value.blank? ? all : search_column(:url, value) }
  scope :search_user, ->(value) { value.blank? ? all : where(user_id: User.publishers.search_name(value)) }
  scope :search_user_id, ->(value) { value.blank? ? all : where(user_id: value) }
  scope :without_estimates, -> {
    subquery = PropertyTrafficEstimate.select(:property_id)
    where.not(id: subquery)
  }
  scope :for_campaign, ->(campaign) {
    relation = active.with_any_keywords(*campaign.keywords).without_any_keywords(*campaign.negative_keywords)
    relation = relation.where(prohibit_fallback_campaigns: false) if campaign.fallback?
    relation = relation.without_all_prohibited_organization_ids(campaign.organization_id)
    relation
  }
  scope :with_assigned_fallback_campaign_id, ->(campaign_id) {
    value = Arel::Nodes::SqlLiteral.new(sanitize_sql_array(["ARRAY[?]", campaign_id]))
    value_cast = Arel::Nodes::NamedFunction.new("CAST", [value.as("bigint[]")])
    where Arel::Nodes::InfixOperation.new("@>", arel_table[:assigned_fallback_campaign_ids], value_cast)
  }
  scope :order_by_status, -> {
                            order_by = ["CASE"]
                            ENUMS::PROPERTY_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_all_prohibited_organization_ids
  # - with_any_prohibited_organization_ids
  # - with_prohibited_organization_ids
  # - without_all_prohibited_organization_ids
  # - without_any_prohibited_organization_ids
  # - without_prohibited_organization_ids
  #
  # - with_all_keywords
  # - with_any_keywords
  # - with_keywords
  # - without_all_keywords
  # - without_any_keywords
  # - without_keywords

  # additional config (i.e. accepts_nested_attribute_for etc...) ..............
  tag_columns :prohibited_organization_ids
  tag_columns :keywords
  has_one_attached :screenshot
  acts_as_commentable
  has_paper_trail on: %i[update], only: %i[
    ad_template
    ad_theme
    keywords
    language
    prohibit_fallback_campaigns
    prohibited_organization_ids
    name
    property_type
    status
    url
    user_id
  ]

  # class methods .............................................................
  class << self
  end

  # public instance methods ...................................................

  attr_accessor :temporary_id

  def to_stashable_attributes
    as_json.merge temporary_id: temporary_id
  end

  def host
    URI.parse(url).host
  end

  def restrict_to_sponsor_campaigns?
    host == "github.com"
  end

  def active?
    status == ENUMS::PROPERTY_STATUSES::ACTIVE
  end

  def archived?
    status == ENUMS::PROPERTY_STATUSES::ARCHIVED
  end

  def pending?
    status == ENUMS::PROPERTY_STATUSES::PENDING
  end

  def hide_on_responsive?
    responsive_behavior == ENUMS::PROPERTY_RESPONSIVE_BEHAVIORS::HIDE
  end

  def show_footer_on_responsive?
    responsive_behavior == ENUMS::PROPERTY_RESPONSIVE_BEHAVIORS::FOOTER
  end

  def assigner_campaigns
    Campaign.with_assigned_property_id id
  end

  def sponsor_campaigns
    assigner_campaigns.sponsor
  end

  def current_sponsor_campaign
    return nil unless restrict_to_sponsor_campaigns?
    sponsor_campaigns.premium.available_on(Date.current).first ||
      Campaign.sponsor.fallback_with_assigned_property_id(id).available_on(Date.current).first ||
      Campaign.sponsor.fallback.permitted_for_property_id(id).available_on(Date.current).first
  end

  def assigned_fallback_campaigns
    return Campaign.none if assigned_fallback_campaign_ids.blank?
    Campaign.where id: assigned_fallback_campaign_ids
  end

  def favicon_image_url
    domain = url.gsub(/^https?:\/\//, "")
    "//www.google.com/s2/favicons?domain=#{domain}"
  end

  def matching_campaigns
    Campaign.targeted_premium_for_property self
  end

  # Returns a relation for campaigns that have been rendered on this property
  # NOTE: Expects scoped daily_summaries to be pre-built by EnsureScopedDailySummariesJob
  def displayed_campaigns(start_date = nil, end_date = nil)
    subquery = daily_summaries.displayed.where(scoped_by_type: "Campaign")
    subquery = subquery.between(start_date, end_date) if start_date
    Campaign.where id: subquery.distinct.pluck(:scoped_by_id).map(&:to_i)
  end

  def update_audience
    update_columns audience_id: Audience.match(keywords)&.id
  end

  def assign_keywords(force_audience_keywords: false)
    return unless audience

    original_keywords = keywords
    audience_keywords = audience.keywords
    force_audience_keywords ||= audience_id_changed? || original_keywords.blank?

    self.keywords = if force_audience_keywords
      audience_keywords
    else
      (original_keywords & audience_keywords).uniq.sort
    end
  end

  def can_pass_ip_address?
    ENV.fetch("API_BASED_PROPERTY_IDS", "").split(",").include?(id.to_s)
  end

  # protected instance methods ................................................

  # private instance methods ..................................................
  private

  def generate_screenshot
    GeneratePropertyScreenshotJob.perform_later(id) if saved_change_to_url?
  end

  def sanitize_assigned_fallback_campaign_ids
    self.assigned_fallback_campaign_ids = assigned_fallback_campaign_ids.select(&:present?).uniq.sort
  end

  def status_changed_to_active_on_preceding_save?
    return false unless active?
    status_previously_changed? && status_previous_change.last == ENUMS::PROPERTY_STATUSES::ACTIVE
  end

  def destroy_paper_trail_versions
    PaperTrail::Version.where(id: versions.select(:id)).delete_all
  end

  def assign_audience(force: false)
    self.audience = nil if force
    self.audience ||= Audience.match(keywords)
    assign_keywords
    audience
  end

  def assign_restrict_to_assigner_campaigns
    self.restrict_to_assigner_campaigns ||= restrict_to_sponsor_campaigns?
  end

  def validate_destroyable
    return unless DailySummary.find_by(scoped_by_type: "Property", scoped_by_id: id)
    errors.add :base, "Record has associated daily summaries, try archiving it instead."
    throw :abort
  end
end