panda_pal 5.0.0 → 5.1.0

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