panda_pal 4.0.11 → 4.1.0.beta2

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
  SHA1:
3
- metadata.gz: d39c561ca39a55c980cf4564ec11717ac0198961
4
- data.tar.gz: 220c0e4e588e8172ccb0bc60fab57e5a11c03bfa
3
+ metadata.gz: 2c6ac3694136d009439988eff1703cd4df593166
4
+ data.tar.gz: 0d41152d271eb99d7d248062a4f3500c8c146af1
5
5
  SHA512:
6
- metadata.gz: 242ec66296e0685ae1e5378ba34f94fbfaf0d502b3542a5d1cf18749d92575689c3d7fe92bc6c9e8cfa5e63b756b0438551f65f41e6050c0e8f33eddcb7440e3
7
- data.tar.gz: e2010c28cd1124f5800b45ce5716ad41ddb39b49356b0bd9411e0215062cca65c5ceb63f83d2fc250b432a7a23dc73ac7cf4633b5bcf76552e8479f092fbe6bd
6
+ metadata.gz: b253820d23c08f25c61de72d420da969647e6c2cda98d3957bfd076c7e26b1752d383ea7c3d38018945eadb4b36fb880ccd2f0e5fdf5d0e38ce808aa8e3cf23f
7
+ data.tar.gz: b9bae0c7b68314c457beeefc7f78d7f97fc6810be4aa94eb826779e9856c62b8ec9f80eb5fae43edb3870256773e2e5a30c3e56e7e29cab981ac0246cd28544a
data/README.md CHANGED
@@ -28,6 +28,38 @@ 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/organization.rb
35
+ module PandaPal
36
+ class Organization
37
+ # Will invoke CanvasSyncStarterWorker.perform_async() according to the cron schedule
38
+ scheduled_task '0 15 05 * * *', :identifier, worker: CanvasSyncStarterWorker
39
+
40
+ # Will invoke the method 'organization_method' on the Organization
41
+ scheduled_task '0 15 05 * * *', :organization_method_and_identifier
42
+
43
+ # If you need to invoke the same method on multiple schedules
44
+ scheduled_task '0 15 05 * * *', :identifier, worker: :organization_method
45
+
46
+ # You can also use a block
47
+ scheduled_task '0 15 05 * * *', :identifier do
48
+ # Do Stuff
49
+ end
50
+
51
+ # You can use a Proc (called in the context of the Organization) to determine the schedule
52
+ scheduled_task -> { settings[:cron] }, :identifier
53
+
54
+ # You can specify a timezone. If a TZ is not coded and settings[:timezone] is present, it will be appended automatically
55
+ scheduled_task '0 15 05 * * * America/Denver', :identifier, worker: :organization_method
56
+
57
+ # Setting settings[:task_schedules][:identifier] will override the code cron schedule. Setting it to false will disable the Task
58
+ # :identifer values _must_ be unique, but can be nil, in which case they will be determined by where (lineno etc) scheduled_task is called
59
+ end
60
+ end
61
+ ```
62
+
31
63
  # Organization Attributes
32
64
  id: Primary Key
33
65
  name: Name of the organization. Used to on requests to select the tenant
@@ -226,9 +258,43 @@ In your panda_pal initializer (i.e. config/initializers/panda_pal.rb or config/i
226
258
  You can specify options that can include a structure for your settings. If specified, PandaPal will
227
259
  enforce this structure on any new / updated organizations.
228
260
 
261
+ ```ruby
262
+ PandaPal.lti_options = {
263
+ title: 'LBS Gradebook',
264
+ settings_structure: {
265
+ allow_additional: true, # Allow additional properties that aren't included in the :properties Hash.
266
+ allow_additional: { type: 'String' }, # You can also set :allow_additional to a settings specification that will be used to validate each additional setting
267
+ validate: ->(value, spec, **kwargs) {
268
+ # kwargs currently includes:
269
+ # :errors => [An array to push errors to]
270
+ # :path => [An array representation of the current path in the settings object]
271
+
272
+ # To add errors, you may:
273
+ # Push strings to the kwargs[:errors] Array:
274
+ kwargs[:errors] << "Your error message at <path>" unless value < 10
275
+ # Or return a string or string array:
276
+ value.valid? ? nil : "Your error message at <path>" # <path> will be replaced with the actual path that the error occurred at
277
+ },
278
+ properties: {
279
+ canvas_api_token: { type: 'String', required: true, },
280
+ catalog: { # :validate, :allow_additional, :properties keys are all supported at this level as well
281
+ type: 'Hash',
282
+ required: false,
283
+ validate: -> (*args) {},
284
+ allow_additional: false,
285
+ properties: {
286
+
287
+ },
288
+ }
289
+ }
290
+ },
291
+ }
292
+ ```
293
+
294
+ #### Legacy Settings Structure:
229
295
  Here is an example options specification:
230
296
 
231
- ```
297
+ ```ruby
232
298
  PandaPal.lti_options = {
233
299
  title: 'LBS Gradebook',
234
300
  settings_structure: YAML.load("
@@ -280,3 +346,8 @@ Safari is weird, and you'll potentially run into issues getting `POST` requests
280
346
  - Make sure your other controller calls `before_action :forbid_access_if_lacking_session`
281
347
 
282
348
  This will allow `PandaPal` to apply an iframe cookie fix that will allow CSRF validation to work.
349
+
350
+ ## Running Specs:
351
+ Initialize the Specs DB:
352
+ `cd spec/dummy; bundle exec rake db:drop; bundle exec rake db:create; bundle exec rake db:schema:load`
353
+ Then `bundle exec rspec`
@@ -38,9 +38,9 @@ module LtiXml
38
38
  elsif PandaPal.lti_options[:launch_url].present? # Assumes full URL is provided
39
39
  ims_lti_config[:launch_url] = PandaPal.lti_options[:launch_url]
40
40
  elsif PandaPal.lti_options[:launch_path].present? # Assumes path is provided
41
- ims_lti_config[:launch_url] = [parsed_request_url.to_s, PandaPal.lti_options[:launch_path]].join
41
+ ims_lti_config[:launch_url] = "#{parsed_request_url.host}#{PandaPal.lti_options[:launch_path]}"
42
42
  else
43
- ims_lti_config[:launch_url] = parsed_request_url.to_s
43
+ ims_lti_config[:launch_url] = parsed_request_url.host
44
44
  end
45
45
  @tc = IMS::LTI::Services::ToolConfig.new(ims_lti_config)
46
46
  end
@@ -0,0 +1,115 @@
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
+ switch_tenant do
44
+ validate_settings_level(settings || {}, settings_structure).each do |err|
45
+ errors[:settings] << err
46
+ end
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def validate_settings_level(settings, spec, path: [], errors: [])
53
+ human_path = "[:#{path.join('][:')}]"
54
+
55
+ if settings.nil?
56
+ errors << "Entry #{human_path} is required" if spec[:required]
57
+ return
58
+ end
59
+
60
+ if spec[:type]
61
+ resolved_type = spec[:type]
62
+ resolved_type = resolved_type.constantize if resolved_type.is_a?(String)
63
+ unless settings.is_a?(resolved_type)
64
+ errors << "Expected #{human_path} to be a #{spec[:type]}. Was a #{settings.class.to_s}"
65
+ return
66
+ end
67
+ end
68
+
69
+ if spec[:validate].present?
70
+ val_errors = []
71
+ if spec[:validate].is_a?(Symbol)
72
+ proc_result = send(spec[:validate], settings, spec, path: path, errors: val_errors)
73
+ elsif spec[:validate].is_a?(String)
74
+ split_val = spec[:validate].split?('.')
75
+ split_val << 'validate_settings' if split_val.count == 1
76
+ resolved_module = split_val[0].constantize
77
+ proc_result = resolved_module.send(split_val[1].to_sym, settings, spec, path: path, errors: val_errors)
78
+ elsif spec[:validate].is_a?(Proc)
79
+ proc_result = instance_eval do
80
+ spec[:validate].call(settings, spec, path: path, errors: val_errors)
81
+ end
82
+ end
83
+ val_errors << proc_result unless val_errors.present? || proc_result == val_errors
84
+ val_errors = val_errors.flatten.uniq.compact.map do |ve|
85
+ ve.gsub('<path>', human_path)
86
+ end
87
+ errors.concat(val_errors)
88
+ end
89
+
90
+ if settings.is_a?(Hash)
91
+ if spec[:properties] != nil
92
+ spec[:properties].each do |key, pspec|
93
+ validate_settings_level(settings[key], pspec, path: [*path, key], errors: errors)
94
+ end
95
+ end
96
+
97
+ if spec[:properties] != nil || spec[:allow_additional] != nil
98
+ extra_keys = settings.keys - (spec[:properties]&.keys || [])
99
+ if extra_keys.present?
100
+ if spec[:allow_additional].is_a?(Hash)
101
+ extra_keys.each do |key|
102
+ validate_settings_level(settings[key], spec[:allow_additional], path: [*path, key], errors: errors)
103
+ end
104
+ elsif !spec[:allow_additional]
105
+ errors << "Did not expect #{human_path} to contain [#{extra_keys.join(', ')}]"
106
+ end
107
+ end
108
+ end
109
+ end
110
+
111
+ errors
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,164 @@
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][:task_schedules] = {
28
+ type: 'Hash',
29
+ required: false,
30
+ properties: _schedule_descriptors.keys.reduce({}) do |hash, k|
31
+ hash.tap do |hash|
32
+ hash[k.to_sym] = hash[k.to_s] = {
33
+ required: false,
34
+ validate: ->(value, *args, errors:, **kwargs) {
35
+ begin
36
+ Rufus::Scheduler.parse(value) if value
37
+ nil
38
+ rescue ArgumentError
39
+ errors << "<path> must be false or a Crontab string"
40
+ end
41
+ }
42
+ }
43
+ end
44
+ end,
45
+ }
46
+ end
47
+ end
48
+
49
+ def scheduled_task(cron_time, name_or_method = nil, worker: nil, queue: nil, &block)
50
+ task_key = (name_or_method.presence || "scheduled_task_#{caller_locations[0].lineno}").to_s
51
+ raise "Task key '#{task_key}' already taken!" if _schedule_descriptors.key?(task_key)
52
+
53
+ _schedule_descriptors[task_key] = {
54
+ key: task_key,
55
+ schedule: cron_time,
56
+ worker: worker || block || name_or_method.to_sym,
57
+ queue: queue || 'default',
58
+ }
59
+ end
60
+
61
+ def sync_schedules
62
+ # Ensure deleted Orgs are removed
63
+ existing_orgs = pluck(:name)
64
+ old_schedules = Sidekiq.get_schedule.select do |k, v|
65
+ m = k.match(/^org:([a-z0-9_]+)\-/i)
66
+ m.present? && !existing_orgs.include?(m[1])
67
+ end
68
+ old_schedules.keys.each do |k|
69
+ Sidekiq.remove_schedule(k)
70
+ end
71
+
72
+ find_each(&:sync_schedule)
73
+ end
74
+ end
75
+
76
+ def generate_schedule
77
+ schedule = {}
78
+ self.class._schedule_descriptors.values.each do |desc|
79
+ cron_time = schedule_task_cron_time(desc)
80
+ next unless cron_time.present?
81
+
82
+ schedule["org:#{name}-#{desc[:key]}"] = {
83
+ 'cron' => cron_time,
84
+ 'queue' => desc[:queue],
85
+ 'class' => ScheduledTaskExecutor.to_s,
86
+ 'args' => [name, desc[:key]],
87
+ }
88
+ end
89
+ schedule
90
+ end
91
+
92
+ def sync_schedule
93
+ new_schedules = generate_schedule
94
+ unschedule_tasks(new_schedules.keys)
95
+ new_schedules.each do |k, v|
96
+ Sidekiq.set_schedule(k, v)
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ def unschedule_tasks(new_task_keys = nil)
103
+ current_schedules = Sidekiq.get_schedule.select { |k,v| k.starts_with?("org:#{name}-") }
104
+ del_tasks = current_schedules.keys
105
+ del_tasks -= new_task_keys if new_task_keys
106
+ del_tasks.each do |k|
107
+ Sidekiq.remove_schedule(k)
108
+ end
109
+ end
110
+
111
+ def schedule_task_cron_time(desc)
112
+ cron_time = nil
113
+ cron_time = settings&.dig(:task_schedules, desc[:key].to_s) if cron_time.nil?
114
+ cron_time = settings&.dig(:task_schedules, desc[:key].to_sym) if cron_time.nil?
115
+ cron_time = desc[:schedule] if cron_time.nil?
116
+
117
+ return nil unless cron_time.present?
118
+
119
+ cron_time = instance_eval(&cron_time) if cron_time.is_a?(Proc)
120
+ if !Rufus::Scheduler.parse(cron_time).zone.present? && settings && settings[:timezone]
121
+ cron_time += " #{settings[:timezone]}"
122
+ end
123
+
124
+ cron_time
125
+ end
126
+
127
+ class ScheduledTaskExecutor
128
+ include Sidekiq::Worker
129
+
130
+ def perform(org_name, task_key)
131
+ org = Organization.find_by!(name: org_name)
132
+ task = Organization._schedule_descriptors[task_key]
133
+ worker = task[:worker]
134
+
135
+ Apartment::Tenant.switch(org.name) do
136
+ if worker.is_a?(Proc)
137
+ org.instance_eval(&worker)
138
+ elsif worker.is_a?(Symbol)
139
+ org.send(worker)
140
+ elsif worker.is_a?(String)
141
+ worker.constantize.perform_async
142
+ elsif worker.is_a?(Class)
143
+ worker.perform_async
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
151
+
152
+ SidekiqScheduler::Scheduler.instance.dynamic = true
153
+
154
+ module SidekiqScheduler
155
+ module Schedule
156
+ original_schedule_setter = instance_method(:schedule=)
157
+
158
+ define_method :schedule= do |sched|
159
+ original_schedule_setter.bind(self).(sched).tap do
160
+ PandaPal::Organization.sync_schedules
161
+ end
162
+ end
163
+ end
164
+ end
@@ -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
@@ -1,3 +1,3 @@
1
1
  module PandaPal
2
- VERSION = "4.0.11"
2
+ VERSION = "4.1.0.beta2"
3
3
  end
data/panda_pal.gemspec CHANGED
@@ -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,134 @@
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 'allows [:task_schedules] entry to be missing' do
105
+ expect(organization).to be_valid
106
+ end
107
+
108
+ it 'does not require [:task_schedules] sub-entries' do
109
+ organization.settings[:task_schedules] = {}
110
+ expect(organization).to be_valid
111
+ end
112
+
113
+ it 'allows task entry to be false' do
114
+ organization.settings[:task_schedules] = { ident: false }
115
+ expect(organization).to be_valid
116
+ end
117
+
118
+ it 'allows task entry to be a valid cron string' do
119
+ organization.settings[:task_schedules] = { ident: '0 0 0 * * * America/Denver' }
120
+ expect(organization).to be_valid
121
+ end
122
+
123
+ it 'does not allow task entry to be an invalid cron string' do
124
+ organization.settings[:task_schedules] = { ident: 'blort' }
125
+ expect(organization).not_to be_valid
126
+ end
127
+
128
+ it 'does not allow sub-entries for unknown tasks' do
129
+ organization.settings[:task_schedules] = { missing_ident: '0 0 0 * * * America/Denver' }
130
+ expect(organization).not_to be_valid
131
+ end
132
+ end
133
+ end
134
+ 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
data/spec/spec_helper.rb CHANGED
@@ -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'
@@ -40,6 +44,10 @@ RSpec.configure do |config|
40
44
  title: 'Test App',
41
45
  settings_structure: {}
42
46
  }
47
+
48
+ allow_any_instance_of(PandaPal::Organization).to receive(:switch_tenant) do |inst, &block|
49
+ block.call if block.present?
50
+ end
43
51
  end
44
52
 
45
53
  # The settings below are suggested to provide a good initial experience
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: 4.0.11
4
+ version: 4.1.0.beta2
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-04-21 00:00:00.000000000 Z
11
+ date: 2020-05-04 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/iframe_cookie_fix.html.erb
@@ -203,7 +233,6 @@ files:
203
233
  - spec/dummy/config/environments/development.rb
204
234
  - spec/dummy/config/environments/production.rb
205
235
  - spec/dummy/config/environments/test.rb
206
- - spec/dummy/config/initializers/assets.rb
207
236
  - spec/dummy/config/initializers/backtrace_silencers.rb
208
237
  - spec/dummy/config/initializers/cookies_serializer.rb
209
238
  - spec/dummy/config/initializers/filter_parameter_logging.rb
@@ -221,6 +250,8 @@ files:
221
250
  - spec/dummy/public/favicon.ico
222
251
  - spec/factories/panda_pal_organizations.rb
223
252
  - spec/factories/panda_pal_sessions.rb
253
+ - spec/models/panda_pal/organization/settings_validation_spec.rb
254
+ - spec/models/panda_pal/organization/task_scheduling_spec.rb
224
255
  - spec/models/panda_pal/organization_spec.rb
225
256
  - spec/models/panda_pal/session_spec.rb
226
257
  - spec/rails_helper.rb
@@ -240,9 +271,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
240
271
  version: '0'
241
272
  required_rubygems_version: !ruby/object:Gem::Requirement
242
273
  requirements:
243
- - - ">="
274
+ - - ">"
244
275
  - !ruby/object:Gem::Version
245
- version: '0'
276
+ version: 1.3.1
246
277
  requirements: []
247
278
  rubyforge_project:
248
279
  rubygems_version: 2.6.11
@@ -275,7 +306,6 @@ test_files:
275
306
  - spec/dummy/config/initializers/filter_parameter_logging.rb
276
307
  - spec/dummy/config/initializers/session_store.rb
277
308
  - spec/dummy/config/initializers/wrap_parameters.rb
278
- - spec/dummy/config/initializers/assets.rb
279
309
  - spec/dummy/config/initializers/cookies_serializer.rb
280
310
  - spec/dummy/config/initializers/inflections.rb
281
311
  - spec/dummy/config.ru
@@ -286,6 +316,8 @@ test_files:
286
316
  - spec/dummy/public/404.html
287
317
  - spec/dummy/db/schema.rb
288
318
  - spec/dummy/README.rdoc
319
+ - spec/models/panda_pal/organization/settings_validation_spec.rb
320
+ - spec/models/panda_pal/organization/task_scheduling_spec.rb
289
321
  - spec/models/panda_pal/session_spec.rb
290
322
  - spec/models/panda_pal/organization_spec.rb
291
323
  - 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 )