theforeman/foreman

View on GitHub
app/services/foreman/renderer/scope/report.rb

Summary

Maintainability
A
35 mins
Test Coverage
module Foreman
  module Renderer
    module Scope
      class Report < Foreman::Renderer::Scope::Template
        extend ApipieDSL::Class

        apipie :class, 'Macros specific for report rendering' do
          name 'Report'
          sections only: %w[all reports]
        end

        def initialize(**args)
          super
          @report_data = []
          @report_headers = []
        end

        apipie :method, 'Render a report for all rows defined' do
          desc 'This macro is typically called at the end of the report template, after all rows
            with data has been registered.'
          keyword :format, ReportTemplateFormat.all.map(&:id), desc: 'The desired format of output', default: ReportTemplateFormat.default.id
          keyword :order, String, desc: "The desired order of the reported data. It needs to be the name of the column or an array of more of them, e.g. <code>'name'</code> or <code>['ip', 'name']</code>. If no order is specified, the report will be sorted by the order of report_row calls.", default: nil
          keyword :reverse_order, [true, false], desc: 'Reverse the order of the reported data', default: false
          returns String, desc: 'This is the resulting report'
          example "report_render # => 'name,ip\nhost1.example.com,192.168.0.2\nhost2.example.com,192.168.0.3'"
          example "report_render(format: :yaml) # => '---\n- name: host1.example.com\n  ip: 192.168.0.2\n- name: host2.example.com\n  ip: 192.168.0.3'"
          example "report_render(order: :ip)  # => \n#'name,ip\n#host1.example.com,192.168.0.2\n#host2.example.com,192.168.0.3'"
          example "report_render(order: [:name, :ip]) # => \n#'name,ip\n#host1.example.com,192.168.0.2\n#host2.example.com,192.168.0.3'"
          example "report_render(order: :ip, reverse_order: true) # => \n#'name,ip\n#host2.example.com,192.168.0.3\n#host1.example.com,192.168.0.2'"
        end
        def report_render(format: report_format&.id, order: nil, reverse_order: false)
          apply_order!(order) if order.present?
          @report_data.reverse! if reverse_order

          case format
          when :csv, :txt, nil
            report_render_csv
          when :yaml
            report_render_yaml
          when :json
            report_render_json
          when :html
            report_render_html
          end
        end

        apipie :method, 'Register minimal headers for the report' do
          desc "Report template gathers the column names when report_row is called. However, if the method is not ever called, e.g. because the collection is empty, the report wouldn't have any headers. This macro allows to explicitly define expected headers. If new header is registered by report_row, it's just added to the list of known headers."
          list :headers, desc: 'List of headers'
          returns Array, desc: 'Minimal registered headers'
          example "<%- report_headers 'id', 'name' -%>"
        end
        def report_headers(*headers)
          @report_headers = headers.map(&:to_s)
        end

        apipie :method, 'Register a row of data for the report' do
          desc "For every record that should be part of the report, **report_row** macro needs to be called.
            The only argument it accepts is a record definition. This is typically called in some **each** loop. Calling
            this at least once is important so we know what columns are to be rendered in this report.
            Calling this macro adds a record to the rendering queue."
          optional :row_data, Hash, desc: 'Data in form of hash, keys are column names, values are values for this record', default: {}
          kwlist :row_data_kw, desc: 'Data in form of key: value list, keys are column names, values are values for this record'
          returns Array, desc: 'Currently registered report data'
          example "report_row(:name => 'host1.example.com', :ip => '192.168.0.2')"
          example "<%- load_hosts.each_record do |host|\n  report_row(:name => host.name, :ip => host.ip)\nend -%>"
        end
        # accept either a hash or kwargs for compatibility with Ruby 2.7 & 3
        def report_row(row_data = {}, **row_data_kws)
          data = row_data_kws.transform_keys(&:to_s).merge(row_data.transform_keys(&:to_s))
          new_headers = data.keys
          if @report_headers.size < new_headers.size
            @report_headers |= new_headers
          end
          @report_data << data.values
        end

        def apply_order!(order)
          order = [order].flatten.map(&:to_s)
          if (unknown = order - @report_headers).present?
            raise UnknownReportColumn.new(:unknown => unknown.join(', '))
          end

          indexes = order.map { |column| @report_headers.index(column) }
          @report_data.sort_by! do |values|
            indexes.map { |i| values[i] }
          end
        end

        def allowed_helpers
          @allowed_helpers ||= super + [:report_row, :report_render, :report_format, :report_headers]
        end

        def report_format
          @params[:format]
        end

        private

        def report_render_yaml
          @report_data.map do |row|
            valid_row = row.map { |cell| valid_yaml_type(cell) }
            Hash[@report_headers.zip(valid_row)]
          end.to_yaml
        end

        def report_render_json
          @report_data.map do |row|
            valid_row = row.map { |cell| valid_json_type(cell) }
            Hash[@report_headers.zip(valid_row)]
          end.to_json
        end

        def report_render_csv
          CSV.generate(headers: true, encoding: Encoding::UTF_8) do |csv|
            csv << @report_headers
            @report_data.each do |row|
              csv << row.map { |cell| serialize_cell(cell) }
            end
          end
        end

        def report_render_html
          html = ""

          html << "<html><head><title>#{@template_name}</title><style>#{html_style}</style></head><body><table><thead><tr>"
          html << @report_headers.map { |header| "<th>#{ERB::Util.html_escape(header)}</th>" }.join('')
          html << "</tr></thead><tbody>"

          @report_data.each do |row|
            html << "<tr>"
            html << row.map { |cell| "<td>#{ERB::Util.html_escape(cell)}</td>" }.join('')
            html << "</tr>"
          end
          html << "</tbody></table></body></html>"

          html
        end

        def html_style
          <<~CSS
            th { background-color: black; color: white; }
            table,th,td { border-collapse: collapse; border: 1px solid black; }
          CSS
        end

        def serialize_cell(cell)
          if cell.is_a?(Enumerable)
            cell.map(&:to_s).join(',')
          else
            cell.to_s
          end
        end

        def valid_yaml_type(cell)
          if cell.is_a?(String) || [true, false].include?(cell) || cell.is_a?(Numeric) || cell.nil?
            cell
          elsif cell.is_a?(Enumerable)
            cell.map { |item| valid_yaml_type(item) }
          else
            cell.to_s
          end
        end

        def valid_json_type(cell)
          if cell.is_a?(String) || [true, false].include?(cell) || cell.is_a?(Numeric) || cell.nil?
            cell
          elsif cell.is_a?(Enumerable)
            hashify = cell.is_a?(Hash)
            cell = cell.map { |item| valid_json_type(item) }
            cell = cell.to_h if hashify
            cell
          else
            cell.to_s
          end
        end
      end
    end
  end
end