canvas_sync 0.22.5 → 0.22.8

Sign up to get free protection for your applications and to get access to all the features.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +0 -0
  3. data/lib/canvas_sync/concerns/api_syncable.rb +9 -6
  4. data/lib/canvas_sync/concerns/sync_mapping.rb +11 -1
  5. data/lib/canvas_sync/generators/templates/migrations/create_enrollments.rb +1 -0
  6. data/lib/canvas_sync/generators/templates/models/course_progress.rb +8 -0
  7. data/lib/canvas_sync/importers/bulk_importer.rb +39 -69
  8. data/lib/canvas_sync/job.rb +0 -0
  9. data/lib/canvas_sync/job_batches/batch.rb +1 -1
  10. data/lib/canvas_sync/job_batches/chain_builder.rb +3 -24
  11. data/lib/canvas_sync/job_batches/jobs/managed_batch_job.rb +0 -4
  12. data/lib/canvas_sync/job_batches/status.rb +0 -1
  13. data/lib/canvas_sync/job_uniqueness/lock_context.rb +3 -15
  14. data/lib/canvas_sync/jobs/term_batches_job.rb +1 -4
  15. data/lib/canvas_sync/processors/model_mappings.yml +3 -0
  16. data/lib/canvas_sync/version.rb +1 -1
  17. data/spec/canvas_sync/canvas_sync_spec.rb +41 -59
  18. data/spec/canvas_sync/processors/provisioning_report_processor_spec.rb +4 -0
  19. data/spec/dummy/app/models/course_progress.rb +8 -0
  20. data/spec/dummy/app/models/learning_outcome_result.rb +0 -0
  21. data/spec/dummy/app/models/rubric.rb +0 -0
  22. data/spec/dummy/app/models/rubric_assessment.rb +0 -0
  23. data/spec/dummy/app/models/rubric_association.rb +0 -0
  24. data/spec/dummy/app/models/user.rb +0 -0
  25. data/spec/dummy/db/migrate/20190702203624_create_enrollments.rb +1 -0
  26. data/spec/dummy/db/migrate/20240408223326_create_course_nicknames.rb +0 -0
  27. data/spec/dummy/db/migrate/20240509105100_create_rubrics.rb +0 -0
  28. data/spec/dummy/db/schema.rb +1 -0
  29. metadata +196 -211
  30. data/lib/canvas_sync/concerns/auto_relations.rb +0 -11
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6fbc16dd889dae43881098a80d91c68ec64412b1ce872b51f176f89a0d2ce8fe
4
- data.tar.gz: f202ff4cae0337f6a34872654b55371b5fdfabdccd1358fc7fceb14397549425
3
+ metadata.gz: '0882011929080b4e55e1798ae3639c4431a583dd2a31634b2b9c2ba5f450fcb4'
4
+ data.tar.gz: 7a339eef193de9b640b3d68d5474a7771d363f7dee723be49da33cf319afe610
5
5
  SHA512:
6
- metadata.gz: 35c21c3717c63c372fd840b511b2b51161f1114b4e9f8a8140d38ee0d4ca6393950d842b9619ded9731ae32b0abda8ef147ceaacba76f8bcc77d5b6966898b9f
7
- data.tar.gz: a1b9a4f2129f9f441786237ed2bc69ff58009343d67d74f5724376f2fc77c0797fd3f932ea404d6b993d26ba210beeeafc1a9b0ad1025ad11169ebf7767f84af
6
+ metadata.gz: a54b1779112dce3d106a503cc720b74d5b54ed2688c95c9d2c3523024813a0ac34d144417a8959bc81561dc4f78a7cf9914dd124037c85376a8a35b455bd5be1
7
+ data.tar.gz: 74e8b3e7d9317bbc2bbd7f902cfbedeabfd826ddbfd70aa4c8bf57c6f05dc2f4176f7bf2c0cc0352adae4ecaa4978f57f02fc3dcbbed8d1d8130df58bbaea51d
data/README.md CHANGED
File without changes
@@ -40,14 +40,17 @@ module CanvasSync::Concerns
40
40
  options = { validate: false, on_duplicate_key_update: update_conditions }.merge(import_args)
41
41
 
42
42
  if all_pages
