reports_kit 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (91) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +6 -0
  3. data/.rubocop.yml +83 -0
  4. data/Gemfile +3 -0
  5. data/MIT-LICENSE +20 -0
  6. data/README.md +468 -0
  7. data/Rakefile +2 -0
  8. data/app/assets/javascripts/reports_kit/application.js +14 -0
  9. data/app/assets/javascripts/reports_kit/lib/_init.js +8 -0
  10. data/app/assets/javascripts/reports_kit/lib/chart.js +39 -0
  11. data/app/assets/javascripts/reports_kit/lib/report.js +119 -0
  12. data/app/assets/javascripts/reports_kit/vendor/chart.js +12269 -0
  13. data/app/assets/javascripts/reports_kit/vendor/daterangepicker.js +1627 -0
  14. data/app/assets/javascripts/reports_kit/vendor/moment.js +4040 -0
  15. data/app/assets/javascripts/reports_kit/vendor/select2.full.js +6436 -0
  16. data/app/assets/stylesheets/reports_kit/application.css.scss +3 -0
  17. data/app/assets/stylesheets/reports_kit/reports.css.sass +7 -0
  18. data/app/assets/stylesheets/reports_kit/select2_overrides.css.sass +7 -0
  19. data/app/assets/stylesheets/reports_kit/vendor/daterangepicker.css +269 -0
  20. data/app/assets/stylesheets/reports_kit/vendor/select2-bootstrap.css +721 -0
  21. data/app/assets/stylesheets/reports_kit/vendor/select2.css +484 -0
  22. data/config/routes.rb +10 -0
  23. data/docs/images/chart_options.png +0 -0
  24. data/docs/images/dashed_line.png +0 -0
  25. data/docs/images/flights_by_carrier.png +0 -0
  26. data/docs/images/flights_by_carrier_and_flight_at.png +0 -0
  27. data/docs/images/flights_by_delay.png +0 -0
  28. data/docs/images/flights_by_flight_at.png +0 -0
  29. data/docs/images/flights_by_hours_delayed.png +0 -0
  30. data/docs/images/flights_with_check_box.png +0 -0
  31. data/docs/images/flights_with_configured_boolean.png +0 -0
  32. data/docs/images/flights_with_configured_datetime.png +0 -0
  33. data/docs/images/flights_with_configured_number.png +0 -0
  34. data/docs/images/flights_with_configured_string.png +0 -0
  35. data/docs/images/flights_with_date_range.png +0 -0
  36. data/docs/images/flights_with_filters.png +0 -0
  37. data/docs/images/flights_with_multi_autocomplete.png +0 -0
  38. data/docs/images/flights_with_string_filter.png +0 -0
  39. data/docs/images/horizontal_bar.png +0 -0
  40. data/docs/images/legend_right.png +0 -0
  41. data/docs/images/users_by_created_at.png +0 -0
  42. data/gists/doc.txt +58 -0
  43. data/lib/reports_kit.rb +17 -0
  44. data/lib/reports_kit/base_controller.rb +11 -0
  45. data/lib/reports_kit/configuration.rb +10 -0
  46. data/lib/reports_kit/engine.rb +21 -0
  47. data/lib/reports_kit/helper.rb +19 -0
  48. data/lib/reports_kit/model.rb +16 -0
  49. data/lib/reports_kit/model_configuration.rb +23 -0
  50. data/lib/reports_kit/rails.rb +5 -0
  51. data/lib/reports_kit/report_builder.rb +76 -0
  52. data/lib/reports_kit/reports/data/chart_options.rb +132 -0
  53. data/lib/reports_kit/reports/data/generate.rb +65 -0
  54. data/lib/reports_kit/reports/data/one_dimension.rb +71 -0
  55. data/lib/reports_kit/reports/data/two_dimensions.rb +129 -0
  56. data/lib/reports_kit/reports/data/utils.rb +79 -0
  57. data/lib/reports_kit/reports/dimension.rb +78 -0
  58. data/lib/reports_kit/reports/filter.rb +84 -0
  59. data/lib/reports_kit/reports/filter_types/base.rb +47 -0
  60. data/lib/reports_kit/reports/filter_types/boolean.rb +30 -0
  61. data/lib/reports_kit/reports/filter_types/datetime.rb +27 -0
  62. data/lib/reports_kit/reports/filter_types/number.rb +28 -0
  63. data/lib/reports_kit/reports/filter_types/records.rb +26 -0
  64. data/lib/reports_kit/reports/filter_types/string.rb +38 -0
  65. data/lib/reports_kit/reports/generate_autocomplete_results.rb +55 -0
  66. data/lib/reports_kit/reports/inferrable_configuration.rb +113 -0
  67. data/lib/reports_kit/reports/measure.rb +58 -0
  68. data/lib/reports_kit/reports_controller.rb +15 -0
  69. data/lib/reports_kit/resources_controller.rb +8 -0
  70. data/lib/reports_kit/version.rb +3 -0
  71. data/reports_kit.gemspec +23 -0
  72. data/spec/factories/issue_factory.rb +4 -0
  73. data/spec/factories/issues_label_factory.rb +4 -0
  74. data/spec/factories/label_factory.rb +4 -0
  75. data/spec/factories/repo_factory.rb +5 -0
  76. data/spec/factories/tag_factory.rb +4 -0
  77. data/spec/fixtures/generate_inputs.yml +35 -0
  78. data/spec/fixtures/generate_outputs.yml +208 -0
  79. data/spec/reports_kit/reports/data/generate_spec.rb +275 -0
  80. data/spec/reports_kit/reports/dimension_spec.rb +38 -0
  81. data/spec/reports_kit/reports/filter_spec.rb +38 -0
  82. data/spec/spec_helper.rb +58 -0
  83. data/spec/support/factory_girl.rb +5 -0
  84. data/spec/support/helpers.rb +13 -0
  85. data/spec/support/models/issue.rb +6 -0
  86. data/spec/support/models/issues_label.rb +4 -0
  87. data/spec/support/models/label.rb +5 -0
  88. data/spec/support/models/repo.rb +7 -0
  89. data/spec/support/models/tag.rb +4 -0
  90. data/spec/support/schema.rb +38 -0
  91. metadata +232 -0
