reports_kits 0.7.5 → 0.7.7

Sign up to get free protection for your applications and to get access to all the features.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +6 -0
  3. data/.rubocop.yml +85 -0
  4. data/.travis.yml +21 -0
  5. data/Appraisals +27 -0
  6. data/Gemfile +3 -0
  7. data/MIT-LICENSE +20 -0
  8. data/README.md +35 -0
  9. data/Rakefile +2 -0
  10. data/app/assets/javascripts/reports_kits/application.js +14 -0
  11. data/app/assets/javascripts/reports_kits/lib/_init.js +9 -0
  12. data/app/assets/javascripts/reports_kits/lib/chart.js +73 -0
  13. data/app/assets/javascripts/reports_kits/lib/report.js +135 -0
  14. data/app/assets/javascripts/reports_kits/lib/table.js +87 -0
  15. data/app/assets/javascripts/reports_kits/vendor/chart.js +12269 -0
  16. data/app/assets/javascripts/reports_kits/vendor/daterangepicker.js +1627 -0
  17. data/app/assets/javascripts/reports_kits/vendor/jquery.tablesorter.min.js +4 -0
  18. data/app/assets/javascripts/reports_kits/vendor/moment.js +4040 -0
  19. data/app/assets/javascripts/reports_kits/vendor/select2.full.js +6436 -0
  20. data/app/assets/stylesheets/reports_kits/application.css.scss +3 -0
  21. data/app/assets/stylesheets/reports_kits/reports.css.sass +33 -0
  22. data/app/assets/stylesheets/reports_kits/select2_overrides.css.sass +7 -0
  23. data/app/assets/stylesheets/reports_kits/vendor/daterangepicker.css +269 -0
  24. data/app/assets/stylesheets/reports_kits/vendor/select2-bootstrap.css +721 -0
  25. data/app/assets/stylesheets/reports_kits/vendor/select2.css +484 -0
  26. data/config/initializers/mime_types.rb +1 -0
  27. data/config/routes.rb +10 -0
  28. data/docs/images/demo.gif +0 -0
  29. data/docs/images/users_by_created_at.png +0 -0
  30. data/gemfiles/mysql.gemfile +7 -0
  31. data/gemfiles/mysql.gemfile.lock +167 -0
  32. data/gemfiles/postgresql.gemfile +7 -0
  33. data/gemfiles/postgresql.gemfile.lock +165 -0
  34. data/gemfiles/postgresql_rails_5.1.4.gemfile +8 -0
  35. data/gemfiles/postgresql_rails_5.1.4.gemfile.lock +168 -0
  36. data/gemfiles/rails_4_mysql.gemfile +8 -0
  37. data/gemfiles/rails_4_mysql.gemfile.lock +165 -0
  38. data/gemfiles/rails_4_postgresql.gemfile +8 -0
  39. data/gemfiles/rails_4_postgresql.gemfile.lock +163 -0
  40. data/gemfiles/rails_5.1.4_postgresql.gemfile +8 -0
  41. data/gemfiles/rails_5.1.4_postgresql.gemfile.lock +169 -0
  42. data/gemfiles/rails_5_mysql.gemfile +8 -0
  43. data/gemfiles/rails_5_mysql.gemfile.lock +171 -0
  44. data/gemfiles/rails_5_postgresql.gemfile +8 -0
  45. data/gemfiles/rails_5_postgresql.gemfile.lock +169 -0
  46. data/lib/reports_kits/base_controller.rb +17 -0
  47. data/lib/reports_kits/cache.rb +37 -0
  48. data/lib/reports_kits/configuration.rb +50 -0
  49. data/lib/reports_kits/engine.rb +21 -0
  50. data/lib/reports_kits/entity.rb +3 -0
  51. data/lib/reports_kits/filters_controller.rb +11 -0
  52. data/lib/reports_kits/form_builder.rb +66 -0
  53. data/lib/reports_kits/helper.rb +24 -0
  54. data/lib/reports_kits/model.rb +16 -0
  55. data/lib/reports_kits/model_configuration.rb +28 -0
  56. data/lib/reports_kits/normalized_params.rb +16 -0
  57. data/lib/reports_kits/order.rb +33 -0
  58. data/lib/reports_kits/relative_time.rb +42 -0
  59. data/lib/reports_kits/report_builder.rb +88 -0
  60. data/lib/reports_kits/reports/abstract_series.rb +9 -0
  61. data/lib/reports_kits/reports/adapters/mysql.rb +26 -0
  62. data/lib/reports_kits/reports/adapters/postgresql.rb +26 -0
  63. data/lib/reports_kits/reports/composite_series.rb +48 -0
  64. data/lib/reports_kits/reports/contextual_filter.rb +19 -0
  65. data/lib/reports_kits/reports/data/add_table_aggregations.rb +105 -0
  66. data/lib/reports_kits/reports/data/aggregate_composite.rb +97 -0
  67. data/lib/reports_kits/reports/data/aggregate_one_dimension.rb +39 -0
  68. data/lib/reports_kits/reports/data/aggregate_two_dimensions.rb +39 -0
  69. data/lib/reports_kits/reports/data/chart_data_for_data_method.rb +62 -0
  70. data/lib/reports_kits/reports/data/chart_options.rb +155 -0
  71. data/lib/reports_kits/reports/data/format_one_dimension.rb +140 -0
  72. data/lib/reports_kits/reports/data/format_table.rb +63 -0
  73. data/lib/reports_kits/reports/data/format_two_dimensions.rb +143 -0
  74. data/lib/reports_kits/reports/data/generate.rb +156 -0
  75. data/lib/reports_kits/reports/data/generate_for_properties.rb +97 -0
  76. data/lib/reports_kits/reports/data/normalize_properties.rb +62 -0
  77. data/lib/reports_kits/reports/data/populate_one_dimension.rb +54 -0
  78. data/lib/reports_kits/reports/data/populate_two_dimensions.rb +104 -0
  79. data/lib/reports_kits/reports/data/utils.rb +178 -0
  80. data/lib/reports_kits/reports/dimension.rb +27 -0
  81. data/lib/reports_kits/reports/dimension_with_series.rb +144 -0
  82. data/lib/reports_kits/reports/filter.rb +29 -0
  83. data/lib/reports_kits/reports/filter_types/base.rb +48 -0
  84. data/lib/reports_kits/reports/filter_types/boolean.rb +47 -0
  85. data/lib/reports_kits/reports/filter_types/datetime.rb +51 -0
  86. data/lib/reports_kits/reports/filter_types/number.rb +30 -0
  87. data/lib/reports_kits/reports/filter_types/records.rb +26 -0
  88. data/lib/reports_kits/reports/filter_types/string.rb +38 -0
  89. data/lib/reports_kits/reports/filter_with_series.rb +97 -0
  90. data/lib/reports_kits/reports/generate_autocomplete_method_results.rb +29 -0
  91. data/lib/reports_kits/reports/generate_autocomplete_results.rb +57 -0
  92. data/lib/reports_kits/reports/inferrable_configuration.rb +116 -0
  93. data/lib/reports_kits/reports/model_settings.rb +30 -0
  94. data/lib/reports_kits/reports/properties.rb +10 -0
  95. data/lib/reports_kits/reports/properties_to_filter.rb +40 -0
  96. data/lib/reports_kits/reports/series.rb +121 -0
  97. data/lib/reports_kits/reports_controller.rb +65 -0
  98. data/lib/reports_kits/utils.rb +11 -0
  99. data/lib/reports_kits/value.rb +3 -0
  100. data/lib/reports_kits/version.rb +3 -0
  101. data/lib/reports_kits.rb +79 -0
  102. data/reports_kits.gemspec +26 -0
  103. data/spec/factories/issue_factory.rb +4 -0
  104. data/spec/factories/issues_label_factory.rb +4 -0
  105. data/spec/factories/label_factory.rb +4 -0
  106. data/spec/factories/pro_repo_factory.rb +5 -0
  107. data/spec/factories/repo_factory.rb +5 -0
  108. data/spec/factories/tag_factory.rb +4 -0
  109. data/spec/fixtures/generate_inputs.yml +254 -0
  110. data/spec/fixtures/generate_outputs.yml +1216 -0
  111. data/spec/reports_kit/form_builder_spec.rb +26 -0
  112. data/spec/reports_kit/relative_time_spec.rb +29 -0
  113. data/spec/reports_kit/reports/data/generate/contextual_filters_spec.rb +60 -0
  114. data/spec/reports_kit/reports/data/generate_spec.rb +1371 -0
  115. data/spec/reports_kit/reports/data/normalize_properties_spec.rb +196 -0
  116. data/spec/reports_kit/reports/dimension_with_series_spec.rb +67 -0
  117. data/spec/reports_kit/reports/filter_with_series_spec.rb +39 -0
  118. data/spec/reports_kit/reports/generate_autocomplete_results_spec.rb +69 -0
  119. data/spec/spec_helper.rb +77 -0
  120. data/spec/support/config.rb +41 -0
  121. data/spec/support/example_data_methods.rb +25 -0
  122. data/spec/support/factory_girl.rb +5 -0
  123. data/spec/support/helpers.rb +25 -0
  124. data/spec/support/models/issue.rb +14 -0
  125. data/spec/support/models/issues_label.rb +4 -0
  126. data/spec/support/models/label.rb +5 -0
  127. data/spec/support/models/pro/repo.rb +5 -0
  128. data/spec/support/models/pro/special_issue.rb +4 -0
  129. data/spec/support/models/repo.rb +13 -0
  130. data/spec/support/models/tag.rb +4 -0
  131. data/spec/support/schema.rb +39 -0
  132. metadata +134 -4
