reports_kits 0.7.5 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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