port conditional release model logic and specs

test plan:
* associations and other model logic should correspond to
 conditional release (aside from changes being made
 for the migration)
* specs run

closes #LA-1094

Change-Id: I2ae698e11a00b8dbda3dbd839d54cbc067190afc
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/239176
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
QA-Review: Jeremy Stanley <jeremy@instructure.com>
Product-Review: Jeremy Stanley <jeremy@instructure.com>
This commit is contained in:
James Williams 2020-06-03 14:58:53 -06:00
parent bd3cf180b7
commit 6a1af4d748
29 changed files with 1302 additions and 0 deletions

View File

@ -66,4 +66,6 @@ group :test do
gem 'parallel_tests' gem 'parallel_tests'
gem 'flakey_spec_catcher', require: false gem 'flakey_spec_catcher', require: false
gem 'factory_bot', '5.2.0', require: false
end end

View File

@ -17,5 +17,16 @@
module ConditionalRelease module ConditionalRelease
class AssignmentSet < ActiveRecord::Base class AssignmentSet < ActiveRecord::Base
include Deletion
belongs_to :scoring_range, required: true
has_many :assignment_set_associations, -> { active.order(position: :asc) }, inverse_of: :assignment_set, dependent: :destroy
accepts_nested_attributes_for :assignment_set_associations, allow_destroy: true
acts_as_list :scope => {:scoring_range => self, :deleted_at => nil}
has_one :rule, through: :scoring_range
def self.collect_associations(sets)
sets.map(&:assignment_set_associations).flatten.sort_by(&:id).uniq(&:assignment_id)
end
end end
end end

View File

@ -17,5 +17,35 @@
module ConditionalRelease module ConditionalRelease
class AssignmentSetAction < ActiveRecord::Base class AssignmentSetAction < ActiveRecord::Base
include Deletion
validates :action, inclusion: { in: %w(assign unassign) }
validates :source, inclusion: { in: %w(grade_change select_assignment_set) }
validates :student_id, presence: true
validates :actor_id, presence: true
validates :assignment_set_id, presence: true
belongs_to :assignment_set
scope :latest, -> {
select('DISTINCT ON (assignment_set_id, student_id) id').
order('assignment_set_id, student_id, created_at DESC')
}
def self.current_assignments(student_id_or_ids, sets=nil)
conditions = { student_id: student_id_or_ids }
conditions[:assignment_set] = sets if sets
self.where(id: self.latest.where(conditions), action: 'assign')
end
def self.create_from_sets(assigned, unassigned, opts={})
opts[:actor_id] ||= opts[:student_id]
[['assign', assigned], ['unassign', unassigned]].each do |action, sets|
sets = Array.wrap(sets)
sets.each do |set|
create! opts.merge(action: action, assignment_set: set, root_account_id: set.root_account_id)
end
end
end
end end
end end

View File

@ -17,5 +17,26 @@
module ConditionalRelease module ConditionalRelease
class AssignmentSetAssociation < ActiveRecord::Base class AssignmentSetAssociation < ActiveRecord::Base
include Deletion
validates :assignment_id, presence: true
validates :assignment_id, uniqueness: { scope: :assignment_set_id, conditions: -> { active } }
validate :not_trigger
acts_as_list :scope => {:assignment_set => self, :deleted_at => nil}
belongs_to :assignment_set, required: true
belongs_to :assignment
has_one :scoring_range, through: :assignment_set
has_one :rule, through: :assignment_set
delegate :course_id, to: :rule, allow_nil: true
private
def not_trigger
if rule && assignment_id == rule.trigger_assignment_id
errors.add(:assignment_id, "can't match rule trigger_assignment_id")
end
end
end end
end end

View File

