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 +4 -4
- data/README.md +73 -1
- data/app/models/panda_pal/organization.rb +17 -46
- data/app/models/panda_pal/organization/settings_validation.rb +111 -0
- data/app/models/panda_pal/organization/task_scheduling.rb +172 -0
- data/lib/panda_pal/version.rb +1 -1
- data/panda_pal.gemspec +3 -0
- data/spec/dummy/config/application.rb +7 -1
- data/spec/dummy/config/environments/development.rb +0 -14
- data/spec/dummy/config/environments/production.rb +0 -11
- data/spec/models/panda_pal/organization/settings_validation_spec.rb +175 -0
- data/spec/models/panda_pal/organization/task_scheduling_spec.rb +144 -0
- data/spec/models/panda_pal/organization_spec.rb +0 -89
- data/spec/spec_helper.rb +4 -0
- metadata +36 -4
- data/spec/dummy/config/initializers/assets.rb +0 -11
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: 8a2c4fed3544a682a44b4e9acfc30742ab7f7bcdf2ec22db97d90df80af31e04
         | 
| 4 | 
            +
              data.tar.gz: f0ee1ba262458c9a5fee095c17c1c673e58e90cc821ea92d29b59cee2afcd1e4
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 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 | 
            -
             | 
| 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
         | 
    
        data/lib/panda_pal/version.rb
    CHANGED
    
    
    
        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  | 
| 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
         | 
    
        data/spec/spec_helper.rb
    CHANGED
    
    
    
        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. | 
| 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- | 
| 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 )
         |