43
- batcher = CanvasSync::BatchProcessor.new(of: batch_size) do |batch|
44
- import(columns, batch, options)
43
+ enumer = api_array.all_pages_each.lazy
44
+
45
+ # Map the API response to instances of this model
46
+ enumer = enumer.map do |api_item|
47
+ new.assign_from_api_params(api_item)
45
48
  end
46
- api_array.all_pages_each do |api_item|
47
- item = new.assign_from_api_params(api_item)
48
- batcher << item
49
+
50
+ # Import in batches
51
+ enumer.each_slice(batch_size) do |batch|
52
+ import(columns, batch, options)
49
53
  end
50
- batcher.flush
51
54
  else
52
55
  items = api_array.map do |api_item|
53
56
  new.assign_from_api_params(api_item)
@@ -51,10 +51,11 @@ module CanvasSync::Concerns
51
51
  m = maps[mname] = {}
52
52
 
53
53
  m[:conflict_target] = Array(legacy[:conflict_target]).map(&:to_sym).map do |lct|
54
- legacy[:report_columns][lct][:database_column_name]
54
+ legacy[:report_columns]&.[](lct)&.[](:database_column_name)
55
55
  end
56
56
 
57
57
  m[:columns] = {}
58
+
58
59
  legacy[:report_columns].each do |rcol, opts|
