ManageIQ/manageiq

View on GitHub
app/models/miq_report/generator.rb

Summary

Maintainability
D
1 day
Test Coverage
D
64%
module MiqReport::Generator
  extend ActiveSupport::Concern

  include Aggregation
  include Async
  include Html
  include Sorting
  include Trend
  include Utilization

  DATE_TIME_BREAK_SUFFIXES = [
    [N_("Hour"),              "hour"],
    [N_("Day"),               "day"],
    [N_("Week"),              "week"],
    [N_("Month"),             "month"],
    [N_("Quarter"),           "quarter"],
    [N_("Year"),              "year"],
    [N_("Hour of the Day"),   "hour_of_day"],
    [N_("Day of the Week"),   "day_of_week"],
    [N_("Day of the Month"),  "day_of_month"],
    [N_("Week of the Year"),  "week_of_year"],
    [N_("Month of the Year"), "month_of_year"]
  ].freeze

  module ClassMethods
    def date_time_break_suffixes
      DATE_TIME_BREAK_SUFFIXES
    end

    def get_col_break_suffixes(col)
      col_type = MiqExpression::Target.parse(col).column_type
      case col_type
      when :date
        date_time_break_suffixes.select { |_name, suffix| !suffix.to_s.starts_with?("hour") }
      when :datetime
        date_time_break_suffixes
      else
        []
      end
    end

    def all_break_suffixes
      date_time_break_suffixes.collect(&:last)
    end

    def is_break_suffix?(suffix)
      all_break_suffixes.include?(suffix)
    end

    def default_queue_timeout
      ::Settings.reporting.queue_timeout.to_i_with_method
    end
  end

  def col_to_expression_col(col)
    parts = col.split(".")
    if parts.length == 1
      table = db
    else
      table, col = parts[-2..-1]
    end
    "#{table2class(table)}-#{col}"
  end

  def table2class(table)
    @table2class ||= {}

    @table2class[table] ||= begin
      case table.to_sym
      when :ports, :nics, :storage_adapters
        "GuestDevice"
      when :"<compare>"
        self.class.name
      else
        ref = db_class.reflection_with_virtual(table.to_sym)
        ref ? ref.class_name : table.singularize.camelize
      end
    end

    @table2class[table]
  end

  def get_include_for_find
    get_include.deep_merge(include_for_find || {}).presence
  end

  def get_include
    include_as_hash(include.presence || invent_report_includes)
  end

  def polymorphic_includes
    @polymorphic_includes ||= begin
      top_level_rels = Array(get_include.try(:keys)) + Array(get_include_for_find.try(:keys))

      top_level_rels.uniq.each_with_object([]) do |assoc, polymorphic_rels|
        reflection = db_class.reflect_on_association(assoc)
        polymorphic_rels << assoc if reflection && reflection.polymorphic?
      end
    end
  end

  def get_include_for_find_rbac
    polymorphic_includes.each_with_object(get_include_for_find.dup) do |key, includes|
      includes.delete(key)
    end
  end

  def get_include_rbac
    polymorphic_includes.each_with_object(get_include.dup) do |key, includes|
      includes.delete(key)
    end
  end

  def invent_includes
    include_as_hash(invent_report_includes)
  end

  # would like this format to go away
  # will go away when we drop build_reportable_data
  def invent_report_includes
    return {} unless col_order

    col_order.each_with_object({}) do |col, ret|
      next unless col.include?(".")

      *rels, column = col.split(".")
      if col !~ /managed\./ && col !~ /virtual_custom/
        (rels.inject(ret) { |h, rel| h[rel] ||= {} }["columns"] ||= []) << column
      end
    end
  end

  def include_as_hash(includes = include, klass = db_class, klass_cols = cols)
    result = {}
    if klass_cols && klass && klass.respond_to?(:virtual_attribute?)
      klass_cols.each do |c|
        result[c.to_sym] = {} if klass.virtual_attribute?(c) && !klass.attribute_supported_by_sql?(c)
      end
    end

    if includes.kind_of?(Hash)
      includes.each do |k, v|
        k = k.to_sym
        if k == :managed
          result[:tags] = {}
        else
          assoc_reflection = klass.reflect_on_association(k)
          assoc_klass = (assoc_reflection.options[:polymorphic] ? k : assoc_reflection.klass) if assoc_reflection

          result[k] = include_as_hash(v && v["include"], assoc_klass, v && v["columns"])
        end
      end
    elsif includes.kind_of?(Array)
      includes.each { |i| result[i.to_sym] = {} }
    end

    result
  end

  def queue_generate_table(options = {})
    options[:userid] ||= "system"
    options[:mode] ||= "async"
    options[:report_source] ||= "Requested by user"

    sync = options.delete(:report_sync) || ::Settings.product.report_sync

    task = MiqTask.create(:name => "Generate Report: '#{name}'", :userid => options[:userid])

    report_result = MiqReportResult.create(
      :name          => title,
      :userid        => options[:userid],
      :report_source => options[:report_source],
      :db            => db,
      :miq_report_id => id,
      :miq_task_id   => task.id
    )

    AuditEvent.success(
      :event        => "generate_table",
      :target_class => self.class.base_class.name,
      :target_id    => id,
      :userid       => options[:userid],
      :message      => "#{task.name}, successfully initiated"
    )

    task.update_status("Queued", "Ok", "Task has been queued")

    if sync
      _async_generate_table(task.id, options)
    else
      MiqQueue.submit_job(
        :service     => "reporting",
        :class_name  => self.class.name,
        :instance_id => id,
        :method_name => "_async_generate_table",
        :args        => [task.id, options],
        :msg_timeout => queue_timeout
      )
    end

    report_result.id
  end

  def generate_table(options = {})
    if options[:user]
      User.with_user(options[:user]) { _generate_table(options) }
    elsif options[:userid]
      userid = MiqReportResult.parse_userid(options[:userid])
      user = User.lookup_by_userid(userid)
      User.with_user(user, userid) { _generate_table(options) }
    else
      _generate_table(options)
    end
  end

  def _generate_table(options = {})
    return build_table_from_report(options) if db == self.class.name # Build table based on data from passed in report object

    _generate_table_prep

    results = if custom_results_method
                generate_custom_method_results(options)
              elsif performance
                generate_performance_results(options)
              elsif interval == 'daily' && db_class <= MetricRollup
                generate_daily_metric_rollup_results(options)
              elsif interval
                generate_interval_metric_results(options)
              else
                generate_basic_results(options)
              end

    if db_options && db_options[:long_term_averages] && results.first.kind_of?(MetricRollup)
      # Calculate long_term_averages and save in extras
      extras[:long_term_averages] = Metric::LongTermAverages.get_averages_over_time_period(results.first.resource, db_options[:long_term_averages].merge(:ext_options => ext_options))
    end

    build_apply_time_profile(results)
    build_table(results, db, options)
  end

  def generate_custom_method_results(options = {})
    if db_class.respond_to?(custom_results_method)
      # Use custom method in DB class to get report results if defined
      results, ext = db_class.send(custom_results_method, db_options[:options].merge(:userid      => options[:userid],
                                                                                     :ext_options => ext_options,
                                                                                     :report_cols => cols))
    elsif respond_to?(custom_results_method)
      # Use custom method in MiqReport class to get report results if defined
      results, ext = send(custom_results_method, options)
    else
      raise _("Unsupported report type '%{type}'") % {:type => db_options[:rpt_type]}
    end
    # TODO: results = results.select(only_cols)
    extras.merge!(ext) if ext && ext.kind_of?(Hash)
    results
  end

  # Original C&U charts breakdown by tags
  def generate_performance_results(options = {})
    if performance[:group_by_category] && performance[:interval_name]
      results, extras[:interval] = db_class.vms_by_category(performance)
    else
      results, extras[:group_by_tag_cols], extras[:group_by_tags] = db_class.group_by_tags(
        db_class.find_entries(ext_options).where(where_clause).where(options[:where_clause]),
        :category  => performance[:group_by_category],
        :cat_model => options[:cat_model]
      )
      build_correlate_tag_cols
    end
    results
  end

  def performance_report_time_range
    if db_options[:custom_time_range]
      db_options[:start_date]..db_options[:end_date]
    else
      Metric::Helper.time_range_from_offset(interval, db_options[:start_offset], db_options[:end_offset], tz)
    end
  end

  # Ad-hoc daily performance reports
  #   Daily for: Performance - Clusters...
  def generate_daily_metric_rollup_results(options = {})
    unless conditions.nil?
      conditions.preprocess_options = {:vim_performance_daily_adhoc => (time_profile && time_profile.rollup_daily_metrics)}
    end

    results = Metric::Helper.find_for_interval_name('daily', time_profile || tz, db_class)
                            .where(where_clause)
                            .where(options[:where_clause])
                            .where(:timestamp => performance_report_time_range)
                            .includes(get_include_for_find)
                            .references(db_class.includes_to_references(get_include))
                            .limit(options[:limit])
    results = Rbac.filtered(results, :class        => db,
                                     :filter       => conditions,
                                     :userid       => options[:userid],
                                     :miq_group_id => options[:miq_group_id])
    Metric::Helper.remove_duplicate_timestamps(results)
  end

  # Ad-hoc performance reports
  def generate_interval_metric_results(options = {})
    results = db_class.with_interval_and_time_range(interval, performance_report_time_range)
                      .where(where_clause)
                      .where(options[:where_clause])
                      .includes(get_include_for_find)
                      .references(db_class.includes_to_references(get_include))
                      .limit(options[:limit])

    # Rbac will only add miq_expression for hourly report. It will not work properly for daily because many values are rolled up from hourly.
    results = Rbac.filtered(results, :class        => db,
                                     :filter       => conditions,
                                     :userid       => options[:userid],
                                     :miq_group_id => options[:miq_group_id])
    Metric::Helper.remove_duplicate_timestamps(results)
  end

  # Basic report
  # Daily and Hourly for: C&U main reports go through here too
  def generate_basic_results(options = {})
    # TODO: need to enhance only_cols to better support virtual columns
    # only_cols += conditions.columns_for_sql if conditions # Add cols references in expression to ensure they are present for evaluation
    # NOTE: using search to get user property "managed", otherwise this is overkill
    targets = db_class
    targets = db_class.find_entries(ext_options) if targets.respond_to?(:find_entries)
    # TODO: add once only_cols is fixed
    # targets = targets.select(only_cols)
    targets = targets.where(where_clause) if where_clause
    targets = targets.where(options[:where_clause]) if options[:where_clause]

    # Remove custom_attributes as part of the `includes` if all of them exist
    # in the select statement
    if all_custom_attributes_are_virtual_sql_attributes?
      remove_loading_relations_for_virtual_custom_attributes
    end

    rbac_opts = options.merge(
      :targets          => targets,
      :filter           => conditions,
      :include_for_find => get_include_for_find_rbac,
      :references       => get_include_rbac,
      :skip_counts      => true
    )

    ## add in virtual attributes that can be calculated from sql
    rbac_opts[:extra_cols] = va_sql_cols if va_sql_cols.present?
    rbac_opts[:use_sql_view] = if db_options.nil? || db_options[:use_sql_view].nil?
                                 MiqReport.default_use_sql_view
                               else
                                 db_options[:use_sql_view]
                               end

    results, attrs = Rbac.search(rbac_opts)
    results = Metric::Helper.remove_duplicate_timestamps(results)
    results = BottleneckEvent.remove_duplicate_find_results(results) if db == "BottleneckEvent"
    @user_categories = attrs[:user_filters]["managed"]
    results
  end

  def build_create_results(options, taskid = nil)
    ts = Time.now.utc
    attrs = {
      :name             => title,
      :userid           => options[:userid],
      :report_source    => options[:report_source],
      :db               => db,
      :last_run_on      => ts,
      :last_accessed_on => ts,
      :miq_report_id    => id,
      :miq_group_id     => options[:miq_group_id]
    }

    _log.info("Creating report results with hash: [#{attrs.inspect}]")
    res   = MiqReportResult.find_by_miq_task_id(taskid) unless taskid.nil?
    res ||= MiqReportResult.find_by_userid(options[:userid]) if options[:userid].include?("|") # replace results if adhoc (<userid>|<session_id|<mode>) user report
    res ||= MiqReportResult.new
    res.attributes = attrs

    res.report_results = self

    curr_tz = Time.zone # Save current time zone setting
    userid = options[:userid].split("|").first if options[:userid]
    user = User.lookup_by_userid(userid) if userid

    # TODO: user is nil from MiqWidget#generate_report_result due to passing the username as the second part of :userid, such as widget_id_735|admin...
    # Looks like widget generation for a user doesn't expect multiple timezones, could be an issue with MiqGroups.
    timezone = options[:timezone]
    timezone ||= user.respond_to?(:get_timezone) ? user.get_timezone : User.server_timezone

    Time.zone = timezone

    html_rows = build_html_rows

    Time.zone = curr_tz # Restore current time zone setting

    res.report_html = html_rows
    self.extras ||= {}
    self.extras[:total_html_rows] = html_rows.length

    append_user_filters_to_title(user)

    report = dup
    report.table = nil
    res.report = report
    res.save
    _log.info("Finished creating report result with id [#{res.id}] for report id: [#{id}], name: [#{name}]")

    res
  end

  def build_table(data, _db, options = {})
    data = data.to_a
    objs = data[0] && data[0].kind_of?(Integer) ? db_class.where(:id => data) : data.compact

    remove_loading_relations_for_virtual_custom_attributes

    # Add resource columns to performance reports cols and col_order arrays for widget click thru support
    if db_class.to_s.ends_with?("Performance")
      res_cols = ['resource_name', 'resource_type', 'resource_id']
      self.cols = (cols + res_cols).uniq
      orig_col_order = col_order.dup
      self.col_order = (col_order + res_cols).uniq
    end

    only_cols = options[:only] || cols_for_report(['id'])
    self.col_order = cols_for_report if col_order.blank?

    build_trend_data(objs)
    build_trend_limits(objs)

    # Add missing timestamps after trend calculation to prevent timestamp adjustment for added timestamps.
    objs = build_add_missing_timestamps(objs)

    data = build_includes(objs)
    inc = include.presence || invent_report_includes
    result = data.collect do |entry|
      build_reportable_data(entry, {:only => only_cols, "include" => inc}, nil)
    end.flatten

    if rpt_options && rpt_options[:pivot]
      result = build_pivot(result)
      column_names = col_order
    else
      column_names = only_cols
    end
    result = build_apply_display_filter(result) unless display_filter.nil?

    @table = Ruport::Data::Table.new(:data => result, :column_names => column_names)
    @table.reorder(column_names) unless @table.data.empty?

    # Remove any resource columns that were added earlier to col_order so they won't appear in the report
    col_order.delete_if { |c| res_cols.include?(c) && !orig_col_order.include?(c) } if res_cols

    build_sort_table unless options[:no_sort]

    if options[:limit]
      options[:offset] ||= 0
      self.extras[:target_ids_for_paging] = @table.data.collect { |d| d["id"] } # Save ids of targets, since we have then all, to avoid going back to SQL for the next page
      @table = @table.sub_table(@table.column_names, options[:offset]..options[:offset] + options[:limit] - 1)
    end

    build_subtotals
  end

  def build_table_from_report(options = {})
    unless db_options && db_options[:report]
      raise _("No %{class_name} object provided") % {:class_name => self.class.name}
    end
    unless db_options[:report].kind_of?(self.class)
      raise _("DB option :report must be a %{class_name} object") % {:class_name => self.class.name}
    end

    result = generate_rows_from_data(get_data_from_report(db_options[:report]))

    self.cols ||= []
    only_cols = options[:only] || cols_for_report(generate_cols)
    column_names = result.empty? ? self.cols : result.first.keys
    @table = Ruport::Data::Table.new(:data => result, :column_names => column_names)
    @table.reorder(only_cols) unless @table.data.empty?

    build_sort_table
  end

  def get_data_from_report(rpt)
    raise _("Report table is nil") if rpt.table.nil?

    if db_options[:row_col] && db_options[:row_val]
      rpt.table.find_all { |d| d.data.key?(db_options[:row_col]) && (d.data[db_options[:row_col]] == db_options[:row_val]) }.collect(&:data)
    else
      rpt.table.collect(&:data)
    end
  end

  def generate_rows_from_data(data)
    data.each_with_object([]) do |d, arr|
      generate_rows.each do |gen_row|
        row = {}
        gen_row.each_with_index do |col_def, col_idx|
          new_col_name = generate_cols[col_idx]
          row[new_col_name] = generate_col_from_data(col_def, d)
        end
        arr << row
      end
    end
  end

  def generate_col_from_data(col_def, data)
    if col_def.kind_of?(Hash)
      unless data.key?(col_def[:col_name])
        raise _("Column '%{name} does not exist in data") % {:name => col_def[:col_name]}
      end

      col_def.key?(:function) ? apply_col_function(col_def, data) : data[col_def[:col_name]]
    else
      col_def
    end
  end

  def apply_col_function(col_def, data)
    case col_def[:function]
    when 'percent_of_col'
      unless data.key?(col_def[:col_name])
        raise _("Column '%{name} does not exist in data") % {:name => gen_row[:col_name]}
      end
      unless data.key?(col_def[:pct_col_name])
        raise _("Column '%{name} does not exist in data") % {:name => gen_row[:pct_col_name]}
      end

      col_val = data[col_def[:col_name]] || 0
      pct_val = data[col_def[:pct_col_name]] || 0
      pct_val == 0 ? 0 : (col_val / pct_val * 100.0)
    else
      raise _("Column function '%{name}' not supported") % {:name => col_def[:function]}
    end
  end

  def build_correlate_tag_cols
    tags2desc = {}
    arr = self.cols.each_with_object([]) do |c, a|
      self.extras[:group_by_tag_cols].each do |tc|
        tag = tc[(c.length + 1)..-1]
        if tc.starts_with?(c)
          unless tags2desc.key?(tag)
            if tag == "_none_"
              tags2desc[tag] = "[None]"
            else
              entry = Classification.lookup_by_name([performance[:group_by_category], tag].join("/"))
              tags2desc[tag] = entry.nil? ? tag.titleize : entry.description
            end
          end
          a << [tc, tags2desc[tag]]
        end
      end
    end
    arr.sort_by! { |a| a[1] }
    while arr.first[1] == "[None]"
      arr.push(arr.shift)
    end unless arr.blank? || (arr.first[1] == "[None]" && arr.last[1] == "[None]")
    arr.each do |c, h|
      self.cols.push(c)
      col_order.push(c)
      headers.push(h)
    end

    tarr = Array(tags2desc).sort_by { |t| t[1] }
    while tarr.first[1] == "[None]"
      tarr.push(tarr.shift)
    end unless tarr.blank? || (tarr.first[1] == "[None]" && tarr.last[1] == "[None]")
    self.extras[:group_by_tags] = tarr.collect { |a| a[0] }
    self.extras[:group_by_tag_descriptions] = tarr.collect { |a| a[1] }
  end

  def build_add_missing_timestamps(recs)
    return recs unless !recs.empty? && (recs.first.kind_of?(Metric) || recs.first.kind_of?(MetricRollup))
    return recs if db_options && db_options[:calc_avgs_by] && db_options[:calc_avgs_by] != "time_interval" # Only fill in missing timestamps if averages are requested to be based on time

    base_cols = Metric::BASE_COLS - ["id"]
    int = recs.first.capture_interval_name == 'daily' ? 1.day.to_i : 1.hour.to_i
    klass = recs.first.class
    last_rec = nil

    recs.sort_by { |r| [r.resource_type, r.resource_id.to_s, r.timestamp.iso8601] }.each_with_object([]) do |rec, arr|
      last_rec ||= rec
      while (rec.timestamp - last_rec.timestamp) > int
        base_attrs = last_rec.attributes.reject { |k, _v| !base_cols.include?(k) }
        last_rec = klass.new(base_attrs.merge(:timestamp => (last_rec.timestamp + int)))
        last_rec.inside_time_profile = false if last_rec.respond_to?(:inside_time_profile)
        arr << last_rec
      end
      arr << rec
      last_rec = rec
    end
  end

  def build_apply_time_profile(results)
    return unless time_profile

    # Apply time profile if one was provided
    results.each { |rec| rec.apply_time_profile(time_profile) if rec.respond_to?(:apply_time_profile) }
  end

  def build_apply_display_filter(results)
    return results if display_filter.nil?

    if display_filter.kind_of?(MiqExpression)
      display_filter.context_type = "hash" # Tell MiqExpression that the context objects are hashes
      results.find_all { |h| display_filter.evaluate(h) }
    elsif display_filter.kind_of?(Proc)
      results.select(&display_filter)
    elsif display_filter.kind_of?(Hash)
      op  = display_filter[:operator]
      fld = display_filter[:field].to_s
      val = display_filter[:value]
      results.select do |r|
        case op
        when "="  then (r[fld] == val)
        when "!=" then (r[fld] != val)
        when "<"  then (r[fld] < val)
        when "<=" then (r[fld] <= val)
        when ">"  then (r[fld] > val)
        when ">=" then (r[fld] >= val)
        else
          false
        end
      end
    end
  end

  def get_group_val(row, keys)
    keys.inject([]) { |a, k| a << row[k] }.join("__")
  end

  def process_group_break(gid, group, totals, result)
    result[gid] = group
    totals[:count] += group[:count]
    process_totals(group)
  end

  def build_pivot(data)
    return data unless rpt_options && rpt_options.key?(:pivot)
    return data if data.blank?

    # Build a tempory table so that ruport sorting can be used to sort data before summarizing pivot data
    column_names = (data.first.keys.collect(&:to_s) + col_order).uniq
    data = Ruport::Data::Table.new(:data => data, :column_names => column_names)
    data = sort_table(data, rpt_options[:pivot][:group_cols].collect(&:to_s), :order => :ascending)

    # build grouping options for subtotal
    options = col_order.each_with_object({}) do |col, h|
      next(h) unless col.include?("__")

      c, g = col.split("__")
      h[c] ||= {}
      h[c][:grouping] ||= []
      h[c][:grouping] << g.to_sym
    end

    group_key = rpt_options[:pivot][:group_cols]
    data = generate_subtotals(data, group_key, options)
    data.inject([]) do |a, (k, v)|
      next(a) if k == :_total_

      row = col_order.each_with_object({}) do |col, h|
        if col.include?("__")
          c, g = col.split("__")
          h[col] = v[g.to_sym][c]
        else
          h[col] = v[:row][col]
        end
      end
      a << row
    end
  end

  # the columns that are needed for this report.
  # there may be some columns that are used to derive columns,
  # so we currently include '*'
  def cols_for_report(extra_cols = [])
    ((cols || []) + (col_order || []) + (extra_cols || []) + build_cols_from_include(include)).uniq
  end

  def build_cols_from_include(hash, parent_association = nil)
    return [] if hash.blank?

    hash.inject([]) do |a, (k, v)|
      full_path = get_full_path(parent_association, k)
      v["columns"].each { |c| a << get_full_path(full_path, c) } if v.key?("columns")
      a += (build_cols_from_include(v["include"], full_path) || []) if v.key?("include")
      a
    end
  end

  def build_includes(objs)
    results = []

    inc = include.presence || invent_report_includes
    objs.each do |obj|
      entry = {:obj => obj}
      build_search_includes(obj, entry, inc) if inc
      results.push(entry)
    end

    results
  end

  def build_search_includes(obj, entry, includes)
    includes.each_key do |assoc|
      next unless obj.respond_to?(assoc)

      assoc_objects = [obj.send(assoc)].flatten.compact

      entry[assoc.to_sym] = assoc_objects.collect do |rec|
        new_entry = {:obj => rec}
        build_search_includes(rec, new_entry, includes[assoc]["include"]) if includes[assoc]["include"]
        new_entry
      end
    end
  end

  # simplify to use col_sort_order. "include" won't be necessary)
  def build_reportable_data(entry, options, parent_association)
    rec = entry[:obj]
    data_records = [build_get_attributes_with_options(rec, options)]
    data_records = build_add_includes(data_records, entry, options["include"], parent_association) if options["include"]
    data_records
  end

  def build_get_attributes_with_options(rec, options = {})
    only_or_except =
      if options[:only] || options[:except]
        {:only => options[:only], :except => options[:except]}
      end
    return {} unless only_or_except

    attrs = {}
    options[:only].each do |a|
      if self.class.is_trend_column?(a)
        attrs[a] = build_calculate_trend_point(rec, a)
      else
        attrs[a] = rec.send(a) if rec.respond_to?(a)
      end
    end
    attrs = attrs.each_with_object({}) do |(k, v), h|
      h["#{options[:qualify_attribute_names]}.#{k}"] = v
    end if options[:qualify_attribute_names]
    attrs
  end

  def build_add_includes(data_records, entry, includes, parent_association)
    include_has_options = includes.kind_of?(Hash)
    associations = include_has_options ? includes.keys : Array(includes)

    associations.each do |association|
      existing_records = data_records.dup
      data_records = []
      full_path = get_full_path(parent_association, association)
      if include_has_options
        assoc_options = includes[association].merge(:qualify_attribute_names => full_path,
                                                    :only                    => includes[association]["columns"])
      else
        assoc_options = {:qualify_attribute_names => full_path, :only => includes[association]["columns"]}
      end

      if ["categories", "managed"].include?(association)
        association_objects = []
        assochash = {}
        @descriptions_by_tag_id ||= Classification.is_entry.each_with_object({}) do |c, h|
          h[c.tag_id] = c.description
        end

        assoc_options[:only].each do |c|
          entarr = []
          entry[:obj].tags.each do |t|
            next unless t.name.starts_with?("/managed/#{c}/")
            next unless @descriptions_by_tag_id.key?(t.id)

            entarr << @descriptions_by_tag_id[t.id]
          end
          assochash[full_path + "." + c] = entarr unless entarr.empty?
        end
        # join the the category data together
        longest = 0
        idx = 0
        assochash.each_key { |k| longest = assochash[k].length if assochash[k].length > longest }
        longest.times do
          nh = {}
          assochash.each_key { |k| nh[k] = assochash[k][idx].nil? ? assochash[k].last : assochash[k][idx] }
          association_objects.push(nh)
          idx += 1
        end
      else
        association_objects = entry[association.to_sym]
      end

      existing_records.each do |existing_record|
        if association_objects.empty?
          data_records << existing_record
        else
          association_objects.each do |obj|
            if ["categories", "managed"].include?(association)
              association_records = [obj]
            else
              association_records = build_reportable_data(obj, assoc_options, full_path)
            end
            association_records.each do |assoc_record|
              data_records << existing_record.merge(assoc_record)
            end
          end
        end
      end
    end
    data_records
  end

  def queue_report_result(options, res_opts)
    options[:userid] ||= "system"
    _log.info("Adding generate report task to the message queue...")
    task = MiqTask.create(:name => "Generate Report: '#{name}'", :userid => options[:userid])

    MiqQueue.submit_job(
      :service     => "reporting",
      :class_name  => self.class.name,
      :instance_id => id,
      :method_name => "build_report_result",
      :msg_timeout => queue_timeout,
      :args        => [task.id, options, res_opts]
    )
    AuditEvent.success(:event => "generate_table", :target_class => self.class.base_class.name, :target_id => id, :userid => options[:userid], :message => "#{task.name}, successfully initiated")
    task.update_status("Queued", "Ok", "Task has been queued")

    _log.info("Finished adding generate report task with id [#{task.id}] to the message queue")
    task.id
  end

  def build_report_result(taskid, options, res_opts = {})
    task = MiqTask.find(taskid)

    # Generate the table only if the task does not already contain a MiqReport object
    if task.task_results.blank?
      _log.info("Generating report table with taskid [#{taskid}] and options [#{options.inspect}]")
      _async_generate_table(taskid, options.merge(:mode => "schedule", :report_source => res_opts[:source]))

      # Reload the task after the _async_generate_table has updated it
      task.reload
      if !task.results_ready?
        _log.warn("Generating report table with taskid [#{taskid}]... Failed to complete, '#{task.message}'")
        return
      else
        _log.info("Generating report table with taskid [#{taskid}]... Complete")
      end
    end

    res_last_run_on  = Time.now.utc

    # If a scheduler :at time was provided, convert that to a Time object, otherwise use the current time
    if res_opts[:at]
      unless res_opts[:at].kind_of?(Numeric)
        raise _("Expected scheduled time 'at' to be 'numeric', received '%{type}'") % {:type => res_opts[:at].class}
      end

      at = Time.at(res_opts[:at]).utc
    else
      at = res_last_run_on
    end

    res = task.miq_report_result
    nh = {:miq_task_id => taskid, :scheduled_on => at}
    _log.info("Updating report results with hash: [#{nh.inspect}]")
    res.update(nh)
    _log.info("Finished creating report result with id [#{res.id}] for report id: [#{id}], name: [#{name}]")

    notify_user_of_report(res_last_run_on, res, options) if options[:send_email]

    # Remove the table in the task_results since we now have it in the report_results
    task.task_results = nil
    task.save
    res
  end

  def table_has_records?
    !table.empty?
  end

  def queue_timeout
    ((rpt_options || {})[:queue_timeout] || self.class.default_queue_timeout).to_i_with_method
  end

  def queue_timeout=(value)
    self.rpt_options ||= {}
    self.rpt_options[:queue_timeout] = value
  end

  #####################################################

  def append_to_title!(title_suffix)
    self.title += title_suffix
  end

  def append_user_filters_to_title(user)
    return unless user && user.has_filters?

    append_to_title!(" (filtered for #{user.name})")
  end

  def get_time_zone(default_tz = nil)
    time_profile&.tz || tz || default_tz
  end

  private

  def get_full_path(parent, child)
    if parent
      "#{parent}.#{child}"
    else
      child.to_s
    end
  end

  # Preps the current instance and db class for building a report
  def _generate_table_prep
    # Make sure the db_class has the custom_attribute definitions defined for
    # the report being built.
    load_custom_attributes

    # Default time zone in profile to report time zone
    time_profile.tz ||= tz if time_profile
    self.ext_options  = {:tz => tz, :time_profile => time_profile}

    # TODO: these columns need to be converted to real SQL columns
    # only_cols = cols

    self.extras ||= {}
  end

  def interval
    @interval ||= db_options.present? && db_options[:interval]
  end

  def custom_results_method
    return @custom_results_method if defined?(@custom_results_method)

    db_rpt_type = db_options && db_options[:rpt_type]
    @custom_results_method = db_rpt_type ? "build_results_for_report_#{db_rpt_type}" : nil
  end
end