canvas_sync 0.12.0 → 0.16.1

Sign up to get free protection for your applications and to get access to all the features.
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