59
60
  m[:columns][opts[:database_column_name]] = opts.except(:database_column_name).merge!(
60
61
  report_column: rcol,
@@ -68,6 +69,15 @@ module CanvasSync::Concerns
68
69
  def self.default_v1_mappings
69
70
  @legacy_mappings ||= begin
70
71
  mapping = YAML.load_file(File.join(__dir__, '../processors', "model_mappings.yml")).deep_symbolize_keys!
72
+ # Default columns can be excluded if the table has not been migrated to accept them
73
+ mapping.each do |mname, legacy|
74
+ legacy[:report_columns].select! do |rcol, opts|
75
+ model = mname.to_s&.classify&.safe_constantize
76
+ # we need to make sure this is a model otherwise they will be systematically removed - some mappings are not models (e.g xlists)
77
+ model.present? && !model.column_names.include?(opts[:database_column_name].to_s) ? false : true
78
+ end
79
+ end
80
+
71
81
  override_filepath = Rails.root.join("config/canvas_sync_provisioning_mapping.yml")
72
82
 
73
83
  if File.file?(override_filepath)
@@ -13,6 +13,7 @@ class CreateEnrollments < ActiveRecord::Migration[5.1]
13
13
  t.bigint :canvas_section_id
14
14
  t.string :workflow_state
15
15
  t.string :base_role_type
16
+ t.datetime :completed_at
16
17
 
17
18
  t.timestamps
18
19
  end
@@ -3,6 +3,7 @@
3
3
  # CourseProgress is not a Canvas model. It is a table built from the Custom Report
4
4
  class CourseProgress < ApplicationRecord
5
5
  include CanvasSync::Record
6
+ include CanvasSync::Concerns::ApiSyncable
6
7
 
7
8
  canvas_sync_features :defaults
8
9
 
@@ -11,4 +12,11 @@ class CourseProgress < ApplicationRecord
11
12
 
12
13
  validates_presence_of :canvas_user_id, :canvas_course_id
13
14
  validates_uniqueness_of :canvas_user_id, scope: :canvas_course_id
15
+
16
+ api_syncable({
17
+ requirement_count: :requirement_count,
18
+ requirement_completed_count: :requirement_completed_count,
19
+ # provisioning report has completion_date instead of completed_at in the API
20
+ completion_date: :completed_at
21
+ }, -> (api) { api.course_progress(canvas_course_id, canvas_user_id) })
14
22
  end
@@ -23,7 +23,7 @@ module CanvasSync
23
23
  end
24
24
  end
25
25
 
26
- def self.perform_in_batches(report_file_path, raw_mapping, klass, conflict_target, import_args: {})
26
+ def self.perform_in_batches(report_file_path, raw_mapping, klass, conflict_target, import_args: {}, &block)
27
27
  mapping = {}.with_indifferent_access
28
28
  raw_mapping.each do |db_col, opts|
29
29
  next if opts[:deprecated] && !klass.column_names.include?(db_col.to_s)
@@ -37,14 +37,27 @@ module CanvasSync
37
37
  conflict_target = Array(conflict_target).map(&:to_s)
38
38
  conflict_target_indices = conflict_target.map{|ct| database_column_names.index(ct) }
39
39
 
40
- row_ids = {}
41
- batcher = CanvasSync::BatchProcessor.new(of: batch_size) do |batch|
42
- row_ids = {}
43
- perform_import(klass, database_column_names, batch, conflict_target, import_args)
40
+ enumer = CSV.foreach(report_file_path, headers: true, header_converters: :symbol).lazy
41
+
42
+ # Optionally filter rows by a passed block
43
+ if block
44
+ enumer = enumer.filter_map do |row|
45
+ catch :skip do
46
+ block.call(row)
47
+ end
48
+ end
49
+ end
50
+
51
+ # Optionally chunk by a computed value. Mainly so we can group duplicate rows and choose one
52
+ chunker = nil
53
+ chunker = UserChunker.new if defined?(User) && klass == User && csv_column_names.include?('user_id')
54
+ if chunker
55
+ enumer = enumer.chunk{|row| chunker.key(row) }.flat_map{|key, rows| chunker.choose(key, rows) }
44
56
  end
45
57
 
46
- row_buffer_out = ->(row) {
47
- formatted_row = mapping.map do |db_col, col_def|
58
+ # Prepare the rows for import
59
+ enumer = enumer.map do |row|
60
+ mapping.map do |db_col, col_def|
48
61
  value = nil
49
62
  value = row[col_def[:report_column]] if col_def[:report_column]
50
63
 
@@ -63,33 +76,25 @@ module CanvasSync
63
76
 
64
77
  value
65
78
  end
79
+ end
66
80
 
67
- if conflict_target.present?
68
- key = conflict_target_indices.map{|ct| formatted_row[ct] }
69
- next if row_ids[key]
70
-
81
+ # Reject rows within a single batch that have the same ID
82
+ row_ids = nil
83
+ if conflict_target.present?
84
+ enumer = enumer.reject do |row|
85
+ key = conflict_target_indices.map{|ct| row[ct] }
86
+ skip = row_ids[key]
71
87
  row_ids[key] = true
88
+ skip
72
89
  end
73
-
74
- batcher << formatted_row
75
- }
76
-
77
- row_buffer = nil
78
- if defined?(User) && klass == User && csv_column_names.include?('user_id')
79
- row_buffer = UserRowBuffer.new(&row_buffer_out)
80
- else
81
- row_buffer = NullRowBuffer.new(&row_buffer_out)
82
90
  end
83
91
 
84
- CSV.foreach(report_file_path, headers: true, header_converters: :symbol) do |row|
85
- row = yield(row) if block_given?
86
- next if row.nil?
87
-
88
- row_buffer << row
92
+ # Start importing
93
+ row_ids = {}
94
+ enumer.each_slice(batch_size) do |batch|
95
+ perform_import(klass, database_column_names, batch, conflict_target, import_args)
96
+ row_ids = {}
89
97
  end
90
-
91
- row_buffer.flush
92
- batcher.flush
93
98
  end
94
99
 
95
100
  def self.perform_import(klass, columns, rows, conflict_target, import_args={})
@@ -157,52 +162,17 @@ module CanvasSync
157
162
  batch_size > 0 ? batch_size : DEFAULT_BATCH_SIZE
158
163
  end
159
164
 
160
- class RowBuffer
161
- def initialize(&block)
162
- @flush_out = block
163
- @buffered_rows = []
164
- end
165
-
166
- def <<(v)
167
- @buffered_rows << v
168
- end
169
-
170
- def flush(value = @buffered_rows)
171
- if value.is_a?(Array)
172
- value.each do |v|
173
- @flush_out.call(v)
174
- end
175
- else
176
- @flush_out.call(value)
177
- end
178
- @buffered_rows = []
179
- end
180
- end
181
-
182
- class NullRowBuffer
183
- def initialize(&block)
184
- @flush_out = block
185
- end
186
-
187
- def <<(v)
188
- @flush_out.call(v)
189
- end
190
-
191
- def flush; end
192
- end
193
-
194
165
  # Ensures that, if a User has multiple rows, one with a SIS ID is preferred.
195
166
  # This is mainly to fix issues in legacy apps - the suggested approach for new apps
196
167
  # is to sync and use the Pseudonymes table
197
- class UserRowBuffer < RowBuffer
198
- def <<(v)
199
- flush if @buffered_rows[0] && @buffered_rows[0][:canvas_user_id] != v[:canvas_user_id]
200
- super
168
+ class UserChunker
169
+ def key(row)
170
+ row[:canvas_user_id]
201
171
  end
202
172
 
203
- def flush
204
- row = @buffered_rows.find{|r| r[:user_id].present? } || @buffered_rows.last
205
- super(row.present? ? [row] : [])
173
+ def choose(key, rows)
174
+ row = rows.find{|r| r[:user_id].present? } || rows.last
175
+ row.present? ? [row] : []
206
176
  end
207
177
  end
208
178
  end
File without changes
@@ -337,7 +337,7 @@ module CanvasSync::JobBatches
337
337
  trigger_callback.call(:complete)
338
338
  end
339
339
 
340
- if all_successful # All Successfull
340
+ if all_successful # All Successful
341
341
  trigger_callback.call(:success)
342
342
  elsif pending_jobs == dead_jobs && pending_batches == stagnated_batches # Stagnated
343
343
  trigger_callback.call(:stagnated)
@@ -4,9 +4,7 @@ module CanvasSync::JobBatches
4
4
 
5
5
  attr_reader :base_job
6
6
 
7
- def initialize(base_type = SerialBatchJob, chain_id: nil)
8
- @chain_id = chain_id || SecureRandom.urlsafe_base64(10)
9
-
7
+ def initialize(base_type = SerialBatchJob)
10
8
  if base_type.is_a?(Hash)
11
9
  @base_job = base_type
12
10
  @base_job[:args] ||= @base_job[:parameters] || []
@@ -111,7 +109,7 @@ module CanvasSync::JobBatches
111
109
  return nil if matching_jobs.count == 0
112
110
 
113
111
  job = matching_jobs[0][0]
114
- job = self.class.new(job, chain_id: @chain_id) unless job.is_a?(ChainBuilder)
112
+ job = self.class.new(job) unless job.is_a?(ChainBuilder)
115
113
  job
116
114
  end
117
115
 
@@ -120,7 +118,6 @@ module CanvasSync::JobBatches
120
118
  job_def.normalize!
121
119
  else
122
120
  job_def[:job] = job_def[:job].to_s
123
- job_def[:chain_link] ||= "#{@chain_id}-#{SecureRandom.urlsafe_base64(10)}"
124
121
  if (chain = self.class.get_chain_parameter(job_def, raise_error: false)).present?
125
122
  chain.map! { |sub_job| normalize!(sub_job) }
126
123
  end
@@ -141,7 +138,7 @@ module CanvasSync::JobBatches
141
138
  args: args,
142
139
  kwargs: kwargs,
143
140
  }
