canvas_sync 0.12.0 → 0.16.1

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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/canvas_sync.rb +85 -30
  4. data/lib/canvas_sync/api_syncable.rb +4 -162
  5. data/lib/canvas_sync/class_callback_executor.rb +35 -0
  6. data/lib/canvas_sync/concerns/account/ancestry.rb +60 -0
  7. data/lib/canvas_sync/concerns/api_syncable.rb +189 -0
  8. data/lib/canvas_sync/concerns/legacy_columns.rb +34 -0
  9. data/lib/canvas_sync/generators/templates/migrations/create_group_memberships.rb +18 -0
  10. data/lib/canvas_sync/generators/templates/migrations/create_groups.rb +23 -0
  11. data/lib/canvas_sync/generators/templates/migrations/create_pseudonyms.rb +18 -0
  12. data/lib/canvas_sync/generators/templates/migrations/create_submissions.rb +1 -0
  13. data/lib/canvas_sync/generators/templates/models/account.rb +11 -1
  14. data/lib/canvas_sync/generators/templates/models/admin.rb +2 -1
  15. data/lib/canvas_sync/generators/templates/models/assignment.rb +2 -1
  16. data/lib/canvas_sync/generators/templates/models/assignment_group.rb +2 -1
  17. data/lib/canvas_sync/generators/templates/models/context_module.rb +2 -1
  18. data/lib/canvas_sync/generators/templates/models/context_module_item.rb +2 -1
  19. data/lib/canvas_sync/generators/templates/models/course.rb +4 -2
  20. data/lib/canvas_sync/generators/templates/models/enrollment.rb +2 -1
  21. data/lib/canvas_sync/generators/templates/models/group.rb +19 -0
  22. data/lib/canvas_sync/generators/templates/models/group_membership.rb +17 -0
  23. data/lib/canvas_sync/generators/templates/models/pseudonym.rb +8 -0
  24. data/lib/canvas_sync/generators/templates/models/role.rb +2 -1
  25. data/lib/canvas_sync/generators/templates/models/section.rb +2 -1
  26. data/lib/canvas_sync/generators/templates/models/submission.rb +3 -1
  27. data/lib/canvas_sync/generators/templates/models/term.rb +2 -1
  28. data/lib/canvas_sync/generators/templates/models/user.rb +4 -1
  29. data/lib/canvas_sync/importers/bulk_importer.rb +7 -1
  30. data/lib/canvas_sync/importers/legacy_importer.rb +4 -2
  31. data/lib/canvas_sync/job.rb +3 -1
  32. data/lib/canvas_sync/job_chain.rb +57 -0
  33. data/lib/canvas_sync/jobs/{sync_users_job.rb → sync_accounts_job.rb} +11 -6
  34. data/lib/canvas_sync/jobs/sync_provisioning_report_job.rb +2 -0
  35. data/lib/canvas_sync/processors/model_mappings.yml +81 -0
  36. data/lib/canvas_sync/processors/provisioning_report_processor.rb +28 -0
  37. data/lib/canvas_sync/record.rb +9 -0
  38. data/lib/canvas_sync/version.rb +1 -1
  39. data/spec/canvas_sync/canvas_sync_spec.rb +19 -16
  40. data/spec/canvas_sync/models/accounts_spec.rb +3 -0
  41. data/spec/canvas_sync/models/course_spec.rb +4 -0
  42. data/spec/canvas_sync/models/group_membership_spec.rb +26 -0
  43. data/spec/canvas_sync/models/group_spec.rb +26 -0
  44. data/spec/canvas_sync/models/user_spec.rb +2 -0
  45. data/spec/canvas_sync/processors/provisioning_report_processor_spec.rb +20 -0
  46. data/spec/dummy/app/models/account.rb +8 -1
  47. data/spec/dummy/app/models/admin.rb +2 -1
  48. data/spec/dummy/app/models/assignment.rb +2 -1
  49. data/spec/dummy/app/models/assignment_group.rb +2 -1
  50. data/spec/dummy/app/models/context_module.rb +2 -1
  51. data/spec/dummy/app/models/context_module_item.rb +2 -1
  52. data/spec/dummy/app/models/course.rb +4 -2
  53. data/spec/dummy/app/models/enrollment.rb +2 -1
  54. data/spec/dummy/app/models/group.rb +25 -0
  55. data/spec/dummy/app/models/group_membership.rb +23 -0
  56. data/spec/dummy/app/models/role.rb +2 -1
  57. data/spec/dummy/app/models/section.rb +2 -1
  58. data/spec/dummy/app/models/submission.rb +2 -1
  59. data/spec/dummy/app/models/term.rb +2 -1
  60. data/spec/dummy/app/models/user.rb +3 -1
  61. data/spec/dummy/config/application.rb +12 -1
  62. data/spec/dummy/config/database.yml +11 -11
  63. data/spec/dummy/config/environments/development.rb +3 -3
  64. data/spec/dummy/config/initializers/assets.rb +1 -1
  65. data/spec/dummy/db/migrate/20190702203627_create_submissions.rb +1 -0
  66. data/spec/dummy/db/migrate/20200415171620_create_groups.rb +29 -0
  67. data/spec/dummy/db/migrate/20200416214248_create_group_memberships.rb +24 -0
  68. data/spec/dummy/db/schema.rb +31 -1
  69. data/spec/factories/group_factory.rb +8 -0
  70. data/spec/factories/group_membership_factory.rb +6 -0
  71. data/spec/support/fixtures/reports/group_memberships.csv +3 -0
  72. data/spec/support/fixtures/reports/groups.csv +3 -0
  73. data/spec/support/fixtures/reports/submissions.csv +3 -3
  74. metadata +36 -6
  75. data/spec/canvas_sync/jobs/sync_users_job_spec.rb +0 -15
