panda_pal 5.0.0 → 5.1.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b728d9c41289ccb3ddeba5c13245345750900733052dd26482f1f2efc2d048a8
4
- data.tar.gz: e5418d426bbe784e5a4ec804339a81f326d936297b6d0c20bba432c3e2d851d2
3
+ metadata.gz: 8a2c4fed3544a682a44b4e9acfc30742ab7f7bcdf2ec22db97d90df80af31e04
4
+ data.tar.gz: f0ee1ba262458c9a5fee095c17c1c673e58e90cc821ea92d29b59cee2afcd1e4
5
5
  SHA512:
6
- metadata.gz: 58c36a718b50b9f320f101b3d52318ad6557cae9aa80dbb13531a798061a6243156834cc9170a452235be0b330f06772e6d571dc73fa25da2517254fc81850a7
7
- data.tar.gz: 903d775a21f5550e0aab21593242308ff3e7ae991fc0df1467376a6a7b210b3c4fec7ae567e074595f48a254ea896f66d9770ebfb8904522cc754e2f7ac14d0b
6
+ metadata.gz: adcb31a168a7c5eb09c5ac4ac4c28de5f45ba480873210e309bf5ba60c4c29b4f172648ed03ccee54cfc0e585eac0a25c56be3686dadf582d11e8b95c24a2870
7
+ data.tar.gz: b06936171d9f3424d2de2cac678726e33d081084a0a99195c6c6d4b7d4286d0a88bb91c9e1ea269ccccbb3f3ea9698a60096b3b7baf8b162f5db751f7b45521c
data/README.md CHANGED
@@ -28,6 +28,40 @@ Use one of these 6 options in `PandaPal.lti_options` hash.
28
28
  5. Leave this property off, and you will get the dynamic host with the root path ('http://appdomain.com/') by default.
29
29
  6. If you really do not want this property use the option `launch_url: false` for it to be left off.
30
30
 
31
+ ### Task Scheduling
32
+ `PandaPal` includes an integration with `sidekiq-scheduler`. You can define tasks on an Organization class Stub like so:
33
+ ```ruby
34
+ # <your_app>/app/models/panda_pal/organization.rb
35
+ require File.expand_path('../../app/models/panda_pal/organization.rb', PandaPal::Engine.called_from)
36
+
37
+ module PandaPal
38
+ class Organization
39
+ # Will invoke CanvasSyncStarterWorker.perform_async() according to the cron schedule
40
+ scheduled_task '0 15 05 * * *', :identifier, worker: CanvasSyncStarterWorker
41
+
42
+ # Will invoke the method 'organization_method' on the Organization
43
+ scheduled_task '0 15 05 * * *', :organization_method_and_identifier
44
+
45
+ # If you need to invoke the same method on multiple schedules
46
+ scheduled_task '0 15 05 * * *', :identifier, worker: :organization_method
47
+
48
+ # You can also use a block
49
+ scheduled_task '0 15 05 * * *', :identifier do
50
+ # Do Stuff
51
+ end
52
+
53
+ # You can use a Proc (called in the context of the Organization) to determine the schedule
54
+ scheduled_task -> { settings[:cron] }, :identifier
55
+
56
+ # You can specify a timezone. If a TZ is not coded and settings[:timezone] is present, it will be appended automatically
57
+ scheduled_task '0 15 05 * * * America/Denver', :identifier, worker: :organization_method
58
+
59
+ # Setting settings[:task_schedules][:identifier] will override the code cron schedule. Setting it to false will disable the Task
60
+ # :identifer values _must_ be unique, but can be nil, in which case they will be determined by where (lineno etc) scheduled_task is called
61
+ end
62
+ end
63
+ ```
64
+
31
65
  # Organization Attributes
32
66
  id: Primary Key
33
67
  name: Name of the organization. Used to on requests to select the tenant