@ -0,0 +1,42 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
# Basic bounds validations for classes including :upper_bound and :lower_bound attributes
module ConditionalRelease
module BoundsValidations
extend ActiveSupport::Concern
include ActiveModel::Validations
included do
validates :lower_bound, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
validates :upper_bound, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
validate :lower_bound_less_than_upper_bound
validate :bound_must_exist
end
private
def lower_bound_less_than_upper_bound
if lower_bound.is_a?(Numeric) && upper_bound.is_a?(Numeric) && lower_bound > upper_bound
errors.add(:base, 'lower bound must be less than upper bound')
end
end
def bound_must_exist
errors.add(:base, 'one bound must exist') unless lower_bound || upper_bound
end
end
end

View File

@ -0,0 +1,41 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
# yes this isn't really any different than SoftDeletable but this preserves the db structure
# from conditional_release and workflow_state kind of sucks anyway
module ConditionalRelease
module Deletion
extend ActiveSupport::Concern
included do
scope :active, -> { where(:deleted_at => nil) }
alias_method :destroy_permanently!, :destroy
def destroy
return true if deleted_at.present?
self.deleted_at = Time.now.utc
run_callbacks(:destroy) { save! }
end
def restore
self.deleted_at = nil
save!
true
end
end
end
end

View File

@ -17,5 +17,29 @@
module ConditionalRelease module ConditionalRelease
class Rule < ActiveRecord::Base class Rule < ActiveRecord::Base
include Deletion
validates :course_id, presence: true
validates :trigger_assignment_id, presence: true
belongs_to :trigger_assignment, :class_name => "Assignment"
belongs_to :course
has_many :scoring_ranges, -> { active.order(position: :asc) }, inverse_of: :rule, dependent: :destroy
has_many :assignment_sets, -> { active }, through: :scoring_ranges
has_many :assignment_set_associations, -> { active.order(position: :asc) }, through: :scoring_ranges
accepts_nested_attributes_for :scoring_ranges, allow_destroy: true
scope :with_assignments, -> do
having_assignments = joins(all_includes).group(Arel.sql("conditional_release_rules.id"))
preload(all_includes).where(id: having_assignments.pluck(:id))
end
def self.all_includes
{ scoring_ranges: { assignment_sets: :assignment_set_associations } }
end
def assignment_sets_for_score(score)
AssignmentSet.where(scoring_range: scoring_ranges.for_score(score)).preload(:assignment_set_associations)
end
end end
end end

View File

@ -17,5 +17,22 @@
module ConditionalRelease module ConditionalRelease
class RuleTemplate < ActiveRecord::Base class RuleTemplate < ActiveRecord::Base
include Deletion
validates :name, presence: true
validates :context_id, presence: true
validates :context_type, inclusion: { in: %w(Account Course) }
belongs_to :context, polymorphic: [:course, :account]
has_many :scoring_range_templates, -> { active }, inverse_of: :rule_template, dependent: :destroy
accepts_nested_attributes_for :scoring_range_templates, allow_destroy: true
def build_rule
rule = Rule.new root_account_id: root_account_id
scoring_range_templates.each do |t|
rule.scoring_ranges << t.build_scoring_range
end
rule
end
end end
end end

View File

@ -17,5 +17,33 @@
module ConditionalRelease module ConditionalRelease
class ScoringRange < ActiveRecord::Base class ScoringRange < ActiveRecord::Base
include BoundsValidations
include Deletion
belongs_to :rule, required: true
has_many :assignment_sets, -> { active.order(position: :asc) }, inverse_of: :scoring_range, dependent: :destroy
has_many :assignment_set_associations, -> { active.order(position: :asc) }, through: :assignment_sets
accepts_nested_attributes_for :assignment_sets, allow_destroy: true
delegate :course_id, to: :rule
acts_as_list :scope => {:rule => self, :deleted_at => nil}
scope :for_score, lambda { |score|
where(arel_table[:upper_bound].gt(score).or(arel_table[:upper_bound].eq(nil)).
and(arel_table[:lower_bound].lteq(score).or(arel_table[:lower_bound].eq(nil))))
}
def contains_score(score)
return false unless score
return false if lower_bound.present? && lower_bound > score
return false if upper_bound.present? && upper_bound <= score
true
end
def assignment_sets
super.build if super.empty?
super
end
end end
end end