@@ -0,0 +1,65 @@
1
+ module ReportsKit
2
+ module Reports
3
+ module Data
4
+ class Generate
5
+ ROUND_PRECISION = 3
6
+
7
+ attr_accessor :properties, :context_record
8
+
9
+ def initialize(properties, context_record: nil)
10
+ self.properties = properties.deep_symbolize_keys
11
+ self.context_record = context_record
12
+ end
13
+
14
+ def perform
15
+ if second_dimension
16
+ data = Data::TwoDimensions.new(measure, dimension, second_dimension).perform
17
+ else
18
+ data = Data::OneDimension.new(measure, dimension).perform
19
+ end
20
+
21
+ ChartOptions.new(data, options: properties[:chart], inferred_options: inferred_options).perform
22
+ end
23
+
24
+ private
25
+
26
+ def measure
27
+ @measure ||= begin
28
+ measure_hash = properties[:measure]
29
+ raise ArgumentError.new('The number of measures must be exactly one') if measure_hash.blank?
30
+ Measure.new(measure_hash, context_record: context_record)
31
+ end
32
+ end
33
+
34
+ def dimension
35
+ @dimension ||= begin
36
+ Dimension.new(dimension_hashes[0], measure: measure)
37
+ end
38
+ end
39
+
40
+ def second_dimension
41
+ @second_dimension ||= begin
42
+ Dimension.new(dimension_hashes[1], measure: measure) if dimension_hashes[1]
43
+ end
44
+ end
45
+
46
+ def dimension_hashes
47
+ @dimension_hashes ||= begin
48
+ dimension_hashes = properties[:dimensions]
49
+ raise ArgumentError.new('Blank dimensions') if dimension_hashes.blank?
50
+ raise ArgumentError.new('The number of dimensions must be 1-2') unless dimension_hashes.length.in?([1, 2])
51
+ dimension_hashes = dimension_hashes.values if dimension_hashes.is_a?(Hash) && dimension_hashes.key?(:'0')
52
+ dimension_hashes
53
+ end
54
+ end
55
+
56
+ def inferred_options
57
+ {
58
+ x_axis_label: dimension.label,
59
+ y_axis_label: measure.label
60
+ }
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,71 @@
1
+ module ReportsKit
2
+ module Reports
3
+ module Data
4
+ class OneDimension
5
+ attr_accessor :measure, :dimension
6
+
7
+ def initialize(measure, dimension)
8
+ self.measure = measure
9
+ self.dimension = dimension
10
+ end
11
+
12
+ def perform
13
+ {
14
+ chart_data: {
15
+ labels: labels,
16
+ datasets: datasets
17
+ }
18
+ }
19
+ end
20
+
21
+ private
22
+
23
+ def dimension_keys_values
24
+ @dimension_keys_values ||= begin
25
+ relation = measure.filtered_relation
26
+ relation = relation.group(dimension.group_expression)
27
+ relation = relation.joins(dimension.joins) if dimension.joins
28
+ relation = relation.limit(dimension.dimension_instances_limit) if dimension.dimension_instances_limit
29
+ if dimension.should_be_sorted_by_count?
30
+ relation = relation.order('1 DESC')
31
+ else
32
+ relation = relation.order('2')
33
+ end
34
+ dimension_keys_values = relation.distinct.public_send(*measure.aggregate_function)
35
+ dimension_keys_values = Utils.populate_sparse_values(dimension_keys_values)
36
+ dimension_keys_values.delete(nil)
37
+ dimension_keys_values.delete('')
38
+ dimension_keys_values
39
+ end
40
+ end
41
+
42
+ def datasets
43
+ [
44
+ {
45
+ label: measure.label,
46
+ data: values
47
+ }
48
+ ]
49
+ end
50
+
51
+ def values
52
+ dimension_keys_values.values.map { |value| value.round(Generate::ROUND_PRECISION) }
53
+ end
54
+
55
+ def labels
56
+ keys = dimension_keys_values.keys
57
+ keys.map do |key|
58
+ Utils.dimension_key_to_label(key, dimension, dimension_ids_dimension_instances)
59
+ end
60
+ end
61
+
62
+ def dimension_ids_dimension_instances
63
+ @dimension_ids_dimension_instances ||= begin
64
+ dimension_ids = dimension_keys_values.keys
65
+ Utils.dimension_to_dimension_ids_dimension_instances(dimension, dimension_ids)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,129 @@
1
+ module ReportsKit
2
+ module Reports
3
+ module Data
4
+ class TwoDimensions
5
+ attr_accessor :measure, :dimension, :second_dimension
6
+
7
+ def initialize(measure, dimension, second_dimension)
8
+ self.measure = measure
9
+ self.dimension = dimension
10
+ self.second_dimension = second_dimension
11
+ end
12
+
13
+ def perform
14
+ {
15
+ chart_data: {
16
+ labels: labels,
17
+ datasets: datasets
18
+ }
19
+ }
20
+ end
21
+
22
+ private
23
+
24
+ def dimension_keys_values
25
+ @dimension_keys_values ||= begin
26
+ relation = measure.filtered_relation
27
+ relation = measure.conditions.call(relation) if measure.conditions
28
+ relation = relation.group(dimension.group_expression, second_dimension.group_expression)
29
+
30
+ relation = relation.joins(dimension.joins) if dimension.joins
31
+ relation = relation.joins(second_dimension.joins) if second_dimension.joins
32
+
33
+ if dimension.should_be_sorted_by_count?
34
+ relation = relation.order('1 DESC')
35
+ else
36
+ relation = relation.order('2')
37
+ end
38
+ dimension_keys_values = relation.count
39
+
40
+ if dimension.should_be_sorted_by_count?
41
+ dimension_keys_values = sort_dimension_keys_values_by_count(dimension_keys_values)
42
+ end
43
+ Hash[dimension_keys_values]
44
+ end
45
+ end
46
+
47
+ def primary_keys_secondary_keys_values
48
+ @primary_keys_secondary_keys_values ||= begin
49
+ primary_keys_secondary_keys_values = {}
50
+ dimension_keys_values.each do |(primary_key, secondary_key), value|
51
+ primary_keys_secondary_keys_values[primary_key] ||= {}
52
+ primary_keys_secondary_keys_values[primary_key][secondary_key] = value
53
+ end
54
+ primary_keys_secondary_keys_values
55
+ end
56
+ end
57
+
58
+ def sort_dimension_keys_values_by_count(dimension_keys_values)
59
+ primary_keys_counts = Hash.new(0)
60
+ dimension_keys_values.each do |(primary_key, secondary_key), count|
61
+ primary_keys_counts[primary_key] += count
62
+ end
63
+ primary_keys_counts = primary_keys_counts.to_a
64
+ sorted_primary_keys = primary_keys_counts.sort_by { |primary_key, count| count }.reverse.map(&:first)
65
+ dimension_keys_values = dimension_keys_values.sort_by { |(primary_key, secondary_key), count| sorted_primary_keys.index(primary_key) }
66
+ Hash[dimension_keys_values]
67
+ end
68
+
69
+ def dimension_ids
70
+ dimension_keys_values.keys.map(&:first)
71
+ end
72
+
73
+ def dimension_ids_dimension_instances
74
+ @dimension_ids_dimension_instances ||= begin
75
+ Utils.dimension_to_dimension_ids_dimension_instances(dimension, dimension_keys_values.keys.map(&:first))
76
+ end
77
+ end
78
+
79
+ def second_dimension_ids_dimension_instances
80
+ @second_dimension_ids_dimension_instances ||= begin
81
+ Utils.dimension_to_dimension_ids_dimension_instances(second_dimension, dimension_keys_values.keys.map(&:last))
82
+ end
83
+ end
84
+
85
+ def datasets
86
+ secondary_keys_values = secondary_keys.map do |secondary_key|
87
+ values = primary_keys.map do |primary_key|
88
+ primary_keys_secondary_keys_values[primary_key].try(:[], secondary_key) || 0
89
+ end
90
+ [secondary_key, values]
91
+ end
92
+ secondary_keys_values = secondary_keys_values.sort_by { |_, values| values.sum }.reverse
93
+ secondary_keys_values.map do |secondary_key, values|
94
+ {
95
+ label: Utils.dimension_key_to_label(secondary_key, second_dimension, second_dimension_ids_dimension_instances),
96
+ data: values
97
+ }
98
+ end
99
+ end
100
+
101
+ def primary_keys
102
+ @primary_keys ||= begin
103
+ keys = Utils.populate_sparse_keys(dimension_keys_values.keys.map(&:first).uniq)
104
+ if dimension.should_be_sorted_by_count?
105
+ limit = dimension.dimension_instances_limit
106
+ keys = keys.first(limit) if limit
107
+ end
108
+ keys
109
+ end
110
+ end
111
+
112
+ def secondary_keys
113
+ @secondary_keys ||= begin
114
+ keys = Utils.populate_sparse_keys(dimension_keys_values.keys.map(&:last).uniq)
115
+ limit = second_dimension.dimension_instances_limit
116
+ keys = keys.first(limit) if limit
117
+ keys
118
+ end
119
+ end
120
+
121
+ def labels
122
+ primary_keys.map do |primary_key|
123
+ Utils.dimension_key_to_label(primary_key, dimension, dimension_ids_dimension_instances)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,79 @@
1
+ module ReportsKit
2
+ module Reports
3
+ module Data
4
+ class Utils
5
+ def self.format_time(time)
6
+ time.strftime('%b %-d, \'%y')
7
+ end
8
+
9
+ def self.populate_sparse_values(dimension_keys_values, use_first_value_key: false)
10
+ return dimension_keys_values if dimension_keys_values.blank?
11
+ first_key = dimension_keys_values.first.first
12
+ return dimension_keys_values unless first_key.is_a?(Time)
13
+
14
+ beginning_of_current_week = Time.now.utc.beginning_of_week(ReportsKit.configuration.first_day_of_week)
15
+ last_key = dimension_keys_values.to_a.last.first
16
+ last_key = [beginning_of_current_week, last_key].compact.max
17
+
18
+ time = first_key
19
+ full_dimension_instances_values = []
20
+ if use_first_value_key
21
+ first_value_key = dimension_keys_values.first.last.keys.first
22
+ blank_value = { first_value_key => 0 }
23
+ else
24
+ blank_value = 0
25
+ end
26
+ loop do
27
+ full_dimension_instances_values << [time, dimension_keys_values[time] || blank_value]
28
+ break if time >= last_key
29
+ time += 1.week
30
+ end
31
+ Hash[full_dimension_instances_values]
32
+ end
33
+
34
+ def self.populate_sparse_keys(keys)
35
+ return keys if keys.blank?
36
+ first_key = keys.first
37
+ return keys unless first_key.is_a?(Time)
38
+ keys = keys.sort
39
+ beginning_of_current_week = Time.now.utc.beginning_of_week(ReportsKit.configuration.first_day_of_week)
40
+ last_key = keys.last
41
+ last_key = [beginning_of_current_week, last_key].compact.max
42
+
43
+ time = first_key
44
+ populated_keys = []
45
+ loop do
46
+ populated_keys << time
47
+ break if time >= last_key
48
+ time += 1.week
49
+ end
50
+ populated_keys
51
+ end
52
+
53
+ def self.dimension_to_dimension_ids_dimension_instances(dimension, dimension_ids)
54
+ return nil unless dimension.instance_class
55
+ dimension_instances = dimension.instance_class.where(id: dimension_ids.uniq)
56
+ dimension_ids_dimension_instances = dimension_instances.map do |dimension_instance|
57
+ [dimension_instance.id, dimension_instance]
58
+ end
59
+ Hash[dimension_ids_dimension_instances]
60
+ end
61
+
62
+ def self.dimension_key_to_label(dimension_instance, dimension, ids_dimension_instances)
63
+ return dimension_instance.to_s if dimension.configured_by_column? && dimension.column_type == :integer
64
+ case dimension_instance
65
+ when Time
66
+ Utils.format_time(dimension_instance)
67
+ when Fixnum
68
+ raise ArgumentError.new("ids_dimension_instances must be present for Dimension with identifier: #{dimension_instance}") unless ids_dimension_instances
69
+ instance = ids_dimension_instances[dimension_instance.to_i]
70
+ raise ArgumentError.new("instance could not be found for Dimension with identifier: #{dimension_instance}") unless instance
71
+ instance.to_s
72
+ else
73
+ dimension_instance.to_s.gsub(/\.0$/, '')
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,78 @@
1
+ module ReportsKit
2
+ module Reports
3
+ class Dimension
4
+ DEFAULT_DIMENSION_INSTANCES_LIMIT = 30
5
+
6
+ attr_accessor :properties, :measure, :configuration
7
+
8
+ delegate :configured_by_association?, :configured_by_column?, :configured_by_model?, :configured_by_time?,
9
+ :settings_from_model, :reflection, :instance_class, :model_class, :column_type,
10
+ to: :configuration
11
+
12
+ def initialize(properties, measure:)
13
+ self.configuration = InferrableConfiguration.new(self, :dimensions)
14
+ self.measure = measure
15
+
16
+ raise ArgumentError.new('Blank properties') if properties.blank?
17
+ properties = { key: properties } if properties.is_a?(String)
18
+ raise ArgumentError.new("Measure properties must be a String or Hash, not a #{properties.class.name}: #{properties.inspect}") unless properties.is_a?(Hash)
19
+ properties = properties.deep_symbolize_keys
20
+ self.properties = properties
21
+ missing_group_setting = settings && !settings.key?(:group)
22
+ raise ArgumentError.new("Dimension settings for dimension '#{key}' of #{model_class} must include :group") if missing_group_setting
23
+ end
24
+
25
+ def key
26
+ properties[:key]
27
+ end
28
+
29
+ def label
30
+ key.titleize
31
+ end
32
+
33
+ def settings
34
+ inferred_settings.merge(settings_from_model)
35
+ end
36
+
37
+ def inferred_settings
38
+ configuration.inferred_settings.merge(inferred_dimension_settings)
39
+ end
40
+
41
+ def inferred_dimension_settings
42
+ {
43
+ group: group_expression
44
+ }
45
+ end
46
+
47
+ def group_expression
48
+ if configured_by_model?
49
+ settings_from_model[:group]
50
+ elsif configured_by_association?
51
+ "#{model_class.table_name}.#{reflection.foreign_key}"
52
+ elsif configured_by_column? && configured_by_time?
53
+ "date_trunc('week', #{model_class.table_name}.#{key}::timestamp)"
54
+ elsif configured_by_column?
55
+ "#{model_class.table_name}.#{key}"
56
+ else
57
+ raise ArgumentError.new('Invalid group_expression')
58
+ end
59
+ end
60
+
61
+ def joins
62
+ settings_from_model[:joins] if configured_by_model?
63
+ end
64
+
65
+ def dimension_instances_limit
66
+ if configured_by_time?
67
+ properties[:limit]
68
+ else
69
+ properties[:limit] || DEFAULT_DIMENSION_INSTANCES_LIMIT
70
+ end
71
+ end
72
+
73
+ def should_be_sorted_by_count?
74
+ !configured_by_time?
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,84 @@
1
+ module ReportsKit
2
+ module Reports
3
+ class Filter
4
+ CONFIGURATION_STRATEGIES_FILTER_TYPE_CLASSES = {
5
+ association: FilterTypes::Records,
6
+ boolean: FilterTypes::Boolean,
7
+ datetime: FilterTypes::Datetime,
8
+ integer: FilterTypes::Number,
9
+ string: FilterTypes::String
10
+ }
11
+ COLUMN_TYPES_FILTER_TYPE_CLASSES = {
12
+ boolean: FilterTypes::Boolean,
13
+ datetime: FilterTypes::Datetime,
14
+ integer: FilterTypes::Number,
15
+ string: FilterTypes::String
16
+ }
17
+
18
+ attr_accessor :properties, :measure, :configuration
19
+
20
+ delegate :configured_by_association?, :configured_by_column?, :configured_by_model?, :configured_by_time?,
21
+ :settings_from_model, :configuration_strategy, :instance_class, :column_type, :column,
22
+ to: :configuration
23
+
24
+ def initialize(properties, measure:)
25
+ self.configuration = InferrableConfiguration.new(self, :filters)
26
+ self.measure = measure
27
+
28
+ properties = { key: properties } if properties.is_a?(String)
29
+ raise ArgumentError.new("Measure properties must be a String or Hash, not a #{properties.class.name}: #{properties.inspect}") unless properties.is_a?(Hash)
30
+ self.properties = properties.deep_symbolize_keys
31
+ self.properties[:criteria] = filter_type.default_criteria unless self.properties[:criteria]
32
+ self.properties = self.properties
33
+ end
34
+
35
+ def key
36
+ properties[:key]
37
+ end
38
+
39
+ def label
40
+ key.titleize
41
+ end
42
+
43
+ def settings
44
+ inferred_settings.merge(settings_from_model)
45
+ end
46
+
47
+ def inferred_settings
48
+ configuration.inferred_settings.merge(inferred_filter_settings)
49
+ end
50
+
51
+ def inferred_filter_settings
52
+ {
53
+ column: column
54
+ }
55
+ end
56
+
57
+ def type_klass
58
+ type_klass_for_configuration_strategy = CONFIGURATION_STRATEGIES_FILTER_TYPE_CLASSES[configuration_strategy]
59
+ return type_klass_for_configuration_strategy if type_klass_for_configuration_strategy
60
+ type_klass_for_column_type = COLUMN_TYPES_FILTER_TYPE_CLASSES[column_type]
61
+ return type_klass_for_column_type if type_klass_for_column_type
62
+ return filter_type_class_from_model if configured_by_model?
63
+ raise ArgumentError.new("No configuration found for filter with key: '#{key}'")
64
+ end
65
+
66
+ def filter_type
67
+ type_klass.new(settings, properties)
68
+ end
69
+
70
+ def filter_type_class_from_model
71
+ return unless settings
72
+ type_key = settings[:type_key]
73
+ raise ArgumentError.new("No type specified for filter with key: '#{key}'") unless type_key
74
+ type_class = CONFIGURATION_STRATEGIES_FILTER_TYPE_CLASSES[type_key]
75
+ raise ArgumentError.new("Invalid type ('#{type_key}') specified for filter with key: '#{key}'") unless type_class
76
+ type_class
77
+ end
78
+
79
+ def apply(relation)
80
+ filter_type.apply_filter(relation)
81
+ end
82
+ end
83
+ end
84
+ end