eco-helpers 1.0.13 → 1.0.14
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/eco/api/common/people/entry_factory.rb +3 -1
- data/lib/eco/api/common/people/person_attribute_parser.rb +33 -10
- data/lib/eco/api/common/people/person_entry.rb +1 -1
- data/lib/eco/api/common/people/person_entry_attribute_mapper.rb +1 -1
- data/lib/eco/api/common/people/person_factory.rb +5 -1
- data/lib/eco/api/common/session/environment.rb +7 -3
- data/lib/eco/api/common/session/mailer.rb +4 -0
- data/lib/eco/api/common/session/sftp.rb +4 -3
- data/lib/eco/api/error.rb +1 -0
- data/lib/eco/api/organization/presets_factory.rb +1 -1
- data/lib/eco/api/organization/tag_tree.rb +1 -1
- data/lib/eco/api/session.rb +119 -74
- data/lib/eco/api/session/batch.rb +23 -25
- data/lib/eco/api/session/batch/base_policy.rb +283 -0
- data/lib/eco/api/session/batch/errors.rb +17 -3
- data/lib/eco/api/session/batch/feedback.rb +112 -0
- data/lib/eco/api/session/batch/job.rb +90 -87
- data/lib/eco/api/session/batch/policies.rb +22 -0
- data/lib/eco/api/session/batch/request_stats.rb +195 -0
- data/lib/eco/api/session/batch/status.rb +66 -19
- data/lib/eco/api/session/config.rb +10 -0
- data/lib/eco/api/session/config/workflow.rb +1 -1
- data/lib/eco/api/usecases/default_cases/set_default_tag_case.rb +4 -3
- data/lib/eco/api/usecases/default_cases/switch_supervisor_case.rb +15 -10
- data/lib/eco/cli/config/default/filters.rb +3 -2
- data/lib/eco/cli/config/default/options.rb +12 -0
- data/lib/eco/cli/config/default/usecases.rb +6 -4
- data/lib/eco/cli/config/default/workflow.rb +3 -2
- data/lib/eco/cli/scripting/args_helpers.rb +1 -1
- data/lib/eco/version.rb +1 -1
- metadata +5 -1
@@ -2,7 +2,13 @@ module Eco
|
|
2
2
|
module API
|
3
3
|
class Session
|
4
4
|
class Batch
|
5
|
-
|
5
|
+
# @attr_reader name [String] the name of this `batch job`
|
6
|
+
# @attr_reader type [Symbol] a valid batch operation
|
7
|
+
# @attr_reader sets [Array<Symbol>] the parts of the person model this batch is supposed to affect
|
8
|
+
# @attr_reader usecase [Eco::API::UseCases::UseCase, nil] when provided: `usecase` that generated this `batch job`
|
9
|
+
# @attr_reader status [Eco::API::Session::Batch::Status] if launched: the `status` of the `batch`
|
10
|
+
# @attr_reader feedback [Eco::API::Session::Batch::Feedback] helper class for feedback and decision making
|
11
|
+
class Job < Eco::API::Common::Session::BaseSession
|
6
12
|
@types = [:get, :create, :update, :delete]
|
7
13
|
@sets = [:core, :details, :account]
|
8
14
|
|
@@ -19,20 +25,26 @@ module Eco
|
|
19
25
|
end
|
20
26
|
end
|
21
27
|
|
22
|
-
attr_reader :name, :type, :
|
28
|
+
attr_reader :name, :type, :sets
|
23
29
|
attr_reader :usecase
|
30
|
+
attr_reader :status, :feedback
|
24
31
|
|
32
|
+
# @param e [Eco::API::Common::Session::Environment] requires a session environmen, as any child of `Eco::API::Common::Session::BaseSession`
|
33
|
+
# @param name [String] the name of this `batch job`
|
34
|
+
# @param type [Symbol] a valid batch operation
|
35
|
+
# @param usecase [Eco::API::UseCases::UseCase, nil] when provided: `usecase` that generated this `batch job`
|
25
36
|
def initialize(e, name:, type:, sets:, usecase: nil)
|
26
37
|
raise "A name is required to refer a job. Given: #{name}" if !name
|
27
|
-
raise "Type should be one of #{self.class.types}. Given: #{type}"
|
28
|
-
raise "Sets should be some of #{self.class.sets}. Given: #{sets}"
|
38
|
+
raise "Type should be one of #{self.class.types}. Given: #{type}" unless self.class.valid_type?(type)
|
39
|
+
raise "Sets should be some of #{self.class.sets}. Given: #{sets}" unless self.class.valid_sets?(sets)
|
29
40
|
raise "usecase must be a Eco::API::UseCases::UseCase object. Given: #{usecase.class}" if usecase && !usecase.is_a?(Eco::API::UseCases::UseCase)
|
30
41
|
super(e)
|
31
42
|
|
32
|
-
@name
|
33
|
-
@type
|
34
|
-
@
|
35
|
-
@
|
43
|
+
@name = name
|
44
|
+
@type = type
|
45
|
+
@sets = [sets].flatten.compact
|
46
|
+
@usecase = usecase
|
47
|
+
@feedback = Eco::API::Session::Batch::Feedback.new(job: self)
|
36
48
|
reset
|
37
49
|
end
|
38
50
|
|
@@ -44,23 +56,22 @@ module Eco
|
|
44
56
|
@status = nil
|
45
57
|
end
|
46
58
|
|
59
|
+
# @return [Boolean] was this `batch job` generated by a `usecase`? (`Eco::API::UseCases::UseCase`)
|
47
60
|
def usecase?
|
48
61
|
!!usecase
|
49
62
|
end
|
50
63
|
|
64
|
+
# @return [Hash] options the root `usecase` is run with
|
51
65
|
def options
|
52
66
|
usecase?? usecase.options : {}
|
53
67
|
end
|
54
68
|
|
55
|
-
def signature
|
56
|
-
"Batch job \"#{name}\" ['#{type.to_s.upcase}': #{sets_title}]"
|
57
|
-
end
|
58
|
-
|
59
69
|
def match?(type:, sets:)
|
60
70
|
sets = [sets].flatten
|
61
|
-
type == self.type && (sets.order ==
|
71
|
+
type == self.type && (sets.order == self.sets.order)
|
62
72
|
end
|
63
73
|
|
74
|
+
# @return [Boolean] has been this `batch job` launched?
|
64
75
|
def pending?
|
65
76
|
@pending
|
66
77
|
end
|
@@ -80,23 +91,40 @@ module Eco
|
|
80
91
|
unless unique && @queue_hash.key?(entry)
|
81
92
|
@queue_hash[entry] = true
|
82
93
|
@queue.push(entry)
|
83
|
-
@callbacks[entry]
|
94
|
+
@callbacks[entry] = Proc.new if block_given?
|
84
95
|
end
|
85
96
|
end
|
86
97
|
end
|
87
98
|
end
|
88
99
|
|
100
|
+
# Helper/shortcut to obtain a people object out of `input`
|
101
|
+
# @note if `input` is not provided, it will use `queue`
|
102
|
+
# @return [Eco::API::Organization::People]
|
89
103
|
def people(input = @queue)
|
90
104
|
Eco::API::Organization::People.new(input)
|
91
105
|
end
|
92
106
|
|
107
|
+
# Processes the `queue` and, unless `simulate` is `true`, launches against the server:
|
108
|
+
# 1. if the entries of `queue` got pending _callbacks_ (delayed changes), it processes them
|
109
|
+
# 2. unless type == `:create`: if there's a defined `api_excluded` _callback_ it calls it (see `Eco::API::Session::Config::People#api_excluded`)
|
110
|
+
# 3. transforms the result to a `Eco::API::Organization::People` object
|
111
|
+
# 4. if there are `api policies` defined, it passes the entries through them in order (see `Eco::API::Session::Config#policies`)
|
112
|
+
# 5. at this point all the transformations have taken place...
|
113
|
+
# 6. only include the entries that, after all above, still hold pending changes (`!as_update.empty?`) to be launched as update
|
114
|
+
# 7. if we are **not** in `dry-run` (or `simulate`), launch the batch request against the server (see `Eco::API::Session::Batch#launch`)
|
115
|
+
# 8. next, it links the resulting batch `status` to this `Batch::Job` (see `Eco::API::Session::Batch::Status`)
|
116
|
+
# 9. the post launch kicks in, and for success requests, it consolidates the associated entries (see `Ecoportal::API::V1::Person#consolidate!`)
|
117
|
+
# 10. launches specific error handlers, if there were **errors** from the Server as a result of the `batch.launch`, and there are `Error::Handlers` defined
|
118
|
+
# 11. if we are **not** in `dry-run` (or `simulate`), it backs up the raw queries launched to the Server
|
93
119
|
def launch(simulate: false)
|
94
|
-
pqueue
|
95
|
-
|
120
|
+
pqueue = processed_queue
|
121
|
+
requests = pqueue.map {|e| as_update(e)}
|
122
|
+
|
123
|
+
pre_checks(requests, simulate: simulate)
|
96
124
|
|
97
125
|
if !simulate
|
98
126
|
if pqueue.length > 0
|
99
|
-
backup_update(
|
127
|
+
backup_update(requests)
|
100
128
|
@status = session.batch.launch(pqueue, method: type)
|
101
129
|
@status.root = self
|
102
130
|
end
|
@@ -111,36 +139,15 @@ module Eco
|
|
111
139
|
|
112
140
|
private
|
113
141
|
|
142
|
+
def as_update(*args)
|
143
|
+
feedback.as_update(*args)
|
144
|
+
end
|
145
|
+
|
114
146
|
def processed_queue
|
115
147
|
@queue.each {|e| @callbacks[e].call(e) if @callbacks.key?(e) }
|
116
148
|
apply_policies(api_included(@queue)).select {|e| !as_update(e).empty?}
|
117
149
|
end
|
118
150
|
|
119
|
-
def post_launch(queue: [], simulate: false)
|
120
|
-
if !simulate && @status
|
121
|
-
@status.queue.map do |entry|
|
122
|
-
if @status.success?(entry)
|
123
|
-
entry.consolidate! if entry.respond_to?(:consolidate!)
|
124
|
-
#else # do not entry.reset! (keep track on changes still)
|
125
|
-
end
|
126
|
-
end
|
127
|
-
# launch_error handlers
|
128
|
-
handlers = session.config.error_handlers
|
129
|
-
if @status.errors.any? && !handlers.empty?
|
130
|
-
err_types = @status.errors.by_type
|
131
|
-
handlers.each do |handler|
|
132
|
-
if entries = err_types[handler.name]
|
133
|
-
handler.launch(people: people(entries), session: session, options: options)
|
134
|
-
end
|
135
|
-
end
|
136
|
-
end
|
137
|
-
elsif simulate
|
138
|
-
queue.map do |entry|
|
139
|
-
entry.consolidate! if entry.respond_to?(:consolidate!)
|
140
|
-
end
|
141
|
-
end
|
142
|
-
end
|
143
|
-
|
144
151
|
# if there is a config definition to exclude entries
|
145
152
|
# and the current batch is not a creation batch
|
146
153
|
# - filter out excluded entries from the api update
|
@@ -151,69 +158,65 @@ module Eco
|
|
151
158
|
end
|
152
159
|
|
153
160
|
def apply_policies(pre_queue)
|
154
|
-
#pre_queue.tap do |entries|
|
155
161
|
people(pre_queue).tap do |entries|
|
156
162
|
policies = session.config.policies
|
157
|
-
unless policies.empty?
|
163
|
+
unless policies.empty? || options.dig(:skip, :api_policies)
|
158
164
|
policies.launch(people: entries, session: session, options: options)
|
159
165
|
end
|
160
166
|
end
|
161
167
|
end
|
162
168
|
|
163
|
-
def
|
164
|
-
|
165
|
-
|
166
|
-
hash = entry.as_json.slice("id", "external_id", "email")
|
167
|
-
else
|
168
|
-
if entry.is_a?(Ecoportal::API::V1::Person)
|
169
|
-
hash = entry.as_update
|
170
|
-
if hfields = hash.dig("details", "fields")
|
171
|
-
hash["details"]["fields"] = hfields.map do |fld|
|
172
|
-
fld.merge!("alt_id" => entry.details.get_field(fld["id"]).alt_id) if entry.details
|
173
|
-
end
|
174
|
-
end
|
175
|
-
end
|
176
|
-
|
177
|
-
fields = hash&.dig('details', 'fields')
|
178
|
-
fields&.map! { |fld| fld&.slice("id", "alt_id", "value") }
|
169
|
+
def batch_policy
|
170
|
+
unless options.dig(:skip, :batch_policy)
|
171
|
+
@batch_policy ||= session.config.batch_policies[self.type]
|
179
172
|
end
|
180
|
-
hash || {}
|
181
173
|
end
|
182
174
|
|
183
|
-
def
|
184
|
-
|
185
|
-
|
175
|
+
def pre_checks(requests, simulate: false)
|
176
|
+
only_stats = options.dig(:feedback, :only_stats)
|
177
|
+
max_chars = simulate ? 2500 : 800
|
178
|
+
msg = feedback.generate(requests, max_chars: max_chars, only_stats: only_stats)
|
179
|
+
logger.info(msg)
|
186
180
|
|
187
|
-
|
188
|
-
|
181
|
+
@request_stats = feedback.request_stats(requests)
|
182
|
+
if simulate && batch_policy && !batch_policy.compliant?(@request_stats)
|
183
|
+
logger.warn("Batch Policy Uncompliance: this and next batches will be aborted!")
|
184
|
+
logger.warn(batch_policy.uncompliance(@request_stats))
|
185
|
+
elsif batch_policy
|
186
|
+
# will throw an Exception if the policy request_stats is not compliant
|
187
|
+
batch_policy.validate!(@request_stats)
|
188
|
+
end
|
189
189
|
end
|
190
190
|
|
191
|
-
def
|
192
|
-
if !
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
191
|
+
def post_launch(queue: [], simulate: false)
|
192
|
+
if !simulate && @status
|
193
|
+
@status.queue.map do |entry|
|
194
|
+
if @status.success?(entry)
|
195
|
+
entry.consolidate! if entry.respond_to?(:consolidate!)
|
196
|
+
#else # do not entry.reset! (keep track on changes still)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
# launch_error handlers
|
200
|
+
handlers = session.config.error_handlers
|
201
|
+
if @status.errors.any? && !handlers.empty?
|
202
|
+
err_types = @status.errors.by_type
|
203
|
+
handlers.each do |handler|
|
204
|
+
if entries = err_types[handler.name]
|
205
|
+
handler.launch(people: people(entries), session: session, options: options)
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
elsif simulate
|
210
|
+
queue.map do |entry|
|
211
|
+
entry.consolidate! if entry.respond_to?(:consolidate!)
|
212
|
+
end
|
205
213
|
end
|
206
|
-
|
207
|
-
logger.info("#{sample.slice(0, sample_length).pretty_inspect}")
|
208
|
-
logger.info("#{type.to_s.upcase} length: #{data.length}")
|
209
|
-
logger.info("*" * header.length)
|
210
214
|
end
|
211
215
|
|
212
|
-
def backup_update(
|
213
|
-
data_body = data.map { |u| as_update(u) }
|
216
|
+
def backup_update(requests)
|
214
217
|
dir = config.people.requests_folder
|
215
218
|
file = File.join(dir, "#{type}_data.json")
|
216
|
-
file_manager.save_json(
|
219
|
+
file_manager.save_json(requests, file, :timestamp)
|
217
220
|
end
|
218
221
|
|
219
222
|
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Eco
|
2
|
+
module API
|
3
|
+
class Session
|
4
|
+
class Batch
|
5
|
+
class Policies < Eco::API::Session::Batch::BasePolicy
|
6
|
+
CORE_ATTRS = Eco::API::Session::Batch::RequestStats.core_attrs(stats: true)
|
7
|
+
ACCOUNT_ATTRS = Eco::API::Session::Batch::RequestStats.account_attrs(stats: true)
|
8
|
+
DETAILS_ATTRS = Eco::API::Session::Batch::RequestStats.details_attrs(stats: true)
|
9
|
+
|
10
|
+
core_model = {core: CORE_ATTRS}
|
11
|
+
account_model = {account: ACCOUNT_ATTRS}
|
12
|
+
details_model = {details: DETAILS_ATTRS}
|
13
|
+
submodel = core_model.merge(account_model).merge(details_model)
|
14
|
+
TOP_MODEL = Eco::API::Session::Batch::Job.types.each_with_object({}) {|t, h| h[t] = submodel}
|
15
|
+
|
16
|
+
self.model = TOP_MODEL
|
17
|
+
policy_attrs *model_attrs
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,195 @@
|
|
1
|
+
module Eco
|
2
|
+
module API
|
3
|
+
class Session
|
4
|
+
class Batch
|
5
|
+
# @attr_reader count [Integer] the total number of requests
|
6
|
+
# @attr_reader stats [Hash] plain `Hash` with the number of requests that include an attribute
|
7
|
+
class RequestStats
|
8
|
+
CORE_ATTRS = Eco::API::Common::People::PersonParser::CORE_ATTRS
|
9
|
+
ACCOUNT_ATTRS = (Eco::API::Common::People::PersonParser::ACCOUNT_ATTRS + ["permissions_custom"]).uniq
|
10
|
+
DETAILS_ATTRS = ["fields"]
|
11
|
+
|
12
|
+
class << self
|
13
|
+
|
14
|
+
def valid_type?(type)
|
15
|
+
Eco::API::Session::Batch::Job.valid_type?(type.to_sym)
|
16
|
+
end
|
17
|
+
|
18
|
+
def core_attrs(stats: false, all: false)
|
19
|
+
CORE_ATTRS.dup.tap do |attrs|
|
20
|
+
attrs.unshift("core") if stats || all
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def account_attrs(stats: false, all: false)
|
25
|
+
ACCOUNT_ATTRS.dup.tap do |attrs|
|
26
|
+
if stats || all
|
27
|
+
attrs.unshift("account_remove")
|
28
|
+
attrs.unshift("account") if all
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def details_attrs(stats: false, all: false)
|
34
|
+
DETAILS_ATTRS.dup.tap do |attrs|
|
35
|
+
if stats || all
|
36
|
+
attrs.unshift("details_remove")
|
37
|
+
attrs.unshift("details") if all
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
attr_reader :type, :count
|
45
|
+
|
46
|
+
def initialize(type:, requests: [])
|
47
|
+
raise "type should be one of #{Eco::API::Session::Batch::Job.types}. Given: #{type}" unless self.class.valid_type?(type.to_sym)
|
48
|
+
@type = type.to_sym
|
49
|
+
@count = requests && requests.length
|
50
|
+
@stats = build(requests)
|
51
|
+
end
|
52
|
+
|
53
|
+
def to_h
|
54
|
+
@stats
|
55
|
+
end
|
56
|
+
|
57
|
+
def core_attrs
|
58
|
+
@core_attrs ||= self.class.core_attrs
|
59
|
+
end
|
60
|
+
|
61
|
+
def account_attrs
|
62
|
+
@account_attrs ||= self.class.account_attrs
|
63
|
+
end
|
64
|
+
|
65
|
+
def details_attrs
|
66
|
+
@details_attrs ||= self.class.details_attrs
|
67
|
+
end
|
68
|
+
|
69
|
+
def attr(attr, percent: false, total: count)
|
70
|
+
i = @stats["#{attr}"]
|
71
|
+
return i unless percent
|
72
|
+
percentage(i, total: total)
|
73
|
+
end
|
74
|
+
|
75
|
+
def core(percent: false)
|
76
|
+
attr("core", percent: percent)
|
77
|
+
end
|
78
|
+
|
79
|
+
def account(percent: false)
|
80
|
+
attr("account", percent: percent)
|
81
|
+
end
|
82
|
+
|
83
|
+
def account_remove(percent: false)
|
84
|
+
attr("account_remove", percent: percent)
|
85
|
+
end
|
86
|
+
|
87
|
+
def details(percent: false)
|
88
|
+
attr("details", percent: percent)
|
89
|
+
end
|
90
|
+
|
91
|
+
def details_remove(percent: false)
|
92
|
+
attr("details_remove", percent: percent)
|
93
|
+
end
|
94
|
+
|
95
|
+
def fields_average
|
96
|
+
if (fields_num = attr("fields")) && (total = details) > 0
|
97
|
+
(fields_num.to_f / total.to_f).round(2)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def message(percent: false)
|
102
|
+
key_val_delimiter = ": "; attr_delimiter = " ++ "
|
103
|
+
pairs_to_line = Proc.new do |pairs|
|
104
|
+
pairs.map do |p|
|
105
|
+
[p.first.to_s, "#{p.last.to_s}" + (percent ? "%" : "")].join(key_val_delimiter)
|
106
|
+
end.join(attr_delimiter)
|
107
|
+
end
|
108
|
+
|
109
|
+
lines = []
|
110
|
+
lines << pairs_to_line.call(core_pairs(percent: percent))
|
111
|
+
lines << pairs_to_line.call(account_pairs(percent: percent))
|
112
|
+
lines << pairs_to_line.call(details_pairs(percent: percent))
|
113
|
+
lines.join("\n")
|
114
|
+
end
|
115
|
+
|
116
|
+
def model
|
117
|
+
|
118
|
+
end
|
119
|
+
|
120
|
+
private
|
121
|
+
|
122
|
+
def percentage(num, total: count)
|
123
|
+
total ||= count
|
124
|
+
if num
|
125
|
+
(num.to_f / total * 100).round(2)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def build(requests)
|
130
|
+
Hash.new(0).tap do |stats|
|
131
|
+
stats[type] = count
|
132
|
+
unless !requests || !requests.is_a?(Enumerable) || requests.empty?
|
133
|
+
requests.each_with_index do |request|
|
134
|
+
add_core_stats(stats, request || {})
|
135
|
+
add_account_stats(stats, request || {})
|
136
|
+
add_details_stats(stats, request || {})
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def attrs_to_stat(stats, hash, attrs)
|
143
|
+
stats.tap do |st|
|
144
|
+
attrs.each {|attr| st[attr] += 1 if hash.key?(attr)}
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def add_core_stats(stats, request)
|
149
|
+
stats["core"] += 1 if (request.keys & core_attrs).length > 0
|
150
|
+
attrs_to_stat(stats, request, core_attrs)
|
151
|
+
end
|
152
|
+
|
153
|
+
def add_account_stats(stats, request)
|
154
|
+
if request.key?("account")
|
155
|
+
stats["account"] += 1
|
156
|
+
stats["account_remove"] += 1 if !request["account"]
|
157
|
+
attrs_to_stat(stats, request["account"] || {}, account_attrs)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def add_details_stats(stats, request)
|
162
|
+
if request.key?("details")
|
163
|
+
stats["details"] += 1
|
164
|
+
stats["details_remove"] += 1 if !request["details"]
|
165
|
+
if fields = request.dig("details", "fields")
|
166
|
+
stats["fields"] += fields.length
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def pairs(attrs, percent: false, total: count)
|
172
|
+
pairs = attrs.map do |a|
|
173
|
+
(v = attr(a, percent: percent, total: count)) > 0 ? [a, v] : nil
|
174
|
+
end.compact
|
175
|
+
end
|
176
|
+
|
177
|
+
def core_pairs(percent: false)
|
178
|
+
[["core", core(percent: percent)]] + pairs(core_attrs, percent: percent, total: core)
|
179
|
+
end
|
180
|
+
|
181
|
+
def account_pairs(percent: false)
|
182
|
+
aattrs = ["account_remove"] + account_attrs
|
183
|
+
[["account", account(percent: percent)]] + pairs(aattrs, percent: percent, total: account)
|
184
|
+
end
|
185
|
+
|
186
|
+
def details_pairs(percent: false)
|
187
|
+
details_pairs = [["details", details(percent: percent)]]
|
188
|
+
details_pairs += [["fields", fields_average]] if attr("fields") && fields_average
|
189
|
+
details_pairs += pairs(["details_remove"], percent: percent, total: details)
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|