View File

@ -17,5 +17,15 @@
module ConditionalRelease module ConditionalRelease
class ScoringRangeTemplate < ActiveRecord::Base class ScoringRangeTemplate < ActiveRecord::Base
include BoundsValidations
include Deletion
belongs_to :rule_template, required: true
delegate :context_id, :context_type, to: :rule_template
def build_scoring_range
ScoringRange.new upper_bound: upper_bound, lower_bound: lower_bound
end
end end
end end

View File

@ -0,0 +1,45 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
require_relative 'factory_bot_spec_helper'
RSpec.shared_examples 'a soft-deletable model' do
it { is_expected.to have_db_column(:deleted_at) }
it 'adds a deleted_at where clause when requested' do
expect(described_class.active.all.to_sql).to include('"deleted_at" IS NULL')
end
it 'skips adding the deleted_at where clause normally' do
# sorry - no default scopes
expect(described_class.all.to_sql).not_to include('deleted_at')
end
it 'soft deletes' do
instance = create described_class.name.underscore.sub("conditional_release/", "").to_sym
instance.destroy!
expect(described_class.exists?(instance.id)).to be true
expect(described_class.active.exists?(instance.id)).to be false
end
it 'allows duplicates on unique attributes when one instance is soft deleted' do
instance = create described_class.name.underscore.sub("conditional_release/", "").to_sym
copy = instance.clone
instance.destroy!
expect { copy.save! }.to_not raise_error
end
end

View File

@ -0,0 +1,22 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
FactoryBot.define do
factory :assignment do
context :factory => :course
end
end

View File

@ -0,0 +1,27 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
FactoryBot.define do
factory :assignment_set_action, class: ConditionalRelease::AssignmentSetAction do
action { 'assign' }
source { 'grade_change' }
student_id { generate(:user_id) }
actor_id { generate(:user_id) }
assignment_set
root_account_id { Account.default.id }
end
end

View File

@ -0,0 +1,24 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
FactoryBot.define do
factory :assignment_set_association, class: ConditionalRelease::AssignmentSetAssociation do
association :assignment_set
assignment
root_account_id { Account.default.id }
end
end

View File

@ -0,0 +1,33 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
FactoryBot.define do
factory :assignment_set, class: ConditionalRelease::AssignmentSet do
scoring_range
root_account_id { Account.default.id }
factory :assignment_set_with_assignments do
transient do
assignment_count { 1 }
end
after(:create) do |assignment_set, evaluator|
create_list :assignment_set_association, evaluator.assignment_count, assignment_set: assignment_set
end
end
end
end

View File

@ -0,0 +1,44 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
FactoryBot.define do
factory :rule_template, class: ConditionalRelease::RuleTemplate do
sequence(:name) { |n| "rule template #{n}" }
context :factory => :course
root_account_id { Account.default.id }
factory :rule_template_with_scoring_ranges do
transient do
scoring_range_template_count { 2 }
end
after(:create) do |template, evaluator|
values = (0..evaluator.scoring_range_template_count).collect { |i| i * 11 }
create_list(
:scoring_range_template,
evaluator.scoring_range_template_count,
rule_template: template
) do |range_template|
# give ascending bounds
range_template.lower_bound = values.shift
range_template.upper_bound = values[0]
range_template.save!
end
end
end
end
end

View File