@@ -0,0 +1,189 @@
1
+ module CanvasSync::Concerns
2
+ module ApiSyncable
3
+ extend ActiveSupport::Concern
4
+ NON_EXISTANT_ERRORS = [Faraday::Error::ResourceNotFound, Footrest::HttpError::NotFound]
5
+
6
+ class_methods do
7
+ def find_or_fetch(canvas_id, save: false, retries: 1, **kwargs)
8
+ inst = find_or_initialize_by(canvas_id: canvas_id)
9
+ return inst if inst.persisted?
10
+
11
+ api_response = inst.request_from_api(retries: retries, **kwargs)
12
+ api_sync_race_create!(inst, save: save) do |inst2|
13
+ inst2.assign_from_api_params(api_response, **kwargs)
14
+ end
15
+ rescue *NON_EXISTANT_ERRORS
16
+ nil
17
+ end
18
+
19
+ def find_or_fetch!(*args)
20
+ inst = find_or_fetch(*args)
21
+ raise ActiveRecord::RecordNotFound unless inst.present?
22
+ inst
23
+ end
24
+
25
+ def create_or_update_from_api_params(api_params)
26
+ api_params = api_params.with_indifferent_access
27
+ api_sync_race_create!(api_params[:id]) do |inst|
28
+ inst.assign_from_api_params(api_params)
29
+ end
30
+ end
31
+
32
+ def api_sync_options=(opts)
33
+ @api_sync_options = opts
34
+ end
35
+
36
+ def api_sync_options
37
+ @api_sync_options || superclass.try(:api_sync_options)
38
+ end
39
+
40
+ # Define the model as being syncable via the Canvas API and configure sync options/parameters
41
+ # @param [Hash] map A hash of local_field => (:api_response_key | ->(api_response){ value })
42
+ # @param [->(bearcat?){ api_response }] fetch <description>
43
+ # @param [Hash] options <description>
44
+ # @option options [] :mark_deleted Hash to be merged | Symbol to invoke | ->(){ }
45
+ # @yield [api_response, [mapped_data]] Callback to merge data into a Model instance
46
+ def api_syncable(map, fetch, options={}, &blk)
47
+ default_options = {
48
+ mark_deleted: -> {
49
+ %i[workflow_state= status=].each do |sym|
50
+ next unless self.respond_to?(sym)
51
+ self.send(sym, 'deleted')
52
+ return
53
+ end
54
+ },
55
+ field_map: map,
56
+ fetch_from_api: fetch,
57
+ process_response: blk,
58
+ }
59
+ default_options.merge!(options)
60
+ self.api_sync_options = default_options.merge!(options)
61
+ end
62
+
63
+ private
64
+
65
+ def api_sync_race_create!(inst, save: true)
66
+ inst = find_or_initialize_by(canvas_id: inst) unless inst.is_a?(self)
67
+ yield inst
68
+ inst.save! if save && inst.changed?
69
+ inst
70
+ rescue ActiveRecord::RecordNotUnique
71
+ inst = find_by(canvas_id: inst.canvas_id)
72
+ yield inst
73
+ inst.save! if save && inst.changed?
74
+ inst
75
+ end
76
+ end
77
+
78
+ # Call the API and Syncs this model.
79
+ # Calls the mark_deleted workflow if a 404 is received.
80
+ # @param [Number] retries Number of retries
81
+ # @return [Hash] Response Hash from API
82
+ def sync_from_api(retries: 3, **kwargs)
83
+ api_response = request_from_api(retries: retries, **kwargs)
84
+ update_from_api_params!(api_response, **kwargs)
85
+ api_response
86
+ rescue *NON_EXISTANT_ERRORS
87
+ api_mark_deleted
88
+ save! if changed?
89
+ nil
90
+ end
91
+
92
+ # Fetch this model from the API and return the response
93
+ # @param [Number] retries Number of retries
94
+ # @return [Hash] Response Hash from API
95
+ def request_from_api(retries: 3, **kwargs)
96
+ api_call_with_retry(retries || 3) {
97
+ blk = api_sync_options[:fetch_from_api]
98
+ case blk.arity
99
+ when 1
100
+ self.instance_exec(canvas_sync_client, &blk)
101
+ else
102
+ self.instance_exec(&blk)
103
+ end
104
+ }
105
+ end
106
+
107
+ # Apply a response Hash from the API to this model's attributes, but do not save
108
+ # @param [Hash] api_params API-format Hash
109
+ # @return [self] self
110
+ def assign_from_api_params(api_params, **kwargs)
111
+ options = self.api_sync_options
112
+ api_params = api_params.with_indifferent_access
113
+
114
+ map = options[:field_map]
115
+ mapped_params = {}
116
+ if map.present?
117
+ map.each do |local_name, remote_name|
118
+ if remote_name.respond_to?(:call)
119
+ mapped_params[local_name] = self.instance_exec(api_params, &remote_name)
120
+ elsif api_params.include?(remote_name)
121
+ mapped_params[local_name] = api_params[remote_name]
122
+ if remote_name == :id
123
+ current_value = send("#{local_name}")
124
+ raise "Mismatched Canvas ID" if current_value.present? && current_value != api_params[remote_name]
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ apply_block = options[:process_response]
131
+ if apply_block.present?
132
+ case apply_block.arity
133
+ when 1
134
+ self.instance_exec(api_params, &apply_block)
135
+ when 2
136
+ self.instance_exec(api_params, mapped_params, &apply_block)
137
+ end
138
+ else
139
+ mapped_params.each do |local_name, val|
140
+ send("#{local_name}=", val)
141
+ end
142
+ end
143
+ self
144
+ end
145
+
146
+ # Apply a response Hash from the API to this model's attributes and save if changed?
147
+ # @param [Hash] api_params API-format Hash
148
+ # @return [self] self
149
+ def update_from_api_params(*args)
150
+ assign_from_api_params(*args)
151
+ save if changed?
152
+ end
153
+
154
+ # Apply a response Hash from the API to this model's attributes, and save! if changed?
155
+ # @param [Hash] api_params API-format Hash
156
+ # @return [self] self
157
+ def update_from_api_params!(*args)
158
+ assign_from_api_params(*args)
159
+ save! if changed?
160
+ end
161
+
162
+ def api_sync_options
163
+ self.class.api_sync_options
164
+ end
165
+
166
+ private
167
+
168
+ def api_call_with_retry(retries=3)
169
+ tries ||= retries
170
+ yield
171
+ rescue Faraday::ConnectionFailed => e
172
+ raise e if (tries -= 1).zero?
173
+ sleep 5
174
+ retry
175
+ end
176
+
177
+ def api_mark_deleted
178
+ action = api_sync_options[:mark_deleted]
179
+ case action
180
+ when Hash
181
+ assign_attributes(action)
182
+ when Symbol
183
+ send(action)
184
+ when Proc
185
+ self.instance_exec(&action)
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,34 @@
1
+ # Concern that can be used when dealing with Legacy code.
2
+ #
3
+ # It automatically aliases `canvas_id` -> `canvas_<model>_id` or vice-versa.
4
+ module CanvasSync::Concerns
5
+ module LegacyColumns
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def inherited(subclass)
10
+ super.tap do
11
+ legacy_column_apply(subclass)
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def legacy_column_apply(cls)
18
+ cid_column = "canvas_#{subclass.name.downcase}_id"
19
+ column_names = subclass.columns.map(&:name)
20
+ return if column_names.include?('canvas_id') && column_names.include?(cid_column)
21
+ if column_names.include?('canvas_id')
22
+ subclass.alias_attribute(cid_column.to_sym, :canvas_id)
23
+ elsif column_names.include?(cid_column)
24
+ subclass.alias_attribute(:canvas_id, cid_column.to_sym)
25
+ end
26
+ rescue ActiveRecord::StatementInvalid
27
+ end
28
+ end
29
+
30
+ included do
31
+ legacy_column_apply(self)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,18 @@
1
+ # <%= autogenerated_migration_warning %>
2
+
3
+ class CreateGroupMemberships < ActiveRecord::Migration[5.1]
4
+ def change
5
+ create_table :group_memberships do |t|
6
+ t.bigint :canvas_id, null: false
7
+ t.bigint :canvas_user_id, null: false
8
+ t.bigint :canvas_group_id, null: false
9
+ t.string :group_sis_id
10
+ t.string :user_sis_id
11
+ t.string :workflow_state
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :group_memberships, :canvas_id, unique: true
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ # <%= autogenerated_migration_warning %>
2
+
3
+ class CreateGroups < ActiveRecord::Migration[5.1]
4
+ def change
5
+ create_table :groups do |t|
6
+ t.bigint :canvas_id, null: false
7
+ t.string :sis_id
8
+ t.bigint :canvas_group_category_id
9
+ t.string :group_category_sis_id
10
+ t.bigint :canvas_account_id
11
+ t.bigint :canvas_course_id
12
+ t.string :name
13
+ t.string :workflow_state
14
+ t.bigint :context_id
15
+ t.string :context_type
16
+ t.integer :max_membership
17
+
18
+ t.timestamps
19
+ end
20
+
21
+ add_index :groups, :canvas_id, unique: true
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ # <%= autogenerated_migration_warning %>
2
+
3
+ class CreatePseudonyms < ActiveRecord::Migration[5.1]
4
+ def change
5
+ create_table :pseudonyms do |t|
6
+ t.bigint :canvas_id, null: false
7
+ t.bigint :canvas_user_id
8
+ t.string :sis_id
9
+ t.string :unique_id
10
+ t.string :workflow_state
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :pseudonyms, :canvas_id, unique: true
16
+ add_index :pseudonyms, :canvas_user_id
17
+ end
18
+ end
@@ -8,6 +8,7 @@ class CreateSubmissions < ActiveRecord::Migration[5.1]
8
8
  t.bigint :canvas_assignment_id