144
- self.class.new(hsh, chain_id: @chain_id).apply_block(&blk) if blk.present?
141
+ self.class.new(hsh).apply_block(&blk) if blk.present?
145
142
  hsh
146
143
  end
147
144
 
@@ -242,24 +239,6 @@ module CanvasSync::JobBatches
242
239
  job_class.perform_later(*job_args, **job_kwargs)
243
240
  end
244
241
  end
245
-
246
- def link_to_batch!(chain_link, batch)
247
- # Or make chains a separate entity - Chains show batches, but batches don't show chain?
248
- # Or "Annotate" a Batch with chain data - could extract chain id from any job entry
249
- end
250
-
251
- def annotate_batch!(batch, chain)
252
-
253
- end
254
-
255
- def handle_step_complete(status, opts)
256
- chain_link = opts[:chain_link]
257
- chain_id, chain_step_id = chain_link.split('-')
258
-
259
- CanvasSync::JobBatches::Batch.redis.multi do |r|
260
- r.hset("CHAIN-#{chain_id}-steps", chain_step_id, "complete")
261
- end
262
- end
263
242
  end
264
243
  end
265
244
 
@@ -144,10 +144,6 @@ module CanvasSync::JobBatches
144
144
  if next_job[:chain_link].present?
145
145
  # Annotate Batch with chain-step info
146
146
  batch.context["csb:chain_link"] = next_job[:chain_link]