@ -0,0 +1,50 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
FactoryBot.define do
sequence(:user_id) { |n| 10000 + n }
factory :rule, class: ConditionalRelease::Rule do
root_account_id { Account.default.id }
course :factory => :course
trigger_assignment :factory => :assignment
factory :rule_with_scoring_ranges do
transient do
scoring_range_count { 2 }
assignment_set_count { 1 }
assignment_count { 2 }
end
after(:create) do |rule, evaluator|
values = (0..evaluator.scoring_range_count).collect { |i| i * 1.0 / evaluator.scoring_range_count }
create_list(
:scoring_range_with_assignments,
evaluator.scoring_range_count,
rule: rule,
assignment_set_count: evaluator.assignment_set_count,
assignment_count: evaluator.assignment_count
) do |range|
# give ascending bounds
range.lower_bound = values.shift
range.upper_bound = values[0]
range.save!
end
end
end
end
end

View File

@ -0,0 +1,25 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
FactoryBot.define do
factory :scoring_range_template, class: ConditionalRelease::ScoringRangeTemplate do
rule_template
lower_bound { 65 }
upper_bound { 95 }
root_account_id { Account.default.id }
end
end

View File

@ -0,0 +1,38 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
FactoryBot.define do
factory :scoring_range, aliases: [:scoring_range_with_bounds], class: ConditionalRelease::ScoringRange do
rule
lower_bound { 65 }
upper_bound { 95 }
root_account_id { Account.default.id }
factory :scoring_range_with_assignments do
transient do
assignment_set_count { 1 }
assignment_count { 2 }
end
after(:create) do |range, evaluator|
create_list :assignment_set_with_assignments, evaluator.assignment_set_count,
scoring_range: range,
assignment_count: evaluator.assignment_count
end
end
end
end

View File

@ -0,0 +1,22 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
FactoryBot.define do
factory :course do
end
end

View File

@ -0,0 +1,37 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
require_relative 'spec_helper'
# NOTE/RANT: bringing this in should not be construed as an endorsement of FactoryBot
# i honestly have no idea how to use it
# but for whatever reason the conditional_release peeps liked it
# and bringing it into canvas seemed like the easiest solution
# since absorbing the code into the hivemind is going to be hard enough
# without having to rewrite all their specs into a canvas-y way
require 'factory_bot'
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
config.before(:suite) do
unless FactoryBot.definition_file_paths == %w{spec/factory_bot} # already loaded
FactoryBot.definition_file_paths = %w{spec/factory_bot}
FactoryBot.find_definitions
end
end
end

View File

