3scale/porta

View on GitHub
app/models/cinstance.rb

Summary

Maintainability
D
2 days
Test Coverage
class Cinstance < Contract
  include SaveDestroyForServiceAssociation
  # Maximum number of cinstances permitted between provider and buyer
  MAX = 10

  self.background_deletion = [:referrer_filters, :application_keys, [:alerts, { action: :delete }]]

  delegate :backend_version, to: :service, allow_nil: true

  belongs_to :plan, class_name: 'ApplicationPlan', foreign_key: :plan_id, inverse_of: :cinstances
  alias application_plan plan

  # TODO: verify the comment is still true and we can't use inverse_of
  belongs_to :service #, inverse_of: :cinstances # this inverse_of messes up some association autosave stuff

  has_many :alerts, dependent: :delete_all
  has_many :line_items

  # this needs to be before the include Backend::ModelExtensions::Cinstance
  # otherwise the application is deleted from backend before the appplication keys are removed, producing errors
  with_options(foreign_key: :application_id, dependent: :destroy, inverse_of: :application) do |backend|
    backend.has_many :referrer_filters, &::ReferrerFilter::AssociationExtension
    backend.has_many :application_keys, &::ApplicationKey::AssociationExtension
  end

  before_validation :set_service_id
  before_validation :set_user_key, on: :create

  before_create :set_service_id
  before_create :set_provider_public_key
  before_create :accept_on_create, :unless => :live?

  attr_readonly :service_id

  include WebHooksHelpers #TODO: make this inclusion more dsl-ish
  fires_human_web_hooks_on_events

  # this has to be before the include Backend::ModelExtensions::Cinstance
  # or callbacks order makes keys not to be saved in backend
  after_create :create_first_key

  # before_destroy :refund_fixed_cost
  after_commit :reject_if_pending, :on => :destroy

  include Logic::Contracting::ApplicationContract

  # FIXME: including Fields after other includes makes Fields break
  include Fields::Fields
  required_fields_are :name
  optional_fields_are :description
  default_fields_are :name, :description
  set_fields_account_source :user_account

  include Backend::ModelExtensions::Cinstance
  include Finance::VariableCost
  include Logic::Authentication::ApplicationContract
  include Logic::Keys::ApplicationContract

  include Indices::AccountIndex::ForDependency
  include ThreeScale::Search::Scopes

  def self.attributes_for_destroy_list
    %w( id user_account_id name description user_key plan_id state trial_period_expires_at created_at extra_fields)
  end

  self.allowed_sort_columns = %w{ cinstances.name cinstances.state accounts.org_name cinstances.created_at cinstances.first_daily_traffic_at } # can't order by plans.name, service.name - mysql blows up
  self.default_sort_column = :created_at
  self.default_sort_direction = :desc
  self.allowed_search_scopes = %w{ service_id plan_id plan_type state account account_query state name user_key active_since inactive_since }
  self.default_search_scopes = { }
  self.sort_columns_joins = {
    'accounts.org_name' => [:user_account],
    'plans.name' => [:plan],
    'service.name' => [:service]
  }

  def redirect_url=(redirect_url)
    super(redirect_url.try(:strip))
  end

  validates :conditions,
    acceptance: { :message => 'you should agree on the terms and conditions for this plan first' }

  validates :plan, presence: true
  validates :name, presence: { :if => :name_required? }

  after_commit :push_webhook_key_updated, :on => :update, :if => :user_key_updated?
  after_commit :push_application_updated_event, on: :update, unless: :only_traffic_updated?

  #this method marks that a human edition of the app is happening, thus description presence will be validated
  # this is done so e.g. to avoid change_plan to fail when the app misses description or name
  def validate_human_edition!
    @validate_human_edition = true
  end

  def validate_plan_is_unique!
    @validate_plan_is_unique = true
  end

  def validate_plan_is_unique?
    @validate_plan_is_unique
  end

  validate :plan_is_unique, if: :validate_plan_is_unique?
  validate :application_id_is_unique, if: :validate_application_id_is_unique?
  validates :application_id, uniqueness: { scope: [:service_id], case_sensitive: true }, unless: :validate_application_id_is_unique?

  validate :user_key_is_unique, unless: :provider_can_duplicate_user_key?

  validates :user_key, uniqueness: { scope: [:service_id], case_sensitive: true }, if: :provider_can_duplicate_user_key?

  validate :same_service, on: :update, if: :plan_id_changed?

  # letter, number, underscore (_), hyphen-minus (-), dot (.), base64 format
  # In base64 encoding, the character set is [A-Z,a-z,0-9,and +], if rest length is less than 4, fill of '=' character.
  # ^([A-Za-z0-9+]{4})* means the String start with 0 time or more base64 group.
  # ([A-Za-z0-9+]{4}|[A-Za-z0-9+]{3}=|[A-Za-z0-9+]{2}==) means the String must end of 3 forms in [A-Za-z0-9+]{4} or [A-Za-z0-9+]{3}= or [A-Za-z0-9+]{2}==
  # matches also the non 64B case with (\A[\w\-\.]+\Z)
  # NOTE: base64 format also accepts forward slash (/), however we don't allow it because of the restriction on the backend
  USER_KEY_FORMAT = /(([\w\-.]+)|([A-Za-z0-9+]{4})*([A-Za-z0-9+]{4}|[A-Za-z0-9+]{3}=|[A-Za-z0-9+]{2}==))/.freeze

  # The following characters are accepted:
  # A-Z a-z 0-9 ! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ \ ] ^ _ ` { | } ~
  # Spaces are not allowed
  validates :application_id, format: { with: /\A[\x21-\x7E]+\Z/ }, length: { in: 4..255 }

  validates :user_key, format: { with: /\A#{USER_KEY_FORMAT}\Z/ }, length: { maximum: 256 }

  scope :order_for_dev_portal, -> { order(service_id: :desc, created_at: :desc) }

  # Return only live cinstances
  #
  # Note that deprecated cinstances are still considered live. This is
  # intentional, because deprecated cinstances can still be used (so they are
  # "live", in a way).
  scope :live, -> { where(:state => ['live', 'deprecated'])}

  scope :active_since, ->(activity_time) {
    where.has { first_daily_traffic_at >= activity_time }
  }

  # Return only cinstances live at given time. The time can be single time or
  # period (Range object) (from..to).
  scope :live_at, ->(period) {
    period = period..period unless period.is_a?(Range)

    where(['cinstances.created_at <= ?', period.end])
  }

  def self.provided_by(account)
    joins(:service).references(:service).merge(Service.of_account(account)).readonly(false)
  end

  scope :not_bought_by, ->(account) { where.has { user_account_id != account.id } }

  scope :can_be_managed, -> {
                           includes(plan: :service)
                              .where(['services.buyers_manage_apps = ?', true])
                              .references(:services)
  }
  scope :latest, -> (count = 5) { reorder(created_at: :desc).limit(count)}
  scope :by_user_key, ->(user_key) { where({:user_key => user_key}) }
  scope :by_name, ->(query) do
    case query.strip
    when /\Auser_key:\s*#{Cinstance::USER_KEY_FORMAT}\z/ then by_user_key($1)
    else all.merge(Contract.by_name(query))
    end
  end
  scope :by_application_id, ->(app_id) { where({:application_id => app_id}) }

  def self.by_service(service)
    if service == :all || service.blank?
      all
    else
      where{ plan_id.in( my{Plan.issued_by(service).select(:id)} ) }
    end
  end

  scope :by_service_id, ->(service_id) {
    where(service_id: service_id)
  }

  scope :by_active_since, ->(date) { where('first_daily_traffic_at >= ?', date) }
  scope :by_inactive_since, ->(date) { where('first_daily_traffic_at <= ?', date) }

  scope :by_plan_system_name, ->(system_names) {
    names = [system_names].flatten

    proc_like_system_name = proc { |cinstance, name| cinstance.plan.system_name.like(name) }
    proc_query_chain = proc { |cinstance| names.map { |name| proc_like_system_name.call(cinstance, name)}.inject(:or) }

    joins(:plan).where.has { |cinstance| proc_query_chain.call(cinstance) }
  }

  ##
  #  Instance Methods
  #  and other stuff :(
  ##

  # maybe move both limit methods to their models?

  def self.serialization_preloading
    includes(:application_keys, :plan, :user_account,
             service: [:account, :default_application_plan])
  end


  def keys_limit
    case service.try(:backend_version)
    when 'oauth'
      1
    else
      ApplicationKey::KEYS_LIMIT
    end
  end

  def filters_limit
    ReferrerFilter::REFERRER_FILTERS_LIMIT
  end

  def buyer_alerts_enabled?
    service && service.notify_alerts?(:buyer, :web)
  end

  def period
    created_at..Time.zone.now
  end

  def display_name
    name.present? ? name : default_name
  end

  def default_name
    "Application on plan #{plan.name}"
  end

  delegate :provided_by?, to: :plan

  # Time value. paid until that Exact time
  def variable_cost_paid_until
    self[:variable_cost_paid_until] || trial_period_expires_at || created_at
  end

  # Shortcut for plan.service.metrics
  def metrics
    service && service.all_metrics
  end

  # Is this cinstance bought by an account?
  def bought_by?(account)
    buyer == account
  end

  def change_provider_public_key!
    update_attribute(:provider_public_key, generate_key)
  end

  def change_user_key!
    @webhook_event = 'user_key_updated'
    update_attribute(:user_key, generate_key)
  end

  def user_key_updated?
    saved_change_to_user_key?
  end

  def push_webhook_key_updated
    #Push only if updated by User
    self.web_hook_event!({user: User.current, event: "key_updated"}) if User.current
  end

  def push_application_updated_event
    Applications::ApplicationUpdatedEvent.create_and_publish!(self)
  end

  # Reson why cinstance was rejected. This is only set after +reject!+ is called
  attr_reader :rejection_reason

  # Reject pending cinstance.
  #
  # Note that this is just convenience method equivalent to:
  #   cinstance.rejection_reason = "i don't like your mother"
  #   cinstance.destroy
  def reject!(reason)
    @rejection_reason = reason
    destroy
  end

  def select_users
    service.cinstances.collect {|c| [ c.user_name, c.id ] }
  end

  def available_application_plans
    plans_table   = Plan.table_name
    stock_not_mine = ["(#{plans_table}.original_id = 0 OR
                       #{plans_table}.original_id IS NULL) AND
                       #{plans_table}.id <> ?", plan_id]

    service.application_plans.where(stock_not_mine)
  end

  # Get a usage status object for this cinstance. This object contains information about
  # how close this cinstance is to it's usage limits. See ServiceTransaction::Status for
  # more details.
  def usage_status(options = {})
    Backend::Transaction.usage_status(provider_account, self, service, options)
  end

  def to_xml(options = {})
    result = options[:builder] || ThreeScale::XML::Builder.new

    result.application do |xml|
      unless new_record?
        xml.id_ id
        xml.created_at created_at.xmlschema
        xml.updated_at updated_at.xmlschema
      end
      xml.state state

      xml.user_account_id user_account_id
      xml.first_traffic_at first_traffic_at.try(:xmlschema)
      xml.first_daily_traffic_at first_daily_traffic_at.try(:xmlschema)
      xml.service_id service.id if service.present?
      if service.backend_version.v1?
        xml.user_key( user_key )
        xml.provider_verification_key( provider_public_key )

      else #v2, oauth on enterprise
        xml.application_id( application_id )

        if service.backend_version.oauth?
          xml.redirect_url redirect_url
        end

        unless destroyed?
          xml.keys do |keys_element|
            keys.each do |k|
              keys_element.key k
            end
          end
        end
      end

      plan.to_xml(:builder => xml)

      service.oidc_configuration.to_xml(builder: xml) if service.oidc?

      unless destroyed?
        fields_to_xml(xml)
        extra_fields_to_xml(xml)

        if persisted?
          if referrer_filters_required?
            xml.referrer_filters do |referer_filters_element|
              referrers.each do |rf|
                referer_filters_element.referrer_filter(rf)
              end
            end
          end
        end
      end
    end

    result.to_xml
  end

  delegate :custom_keys_enabled?, :referrer_filters_required?, :to => :service

  def reload(*)
    super
  ensure
    @backend_object = nil
    @validate_human_edition = nil
  end

  def backend_object
    @backend_object ||= provider_account.backend_object.application(self)
  end

  def create_origin
    origin = self[:create_origin]
    ActiveSupport::StringInquirer.new(origin.to_s)
  end

  def keys
    application_keys.pluck_values
  end

  def referrers
    referrer_filters.pluck_values
  end

  def app_plan_change_should_request_credit_card?
    service.plan_change_permission(ApplicationPlan) == :request_credit_card
  end

  protected

  def account_for_sphinx
    user_account_id
  end

  def correct_plan_subclass?
    unless self.plan.is_a? ApplicationPlan
      errors.add(:plan, 'plan must be an ApplicationPlan')
    end
  end

  private

  def only_traffic_updated?
    (saved_changes.keys - %w[first_traffic_at first_daily_traffic_at updated_at]).empty?
  end

  # It calls to `create_key_after_create` to check if it's possible to add
  # an application key.
  #
  # Return false if it isn't possible to create a first key
  def create_first_key
    create_key_after_create? ? application_keys.add : false
  end

  #
  # Overrides Contract protected method to run WebHooks after sucessful plan change
  #
  def change_plan_internal(new_plan)
    super do
      yield

      @webhook_event = 'plan_changed'
    end
  end

  def same_service
    return if plan.blank? || plan.service == service
    errors.add(:plan, :not_allowed)
  end

  def reject_if_pending
    notify_observers(:rejected) if pending?
  end

  def name_required?
    @validate_human_edition
  end

  def service_intentions_required?
    issuer && issuer.intentions_required?
  end

  def multiple_applications_allowed?
    provider_account && provider_account.multiple_applications_allowed?
  end

  # Custom validation that assures that there is only one non-deleted cinstance
  # per plan and user account.
  #
  # TODO: maybe i can remove this and use regular validates_uniquenes_of?
  # validates_uniqueness_of :plan_id, :scope => [:user_account_id], :unless => :multiple_applications_allowed?, :message => 'is already bought'
  #
  # SURE! If you get rid of acts_as_paranoid because it has to be non-deleted and it keeps cinstances in this table

  def plan_is_unique
    if plan && user_account && !multiple_applications_allowed?
      # All non-deleted cinstance with the same user_account as this one...
      others = plan.cinstances.bought_by(user_account)

      # ...except this one (if already exists in database).
      others = others.without_ids(self.id) unless new_record?

      errors.add(:plan_id, 'is already bought') unless others.empty?
    end
  end

  def application_id_is_unique
    if provider_account
      others = provider_account.provided_cinstances.by_application_id(application_id)
      others = others.without_ids(self.id) unless new_record?
      errors.add(:application_id, :taken) unless others.empty?
    end
  end

  def validate_application_id_is_unique?
    !provider_account.try!(:provider_can_use?, :duplicate_application_id)
  end

  def provider_can_duplicate_user_key?
    provider_account.try!(:provider_can_use?, :duplicate_user_key)
  end

  def user_key_is_unique
    if provider_account
      others = provider_account.provided_cinstances.by_user_key(user_key)
      others = others.without_ids(self.id) unless new_record?
      errors.add(:user_key, :taken) unless others.empty?
    end
  end

  scope :without_ids, ->(id) { where(["#{table_name}.id <> ?", id]) }

  def set_user_key
    self.user_key ||= generate_key
  end

  def set_provider_public_key
    self.provider_public_key ||= generate_key
  end

  def set_service_id
    self.service_id ||= plan.try(:issuer_id)
  end

  def generate_key
    SecureRandom.hex(16)
  end
end