147
- # TODO Add Fiber Batch to chain-link
148
- # With the exception of the top of the Chain, all nested ManagedBatch Roots should be within a Fiber,
149
- # so we shouldn't really need to make the Root checkin with the chain
150
- # ...except to cleanup the chain
151
147
  batch.on(:complete, "#{ChainBuilder.to_s}.chain_step_complete", chain_link: next_job[:chain_link])
152
148
  end
153
149
 
@@ -49,7 +49,6 @@ module CanvasSync::JobBatches
49
49
  end
50
50
 
51
51
  def success?
52
- # TODO (Race Condition) This might not be valid if checked from a :complete callback
53
52
  'true' == Batch.redis { |r| r.hget("BID-#{bid}", 'success') }
54
53
  end
55
54
 
@@ -6,6 +6,8 @@ module CanvasSync::JobUniqueness
6
6
  context_class.new(data, **kwargs)
7
7
  end
8
8
 
9
+ attr_reader :lock_id
10
+
9
11
  # { job_clazz, jid, queue, args?, kwargs?, base_key? }
10
12
  def initialize(data, job_instance: nil, config: nil)
11
13
  @base_key = data[:base_key]
@@ -14,9 +16,7 @@ module CanvasSync::JobUniqueness
14
16
  @config = config || @context_data[:config]
15
17
 
16
18
  # TODO Consider (somewhere) updating the lock_id to the BID of the wrapping Batch (when applicable)
17
- @lock_id ||= data[:lid] || Thread.current[:unique_jobs_previous_context]&.lock_id
18
- @lock_id_locked = @lock_id.present?
19
- @lock_id ||= job_id
19
+ @lock_id ||= data[:lid] || Thread.current[:unique_jobs_previous_context]&.lock_id || job_id
20
20
  end
21
21
 
22
22
  # This is primarily for rehydrating in a Batch Callback, so it is unlikely that args and kwargs are needed.
@@ -84,18 +84,6 @@ module CanvasSync::JobUniqueness
84
84
  end
85
85
  end
86
86
 
87
- def lock_id
88
- @lock_id_locked = true
89
- @lock_id
90
- end
91
-
92
- def lock_id=(new_id)
93
- raise "Lock ID already set" if @lock_id_locked
94
- return unless new_id.present?
95
- @lock_id = new_id
96
- @lock_id_locked = true
97
- end
98
-
99
87
  def job_id
100
88
  @context_data[:jid]
101
89
  end
@@ -12,17 +12,14 @@ module CanvasSync
12
12
  # Override the delta-syncing date if:
13
13
  # 1. the Term hasn't been synced before or
14
14
  # 2. the Term underwent a period of not syncing
15
+ term_last_sync = CanvasSync.redis.get(self.class.last_sync_key(term_id))
15
16
  if batch_context[:updated_after]
16
- term_last_sync = CanvasSync.redis.get(self.class.last_sync_key(term_id))
17
17
  if !term_last_sync.present? || batch_context[:updated_after] > term_last_sync
18
18
  local_context[:updated_after] = term_last_sync.presence
19
19
  end
20
20
  end
21
21
 
22
22
  JobBatches::ManagedBatchJob.make_batch(jobs, ordered: false, concurrency: true) do |b|
23
- # TODO If we do a Chain UI, this will need to checkin somehow to indicate that the chain forked
24
- # Or chain steps just show a summary - eg "Started", "X Jobs Running", "Done" or "X Jobs Running, Y Jobs Done" - and not individual forks
25
- # For a step to be considered done, all previous sibling-level steps must be done and no batches pending
26
23
  b.description = "TermBatchJob(#{term_id}) Root"
27
24
  b.context = local_context
28
25
  b.on(:success, "#{self.class.to_s}.batch_finished") unless options[:mark_synced] == false
@@ -159,6 +159,9 @@ enrollments:
159
159
  base_role_type:
160
160
  database_column_name: base_role_type
161
161
  type: string
162
+ completed_at:
163
+ database_column_name: completed_at
164
+ type: datetime
162
165
 
163
166
  sections:
164
167
  conflict_target: canvas_section_id
@@ -1,3 +1,3 @@
1
1
  module CanvasSync
2
- VERSION = "0.22.5".freeze
2
+ VERSION = "0.22.8".freeze
3
3
  end