@ -0,0 +1,125 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require_relative '../../conditional_release_spec_helper'
require_dependency "conditional_release/assignment_set_action"
module ConditionalRelease
describe AssignmentSetAction, :type => :model do
it_behaves_like 'a soft-deletable model'
it "must have student_id and actor_id" do
set = create :assignment_set
[:student_id, :actor_id].each do |attr|
action = build :assignment_set_action, assignment_set: set
action.send("#{attr}=", nil)
expect(action.valid?).to be false
action.send("#{attr}=", '')
expect(action.valid?).to be false
action.send("#{attr}=", 'person')
expect(action.valid?).to be true
end
end
it "must have action" do
set = create :assignment_set
action = build :assignment_set_action, assignment_set: set
action.action= nil
expect(action.valid?).to be false
action.action= ''
expect(action.valid?).to be false
action.action= 'assign'
expect(action.valid?).to be true
end
it "must have source" do
set = create :assignment_set
action = build :assignment_set_action, assignment_set: set
action.source= nil
expect(action.valid?).to be false
action.source= ''
expect(action.valid?).to be false
action.source= 'grade_change'
expect(action.valid?).to be true
end
it "must have an assignment_set_id" do
set = create :assignment_set
action = build :assignment_set_action
action.assignment_set_id = nil
expect(action.valid?).to be false
action.assignment_set_id = set.id
expect(action.valid?).to be true
end
it "should be valid when assignment_set does not exist" do
action = create :assignment_set_action
set_id = action.assignment_set.id
action.assignment_set.destroy!
expect(action.reload.valid?).to be true
expect(action.assignment_set_id).to eq set_id
end
describe "self.latest" do
it "should select only the most recent Action for each Set and user_id" do
actions = []
actions << create(:assignment_set_action, student_id: 2, assignment_set: create(:assignment_set))
set = create :assignment_set
actions << create(:assignment_set_action, student_id: 1, assignment_set: set)
actions.concat Array.new(2) { create :assignment_set_action, student_id: 2, assignment_set: set }
actions.last.update_attribute(:created_at, 1.hour.ago)
expect(AssignmentSetAction.latest).to eq actions[0..2]
end
end
describe "self.current_assignments" do
it "should select only actions that have not been unassigned" do
set = create :assignment_set
create(:assignment_set_action, action: 'assign', student_id: 'user', assignment_set: set, created_at: 1.hour.ago)
create(:assignment_set_action, action: 'unassign', student_id: 'user', assignment_set: set)
expect(AssignmentSetAction.current_assignments('user')).to eq []
recent = create(:assignment_set_action, action: 'assign', student_id: 'user', assignment_set: set)
expect(AssignmentSetAction.current_assignments('user')).to eq [recent]
end
it "should select only actions for the specified sets" do
actions = Array.new(3) { create(:assignment_set_action, student_id: 'user') }
selected_sets = actions[1..2].map(&:assignment_set)
expect(AssignmentSetAction.current_assignments('user', selected_sets).order(:id)).to eq actions[1..2]
end
end
describe "self.create_from_sets" do
it 'should create records' do
range = create :scoring_range_with_assignments, assignment_set_count: 4
assigned = range.assignment_sets[0..1]
unassigned = range.assignment_sets[2..3]
audit_opts = { student_id: 'Will', actor_id: 'Sean', source: 'grade_change' }
AssignmentSetAction.create_from_sets(assigned, unassigned, audit_opts)
assigned.each do |s|
set_action = AssignmentSetAction.find_by(audit_opts.merge(assignment_set_id: s.id, action: 'assign'))
expect(set_action).to be_present
end
unassigned.each do |s|
set_action = AssignmentSetAction.find_by(audit_opts.merge(assignment_set_id: s.id, action: 'unassign'))
expect(set_action).to be_present
end
end
end
end
end

View File

@ -0,0 +1,49 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require_relative '../../conditional_release_spec_helper'
require_dependency "conditional_release/assignment_set_association"
module ConditionalRelease
describe AssignmentSetAssociation, :type => :model do
it_behaves_like 'a soft-deletable model'
it 'must have an assignment_id' do
assignment = build :assignment_set_association
assignment.assignment_id = nil
expect(assignment.valid?).to be false
end
it 'enforces unique assignment_id in assignment_set' do
asg = create :assignment_set_association
dup = build :assignment_set_association, assignment_id: asg.assignment_id
asg.assignment_set.assignment_set_associations << dup
expect(dup.valid?).to eq false
expect(dup.errors['assignment_id'].to_s).to match(/taken/)
expect(asg.assignment_set.valid?).to eq false
expect(asg.assignment_set.errors['assignment_set_associations.assignment_id'].to_s).to match(/taken/)
end
it 'enforces not having the same assigment_id as the trigger_assignment of its rule' do
asg = create :assignment_set_association
asg.assignment_id = asg.rule.trigger_assignment_id
expect(asg.valid?).to eq false
expect(asg.errors['assignment_id'].to_s).to match(/trigger/)
end
end
end

View File

@ -0,0 +1,31 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require_relative '../../conditional_release_spec_helper'
require_dependency "conditional_release/assignment_set"
module ConditionalRelease
describe AssignmentSet, :type => :model do
it_behaves_like 'a soft-deletable model'
it 'must have a scoring_range_id' do
assignment_set = build :assignment_set, scoring_range_id: nil
expect(assignment_set.valid?).to eq false
end
end
end

View File