@@ -0,0 +1,66 @@
1
+ module ReportsKits
2
+ class FormBuilder
3
+ include ActionView::Helpers
4
+
5
+ DEFAULT_DATE_RANGE_VALUE = ['-2M', 'now']
6
+
7
+ attr_accessor :properties, :additional_params, :context_record, :properties_to_filter
8
+
9
+ def initialize(properties, additional_params: nil, context_record: nil)
10
+ self.properties = properties.deep_symbolize_keys
11
+ self.additional_params = additional_params
12
+ self.context_record = context_record
13
+ self.properties_to_filter = Reports::PropertiesToFilter.new(properties, context_record: context_record)
14
+ end
15
+
16
+ def check_box(filter_key, options = {})
17
+ filter = properties_to_filter.perform(filter_key)
18
+ checked = options.key?(:value) ? options[:value] : filter.normalized_properties[:criteria].try(:[], :value) == 'true'
19
+ check_box_tag(filter_key, '1', checked, options)
20
+ end
21
+
22
+ def date_range(filter_key, options = {})
23
+ filter = properties_to_filter.perform(filter_key)
24
+ defaults = { class: 'form-control input-sm date_range_picker' }
25
+ options = defaults.deep_merge(options)
26
+ value = options[:value].presence || filter.normalized_properties[:criteria].try(:[], :value).presence
27
+ value ||= default_date_range_value
28
+ text_field_tag(filter_key, value, options)
29
+ end
30
+
31
+ def multi_autocomplete(filter_key, options = {})
32
+ filter = properties_to_filter.perform(filter_key)
33
+ reports_kit_path = Rails.application.routes.url_helpers.reports_kit_path
34
+ path = "#{reports_kit_path}reports_kit/filters/#{filter_key}/autocomplete?"
35
+ path += additional_params.to_query if additional_params.present?
36
+
37
+ defaults = {
38
+ class: 'form-control input-sm select2',
39
+ multiple: 'multiple',
40
+ data: {
41
+ placeholder: options[:placeholder],
42
+ path: path
43
+ }
44
+ }
45
+ options = defaults.deep_merge(options)
46
+ select_tag(filter_key, nil, options)
47
+ end
48
+
49
+ def string_filter(filter_key, options = {})
50
+ filter = properties_to_filter.perform(filter_key)
51
+ defaults = { class: 'form-control input-sm' }
52
+ options = defaults.deep_merge(options)
53
+ text_field_tag(filter_key, filter.normalized_properties[:criteria].try(:[], :value), options)
54
+ end
55
+
56
+ private
57
+
58
+ def default_date_range_value
59
+ @default_date_range_value ||= begin
60
+ start_date = Reports::Data::Utils.format_time_value(DEFAULT_DATE_RANGE_VALUE[0])
61
+ end_date = Reports::Data::Utils.format_time_value(DEFAULT_DATE_RANGE_VALUE[1])
62
+ [start_date, Reports::FilterTypes::Datetime::SEPARATOR, end_date].join(' ')
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,24 @@
1
+ module ReportsKits
2
+ module Helper
3
+ include ReportsKits::NormalizedParams
4
+
5
+ def render_report(report_params, context_params: {}, actions: %w(export_csv export_xls), js_report_class: 'Report', &block)
6
+ report_params = { key: report_params } if report_params.is_a?(String)
7
+ params.merge!(context_params: context_params, report_params: report_params)
8
+ properties = Reports::Properties.generate(self)
9
+ builder = ReportBuilder.new(
10
+ report_params: report_params,
11
+ context_params: context_params,
12
+ actions: actions,
13
+ js_report_class: js_report_class,
14
+ properties: properties,
15
+ view_context: self,
16
+ block: block
17
+ )
18
+ capture do
19
+ capture(builder, &block) if block
20
+ builder.render
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,16 @@
1
+ module ReportsKits
2
+ module Model
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class << self
7
+ attr_accessor :reports_kit_configuration
8
+ end
9
+
10
+ def self.reports_kit(&block)
11
+ self.reports_kit_configuration = ModelConfiguration.new
12
+ reports_kit_configuration.instance_eval(&block)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,28 @@
1
+ module ReportsKits
2
+ class ModelConfiguration
3
+ attr_accessor :aggregations, :contextual_filters, :dimensions, :filters
4
+
5
+ def initialize
6
+ self.aggregations = []
7
+ self.contextual_filters = []
8
+ self.dimensions = []
9
+ self.filters = []
10
+ end
11
+
12
+ def aggregation(key, expression, properties = {})
13
+ aggregations << { key: key.to_s, expression: expression }.merge(properties).symbolize_keys
14
+ end
15
+
16
+ def contextual_filter(key, method)
17
+ contextual_filters << { key: key, method: method }
18
+ end
19
+
20
+ def dimension(key, properties)
21
+ dimensions << { key: key.to_s }.merge(properties).symbolize_keys
22
+ end
23
+
24
+ def filter(key, type_key, properties)
25
+ filters << { key: key.to_s, type_key: type_key }.merge(properties).symbolize_keys
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,16 @@
1
+ module ReportsKits
2
+ module NormalizedParams
3
+ def report_params
4
+ params[:report_params]
5
+ end
6
+
7
+ def context_params
8
+ params[:context_params]
9
+ end
10
+
11
+ def report_key
12
+ raise ArgumentError.new('Blank report_params') if report_params.blank?
13
+ report_params[:key]
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,33 @@
1
+ module ReportsKits
2
+ class Order
3
+ attr_accessor :relation, :field, :direction
4
+
5
+ VALID_RELATIONS = %w(count dimension1 dimension2)
6
+ VALID_FIELDS = [nil, 'label']
7
+ VALID_DIRECTIONS = %w(asc desc)
8
+
9
+ def initialize(relation, field, direction)
10
+ self.relation = relation
11
+ self.field = field
12
+ self.direction = direction
13
+ end
14
+
15
+ def self.parse(string)
16
+ string ||= ''
17
+ field_expression, direction = string.to_s.split(/\s+/)
18
+ relation, field = (field_expression || '').split('.')
19
+
20
+ relation = relation.presence
21
+ field = field.presence
22
+ direction = direction.presence || 'asc'
23
+
24
+ relation = relation.to_i if relation =~ /^\d+$/
25
+
26
+ raise ArgumentError.new("Invalid relation: #{relation}") unless VALID_RELATIONS.include?(relation) || relation.is_a?(Fixnum)
27
+ raise ArgumentError.new("Invalid field: #{field}") unless VALID_FIELDS.include?(field)
28
+ raise ArgumentError.new("Invalid direction: #{direction}") unless VALID_DIRECTIONS.include?(direction)
29
+
30
+ new(relation, field, direction)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,42 @@
1
+ module ReportsKits
2
+ class RelativeTime
3
+ LETTERS_DURATION_METHODS = {
4
+ 'y' => :years,
5
+ 'M' => :months,
6
+ 'w' => :weeks,
7
+ 'd' => :days,
8
+ 'h' => :hours,
9
+ 'm' => :minutes,
10
+ 's' => :seconds
11
+ }
12
+ LETTERS = LETTERS_DURATION_METHODS.keys.join
13
+
14
+ def self.parse(string, prevent_exceptions: false)
15
+ return Time.zone.now if string == 'now'
16
+ original_string = string
17
+ string = string.to_s.strip
18
+ is_negative = string[0, 1] == '-'
19
+ string = string[1..-1] if is_negative
20
+
21
+ result_string = is_negative ? '-' : ''
22
+ result_durations = []
23
+
24
+ string.scan(/(\d+)([#{LETTERS}]?)/) do |number, letter|
25
+ result_string += "#{number}#{letter}"
26
+ duration_method = LETTERS_DURATION_METHODS[letter]
27
+ unless duration_method
28
+ return if prevent_exceptions
29
+ raise ArgumentError.new("Invalid duration letter: #{letter.inspect}")
30
+ end
31
+ result_durations << number.to_i.public_send(duration_method)
32
+ end
33
+
34
+ if result_string == '-' || result_string != original_string.to_s.strip
35
+ return if prevent_exceptions
36
+ raise ArgumentError.new("Invalid time duration: #{original_string.inspect}")
37
+ end
38
+ duration = result_durations.reduce(&:+)
39
+ is_negative ? duration.ago : duration.from_now
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,88 @@
1
+ module ReportsKits
2
+ class ReportBuilder
3
+ include ActionView::Helpers
4
+
5
+ attr_accessor :report_params, :context_params, :additional_params, :actions, :js_report_class, :properties, :view_context, :block, :form_builder
6
+
7
+ ACTION_KEYS_METHODS = {
8
+ 'export_csv' => :export_csv_button,
9
+ 'export_xls' => :export_xls_button
10
+ }
11
+
12
+ def initialize(report_params:, context_params: {}, actions: %w(export_csv export_xls), js_report_class: 'Report', properties:, view_context:, block: nil)
13
+ self.report_params = report_params.is_a?(String) ? { key: report_params } : report_params
14
+ self.context_params = context_params
15
+ self.additional_params = { context_params: context_params, report_params: self.report_params }
16
+ self.actions = actions
17
+ self.js_report_class = js_report_class
18
+ self.view_context = view_context
19
+ self.block = block
20
+ self.properties = properties
21
+ context_record = ReportsKits.configuration.context_record(view_context)
22
+ self.form_builder = ReportsKits::FormBuilder.new(properties, additional_params: additional_params, context_record: context_record)
23
+ end
24
+
25
+ def render
26
+ data = { properties: properties.slice(:format), path: reports_data_path, report_class: js_report_class }
27
+ view_context.content_tag :div, nil, class: 'reports_kit_report form-inline', data: data do
28
+ elements = []
29
+ elements << view_context.capture(self, &block) if block
30
+ elements << view_context.content_tag(:div, nil, class: 'reports_kit_visualization')
31
+ elements << action_elements_container
32
+ elements.compact.join.html_safe
33
+ end
34
+ end
35
+
36
+ def form(&block)
37
+ raise ArgumentError.new('No block given for ReportBuilder#form') unless block
38
+ view_context.form_tag(reports_data_path, method: 'get', class: 'reports_kit_report_form') do
39
+ view_context.capture(form_builder, &block)
40
+ end
41
+ end
42
+
43
+ def export_csv_button(text = 'Download CSV', options = {}, &block)
44
+ export_button(text, 'csv', options, &block)
45
+ end
46
+
47
+ def export_xls_button(text = 'Download Excel', options = {}, &block)
48
+ export_button(text, 'xls', options, &block)
49
+ end
50
+
51
+ def export_button(text, format, options, &block)
52
+ data = {
53
+ role: 'reports_kit_export_button',
54
+ path: view_context.reports_kit.reports_kit_reports_path({ format: format }.merge(additional_params))
55
+ }
56
+ options = { class: 'btn btn-primary', data: data }.merge(options)
57
+ if block_given?
58
+ view_context.link_to('#', options, &block)
59
+ else
60
+ view_context.link_to(text, '#', options)
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def reports_data_path
67
+ @reports_data_path ||= view_context.reports_kit.reports_kit_reports_path({ format: 'json' }.merge(additional_params))
68
+ end
69
+
70
+ def action_elements_container
71
+ return if action_elements.blank?
72
+ view_context.content_tag(:div, nil, class: 'reports_kit_actions') do
73
+ action_elements.map { |element| view_context.concat(element) }
74
+ end
75
+ end
76
+
77
+ def action_elements
78
+ @action_elements ||= begin
79
+ return if actions.blank?
80
+ actions.map do |action|
81
+ element_method = ACTION_KEYS_METHODS[action]
82
+ raise ArgumentError.new("Invalid action: #{action}") unless element_method
83
+ send(element_method)
84
+ end.compact
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,9 @@
1
+ module ReportsKits
2
+ module Reports
3
+ class AbstractSeries
4
+ def value_format_method
5
+ ReportsKits.configuration.custom_method(properties[:value_format_method])
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,26 @@
1
+ module ReportsKits
2
+ module Reports
3
+ module Adapters
4
+ class Mysql
5
+ def self.truncate_to_day(column)
6
+ "DATE(#{column})"
7
+ end
8
+
9
+ def self.truncate_to_week(column)
10
+ case ReportsKits.configuration.first_day_of_week
11
+ when :sunday
12
+ "DATE_SUB(DATE(#{column}), INTERVAL DAYOFWEEK(#{column}) - 1 DAY)"
13
+ when :monday
14
+ "DATE_SUB(DATE(#{column}), INTERVAL DAYOFWEEK(#{column}) - 2 DAY)"
15
+ else
16
+ raise ArgumentError.new("Unsupported first_day_of_week: #{ReportsKits.configuration.first_day_of_week}")
17
+ end
18
+ end
19
+
20
+ def self.truncate_to_month(column)
21
+ "DATE_SUB(DATE(#{column}), INTERVAL DAY(#{column}) - 1 DAY)"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,26 @@
1
+ module ReportsKits
2
+ module Reports
3
+ module Adapters
4
+ class Postgresql
5
+ def self.truncate_to_day(column)
6
+ "#{column}::date"
7
+ end
8
+
9
+ def self.truncate_to_week(column)
10
+ case ReportsKits.configuration.first_day_of_week
11
+ when :sunday
12
+ "DATE_TRUNC('week', #{column}::timestamp + '1 day'::interval) - '1 day'::interval"
13
+ when :monday
14
+ "DATE_TRUNC('week', #{column}::timestamp)"
15
+ else
16
+ raise ArgumentError.new("Unsupported first_day_of_week: #{ReportsKits.configuration.first_day_of_week}")
17
+ end
18
+ end
19
+
20
+ def self.truncate_to_month(column)
21
+ "DATE_TRUNC('month', #{column}::date)"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,48 @@
1
+ module ReportsKits
2
+ module Reports
3
+ class CompositeSeries < AbstractSeries
4
+ attr_accessor :properties, :context_record
5
+
6
+ def initialize(properties, context_record:)
7
+ self.properties = properties.dup
8
+ self.context_record = context_record
9
+ end
10
+
11
+ def label
12
+ name
13
+ end
14
+
15
+ def name
16
+ properties[:name]
17
+ end
18
+
19
+ def composite_operator
20
+ properties[:composite_operator]
21
+ end
22
+
23
+ def limit
24
+ properties[:limit]
25
+ end
26
+
27
+ def serieses
28
+ @serieses ||= Reports::Series.new_from_properties!(properties, context_record: context_record)
29
+ end
30
+
31
+ def filters
32
+ serieses.map(&:filters).flatten
33
+ end
34
+
35
+ def primary_series
36
+ serieses.first
37
+ end
38
+
39
+ def dimensions
40
+ primary_series.dimensions
41
+ end
42
+
43
+ def model_class
44
+ primary_series.model_class
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,19 @@
1
+ module ReportsKits
2
+ module Reports
3
+ class ContextualFilter
4
+ attr_accessor :key, :model_settings
5
+
6
+ delegate :settings_from_model, to: :model_settings
7
+
8
+ def initialize(key, model_class)
9
+ self.key = key.to_sym
10
+ self.model_settings = ModelSettings.new(model_class, :contextual_filters, self.key)
11
+ end
12
+
13
+ def apply(relation, context_params)
14
+ raise ArgumentError.new("contextual_filter with key :#{key} not defined in #{model_class}") if settings_from_model.blank?
15
+ settings_from_model[:method].call(relation, context_params)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,105 @@
1
+ module ReportsKits
2
+ module Reports
3
+ module Data
4
+ class AddTableAggregations
5
+ attr_accessor :data, :report_options
6
+
7
+ VALID_AGGREGATION_OPERATORS = [:sum]
8
+
9
+ def initialize(data, report_options:)
10
+ self.data = data
11
+ self.report_options = report_options || {}
12
+ end
13
+
14
+ def perform
15
+ data_with_aggregations
16
+ end
17
+
18
+ private
19
+
20
+ def data_with_aggregations
21
+ return data if row_aggregation_configs.blank? && column_aggregation_configs.blank?
22
+ {
23
+ entities: entities,
24
+ datasets: datasets
25
+ }
26
+ end
27
+
28
+ def entities
29
+ column_aggregation_entities = column_aggregation_configs.map do |config|
30
+ ReportsKits::Entity.new(config[:label], config[:label], config[:label])
31
+ end
32
+ entities_with_row_aggregations + column_aggregation_entities
33
+ end
34
+
35
+ def datasets
36
+ datasets_with_row_aggregations.map do |dataset|
37
+ column_aggregation_configs.each do |config|
38
+ value = aggregate_array(dataset[:values].map(&:formatted), config[:operator])
39
+ dataset[:values] << ReportsKits::Value.new(value, value)
40
+ end
41
+ dataset
42
+ end
43
+ end
44
+
45
+ def entities_with_row_aggregations
46
+ @entities_with_row_aggregations ||= begin
47
+ return original_entities if row_aggregation_configs.blank?
48
+ row_aggregation_entities = row_aggregation_configs.map do |config|
49
+ ReportsKits::Entity.new(config[:label], config[:label], config[:label])
50
+ end
51
+ original_entities + row_aggregation_entities
52
+ end
53
+ end
54
+
55
+ def datasets_with_row_aggregations
56
+ @datasets_with_row_aggregations ||= begin
57
+ return original_datasets if row_aggregation_configs.blank?
58
+ row_aggregation_datasets = row_aggregation_configs.map do |config|
59
+ values = original_datasets.map { |dataset| dataset[:values].map(&:formatted) }.transpose
60
+ aggregated_values = aggregate_array_of_arrays(values, config[:operator])
61
+ values = aggregated_values.map { |value| ReportsKits::Value.new(value, value) }
62
+ {
63
+ entity: ReportsKits::Entity.new(config[:label], config[:label], config[:label]),
64
+ values: values
65
+ }
66
+ end
67
+ original_datasets + row_aggregation_datasets
68
+ end
69
+ end
70
+
71
+ def original_entities
72
+ data[:entities]
73
+ end
74
+
75
+ def original_datasets
76
+ data[:datasets]
77
+ end
78
+
79
+ def row_aggregation_configs
80
+ return [] if report_options[:aggregations].blank?
81
+ report_options[:aggregations].select { |config| config[:from] == 'rows' }
82
+ end
83
+
84
+ def column_aggregation_configs
85
+ return [] if report_options[:aggregations].blank?
86
+ report_options[:aggregations].select { |config| config[:from] == 'columns' }
87
+ end
88
+
89
+ def aggregate_array(values, operator)
90
+ operator = operator.try(:to_sym)
91
+ raise ArgumentError.new("Invalid aggregation operator: #{operator}") unless operator.in?(VALID_AGGREGATION_OPERATORS)
92
+ if values.first.is_a?(Numeric)
93
+ values.public_send(operator)
94
+ else
95
+ nil
96
+ end
97
+ end
98
+
99
+ def aggregate_array_of_arrays(array_of_arrays, operator)
100
+ array_of_arrays.map { |values| aggregate_array(values, operator) }
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,97 @@
1
+ module ReportsKits
2
+ module Reports
3
+ module Data
4
+ class AggregateComposite
5
+ attr_accessor :composite_series, :context_record
6
+
7
+ delegate :composite_operator, :properties, to: :composite_series
8
+
9
+ OPERATORS_METHODS = {
10
+ '+' => :+,
11
+ '-' => :-,
12
+ '*' => :*,
13
+ '/' => :/,
14
+ '%' => -> (values) {
15
+ raise ArgumentError.new('Percentage composite aggregations must have exactly two series') if values.length != 2
16
+ numerator, denominator = values
17
+ return 0 if denominator == 0
18
+ ((numerator.to_f / denominator) * 100).round(1)
19
+ }
20
+ }
21
+
22
+ def initialize(properties, context_record:)
23
+ self.composite_series = CompositeSeries.new(properties, context_record: context_record)
24
+ self.context_record = context_record
25
+ end
26
+
27
+ def perform
28
+ return serieses_results_for_one_dimension if dimension_count == 1
29
+ return serieses_results_for_two_dimensions if dimension_count == 2
30
+ raise ArgumentError.new("Composite aggregations' series can only have 1-2 dimensions")
31
+ end
32
+
33
+ private
34
+
35
+ def serieses_results_for_one_dimension
36
+ serieses_results = Hash[serieses.map { |series| [series, AggregateOneDimension.new(series).perform] }]
37
+ serieses_results = Data::PopulateOneDimension.new(serieses_results, context_record: context_record, properties: properties).perform
38
+ sorted_dimension_keys_values = sort_dimension_keys_values(serieses_results)
39
+ value_lists = sorted_dimension_keys_values.map(&:values)
40
+ composited_values = value_lists.transpose.map { |data| reduce(data) }
41
+ dimension_keys = sorted_dimension_keys_values.first.keys
42
+ composited_keys_values = dimension_keys.zip(composited_values)
43
+ Hash[composited_keys_values]
44
+ end
45
+
46
+ def serieses_results_for_two_dimensions
47
+ serieses_results = Hash[serieses.map { |series| [series, AggregateTwoDimensions.new(series).perform] }]
48
+ serieses_results = Data::PopulateTwoDimensions.new(serieses_results).perform
49
+ value_lists = serieses_results.values.map(&:values)
50
+ composited_values = value_lists.transpose.map { |data| reduce(data) }
51
+ dimension_keys = serieses_results.values.first.keys
52
+ composited_keys_values = dimension_keys.zip(composited_values)
53
+ Hash[composited_keys_values]
54
+ end
55
+
56
+ # Before performing a composition of values, we need to make sure that the values are sorted by the same dimension key.
57
+ def sort_dimension_keys_values(serieses_results)
58
+ dimension_keys_values_list = serieses_results.values
59
+ sorted_dimension_keys_values = dimension_keys_values_list.map do |dimension_keys_values|
60
+ dimension_keys_values = dimension_keys_values.sort_by do |dimension_key, value|
61
+ is_boolean = dimension_key.is_a?(TrueClass) || dimension_key.is_a?(FalseClass)
62
+ is_boolean ? (dimension_key ? 0 : 1) : dimension_key
63
+ end
64
+ Hash[dimension_keys_values]
65
+ end
66
+ sorted_dimension_keys_values
67
+ end
68
+
69
+ def reduce(values)
70
+ if composite_method.is_a?(Symbol)
71
+ values.reduce(&composite_method)
72
+ elsif composite_method.is_a?(Proc)
73
+ values = composite_method.call(values)
74
+ else
75
+ raise ArgumentError.new("Invalid composite method type: #{composite_method.class}")
76
+ end
77
+ end
78
+
79
+ def composite_method
80
+ composite_method = OPERATORS_METHODS[composite_operator]
81
+ raise ArgumentError.new("Invalid composite_operator: #{composite_operator}") unless composite_method
82
+ composite_method
83
+ end
84
+
85
+ def dimension_count
86
+ unique_dimension_counts = serieses.map { |series| series.dimensions.length }.uniq
87
+ raise ArgumentError.new('All series must have the same number of dimensions') if unique_dimension_counts.length > 1
88
+ unique_dimension_counts.first
89
+ end
90
+
91
+ def serieses
92
+ @serieses ||= Series.new_from_properties!(properties, context_record: context_record)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end