canvas_sync 0.17.19 → 0.17.23.beta4
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.
- 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
|