@ -0,0 +1,73 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require_relative '../../spec_helper'
require_dependency "conditional_release/bounds_validations"
module ConditionalRelease
describe BoundsValidations do
class BoundsValidationsSpec
include ActiveModel::Validations
include BoundsValidations
attr_accessor :upper_bound, :lower_bound
end
before(:once) do
@subject = BoundsValidationsSpec.new
end
it 'has to have a bound' do
@subject.upper_bound = @subject.lower_bound = nil
expect(@subject.valid?).to be false
expect(@subject.errors).to include(:base)
end
it 'can have a single lower bound' do
@subject.upper_bound = nil
@subject.lower_bound = 2
expect(@subject.valid?).to be true
end
it 'can have a single upper bound' do
@subject.upper_bound = 10
@subject.lower_bound = nil
expect(@subject.valid?).to be true
end
it 'has to have numbers for bounds' do
@subject.upper_bound = 'foo'
@subject.lower_bound = { bar: :baz }
expect(@subject.valid?).to be false
expect(@subject.errors).to include(:upper_bound, :lower_bound)
end
it 'has to have upper_bound > lower_bound' do
@subject.upper_bound = 10
@subject.lower_bound = 90
expect(@subject.valid?).to be false
expect(@subject.errors).to include(:base)
end
it 'has to have non-negative bounds' do
@subject.upper_bound = -1
@subject.lower_bound = -3
expect(@subject.valid?).to be false
expect(@subject.errors).to include(:upper_bound, :lower_bound)
end
end
end

View File

@ -0,0 +1,102 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require_relative '../../conditional_release_spec_helper'
require_dependency "conditional_release/rule"
module ConditionalRelease
describe Rule, :type => :model do
it_behaves_like 'a soft-deletable model'
describe 'rule definition' do
it 'must have a root account id' do
rule = build :rule
rule.root_account_id = nil
expect(rule.valid?).to be false
rule.root_account_id = ''
expect(rule.valid?).to be false
end
end
describe 'assignment_sets_for_score' do
before :once do
@rule = create :rule
create :scoring_range_with_assignments,
rule: @rule,
lower_bound: 90,
upper_bound: nil,
assignment_set_count: 1
create :scoring_range_with_assignments,
rule: @rule,
lower_bound: 70,
upper_bound: 90,
assignment_set_count: 1
create :scoring_range_with_assignments,
rule: @rule,
lower_bound: 50,
upper_bound: 70,
assignment_set_count: 1
end
it 'must apply all scoring ranges' do
expect(@rule.assignment_sets_for_score(91).length).to eq(1)
# create a range that crosses the ranges above
create :scoring_range_with_assignments,
rule: @rule,
lower_bound: 80,
upper_bound: 95,
assignment_set_count: 2
expect(@rule.assignment_sets_for_score(91).length).to eq(3)
end
it 'must return [] if no assignments match' do
expect(@rule.assignment_sets_for_score(10)).to eq([])
end
it 'must return [] if no scoring ranges are defined' do
rule = create :rule
expect(rule.assignment_sets_for_score(10)).to eq([])
end
end
describe 'with_assignments' do
before do
@rule1 = create :rule_with_scoring_ranges
@rule1.scoring_ranges.last.assignment_sets.destroy_all
@rule2 = create :rule_with_scoring_ranges, assignment_set_count: 0
@rule3 = create :rule_with_scoring_ranges, assignment_count: 0
@rule4 = create :rule_with_scoring_ranges,
scoring_range_count: 1,
assignment_set_count: 1,
assignment_count: 1
@rule5 = create :rule
end
let(:rules) { Rule.with_assignments.to_a }
it 'only returns rules with assignments' do
expect(rules).to match_array [@rule1, @rule4]
end
it 'returns complete rules when assignments are present' do
rule = rules.find{ |r| r.id == @rule1.id }
expect(rule.scoring_ranges.length).to eq @rule1.scoring_ranges.length
end
end
end
end

View File