9
9
  t.bigint :canvas_user_id
10
10
  t.datetime :submitted_at
11
+ t.datetime :due_at
11
12
  t.datetime :graded_at
12
13
  t.float :score
13
14
  t.float :points_possible
@@ -1,11 +1,21 @@
1
1
  # <%= autogenerated_model_warning %>
2
2
 
3
3
  class Account < ApplicationRecord
4
- include CanvasSync::ApiSyncable
4
+ include CanvasSync::Record
5
+ include CanvasSync::Concerns::ApiSyncable
6
+ # include CanvasSync::Concerns::Account::Ancestry # Add support for the ancestry Gem
5
7
 
6
8
  validates :canvas_id, uniqueness: true, presence: true
7
9
 
8
10
  has_many :admins, primary_key: :canvas_id, foreign_key: :canvas_account_id
11
+ belongs_to :canvas_parent, class_name: 'Account', optional: true,
12
+ primary_key: :canvas_id, foreign_key: :canvas_parent_account_id
13
+ has_many :sub_accounts, class_name: 'Account',
14
+ primary_key: :canvas_id, foreign_key: :canvas_parent_account_id
15
+ has_many :groups, primary_key: :canvas_id, foreign_key: :canvas_account_id
16
+
17
+ scope :active, -> { where.not(workflow_state: 'deleted') }
18
+ # scope :should_canvas_sync, -> { active } # Optional - uses .active if not given
9
19
 
