canvas_sync 0.22.5 → 0.22.8

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.
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