@ -0,0 +1,94 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require_relative '../../conditional_release_spec_helper'
require_dependency "conditional_release/rule_template"
module ConditionalRelease
describe RuleTemplate, :type => :model do
it_behaves_like 'a soft-deletable model'
describe 'rule template definition' do
before do
@rule_template = build :rule_template
end
it 'cannot have null context_type' do
@rule_template.context_type = nil
expect(@rule_template.valid?).to be false
end
it 'must have a context' do
[Account, Course].each do |valid_klass|
@rule_template.context = valid_klass.create!
expect(@rule_template.valid?).to be true
end
expect {
@rule_template.context = User.create!
}.to raise_error(ActiveRecord::AssociationTypeMismatch)
end
it 'cannot have a null context id' do
@rule_template.context_id = nil
expect(@rule_template.valid?).to be false
end
it 'cannot have a null name' do
@rule_template.name = nil
expect(@rule_template.valid?).to be false
end
it 'cannot have a null root account id' do
@rule_template.root_account_id = nil
expect(@rule_template.valid?).to be false
end
end
describe 'build_rule' do
it 'has the same account id' do
root_account = Account.create!
template = create :rule_template, :root_account_id => root_account.id
rule = template.build_rule
expect(rule.root_account_id).to eq root_account.id
end
it 'has the same number of ranges' do
template = create :rule_template_with_scoring_ranges, scoring_range_template_count: 5
rule = template.build_rule
expect(rule.scoring_ranges.length).to eq 5
end
it 'has the same values for ranges' do
template = create :rule_template_with_scoring_ranges, scoring_range_template_count: 9
rule = template.build_rule
template.scoring_range_templates.each_with_index do |sr_template, i|
range = rule.scoring_ranges[i]
expect(range.upper_bound).to eq sr_template.upper_bound
expect(range.lower_bound).to eq sr_template.lower_bound
end
end
it 'does not save rules or ranges' do
template = create :rule_template_with_scoring_ranges, scoring_range_template_count: 1
rule = template.build_rule
expect(rule.new_record?).to be true
expect(rule.scoring_ranges.first.new_record?).to be true
end
end
end
end

View File