@@ -226,9 +260,43 @@ In your panda_pal initializer (i.e. config/initializers/panda_pal.rb or config/i
226
260
  You can specify options that can include a structure for your settings. If specified, PandaPal will
227
261
  enforce this structure on any new / updated organizations.
228
262
 
263
+ ```ruby
264
+ PandaPal.lti_options = {
265
+ title: 'LBS Gradebook',
266
+ settings_structure: {
267
+ allow_additional: true, # Allow additional properties that aren't included in the :properties Hash.
268
+ allow_additional: { type: 'String' }, # You can also set :allow_additional to a settings specification that will be used to validate each additional setting
269
+ validate: ->(value, spec, **kwargs) {
270
+ # kwargs currently includes:
271
+ # :errors => [An array to push errors to]
272
+ # :path => [An array representation of the current path in the settings object]
273
+
274
+ # To add errors, you may:
275
+ # Push strings to the kwargs[:errors] Array:
276
+ kwargs[:errors] << "Your error message at <path>" unless value < 10
277
+ # Or return a string or string array:
278
+ value.valid? ? nil : "Your error message at <path>" # <path> will be replaced with the actual path that the error occurred at
279
+ },
280
+ properties: {
281
+ canvas_api_token: { type: 'String', required: true, },
282
+ catalog: { # :validate, :allow_additional, :properties keys are all supported at this level as well
283
+ type: 'Hash',
284
+ required: false,
285
+ validate: -> (*args) {},
286
+ allow_additional: false,
287
+ properties: {
288
+
289
+ },
290
+ }
291
+ }
292
+ },
293
+ }
294
+ ```
295
+
296
+ #### Legacy Settings Structure:
229
297
  Here is an example options specification:
230
298
 
231
- ```
299
+ ```ruby
232
300
  PandaPal.lti_options = {
233
301
  title: 'LBS Gradebook',
234
302
  settings_structure: YAML.load("
@@ -340,3 +408,7 @@ class AccountController < ApplicationController
340
408
  end
341
409
  ```
342
410
 
411
+ ## Running Specs:
412
+ Initialize the Specs DB:
413
+ `cd spec/dummy; bundle exec rake db:drop; bundle exec rake db:create; bundle exec rake db:schema:load`
414
+ Then `bundle exec rspec`
@@ -1,15 +1,23 @@
1
+ Dir[File.dirname(__FILE__) + "/organization/*.rb"].each { |file| require file }
2
+
1
3
  module PandaPal
4
+ module OrganizationConcerns; end
2
5
 
3
6
  class Organization < ActiveRecord::Base
7
+ include OrganizationConcerns::SettingsValidation
8
+ include OrganizationConcerns::TaskScheduling if defined?(Sidekiq.schedule)
9
+
4
10
  attribute :settings
11
+ serialize :settings, Hash
5
12
  attr_encrypted :settings, marshal: true, key: :encryption_key
6
13
  before_save {|a| a.settings = a.settings} # this is a hacky work-around to a bug where attr_encrypted is not saving settings in place
14
+
7
15
  validates :key, uniqueness: { case_sensitive: false }, presence: true
8
16
  validates :secret, presence: true
9
17
  validates :name, uniqueness: { case_sensitive: false }, presence: true, format: { with: /\A[a-z0-9_]+\z/i }
10
18
  validates :canvas_account_id, presence: true
11
19
  validates :salesforce_id, presence: true, uniqueness: true
12
- validate :validate_settings
20
+
13
21
  after_create :create_schema
14
22
  after_commit :destroy_schema, on: :destroy
15
23
 
@@ -17,8 +25,6 @@ module PandaPal
17
25
  errors.add(:name, 'should not be changed after creation') if name_changed?
18
26
  end
19
27
 
20
- serialize :settings, Hash
21
-
22
28
  def encryption_key
23
29
  # production environment might not have loaded secret_key_base yet.
24
30
  # In that case, just read it from env.
@@ -29,6 +35,14 @@ module PandaPal
29
35
  end
30
36
  end
31
37
 
38
+ def switch_tenant(&block)
39
+ if block_given?
40
+ Apartment::Tenant.switch(name, &block)
41
+ else
42
+ Apartment::Tenant.switch!(name)
43
+ end
44
+ end
45
+
32
46
  private
33
47
 
34
48
  def create_schema
@@ -38,48 +52,5 @@ module PandaPal
38
52
  def destroy_schema
39
53
  Apartment::Tenant.drop name
40
54
  end
41
-
42
- def validate_settings
43
- record = self
44
- if PandaPal.lti_options && PandaPal.lti_options[:settings_structure]
45
- validate_level(record, PandaPal.lti_options[:settings_structure], record.settings, [])
46
- end
47
- end
48
- def validate_level(record, expectation, reality, previous_keys)
49
- # Verify that the data elements at this level conform to requirements.
50
- if expectation
51
- expectation.each do |key, value|
52
- is_required = expectation[key].try(:delete, :is_required)
53
- data_type = expectation[key].try(:delete, :data_type)
54
- value = reality.is_a?(Hash) ? reality.dig(key) : reality
55
-
56
- if is_required && !value
57
- record.errors[:settings] << "PandaPal::Organization.settings requires key [:#{previous_keys.push(key).join("][:")}]. It was not found."
58
- end
59
- if data_type && value
60
- if value.class.to_s != data_type
61
- record.errors[:settings] << "PandaPal::Organization.settings expected key [:#{previous_keys.push(key).join("][:")}] to be #{data_type} but it was instead #{value.class}."
62
- end
63
- end
64
- end
65
- end
66
- # Verify that anything that is in the real settings has an expectation.
67
- if reality
68
- if reality.is_a? Hash
69
- reality.each do |key, value|
70
- was_expected = expectation.has_key?(key)
71
- if !was_expected
72
- record.errors[:settings] << "PandaPal::Organization.settings had unexpected key: #{key}. If settings have expanded please update your lti_options accordingly."
73
- end
74
- end
75
- end
76
- end
77
- # Recursively check out any children settings as well.
78
- if expectation
79
- expectation.each do |key, value|
80
- validate_level(record, expectation[key], (reality.is_a?(Hash) ? reality.dig(key) : nil), previous_keys.deep_dup.push(key))
81
- end
82
- end
83
- end
84
55
  end
85
56
  end
@@ -0,0 +1,111 @@
1
+ module PandaPal
2
+ module OrganizationConcerns
3
+ module SettingsValidation
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ validate :validate_settings
8
+ end
9
+
10
+ class_methods do
11
+ def settings_structure
12
+ if PandaPal.lti_options&.[](:settings_structure).present?
13
+ normalize_settings_structure(PandaPal.lti_options[:settings_structure])
14
+ else
15
+ {
16
+ type: Hash,
17
+ allow_additional: true,
18
+ properties: {},
19
+ }
20
+ end
21
+ end
22
+
23
+ def normalize_settings_structure(struc)
24
+ return {} unless struc.present?
25
+ return struc if struc[:properties] || struc[:type] || struc.key?(:required)
26
+
27
+ struc = struc.dup
28
+ nstruc = {}
29
+
30
+ nstruc[:type] = struc.delete(:data_type) if struc.key?(:data_type)
31
+ nstruc[:required] = struc.delete(:is_required) if struc.key?(:is_required)
32
+ nstruc[:properties] = struc.map { |k, sub| [k, normalize_settings_structure(sub)] }.to_h if struc.present?
33
+
34
+ nstruc
35
+ end
36
+ end
37
+
38
+ def settings_structure
39
+ self.class.settings_structure
40
+ end
41
+
42
+ def validate_settings
43
+ validate_settings_level(settings || {}, settings_structure).each do |err|
44
+ errors[:settings] << err
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def validate_settings_level(settings, spec, path: [], errors: [])
51
+ human_path = "[:#{path.join('][:')}]"
52
+
53
+ if settings.nil?
54
+ errors << "Entry #{human_path} is required" if spec[:required]
55
+ return
56
+ end
57
+
58
+ if spec[:type]
59
+ resolved_type = spec[:type]
60
+ resolved_type = resolved_type.constantize if resolved_type.is_a?(String)
61
+ unless settings.is_a?(resolved_type)
62
+ errors << "Expected #{human_path} to be a #{spec[:type]}. Was a #{settings.class.to_s}"
63
+ return
64
+ end
65
+ end
66
+
67
+ if spec[:validate].present?
68
+ val_errors = []
69
+ if spec[:validate].is_a?(Symbol)
70
+ proc_result = send(spec[:validate], settings, spec, path: path, errors: val_errors)
71
+ elsif spec[:validate].is_a?(String)
72
+ split_val = spec[:validate].split?('.')
73
+ split_val << 'validate_settings' if split_val.count == 1
74
+ resolved_module = split_val[0].constantize
75
+ proc_result = resolved_module.send(split_val[1].to_sym, settings, spec, path: path, errors: val_errors)
76
+ elsif spec[:validate].is_a?(Proc)
77
+ proc_result = instance_exec(settings, spec, path: path, errors: val_errors, &spec[:validate])
78
+ end
79
+ val_errors << proc_result unless val_errors.present? || proc_result == val_errors
80
+ val_errors = val_errors.flatten.uniq.compact.map do |ve|
81
+ ve.gsub('<path>', human_path)
82
+ end
83
+ errors.concat(val_errors)
84
+ end
85
+
86
+ if settings.is_a?(Hash)
87
+ if spec[:properties] != nil
88
+ spec[:properties].each do |key, pspec|
89
+ validate_settings_level(settings[key], pspec, path: [*path, key], errors: errors)
90
+ end
91
+ end
92
+
93
+ if spec[:properties] != nil || spec[:allow_additional] != nil
94
+ extra_keys = settings.keys - (spec[:properties]&.keys || [])
95
+ if extra_keys.present?
96
+ if spec[:allow_additional].is_a?(Hash)
97
+ extra_keys.each do |key|
98
+ validate_settings_level(settings[key], spec[:allow_additional], path: [*path, key], errors: errors)
99
+ end
100
+ elsif !spec[:allow_additional]
101
+ errors << "Did not expect #{human_path} to contain [#{extra_keys.join(', ')}]"
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ errors
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,172 @@
1
+ return unless defined?(Sidekiq.schedule)
2
+
3
+ require_relative 'settings_validation'
4
+
5
+ module PandaPal
6
+ module OrganizationConcerns
7
+ module TaskScheduling
8
+ extend ActiveSupport::Concern
9
+ include OrganizationConcerns::SettingsValidation
10
+
11
+ included do
12
+ after_commit :sync_schedule, on: [:create, :update]
13
+ after_commit :unschedule_tasks, on: :destroy
14
+ end
15
+
16
+ class_methods do
17
+ def _schedule_descriptors
18
+ @_schedule_descriptors ||= {}
19
+ end
20
+
21
+ def settings_structure
22
+ return super unless _schedule_descriptors.present?
23
+
24
+ super.tap do |struc|
25
+ struc[:properties] ||= {}
26
+
27
+ struc[:properties][:timezone] ||= {
28
+ type: 'String',
29
+ required: false,
30
+ validate: ->(timezone, *args) {
31
+ ActiveSupport::TimeZone[timezone].present? ? nil : "<path> Invalid Timezone '#{timezone}'"
32
+ },
33
+ }
34
+
35
+ struc[:properties][:task_schedules] = {
36
+ type: 'Hash',
37
+ required: false,
38
+ properties: _schedule_descriptors.keys.reduce({}) do |hash, k|
39
+ hash.tap do |hash|
40
+ hash[k.to_sym] = hash[k.to_s] = {
41
+ required: false,
42
+ validate: ->(value, *args, errors:, **kwargs) {
43
+ begin
44
+ Rufus::Scheduler.parse(value) if value
45
+ nil
46
+ rescue ArgumentError
47
+ errors << "<path> must be false or a Crontab string"
48
+ end
49
+ }
50
+ }
51
+ end
52
+ end,
53
+ }
54
+ end
55
+ end
56
+
57
+ def scheduled_task(cron_time, name_or_method = nil, worker: nil, queue: nil, &block)
58
+ task_key = (name_or_method.presence || "scheduled_task_#{caller_locations[0].lineno}").to_s
59
+ raise "Task key '#{task_key}' already taken!" if _schedule_descriptors.key?(task_key)
60
+
61
+ _schedule_descriptors[task_key] = {
62
+ key: task_key,
63
+ schedule: cron_time,
64
+ worker: worker || block || name_or_method.to_sym,
65
+ queue: queue || 'default',
66
+ }
67
+ end
68
+
69
+ def sync_schedules
70
+ # Ensure deleted Orgs are removed
71
+ existing_orgs = pluck(:name)
72
+ old_schedules = Sidekiq.get_schedule.select do |k, v|
73
+ m = k.match(/^org:([a-z0-9_]+)\-/i)
74
+ m.present? && !existing_orgs.include?(m[1])
75
+ end
76
+ old_schedules.keys.each do |k|
77
+ Sidekiq.remove_schedule(k)
78
+ end
79
+
80
+ find_each(&:sync_schedule)
81
+ end
82
+ end
83
+
84
+ def generate_schedule
85
+ schedule = {}
86
+ self.class._schedule_descriptors.values.each do |desc|
87
+ cron_time = schedule_task_cron_time(desc)
88
+ next unless cron_time.present?
89
+
90
+ schedule["org:#{name}-#{desc[:key]}"] = {
91
+ 'cron' => cron_time,
92
+ 'queue' => desc[:queue],
93
+ 'class' => ScheduledTaskExecutor.to_s,
94
+ 'args' => [name, desc[:key]],
95
+ }
96
+ end
97
+ schedule
98
+ end
99
+
100
+ def sync_schedule
101
+ new_schedules = generate_schedule
102
+ unschedule_tasks(new_schedules.keys)
103
+ new_schedules.each do |k, v|
104
+ Sidekiq.set_schedule(k, v)
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def unschedule_tasks(new_task_keys = nil)
111
+ current_schedules = Sidekiq.get_schedule.select { |k,v| k.starts_with?("org:#{name}-") }
112
+ del_tasks = current_schedules.keys
113
+ del_tasks -= new_task_keys if new_task_keys
114
+ del_tasks.each do |k|
115
+ Sidekiq.remove_schedule(k)
116
+ end
117
+ end
118
+
119
+ def schedule_task_cron_time(desc)
120
+ cron_time = nil
121
+ cron_time = settings&.dig(:task_schedules, desc[:key].to_s) if cron_time.nil?
122
+ cron_time = settings&.dig(:task_schedules, desc[:key].to_sym) if cron_time.nil?
123
+ cron_time = desc[:schedule] if cron_time.nil?
124
+
125
+ return nil unless cron_time.present?
126
+
127
+ cron_time = instance_exec(&cron_time) if cron_time.is_a?(Proc)
128
+ if !Rufus::Scheduler.parse(cron_time).zone.present? && settings && settings[:timezone]
129
+ cron_time += " #{settings[:timezone]}"
130
+ end
131
+
132
+ cron_time
133
+ end
134
+
135
+ class ScheduledTaskExecutor
136
+ include Sidekiq::Worker
137
+
138
+ def perform(org_name, task_key)
139
+ org = Organization.find_by!(name: org_name)
140
+ task = Organization._schedule_descriptors[task_key]
141
+ worker = task[:worker]
142
+
143
+ Apartment::Tenant.switch(org.name) do
144
+ if worker.is_a?(Proc)
145
+ org.instance_exec(&worker)
146
+ elsif worker.is_a?(Symbol)
147
+ org.send(worker)
148
+ elsif worker.is_a?(String)
149
+ worker.constantize.perform_async
150
+ elsif worker.is_a?(Class)
151
+ worker.perform_async
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+
160
+ SidekiqScheduler::Scheduler.instance.dynamic = true
161
+
162
+ module SidekiqScheduler
163
+ module Schedule
164
+ original_schedule_setter = instance_method(:schedule=)
165
+
166
+ define_method :schedule= do |sched|
167
+ original_schedule_setter.bind(self).(sched).tap do
168
+ PandaPal::Organization.sync_schedules
169
+ end
170
+ end
171
+ end
172
+ end
@@ -1,3 +1,3 @@
1
1
  module PandaPal
2
- VERSION = "5.0.0"
2
+ VERSION = "5.1.0"
3
3
  end
@@ -22,6 +22,9 @@ Gem::Specification.new do |s|
22
22
  s.add_dependency 'browser', '2.5.0'
23
23
  s.add_dependency 'attr_encrypted', '~> 3.0.0'
24
24
  s.add_dependency 'secure_headers', '~> 6.1.2'
25
+
26
+ s.add_development_dependency 'sidekiq'
27
+ s.add_development_dependency 'sidekiq-scheduler'
25
28
  s.add_development_dependency 'rspec-rails'
26
29
  s.add_development_dependency 'factory_girl_rails'
27
30
  end
@@ -1,6 +1,12 @@
1
1
  require File.expand_path('../boot', __FILE__)
2
2
 
3
- require 'rails/all'
3
+ require "active_model/railtie"
4
+ require "active_job/railtie"
5
+ require "active_record/railtie"
6
+ # require "active_storage/engine"
7
+ require "action_controller/railtie"
8
+ require "action_mailer/railtie"
9
+ require "action_view/railtie"
4
10
 
5
11
  Bundler.require(*Rails.groups)
6
12
  require "panda_pal"
@@ -22,20 +22,6 @@ Rails.application.configure do
22
22
  # Raise an error on page load if there are pending migrations.
23
23
  config.active_record.migration_error = :page_load
24
24
 
25
- # Debug mode disables concatenation and preprocessing of assets.
26
- # This option may cause significant delays in view rendering with a large
27
- # number of complex assets.
28
- config.assets.debug = true
29
-
30
- # Asset digests allow you to set far-future HTTP expiration dates on all assets,
31
- # yet still be able to expire them through the digest params.
32
- config.assets.digest = true
33
-
34
- # Adds additional error checking when serving assets at runtime.
35
- # Checks for improperly declared sprockets dependencies.
36
- # Raises helpful error messages.
37
- config.assets.raise_runtime_errors = true
38
-
39
25
  # Raises error for missing translations
40
26
  # config.action_view.raise_on_missing_translations = true
41
27
  end
@@ -24,17 +24,6 @@ Rails.application.configure do
24
24
  # Apache or NGINX already handles this.
25
25
  config.serve_static_files = ENV['RAILS_SERVE_STATIC_FILES'].present?
26
26
 
27
- # Compress JavaScripts and CSS.
28
- config.assets.js_compressor = :uglifier
29
- # config.assets.css_compressor = :sass
30
-
31
- # Do not fallback to assets pipeline if a precompiled asset is missed.
32
- config.assets.compile = false
33
-
34
- # Asset digests allow you to set far-future HTTP expiration dates on all assets,
35
- # yet still be able to expire them through the digest params.
36
- config.assets.digest = true
37
-
38
27
  # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb
39
28
 
40
29
  # Specifies the header that your server uses for sending files.
@@ -0,0 +1,175 @@
1
+ require 'rails_helper'
2
+
3
+ module PandaPal
4
+ PandaPal::Organization
5
+ RSpec.describe OrganizationConcerns::SettingsValidation, type: :model do
6
+ let!(:org) { create :panda_pal_organization }
7
+
8
+ def set_test_settings_structure
9
+ PandaPal.lti_options = {
10
+ title: 'Test App',
11
+ settings_structure: structure,
12
+ }
13
+ end
14
+
15
+ RSpec.shared_examples "shared stuff" do
16
+ it 'does not perform any validations if settings is not defined' do
17
+ PandaPal.lti_options = {}
18
+ expect(org.valid?).to be_truthy
19
+ end
20
+
21
+ it 'does not perform any validations if options is not defined' do
22
+ PandaPal.lti_options = nil
23
+ expect(org.valid?).to be_truthy
24
+ end
25
+
26
+ it 'does perform validations if settings_structure is defined' do
27
+ set_test_settings_structure
28
+ org.valid?
29
+ expect(org.valid?).to be_falsey
30
+ end
31
+
32
+ it 'will fail if a required setting is not present' do
33
+ set_test_settings_structure
34
+ expect(org.valid?).to be_falsey
35
+ errors = org.errors.messages[:settings]
36
+ expect(errors[0]).to eq("Entry [:canvas] is required")
37
+ end
38
+
39
+ it 'will fail if a setting is supplied but data_type is wrong' do
40
+ set_test_settings_structure
41
+ org.settings = {canvas: "Dog", reports: {}}
42
+ expect(org.valid?).to be_falsey
43
+ errors = org.errors.messages[:settings]
44
+ expect(errors[0]).to eq("Expected [:canvas] to be a Hash. Was a String")
45
+ end
46
+
47
+ it 'will fail if a required subsetting is missing' do
48
+ set_test_settings_structure
49
+ org.settings = {canvas: {base_url: 'http://'}, reports: {}}
50
+ expect(org.valid?).to be_falsey
51
+ errors = org.errors.messages[:settings]
52
+ expect(errors[0]).to eq("Entry [:canvas][:api_token] is required")
53
+ end
54
+
55
+ it 'will fail if extra options are specified' do
56
+ set_test_settings_structure
57
+ org.settings = {canvas: {base_url: 'http://', api_token: 'TEST'}, reports: {}, unknown_option: "WHAT IS THIS?"}
58
+ expect(org.valid?).to be_falsey
59
+ errors = org.errors.messages[:settings]
60
+ expect(errors[0]).to eq("Did not expect [:] to contain [unknown_option]")
61
+ end
62
+
63
+ it 'will pass if all structure is maintained' do
64
+ set_test_settings_structure
65
+ org.settings = {canvas: {base_url: 'http://', api_token: 'TEST'}, reports: {submissions_report_time_length: 30.minutes, max_recheck_time: 10.hours}}
66
+ expect(org.valid?).to be_truthy
67
+ end
68
+ end
69
+
70
+ context 'new settings_structure' do
71
+ let!(:properties) do
72
+ {
73
+ canvas: {
74
+ required: true,
75
+ type: 'Hash',
76
+ properties: {
77
+ api_token: { type: 'String', required: true },
78
+ base_url: { type: 'String', required: true },
79
+ },
80
+ },
81
+ reports: {
82
+ required: true,
83
+ type: 'Hash',
84
+ allow_additional: true,
85
+ }
86
+ }
87
+ end
88
+
89
+ let!(:structure) do
90
+ {
91
+ properties: properties,
92
+ }
93
+ end
94
+
95
+ it_behaves_like("shared stuff")
96
+
97
+ describe ':allow_additional' do
98
+ it 'passes extra properties if allow_additional is a matching Hash' do
99
+ structure[:allow_additional] = { type: 'String' }
100
+ set_test_settings_structure
101
+ org.settings = {canvas: {base_url: 'http://', api_token: 'TEST'}, reports: {}, unknown_option: "WHAT IS THIS?"}
102
+ expect(org).to be_valid
103
+ end
104
+
105
+ it 'fails extra properties if allow_additional is an unmatching Hash' do
106
+ structure[:allow_additional] = { type: 'Hash' }
107
+ set_test_settings_structure
108
+ org.settings = {canvas: {base_url: 'http://', api_token: 'TEST'}, reports: {}, unknown_option: "WHAT IS THIS?"}
109
+ expect(org).not_to be_valid
110
+ end
111
+
112
+ it 'passes extra properties if allow_additional is a blank Hash' do
113
+ structure[:allow_additional] = { }
114
+ set_test_settings_structure
115
+ org.settings = {canvas: {base_url: 'http://', api_token: 'TEST'}, reports: {}, unknown_option: "WHAT IS THIS?"}
116
+ expect(org).to be_valid
117
+ end
118
+ end
119
+
120
+ describe ':validate' do
121
+ let!(:properties) do
122
+ {
123
+ blah: {
124
+ type: 'String',
125
+ validate: -> (v, *args) { v == 'blah' ? nil : '<path> failed validation' },
126
+ }
127
+ }
128
+ end
129
+
130
+ it 'supports a custom validation proc' do
131
+ set_test_settings_structure
132
+ org.settings = { blah: 'blah' }
133
+ expect(org).to be_valid
134
+ end
135
+
136
+ it 'replaces <path> with the human readable path' do
137
+ set_test_settings_structure
138
+ org.settings = { blah: '' }
139
+ expect(org.valid?).to be_falsey
140
+ errors = org.errors.messages[:settings]
141
+ expect(errors[0]).to eq("[:blah] failed validation")
142
+ end
143
+ end
144
+ end
145
+
146
+ context 'old settings_structure' do
147
+ let!(:structure) do
148
+ YAML.load("
149
+ canvas:
150
+ is_required: true
151
+ data_type: Hash
152
+ api_token:
153
+ is_required: true
154
+ data_type: String
155
+ base_url:
156
+ is_required: true
157
+ data_type: String
158
+ reports:
159
+ is_required: true
160
+ data_type: Hash
161
+ active_term_allowance:
162
+ submissions_report_time_length:
163
+ is_required: false
164
+ data_type: ActiveSupport::Duration
165
+ recheck_wait:
166
+ data_type: ActiveSupport::Duration
167
+ max_recheck_time:
168
+ is_required: false
169
+ ").deep_symbolize_keys
170
+ end
171
+
172
+ it_behaves_like("shared stuff")
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,144 @@
1
+ require 'rails_helper'
2
+
3
+ module PandaPal
4
+ PandaPal::Organization
5
+
6
+ RSpec.describe OrganizationConcerns::TaskScheduling, type: :model do
7
+ let!(:organization) { create(:panda_pal_organization) }
8
+ let(:schedules) { {} }
9
+
10
+ before :each do
11
+ Organization.instance_variable_set(:@_schedule_descriptors, nil)
12
+ Organization.scheduled_task('0 0 0 * * *', :ident) { }
13
+
14
+ allow(Sidekiq).to receive(:remove_schedule)
15
+ allow(Sidekiq).to receive(:get_schedule) { schedules }
16
+ end
17
+
18
+ def descriptors
19
+ Organization.instance_variable_get(:@_schedule_descriptors)
20
+ end
21
+
22
+ def descriptor
23
+ descriptors[descriptors.keys[0]]
24
+ end
25
+
26
+ describe '.scheduled_task' do
27
+ it 'adds to the set of descriptors' do
28
+ expect(descriptors).to match(
29
+ 'ident' => {
30
+ :key=>"ident",
31
+ :schedule=>"0 0 0 * * *",
32
+ :worker=>Proc,
33
+ :queue=>"default",
34
+ }
35
+ )
36
+ end
37
+ end
38
+
39
+ describe '.sync_schedules' do
40
+ it 'adds new schedules' do
41
+ expect(Sidekiq).to receive(:set_schedule).with(/org:\w+-ident/, anything)
42
+ Organization.sync_schedules
43
+ end
44
+
45
+ it 'removes schedules for deleted Orgs' do
46
+ schedules['org:deleted_org-schedule'] = {}
47
+ expect(Sidekiq).to receive(:remove_schedule).with('org:deleted_org-schedule')
48
+ Organization.sync_schedules
49
+ end
50
+
51
+ it 'keeps schedules created from other sources' do
52
+ schedules['other-schedule'] = {}
53
+ expect(Sidekiq).not_to receive(:remove_schedule).with('other-schedule')
54
+ Organization.sync_schedules
55
+ end
56
+ end
57
+
58
+ describe '#generate_schedule' do
59
+ it 'generates the expected schedule' do
60
+ expect(organization.generate_schedule).to eq({
61
+ "org:#{organization.name}-ident" => {
62
+ "cron"=>"0 0 0 * * *",
63
+ "queue"=>"default",
64
+ "class"=>"PandaPal::OrganizationConcerns::TaskScheduling::ScheduledTaskExecutor",
65
+ "args"=>[organization.name, "ident"],
66
+ }
67
+ })
68
+ end
69
+ end
70
+
71
+ describe '#schedule_task_cron_time' do
72
+ before :each do
73
+ organization.settings = { timezone: 'America/Denver' }
74
+ end
75
+
76
+ it 'includes timezone if in settings' do
77
+ expect(organization.send(:schedule_task_cron_time, descriptor)).to eq '0 0 0 * * * America/Denver'
78
+ end
79
+
80
+ it 'does not re-append timezone if already present' do
81
+ descriptor[:schedule] = '1 1 1 * * * America/Chicago'
82
+ expect(organization.send(:schedule_task_cron_time, descriptor)).to eq '1 1 1 * * * America/Chicago'
83
+ end
84
+ end
85
+
86
+ describe '#sync_schedule' do
87
+ it 'adds new schedules' do
88
+ expect(Sidekiq).to receive(:set_schedule).with(/org:\w+-ident/, anything)
89
+ organization.sync_schedule
90
+ end
91
+
92
+ it 'removes old jobs' do
93
+ schedules["org:#{organization.name}-old_schedule"] = {}
94
+ expect(Sidekiq).to receive(:remove_schedule).with("org:#{organization.name}-old_schedule")
95
+ organization.sync_schedule
96
+ end
97
+ end
98
+
99
+ describe 'SettingsValidation' do
100
+ before :each do
101
+ organization.settings = {}
102
+ end
103
+
104
+ it 'passes a valid timezone' do
105
+ organization.settings[:timezone] = 'America/Denver'
106
+ expect(organization).to be_valid
107
+ end
108
+
109
+ it 'fails an invalid timezone' do
110
+ organization.settings[:timezone] = 'Timezone/Blorg'
111
+ expect(organization).not_to be_valid
112
+ end
113
+
114
+ it 'allows [:task_schedules] entry to be missing' do
115
+ expect(organization).to be_valid
116
+ end
117
+
118
+ it 'does not require [:task_schedules] sub-entries' do
119
+ organization.settings[:task_schedules] = {}
120
+ expect(organization).to be_valid
121
+ end
122
+
123
+ it 'allows task entry to be false' do
124
+ organization.settings[:task_schedules] = { ident: false }
125
+ expect(organization).to be_valid
126
+ end
127
+
128
+ it 'allows task entry to be a valid cron string' do
129
+ organization.settings[:task_schedules] = { ident: '0 0 0 * * * America/Denver' }
130
+ expect(organization).to be_valid
131
+ end
132
+
133
+ it 'does not allow task entry to be an invalid cron string' do
134
+ organization.settings[:task_schedules] = { ident: 'blort' }
135
+ expect(organization).not_to be_valid
136
+ end
137
+
138
+ it 'does not allow sub-entries for unknown tasks' do
139
+ organization.settings[:task_schedules] = { missing_ident: '0 0 0 * * * America/Denver' }
140
+ expect(organization).not_to be_valid
141
+ end
142
+ end
143
+ end
144
+ end
@@ -2,35 +2,6 @@ require 'rails_helper'
2
2
 
3
3
  module PandaPal
4
4
  RSpec.describe Organization, type: :model do
5
-
6
- def set_test_settings_structure
7
- PandaPal.lti_options = {
8
- title: 'Test App',
9
- settings_structure: YAML.load("
10
- canvas:
11
- is_required: true
12
- data_type: Hash
13
- api_token:
14
- is_required: true
15
- data_type: String
16
- base_url:
17
- is_required: true
18
- data_type: String
19
- reports:
20
- is_required: true
21
- data_type: Hash
22
- active_term_allowance:
23
- submissions_report_time_length:
24
- is_required: true
25
- data_type: ActiveSupport::Duration
26
- recheck_wait:
27
- data_type: ActiveSupport::Duration
28
- max_recheck_time:
29
- is_required: true
30
- ").deep_symbolize_keys
31
- }
32
- end
33
-
34
5
  it 'creates a schema upon creation' do
35
6
  expect(Apartment::Tenant).to receive(:create)
36
7
  create :panda_pal_organization
@@ -59,65 +30,5 @@ module PandaPal
59
30
  org2 = build :panda_pal_organization, salesforce_id: 'salesforce'
60
31
  expect(org2.valid?).to be_falsey
61
32
  end
62
-
63
- context 'settings validation' do
64
- let!(:org) { create :panda_pal_organization }
65
-
66
- it 'does not perform any validations if settings is not defined' do
67
- PandaPal.lti_options = {}
68
- expect_any_instance_of(PandaPal::Organization).not_to receive(:validate_level)
69
- expect(org.valid?).to be_truthy
70
- end
71
-
72
- it 'does not perform any validations if options is not defined' do
73
- PandaPal.lti_options = nil
74
- expect_any_instance_of(PandaPal::Organization).not_to receive(:validate_level)
75
- expect(org.valid?).to be_truthy
76
- end
77
-
78
- it 'does perform validations if settings_structure is defined' do
79
- set_test_settings_structure
80
- org.valid?
81
- expect(org.valid?).to be_falsey
82
- end
83
-
84
- it 'will fail if a required setting is not present' do
85
- set_test_settings_structure
86
- expect(org.valid?).to be_falsey
87
- errors = org.errors.messages[:settings]
88
- expect(errors[0]).to eq("PandaPal::Organization.settings requires key [:canvas]. It was not found.")
89
- end
90
-
91
- it 'will fail if a setting is supplied but data_type is wrong' do
92
- set_test_settings_structure
93
- org.settings = {canvas: "Dog"}
94
- expect(org.valid?).to be_falsey
95
- errors = org.errors.messages[:settings]
96
- expect(errors[0]).to eq("PandaPal::Organization.settings expected key [:canvas] to be Hash but it was instead String.")
97
- end
98
-
99
- it 'will fail if a required subsetting is missing' do
100
- set_test_settings_structure
101
- org.settings = {canvas: {base_url: 'http://'}, reports: {}}
102
- expect(org.valid?).to be_falsey
103
- errors = org.errors.messages[:settings]
104
- expect(errors[0]).to eq("PandaPal::Organization.settings requires key [:canvas][:api_token]. It was not found.")
105
- end
106
-
107
- it 'will fail if extra options are specified' do
108
- set_test_settings_structure
109
- org.settings = {canvas: {base_url: 'http://'}, reports: {}, unknown_option: "WHAT IS THIS?"}
110
- expect(org.valid?).to be_falsey
111
- errors = org.errors.messages[:settings]
112
- expect(errors[0]).to eq("PandaPal::Organization.settings had unexpected key: unknown_option. If settings have expanded please update your lti_options accordingly.")
113
- end
114
-
115
- it 'will pass if all structure is maintained' do
116
- set_test_settings_structure
117
- org.settings = {canvas: {base_url: 'http://', api_token: 'TEST'}, reports: {submissions_report_time_length: 30.minutes, max_recheck_time: 10.hours}}
118
- expect(org.valid?).to be_truthy
119
- end
120
-
121
- end
122
33
  end
123
34
  end
@@ -1,4 +1,8 @@
1
1
  ENV["RAILS_ENV"] ||= 'test'
2
+
3
+ require 'sidekiq/testing'
4
+ require 'sidekiq-scheduler'
5
+
2
6
  require File.expand_path("../dummy/config/environment.rb", __FILE__)
3
7
  require 'rspec/rails'
4
8
  require 'rspec/autorun'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: panda_pal
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.0
4
+ version: 5.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Instructure ProServe
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-06-08 00:00:00.000000000 Z
11
+ date: 2020-06-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -114,6 +114,34 @@ dependencies:
114
114
  - - "~>"
115
115
  - !ruby/object:Gem::Version
116
116
  version: 6.1.2
117
+ - !ruby/object:Gem::Dependency
118
+ name: sidekiq
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ - !ruby/object:Gem::Dependency
132
+ name: sidekiq-scheduler
133
+ requirement: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ type: :development
139
+ prerelease: false
140
+ version_requirements: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
117
145
  - !ruby/object:Gem::Dependency
118
146
  name: rspec-rails
119
147
  requirement: !ruby/object:Gem::Requirement
@@ -163,6 +191,8 @@ files:
163
191
  - app/lib/lti_xml/bridge_platform.rb
164
192
  - app/lib/lti_xml/canvas_platform.rb
165
193
  - app/models/panda_pal/organization.rb
194
+ - app/models/panda_pal/organization/settings_validation.rb
195
+ - app/models/panda_pal/organization/task_scheduling.rb
166
196
  - app/models/panda_pal/session.rb
167
197
  - app/views/layouts/panda_pal/application.html.erb
168
198
  - app/views/panda_pal/lti/launch.html.erb
@@ -206,7 +236,6 @@ files:
206
236
  - spec/dummy/config/environments/development.rb
207
237
  - spec/dummy/config/environments/production.rb
208
238
  - spec/dummy/config/environments/test.rb
209
- - spec/dummy/config/initializers/assets.rb
210
239
  - spec/dummy/config/initializers/backtrace_silencers.rb
211
240
  - spec/dummy/config/initializers/cookies_serializer.rb
212
241
  - spec/dummy/config/initializers/filter_parameter_logging.rb
@@ -228,6 +257,8 @@ files:
228
257
  - spec/dummy/public/favicon.ico
229
258
  - spec/factories/panda_pal_organizations.rb
230
259
  - spec/factories/panda_pal_sessions.rb
260
+ - spec/models/panda_pal/organization/settings_validation_spec.rb
261
+ - spec/models/panda_pal/organization/task_scheduling_spec.rb
231
262
  - spec/models/panda_pal/organization_spec.rb
232
263
  - spec/models/panda_pal/session_spec.rb
233
264
  - spec/rails_helper.rb
@@ -281,7 +312,6 @@ test_files:
281
312
  - spec/dummy/config/initializers/filter_parameter_logging.rb
282
313
  - spec/dummy/config/initializers/session_store.rb
283
314
  - spec/dummy/config/initializers/wrap_parameters.rb
284
- - spec/dummy/config/initializers/assets.rb
285
315
  - spec/dummy/config/initializers/cookies_serializer.rb
286
316
  - spec/dummy/config/initializers/inflections.rb
287
317
  - spec/dummy/config.ru
@@ -296,6 +326,8 @@ test_files:
296
326
  - spec/dummy/log/test.log
297
327
  - spec/dummy/log/development.log
298
328
  - spec/dummy/README.rdoc
329
+ - spec/models/panda_pal/organization/settings_validation_spec.rb
330
+ - spec/models/panda_pal/organization/task_scheduling_spec.rb
299
331
  - spec/models/panda_pal/session_spec.rb
300
332
  - spec/models/panda_pal/organization_spec.rb
301
333
  - spec/factories/panda_pal_sessions.rb
@@ -1,11 +0,0 @@
1
- # Be sure to restart your server when you modify this file.
2
-
3
- # Version of your assets, change this if you want to expire all your assets.
4
- Rails.application.config.assets.version = '1.0'
5
-
6
- # Add additional assets to the asset load path
7
- # Rails.application.config.assets.paths << Emoji.images_path
8
-
9
- # Precompile additional assets.
10
- # application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
11
- # Rails.application.config.assets.precompile += %w( search.js )