canvas_sync 0.17.19 → 0.17.23.beta4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +45 -1
- data/lib/canvas_sync/concerns/sync_mapping.rb +114 -0
- data/lib/canvas_sync/generators/install_generator.rb +1 -0
- data/lib/canvas_sync/generators/templates/migrations/create_grading_period_groups.rb +22 -0
- data/lib/canvas_sync/generators/templates/migrations/create_grading_periods.rb +22 -0
- data/lib/canvas_sync/generators/templates/migrations/create_user_observers.rb +17 -0
- data/lib/canvas_sync/generators/templates/models/grading_period.rb +8 -0
- data/lib/canvas_sync/generators/templates/models/grading_period_group.rb +9 -0
- data/lib/canvas_sync/generators/templates/models/user_observer.rb +11 -0
- data/lib/canvas_sync/importers/bulk_importer.rb +23 -18
- data/lib/canvas_sync/job_batches/chain_builder.rb +1 -1
- data/lib/canvas_sync/jobs/report_checker.rb +37 -4
- data/lib/canvas_sync/jobs/report_starter.rb +2 -2
- data/lib/canvas_sync/processors/assignment_groups_processor.rb +1 -7
- data/lib/canvas_sync/processors/assignments_processor.rb +1 -7
- data/lib/canvas_sync/processors/context_module_items_processor.rb +1 -7
- data/lib/canvas_sync/processors/context_modules_processor.rb +1 -7
- data/lib/canvas_sync/processors/model_mappings.yml +68 -0
- data/lib/canvas_sync/processors/normal_processor.rb +3 -3
- data/lib/canvas_sync/processors/provisioning_report_processor.rb +21 -63
- data/lib/canvas_sync/processors/report_processor.rb +14 -9
- data/lib/canvas_sync/processors/submissions_processor.rb +1 -7
- data/lib/canvas_sync/record.rb +4 -0
- data/lib/canvas_sync/version.rb +1 -1
- data/lib/canvas_sync.rb +4 -1
- data/spec/canvas_sync/processors/provisioning_report_processor_spec.rb +40 -0
- data/spec/dummy/app/models/grading_period.rb +14 -0
- data/spec/dummy/app/models/grading_period_group.rb +15 -0
- data/spec/dummy/app/models/user_observer.rb +17 -0
- data/spec/dummy/db/migrate/20210907233329_create_user_observers.rb +23 -0
- data/spec/dummy/db/migrate/20210907233330_create_grading_periods.rb +28 -0
- data/spec/dummy/db/migrate/20210907233331_create_grading_period_groups.rb +28 -0
- data/spec/dummy/db/schema.rb +42 -1
- data/spec/dummy/log/development.log +1167 -0
- data/spec/dummy/log/test.log +6693 -0
- data/spec/support/fixtures/reports/grading_period_groups.csv +2 -0
- data/spec/support/fixtures/reports/grading_periods.csv +3 -0
- data/spec/support/fixtures/reports/user_observers.csv +3 -0
- metadata +33 -18
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c53a6ea9523ce389c70e26035183573de72ee06c547e380842216b879a6d8bdc
|
4
|
+
data.tar.gz: 166c315f6ba7b0efa337c55a56b8cd418e080f8f98393c4eaed1fd9fdc75a862
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f7137c46728c087f7e53902099744c4545db67d01da9117f243bbdf9e6ab099c5668a9f9be44752b6d4a4ca4208d1e98ebb2831e777be531416f01cc3befd21c
|
7
|
+
data.tar.gz: 62eecd4a5581297f95ee82c2e81c5c7821565defc9885024153ab17a708c00b5eda22f18e83773decb1884a73d6a7a74453d108c26e50f8f868934cb03a60e54
|
data/README.md
CHANGED
@@ -190,7 +190,30 @@ Overrides are useful for two scenarios:
|
|
190
190
|
- You have an existing application where the column names do not match up with what CanvasSync expects
|
191
191
|
- You want to sync some other column in the report that CanvasSync is not configured to sync
|
192
192
|
|
193
|
-
|
193
|
+
Mappings can be modified by editing the Model class like such:
|
194
|
+
```ruby
|
195
|
+
class User < ApplicationRecord
|
196
|
+
include CanvasSync::Record
|
197
|
+
|
198
|
+
sync_mapping(reset: false) do # `reset: false` is the default
|
199
|
+
# The mapping can be totally cleared with `reset: true` in the `sync_mapping` call, or like such:
|
200
|
+
reset_links
|
201
|
+
|
202
|
+
# Add a new column:
|
203
|
+
link_column :column_in_report => :column_in_database, type: :datetime
|
204
|
+
|
205
|
+
# If the column name on the report and in the DB are the same, a shorthand can be used:
|
206
|
+
link_column :omit_from_final_grade, type: :datetime
|
207
|
+
|
208
|
+
# If the defaults define a column you don't want synced, you can remove it from the mapping:
|
209
|
+
unlink_column :column_in_database
|
210
|
+
end
|
211
|
+
|
212
|
+
# ...
|
213
|
+
end
|
214
|
+
```
|
215
|
+
|
216
|
+
You can also create a file called `canvas_sync_provisioning_mapping.yml` in your Rails `config` directory. However, this approach requires you to re-specify the complete table in order to modify a table. Define the tables and columns you want to override using the following format:
|
194
217
|
|
195
218
|
```ruby
|
196
219
|
users:
|
@@ -383,6 +406,27 @@ Available config options (if you add more, please update this!):
|
|
383
406
|
|
384
407
|
* `config.classes_to_only_log_errors_on` - use this if you are utilizing the `CanvasSync::JobLog` table, but want certain classes to only persist in the `job_logs` table if an error is encountered. This is useful if you've got a very frequently used job that's filling up your database, and only really care about tracking failures.
|
385
408
|
|
409
|
+
## Global Options
|
410
|
+
You can pass in global_options to a job chain. Global options are added to the batch_context and referenced by
|
411
|
+
various internal processes.
|
412
|
+
|
413
|
+
Pass global options into a job chain, using the options param nested in a :global key.
|
414
|
+
options: { global: {...} }
|
415
|
+
|
416
|
+
report_timeout (integer): Number of days until a Canvas report should timeout. Default is 1.
|
417
|
+
report_compilation_timeout (integer): Number of days until a Canvas report should timeout. Default is 1 hour.
|
418
|
+
You can likely pass a float to achieve sub-day timeouts, but not tested.
|
419
|
+
report_max_tries (integer): The number of times to attempt a report before giving up. A report is considered failed
|
420
|
+
if it has an 'error' status in Canvas or is deleted.
|
421
|
+
|
422
|
+
This is an example job chain with global options:
|
423
|
+
job_chain = CanvasSync.default_provisioning_report_chain(
|
424
|
+
MODELS_TO_SYNC,
|
425
|
+
term_scope: :active,
|
426
|
+
full_sync_every: 'sunday',
|
427
|
+
options: { global: { report_timeout: 2 } }
|
428
|
+
)
|
429
|
+
|
386
430
|
## Handling Job errors
|
387
431
|
|
388
432
|
If you need custom handling for when a CanvasSync Job fails, you can add an `:on_failure` option to you Job Chain's `:global_options`.
|
@@ -0,0 +1,114 @@
|
|
1
|
+
module CanvasSync::Concerns
|
2
|
+
module SyncMapping
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
class_methods do
|
6
|
+
def sync_mapping(key = nil, reset: false, &blk)
|
7
|
+
key ||= Mapping.normalize_model_name(self)
|
8
|
+
key = key.to_s
|
9
|
+
existing_map = get_sync_mapping(key)
|
10
|
+
mapper = Mapping.new(existing_map&.deep_dup || {}.with_indifferent_access)
|
11
|
+
mapper.reset_links if reset
|
12
|
+
mapper.instance_exec(&blk)
|
13
|
+
@sync_mappings[key] = mapper.map_def.freeze
|
14
|
+
end
|
15
|
+
|
16
|
+
def get_sync_mapping(key = nil)
|
17
|
+
key ||= Mapping.normalize_model_name(self)
|
18
|
+
key = key.to_s
|
19
|
+
@sync_mappings ||= {}
|
20
|
+
@sync_mappings[key] || superclass.try(:get_sync_mapping, key) || Mapping.default_for(key)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
class Mapping
|
25
|
+
attr_reader :map_def
|
26
|
+
|
27
|
+
def initialize(map_def, model: nil)
|
28
|
+
@model = model
|
29
|
+
@map_def = map_def
|
30
|
+
@map_def[:conflict_target] ||= []
|
31
|
+
@map_def[:report_columns] ||= {}
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.normalize_model_name(model)
|
35
|
+
model = model.name unless model.is_a?(String)
|
36
|
+
model.pluralize.underscore
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.default_for(key)
|
40
|
+
default_mappings[key]
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.default_mappings
|
44
|
+
@mappings ||= begin
|
45
|
+
maps = {}
|
46
|
+
default_v1_mappings.each do |mname, legacy|
|
47
|
+
m = maps[mname] = {}
|
48
|
+
|
49
|
+
m[:conflict_target] = Array(legacy[:conflict_target]).map(&:to_sym).map do |lct|
|
50
|
+
legacy[:report_columns][lct][:database_column_name]
|
51
|
+
end
|
52
|
+
|
53
|
+
m[:report_columns] = {}
|
54
|
+
legacy[:report_columns].each do |rcol, opts|
|
55
|
+
m[:report_columns][opts[:database_column_name]] = opts.except(:database_column_name).merge!(
|
56
|
+
report_column: rcol,
|
57
|
+
).freeze
|
58
|
+
end
|
59
|
+
end
|
60
|
+
maps.with_indifferent_access.freeze
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def self.default_v1_mappings
|
65
|
+
@legacy_mappings ||= begin
|
66
|
+
mapping = YAML.load_file(File.join(__dir__, '../processors', "model_mappings.yml")).deep_symbolize_keys!
|
67
|
+
override_filepath = Rails.root.join("config/canvas_sync_provisioning_mapping.yml")
|
68
|
+
|
69
|
+
if File.file?(override_filepath)
|
70
|
+
override = YAML.load_file(override_filepath).deep_symbolize_keys!
|
71
|
+
mapping = mapping.merge(override)
|
72
|
+
end
|
73
|
+
|
74
|
+
mapping.freeze
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def conflict_target(*columns)
|
79
|
+
if columns.count == 0
|
80
|
+
@map_def[:conflict_target]
|
81
|
+
else
|
82
|
+
@map_def[:conflict_target] = columns.flatten.compact
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def reset_links
|
87
|
+
@map_def[:report_columns] = {}.with_indifferent_access
|
88
|
+
end
|
89
|
+
|
90
|
+
def unlink_column(key)
|
91
|
+
@map_def.delete(key)
|
92
|
+
end
|
93
|
+
|
94
|
+
def link_column(m, type: nil, &blk)
|
95
|
+
if m.is_a?(Hash)
|
96
|
+
raise "Hash should have exactly 1 entry" if m && m.count != 1
|
97
|
+
@map_def[:report_columns][m.values[0]] = {
|
98
|
+
report_column: m.keys[0],
|
99
|
+
type: type,
|
100
|
+
transform: blk,
|
101
|
+
}
|
102
|
+
elsif m.is_a?(Symbol)
|
103
|
+
@map_def[:report_columns][m] = {
|
104
|
+
report_column: m,
|
105
|
+
type: type,
|
106
|
+
transform: blk,
|
107
|
+
}
|
108
|
+
else
|
109
|
+
raise "Cannot handle argument of type #{m.class}"
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# <%= autogenerated_migration_warning %>
|
2
|
+
|
3
|
+
class CreateGradingPeriodGroups < ActiveRecord::Migration[5.1]
|
4
|
+
def change
|
5
|
+
create_table :grading_period_groups do |t|
|
6
|
+
t.bigint :canvas_id, null: false
|
7
|
+
t.bigint :canvas_course_id
|
8
|
+
t.bigint :canvas_account_id
|
9
|
+
t.string :title
|
10
|
+
t.boolean :weighted
|
11
|
+
t.boolean :display_totals_for_all_grading_periods
|
12
|
+
|
13
|
+
t.string :workflow_state
|
14
|
+
|
15
|
+
t.timestamps
|
16
|
+
end
|
17
|
+
|
18
|
+
add_index :grading_period_groups, :canvas_id, unique: true
|
19
|
+
add_index :grading_period_groups, :canvas_course_id
|
20
|
+
add_index :grading_period_groups, :canvas_account_id
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# <%= autogenerated_migration_warning %>
|
2
|
+
|
3
|
+
class CreateGradingPeriods < ActiveRecord::Migration[5.1]
|
4
|
+
def change
|
5
|
+
create_table :grading_periods do |t|
|
6
|
+
t.bigint :canvas_id, null: false
|
7
|
+
t.string :title
|
8
|
+
t.float :weight
|
9
|
+
t.datetime :start_date
|
10
|
+
t.datetime :end_date
|
11
|
+
t.datetime :close_date
|
12
|
+
t.bigint :canvas_grading_period_group_id
|
13
|
+
|
14
|
+
t.string :workflow_state
|
15
|
+
|
16
|
+
t.timestamps
|
17
|
+
end
|
18
|
+
|
19
|
+
add_index :grading_periods, :canvas_id, unique: true
|
20
|
+
add_index :grading_periods, :canvas_grading_period_group_id
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# <%= autogenerated_migration_warning %>
|
2
|
+
|
3
|
+
class CreateUserObservers < ActiveRecord::Migration[5.1]
|
4
|
+
def change
|
5
|
+
create_table :user_observers do |t|
|
6
|
+
t.bigint :observing_user_id
|
7
|
+
t.bigint :observed_user_id
|
8
|
+
t.string :workflow_state
|
9
|
+
|
10
|
+
t.timestamps
|
11
|
+
end
|
12
|
+
|
13
|
+
add_index :user_observers, [:observed_user_id, :observing_user_id], unique: true
|
14
|
+
add_index :user_observers, :observing_user_id
|
15
|
+
add_index :user_observers, :observed_user_id
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,8 @@
|
|
1
|
+
# <%= autogenerated_model_warning %>
|
2
|
+
|
3
|
+
class GradingPeriod < ApplicationRecord
|
4
|
+
include CanvasSync::Record
|
5
|
+
|
6
|
+
validates :canvas_id, uniqueness: true, presence: true
|
7
|
+
belongs_to :grading_period_group, primary_key: :canvas_id, foreign_key: :canvas_grading_period_group_id, optional: true
|
8
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# <%= autogenerated_model_warning %>
|
2
|
+
|
3
|
+
class GradingPeriodGroup < ApplicationRecord
|
4
|
+
include CanvasSync::Record
|
5
|
+
|
6
|
+
validates :canvas_id, uniqueness: true, presence: true
|
7
|
+
belongs_to :course, primary_key: :canvas_id, foreign_key: :canvas_course_id, optional: true
|
8
|
+
belongs_to :account, primary_key: :canvas_id, foreign_key: :canvas_account_id, optional: true
|
9
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# <%= autogenerated_model_warning %>
|
2
|
+
|
3
|
+
class UserObserver < ApplicationRecord
|
4
|
+
include CanvasSync::Record
|
5
|
+
include CanvasSync::Concerns::ApiSyncable
|
6
|
+
|
7
|
+
validates :canvas_id, uniqueness: true, presence: true
|
8
|
+
|
9
|
+
belongs_to :observing_user, primary_key: :canvas_id, foreign_key: :observing_user_id, class_name: 'User', optional: true
|
10
|
+
belongs_to :observed_user, primary_key: :canvas_id, foreign_key: :observed_user_id, class_name: 'User', optional: true
|
11
|
+
end
|
@@ -24,38 +24,43 @@ module CanvasSync
|
|
24
24
|
end
|
25
25
|
|
26
26
|
def self.perform_in_batches(report_file_path, mapping, klass, conflict_target, import_args: {})
|
27
|
-
csv_column_names = mapping.
|
28
|
-
database_column_names = mapping.
|
27
|
+
csv_column_names = mapping.values.map { |value| value[:report_column].to_s }
|
28
|
+
database_column_names = mapping.keys
|
29
29
|
|
30
|
-
|
31
|
-
|
32
|
-
conflict_target = Array(conflict_target).map(&:to_sym)
|
33
|
-
database_conflict_column_name = conflict_target.map{|ct| mapping[ct][:database_column_name] }
|
30
|
+
conflict_target = Array(conflict_target).map(&:to_s)
|
31
|
+
conflict_target_indices = conflict_target.map{|ct| database_column_names.index(ct) }
|
34
32
|
|
35
33
|
row_ids = {}
|
36
34
|
batcher = CanvasSync::BatchProcessor.new(of: batch_size) do |batch|
|
37
35
|
row_ids = {}
|
38
|
-
perform_import(klass, database_column_names, batch,
|
36
|
+
perform_import(klass, database_column_names, batch, conflict_target, import_args)
|
39
37
|
end
|
40
38
|
|
41
39
|
row_buffer_out = ->(row) {
|
40
|
+
formatted_row = mapping.map do |db_col, col_def|
|
41
|
+
value = nil
|
42
|
+
value = row[col_def[:report_column]] if col_def[:report_column]
|
43
|
+
|
44
|
+
if col_def[:type]
|
45
|
+
if col_def[:type].to_sym == :datetime
|
46
|
+
# TODO: add some timezone config to the mapping.
|
47
|
+
# In cases where the timestamp or date doesn't include a timezone, you should be able to specify one
|
48
|
+
value = DateTime.parse(value).utc rescue nil # rubocop:disable Style/RescueModifier
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
value = col_def[:transform].call(value, row) if col_def[:transform]
|
53
|
+
|
54
|
+
value
|
55
|
+
end
|
56
|
+
|
42
57
|
if conflict_target.present?
|
43
|
-
key =
|
58
|
+
key = conflict_target_indices.map{|ct| formatted_row[ct] }
|
44
59
|
next if row_ids[key]
|
45
60
|
|
46
61
|
row_ids[key] = true
|
47
62
|
end
|
48
63
|
|
49
|
-
formatted_row = csv_column_names.map do |column|
|
50
|
-
if mapping[column][:type].to_sym == :datetime
|
51
|
-
# TODO: add some timezone config to the mapping.
|
52
|
-
# In cases where the timestamp or date doesn't include a timezone, you should be able to specify one
|
53
|
-
DateTime.parse(row[column]).utc rescue nil # rubocop:disable Style/RescueModifier
|
54
|
-
else
|
55
|
-
row[column]
|
56
|
-
end
|
57
|
-
end
|
58
|
-
|
59
64
|
batcher << formatted_row
|
60
65
|
}
|
61
66
|
|
@@ -40,7 +40,7 @@ module CanvasSync
|
|
40
40
|
def insert_at(position, new_jobs)
|
41
41
|
chain = self.class.get_chain_parameter(base_job)
|
42
42
|
new_jobs = [new_jobs] unless new_jobs.is_a?(Array)
|
43
|
-
chain.insert(
|
43
|
+
chain.insert(position, *new_jobs)
|
44
44
|
end
|
45
45
|
|
46
46
|
def insert(new_jobs, **kwargs)
|
@@ -4,8 +4,9 @@ module CanvasSync
|
|
4
4
|
# Re-enqueues itself if the report is still processing on Canvas.
|
5
5
|
# Enqueues the ReportProcessor when the report has completed.
|
6
6
|
class ReportChecker < CanvasSync::Job
|
7
|
-
REPORT_TIMEOUT =
|
7
|
+
REPORT_TIMEOUT = 24.hours
|
8
8
|
COMPILATION_TIMEOUT = 1.hour
|
9
|
+
MAX_TRIES = 3
|
9
10
|
|
10
11
|
# @param report_name [Hash] e.g., 'provisioning_csv'
|
11
12
|
# @param report_id [Integer]
|
@@ -13,6 +14,7 @@ module CanvasSync
|
|
13
14
|
# @param options [Hash] hash of options that will be passed to the job processor
|
14
15
|
# @return [nil]
|
15
16
|
def perform(report_name, report_id, processor, options, checker_context = {}) # rubocop:disable Metrics/AbcSize
|
17
|
+
max_tries = options[:report_max_tries] || batch_context[:report_max_tries] || MAX_TRIES
|
16
18
|
account_id = options[:account_id] || batch_context[:account_id] || "self"
|
17
19
|
report_status = CanvasSync.get_canvas_sync_client(batch_context)
|
18
20
|
.report_status(account_id, report_name, report_id)
|
@@ -27,9 +29,17 @@ module CanvasSync
|
|
27
29
|
report_id,
|
28
30
|
)
|
29
31
|
when "error", "deleted"
|
30
|
-
|
32
|
+
checker_context[:failed_attempts] ||= 0
|
33
|
+
checker_context[:failed_attempts] += 1
|
34
|
+
failed_attempts = checker_context[:failed_attempts]
|
35
|
+
message = "Report failed to process; status was #{report_status} for report_name: #{report_name}, report_id: #{report_id}, #{current_organization.name}. This report has now failed #{checker_context[:failed_attempts]} time." # rubocop:disable Metrics/LineLength
|
31
36
|
Rails.logger.error(message)
|
32
|
-
|
37
|
+
if failed_attempts >= max_tries
|
38
|
+
Rails.logger.error("This report has failed #{failed_attempts} times. Giving up.")
|
39
|
+
raise message
|
40
|
+
else
|
41
|
+
restart_report(options, report_name, processor, checker_context)
|
42
|
+
end
|
33
43
|
else
|
34
44
|
report_timeout = parse_timeout(options[:report_timeout] || batch_context[:report_timeout] || REPORT_TIMEOUT)
|
35
45
|
if timeout_met?(options[:sync_start_time], report_timeout)
|
@@ -51,7 +61,7 @@ module CanvasSync
|
|
51
61
|
report_id,
|
52
62
|
processor,
|
53
63
|
options,
|
54
|
-
checker_context
|
64
|
+
checker_context
|
55
65
|
)
|
56
66
|
end
|
57
67
|
end
|
@@ -66,6 +76,29 @@ module CanvasSync
|
|
66
76
|
def parse_timeout(val)
|
67
77
|
val
|
68
78
|
end
|
79
|
+
|
80
|
+
def restart_report(options, report_name, processor, checker_context)
|
81
|
+
account_id = options[:account_id] || batch_context[:account_id] || "self"
|
82
|
+
options[:sync_start_time] = DateTime.now.utc.iso8601
|
83
|
+
new_context = {}
|
84
|
+
new_context[:failed_attempts] = checker_context[:failed_attempts]
|
85
|
+
report_id = start_report(account_id, report_name, options[:report_params])
|
86
|
+
CanvasSync::Jobs::ReportChecker
|
87
|
+
.set(wait: report_checker_wait_time)
|
88
|
+
.perform_later(
|
89
|
+
report_name,
|
90
|
+
report_id,
|
91
|
+
processor,
|
92
|
+
options,
|
93
|
+
new_context
|
94
|
+
)
|
95
|
+
end
|
96
|
+
|
97
|
+
def start_report(account_id, report_name, report_params)
|
98
|
+
report = CanvasSync.get_canvas_sync_client(batch_context)
|
99
|
+
.start_report(account_id, report_name, report_params)
|
100
|
+
report["id"]
|
101
|
+
end
|
69
102
|
end
|
70
103
|
end
|
71
104
|
end
|