@ -0,0 +1,157 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require_relative '../../conditional_release_spec_helper'
require_dependency "conditional_release/scoring_range"
module ConditionalRelease
describe ScoringRange, :type => :model do
it_behaves_like 'a soft-deletable model'
describe 'scoring range definition' do
it 'must have at least one bound' do
range = build :scoring_range
range.lower_bound = range.upper_bound = nil
expect(range.valid?).to be false
end
it 'must have lower bound less than upper' do
range = build :scoring_range
range.lower_bound = 100
range.upper_bound = 1
expect(range.valid?).to be false
end
it 'can have null bounds' do
range = build :scoring_range
range.lower_bound = nil
range.upper_bound = 100
expect(range.valid?).to be true
range.lower_bound = 99
range.upper_bound = nil
expect(range.valid?).to be true
end
it 'must have non-negative bounds' do
range = build :scoring_range
range.lower_bound = -10
expect(range.valid?).to be false
range = build :scoring_range
range.upper_bound = -10
expect(range.valid?).to be false
end
end
describe 'for_score' do
before do
@rule = create :rule
@range = create :scoring_range, rule: @rule
create :assignment_set_association, scoring_range: @range
end
it 'must return an empty relation when nothing matches' do
expect(ScoringRange.for_score(-10000).count).to eq 0
end
it 'must apply bounds when both assigned' do
@range.upper_bound = 80
@range.lower_bound = 40
@range.save!
expect(@rule.scoring_ranges.for_score(90).count).to eq 0
expect(@rule.scoring_ranges.for_score(50).count).to eq 1
expect(@rule.scoring_ranges.for_score(30).count).to eq 0
end
it 'must apply upper bound as > score' do
@range.upper_bound = 90
@range.save!
expect(@rule.scoring_ranges.for_score(90.001).count).to eq 0
expect(@rule.scoring_ranges.for_score(90).count).to eq 0
expect(@rule.scoring_ranges.for_score(89.999).count).to eq 1
end
it 'must apply lower bound as <= score' do
@range.lower_bound = 40
@range.save!
expect(@rule.scoring_ranges.for_score(40.001).count).to eq 1
expect(@rule.scoring_ranges.for_score(40).count).to eq 1
expect(@rule.scoring_ranges.for_score(39.999).count).to eq 0
end
it 'must apply correctly when only upper bound' do
@range.upper_bound = 20
@range.lower_bound = nil
@range.save!
expect(@rule.scoring_ranges.for_score(-10).count).to eq 1
expect(@rule.scoring_ranges.for_score(10).count).to eq 1
expect(@rule.scoring_ranges.for_score(30).count).to eq 0
end
it 'must apply correctly when only lower bound' do
@range.lower_bound = 10
@range.upper_bound = nil
@range.save!
expect(@rule.scoring_ranges.for_score(-10).count).to eq 0
expect(@rule.scoring_ranges.for_score(20).count).to eq 1
expect(@rule.scoring_ranges.for_score(1000).count).to eq 1
end
end
describe 'contains_score' do
before do
@rule = create :rule
@range = create :scoring_range, rule: @rule
create :assignment_set_association, scoring_range: @range
end
it 'must properly evaluate a bound of 0' do
@range.lower_bound = nil
@range.upper_bound = 0
@range.save!
range2 = create :scoring_range, rule: @rule
range2.lower_bound = 0
range2.upper_bound = 1
range2.save!
expect(@rule.scoring_ranges.first.contains_score(0)).to be false
expect(@rule.scoring_ranges.last.contains_score(0)).to be true
end
it 'must properly evaluate bounds' do
@range.lower_bound = 1
@range.upper_bound = 2
@range.save!
expect(@rule.scoring_ranges.first.contains_score(2)).to be false
expect(@rule.scoring_ranges.last.contains_score(1)).to be true
end
end
describe '#assignment_sets' do
it 'builds a assignment_set if one does not exist' do
range = create :scoring_range_with_assignments, assignment_set_count: 0
expect(AssignmentSet.count).to eq 0
expect(range.assignment_sets.length).to eq 1
end
it 'returns existing assignment_sets' do
range = create :scoring_range_with_assignments, assignment_set_count: 2
expect(range.assignment_sets.length).to eq 2
end
end
end
end

View File

@ -0,0 +1,78 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require_relative '../../conditional_release_spec_helper'
require_dependency "conditional_release/scoring_range_template"
module ConditionalRelease
describe ScoringRangeTemplate, :type => :model do
it_behaves_like 'a soft-deletable model'
describe 'scoring range definition' do
before do
@scoring_range_template = build :scoring_range_template
end
it 'uses bounds validations' do
@scoring_range_template.upper_bound = nil
@scoring_range_template.lower_bound = nil
expect(@scoring_range_template.valid?).to be false
@scoring_range_template.upper_bound = 10
@scoring_range_template.lower_bound = 30
expect(@scoring_range_template.valid?).to be false
end
it 'must have an associated rule template' do
@scoring_range_template.rule_template = nil
expect(@scoring_range_template.valid?).to be false
end
end
describe 'build_scoring_range' do
it 'has the same bounds' do
template = create :scoring_range_template
range = template.build_scoring_range
expect(range.upper_bound).to eq template.upper_bound
expect(range.lower_bound).to eq template.lower_bound
end
it 'works with null bounds' do
template = create :scoring_range_template, upper_bound: nil
range = template.build_scoring_range
expect(range.upper_bound).to be nil
template = create :scoring_range_template, lower_bound: nil
range = template.build_scoring_range
expect(range.lower_bound).to be nil
end
it 'does not assign assignments' do
template = create :scoring_range_template
range = template.build_scoring_range
expect(range.assignment_set_associations.count).to be 0
end
it 'does not save to database' do
template = create :scoring_range_template
range = template.build_scoring_range
expect(range.new_record?).to be true
end
end
end
end