10
20
  api_syncable({
11
21
  name: :name,
@@ -1,7 +1,8 @@
1
1
  # <%= autogenerated_model_warning %>
2
2
 
3
3
  class Admin < ApplicationRecord
4
- include CanvasSync::ApiSyncable
4
+ include CanvasSync::Record
5
+ include CanvasSync::Concerns::ApiSyncable
5
6
 
6
7
  validates :canvas_id, uniqueness: true, presence: true
7
8
  belongs_to :account, primary_key: :canvas_id, foreign_key: :canvas_account_id, optional: true
@@ -1,7 +1,8 @@
1
1
  # <%= autogenerated_model_warning %>
2
2
 
3
3
  class Assignment < ApplicationRecord
4
- include CanvasSync::ApiSyncable
4
+ include CanvasSync::Record
5
+ include CanvasSync::Concerns::ApiSyncable
5
6
 
6
7
  validates :canvas_id, uniqueness: true, presence: true
7
8
  belongs_to :context, polymorphic: true, optional: true, primary_key: :canvas_id, foreign_key: :canvas_context_id, foreign_type: :canvas_context_type
@@ -1,7 +1,8 @@
1
1
  # <%= autogenerated_model_warning %>
2
2
 
3
3
  class AssignmentGroup < ApplicationRecord
4
- include CanvasSync::ApiSyncable
4
+ include CanvasSync::Record
5
+ include CanvasSync::Concerns::ApiSyncable
5
6
 
6
7
  validates :canvas_id, uniqueness: true, presence: true
7
8
  belongs_to :course, primary_key: :canvas_id, foreign_key: :canvas_course_id, optional: true
@@ -4,7 +4,8 @@
4
4
  # 1 - Module is a reserved word in Rails and you can't call a model a Module
5
5
  # 2 - Canvas calls them ContextModules
6
6
  class ContextModule < ApplicationRecord
7
- include CanvasSync::ApiSyncable
7
+ include CanvasSync::Record
8
+ include CanvasSync::Concerns::ApiSyncable
8
9
 
9
10
  belongs_to :context, polymorphic: true, optional: true, primary_key: :canvas_id, foreign_key: :canvas_context_id, foreign_type: :canvas_context_type
10
11
  has_many :context_module_items, primary_key: :canvas_id, foreign_key: :canvas_context_module_id
@@ -1,7 +1,8 @@
1
1
  # # <%= autogenerated_migration_warning %>
2
2
 
3
3
  class ContextModuleItem < ApplicationRecord
4
- include CanvasSync::ApiSyncable
4
+ include CanvasSync::Record
5
+ include CanvasSync::Concerns::ApiSyncable
5
6
 
6
7
  belongs_to :context_module, primary_key: :canvas_id, foreign_key: :canvas_context_module_id, optional: true
7
8
  belongs_to :content, polymorphic: true, optional: true, primary_key: :canvas_id, foreign_key: :canvas_content_id, foreign_type: :canvas_content_type
@@ -1,7 +1,8 @@
1
1
  # <%= autogenerated_model_warning %>
2
2
 
3
3
  class Course < ApplicationRecord
4
- include CanvasSync::ApiSyncable
4
+ include CanvasSync::Record
5
+ include CanvasSync::Concerns::ApiSyncable
5
6
 
6
7
  validates :canvas_id, uniqueness: true, presence: true
7
8
  belongs_to :term, foreign_key: :canvas_term_id, primary_key: :canvas_id, optional: true
@@ -10,7 +11,8 @@ class Course < ApplicationRecord
10
11
  has_many :assignments, as: :context, primary_key: :canvas_id, foreign_key: :canvas_context_id, foreign_type: :canvas_context_type
11
12
  has_many :submissions, primary_key: :canvas_id, foreign_key: :canvas_course_id
12
13
  has_many :assignment_groups, primary_key: :canvas_id, foreign_key: :canvas_course_id
13
-
14
+ has_many :groups, primary_key: :canvas_id, foreign_key: :canvas_course_id
15
+
14
16
  api_syncable({
15
17
  sis_id: :sis_course_id,
16
18
  course_code: :course_code,
@@ -1,7 +1,8 @@
1
1
  # <%= autogenerated_model_warning %>
2
2
 
3
3
  class Enrollment < ApplicationRecord
4
- include CanvasSync::ApiSyncable
4
+ include CanvasSync::Record
5
+ include CanvasSync::Concerns::ApiSyncable
5
6
 
6
7
  validates :canvas_id, uniqueness: true, presence: true
7
8
  belongs_to :user, primary_key: :canvas_id, foreign_key: :canvas_user_id, optional: true
@@ -0,0 +1,19 @@
1
+ # <%= autogenerated_model_warning %>
2
+
3
+ class Group < ApplicationRecord
4
+ include CanvasSync::Record
5
+ include CanvasSync::Concerns::ApiSyncable
6
+
7
+ validates :canvas_id, uniqueness: true, presence: true
8
+ belongs_to :course, primary_key: :canvas_id, foreign_key: :canvas_course_id, optional: true
9
+ belongs_to :account, primary_key: :canvas_id, foreign_key: :canvas_account_id, optional: true
10
+ has_many :group_memberships, primary_key: :canvas_id, foreign_key: :canvas_group_id
11
+
12
+ api_syncable({
13
+ canvas_id: :id,
14
+ name: :name,
15
+ canvas_course_id: :course_id,
16
+ sis_id: :sis_group_id,
17
+ workflow_state: ->(r){ 'available' },
18
+ }, -> (api) { api.group(canvas_id) })
19
+ end
@@ -0,0 +1,17 @@
1
+ # <%= autogenerated_model_warning %>
2
+
3
+ class GroupMembership < ApplicationRecord
4
+ include CanvasSync::Record
5
+ include CanvasSync::Concerns::ApiSyncable
6
+
7
+ validates :canvas_id, uniqueness: true, presence: true
8
+ belongs_to :group, primary_key: :canvas_id, foreign_key: :canvas_group_id
9
+ belongs_to :user, primary_key: :canvas_id, foreign_key: :canvas_user_id
10
+
11
+ api_syncable({
12
+ canvas_id: :id,
13
+ canvas_group_id: :group_id,
14
+ canvas_user_id: :user_id,
15
+ workflow_state: :workflow_state,
16
+ }, -> (api) { api.group_membership(canvas_group_id, canvas_id) })
17
+ end
@@ -0,0 +1,8 @@
1
+ # <%= autogenerated_model_warning %>
2
+
3
+ class Pseudonym < ApplicationRecord
4
+ include CanvasSync::Record
5
+
6
+ validates :canvas_id, uniqueness: true, presence: true
7
+ belongs_to :user, primary_key: :canvas_id, foreign_key: :canvas_user_id
8
+ end