port conditional release APIs

should be all of the API endpoints we'll need
to have to integrate with the UI (both existing
and yet to be ported)

test plan:
* specs pass

closes #LA-1103 #LA-1060

Change-Id: I7850df55174dcb593d1c76d09dc21721aa92e30f
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/239664
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
QA-Review: James Williams <jamesw@instructure.com>
Product-Review: James Williams <jamesw@instructure.com>
This commit is contained in:
James Williams 2020-06-05 08:44:11 -06:00
parent f9328de0ca
commit 900d0e3cf5
18 changed files with 1370 additions and 4 deletions

View File

@ -0,0 +1,75 @@
#
# 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/>.
module ConditionalRelease
module Concerns
module ApiToNestedAttributes
extend ActiveSupport::Concern
##
# Transforms an input hash into a form acceptable to accepts_nested_attributes_for
# and adds destroy requests for any associations not specified.
# * model - ActiveRecord model to update
# model class should have accepts_nested_attributes_for set
# can be nil to transform params for creation
# * model_params - nested hash with indifferent access describing model and its associations
# * association_names - list of association names, one for each level of nesting
# Returns modified model_params
# Usage:
# Parent accepts_nested_attributes_for :children
# Child accepts_nested_attributes_for :grandchildren
# New parent record:
# api_params_to_nested_attributes_params(nil, params, :children, :grandchildren)
# { name: "abe", children: [ { name: "homer", grandchildren: [ { name: "lisa" }, { name: "bart" } ] } ] }
# Needs to be transformed to
# { name: "abe", children_attributes: [ { name: "homer", grandchildren_attributes: [ { name: "lisa" }, { name: "bart" } ] } ] }
# Update parent record:
# api_params_to_nested_attributes_params(abe_record, params, :children, :grandchildren)
# { id: 1, name: "abe", children: [ { id: 2, name: "homer", grandchildren: [ { id: 3, name: "lisa" }, { name: "maggie" } ] } ] }
# Needs to be transformed to
# { id: 1, name: "abe", children_attributes: [ { id: 2, name: "homer", grandchildren_attributes: [ { id: 3, name: "lisa" }, { id: 4, _destroy: true }, { name: "maggie" } ] } ] }
def api_params_to_nested_attributes_params(model, model_params, *association_names)
name, *other_names = *association_names
return model_params unless name
collection = model.send(name) if model
collection_params = model_params.delete(name)
return model_params unless collection_params
# recurse on nested associations
collection_params.each do |association_params|
association_model = collection.find(association_params[:id]) if collection && association_params[:id]
api_params_to_nested_attributes_params(association_model, association_params, *other_names)
end
# add destroy requests for missing associations
if collection
existing_ids = collection.pluck(:id)
updated_ids = collection_params.pluck(:id).map(&:to_i)
ids_to_destroy = existing_ids - updated_ids
ids_to_destroy.each do |id|
collection_params << { id: id, _destroy: true }
end
end
# _attributes to play nice with nested attributes
model_params[ "#{name}_attributes"] = collection_params
model_params
end
end
end
end

View File

@ -0,0 +1,112 @@
#
# 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/>.
module ConditionalRelease
module Concerns
module PermittedApiParameters
extend ActiveSupport::Concern
def rule_params_for_create
params.permit(rule_keys_for_create)
end
def rule_params_for_update
params.permit(rule_keys_for_update)
end
def scoring_range_params_for_create
params.permit(scoring_range_keys_for_create)
end
def scoring_range_params_for_update
params.permit(scoring_range_keys_for_update)
end
def assignment_set_association_params
params.permit(assignment_set_association_keys)
end
private
def base_rule_keys
[ :trigger_assignment_id, :position ]
end
def base_scoring_range_keys
[ :upper_bound, :lower_bound, :position ]
end
def base_assignment_set_association_keys
[ :assignment_id, :position ]
end
# permitted inside nested api requests to allow
# child updates
def nested_update_keys
[ :id, :position ]
end
def rule_keys_for_create
base_rule_keys.concat [
scoring_ranges: scoring_range_keys_for_create
]
end
def rule_keys_for_update
base_rule_keys.concat [
scoring_ranges: scoring_range_keys_for_nested_update
]
end
def scoring_range_keys_for_create
base_scoring_range_keys.concat [
assignment_sets: assignment_set_keys_for_create
]
end
def scoring_range_keys_for_update
base_scoring_range_keys.concat [
assignment_sets: assignment_set_keys_for_nested_update
]
end
def scoring_range_keys_for_nested_update
scoring_range_keys_for_update | nested_update_keys
end
def assignment_set_association_keys
base_assignment_set_association_keys
end
def assignment_set_association_keys_for_nested_update
assignment_set_association_keys | nested_update_keys
end
def assignment_set_keys_for_create
[
assignment_set_associations: assignment_set_association_keys
]
end
def assignment_set_keys_for_nested_update
nested_update_keys.concat [
assignment_set_associations: assignment_set_association_keys_for_nested_update
]
end
end
end
end

View File

@ -0,0 +1,144 @@
#
# 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/>.
module ConditionalRelease
class RulesController < ApplicationController
include Concerns::PermittedApiParameters
include Concerns::ApiToNestedAttributes
before_action :get_context, :require_user
before_action :require_course_assignment_edit_permissions, only: [ :create, :update, :destroy ]
before_action :require_course_view_permissions
# GET /api/rules
def index
rules = get_rules
rules = rules.preload(Rule.all_includes) if include_param.include?('all')
rules = rules.with_assignments if value_to_boolean(params[:active])
render json: rules.as_json(include: json_includes, include_root: false)
end
# GET /api/rules/:id
def show
rule = get_rule
render json: rule.as_json(include: json_includes, include_root: false)
end
# POST /api/rules
def create
create_params = api_params_to_nested_attributes_params(
nil,
rule_params_for_create,
:scoring_ranges,
:assignment_sets,
:assignment_set_associations
).merge(course: @context)
rule = Rule.new(create_params)
if rule.save
render json: rule.as_json(include: all_includes, include_root: false)
else
render json: rule.errors, status: :bad_request
end
end
# POST/PUT /api/rules/:id(.:format)
def update
rule = get_rule
ordered_params = add_ordering_to rule_params_for_update
update_params = api_params_to_nested_attributes_params(
rule,
ordered_params,
:scoring_ranges,
:assignment_sets,
:assignment_set_associations
)
if rule.update(update_params)
render json: rule.as_json(include: all_includes, include_root: false)
else
render json: rule.errors, status: :bad_request
end
end
# DELETE /api/rules/:id
def destroy
rule = get_rule
rule.destroy!
render json: {:success => true}
end
private
def get_rules
rules = @context.conditional_release_rules.active
rules = rules.where(trigger_assignment_id: params[:trigger_assignment]) unless params[:trigger_assignment].blank?
rules
end
def get_rule
@context.conditional_release_rules.active.find(params[:id])
end
def include_param
Array.wrap(params[:include])
end
def all_includes
{ scoring_ranges: { include: { assignment_sets: { include: :assignment_set_associations } } } }
end
def json_includes
return all_includes if include_param.include? 'all'
end
def add_ordering_to(attrs)
# Loop through each of the ranges, ordering them
arrange_items(attrs[:scoring_ranges]) do |range|
# Then through each of the sets, ordering them within the context
# of the range
arrange_items(range[:assignment_sets]) do |set|
# Then the assignments, in the context of the assignment set within
# the range
arrange_items(set[:assignment_set_associations])
end
end
attrs
end
def arrange_items(items, &_block)
if items.present?
items.map.with_index(1) do |item, position|
item[:position] = position if item.present?
yield item if block_given?
end
end
end
def require_course_view_permissions
return render_unauthorized_action unless @context.grants_right?(@current_user, :read)
end
def require_course_assignment_edit_permissions
return render_unauthorized_action unless @context.grants_right?(@current_user, :manage_assignments)
end
end
end

View File

@ -0,0 +1,53 @@
#
# 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/>.
module ConditionalRelease
class StatsController < ApplicationController
before_action :get_context, :require_user, :require_course_grade_view_permissions, :require_trigger_assignment
def students_per_range
rule = get_rule
include_trend_data = Array.wrap(params[:include]).include? 'trends'
render json: Stats.students_per_range(rule, include_trend_data)
end
def student_details
rule = get_rule
student_id = params[:student_id]
unless student_id.present?
return render :json => {:message => "student_id required"}, :status => :bad_request
end
render json: Stats.student_details(rule, student_id)
end
private
def get_rule
@context.conditional_release_rules.active.where(:trigger_assignment_id => params[:trigger_assignment]).take!
end
def require_course_grade_view_permissions
return render_unauthorized_action unless @context.grants_right?(@current_user, :view_all_grades)
end
def require_trigger_assignment
unless params[:trigger_assignment].present?
return render :json => {:message => "trigger_assignment required"}, :status => :bad_request
end
end
end
end

View File

@ -24,6 +24,12 @@ module ConditionalRelease
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
belongs_to :root_account, :class_name => "Account"
before_create :set_root_account_id
def set_root_account_id
self.root_account_id ||= scoring_range.root_account_id
end
def self.collect_associations(sets)
sets.map(&:assignment_set_associations).flatten.sort_by(&:id).uniq(&:assignment_id)

View File

@ -25,6 +25,12 @@ module ConditionalRelease
validates :actor_id, presence: true
validates :assignment_set_id, presence: true
belongs_to :assignment_set
belongs_to :root_account, :class_name => "Account"
before_create :set_root_account_id
def set_root_account_id
self.root_account_id ||= assignment_set.root_account_id
end
scope :latest, -> {
select('DISTINCT ON (assignment_set_id, student_id) id').

View File

@ -22,6 +22,7 @@ module ConditionalRelease
validates :assignment_id, presence: true
validates :assignment_id, uniqueness: { scope: :assignment_set_id, conditions: -> { active } }
validate :not_trigger
validate :assignment_in_same_course
acts_as_list :scope => {:assignment_set => self, :deleted_at => nil}
@ -29,14 +30,29 @@ module ConditionalRelease
belongs_to :assignment
has_one :scoring_range, through: :assignment_set
has_one :rule, through: :assignment_set
belongs_to :root_account, :class_name => "Account"
before_create :set_root_account_id
def set_root_account_id
self.root_account_id ||= assignment_set.root_account_id
end
delegate :course_id, to: :rule, allow_nil: true
private
def not_trigger
if rule && assignment_id == rule.trigger_assignment_id
r = self.rule || self.assignment_set.scoring_range.rule # may not be saved yet
if assignment_id == r.trigger_assignment_id
errors.add(:assignment_id, "can't match rule trigger_assignment_id")
end
end
def assignment_in_same_course
r = self.rule || self.assignment_set.scoring_range.rule # may not be saved yet
if assignment_id_changed? && assignment&.context_id != r.course_id
errors.add(:assignment_id, "invalid assignment")
end
end
end
end

View File

@ -28,7 +28,7 @@ module ConditionalRelease
def destroy
return true if deleted_at.present?
self.deleted_at = Time.now.utc
run_callbacks(:destroy) { save! }
run_callbacks(:destroy) { save(validate: false) }
end
def restore

View File

@ -21,14 +21,27 @@ module ConditionalRelease
validates :course_id, presence: true
validates :trigger_assignment_id, presence: true
validate :trigger_assignment_in_same_course
belongs_to :trigger_assignment, :class_name => "Assignment"
belongs_to :course
belongs_to :root_account, :class_name => "Account"
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
before_create :set_root_account_id
def set_root_account_id
self.root_account_id ||= course.root_account_id
end
def trigger_assignment_in_same_course
if trigger_assignment_id_changed? && trigger_assignment&.context_id != course_id
errors.add(:trigger_assignment_id, "invalid trigger assignment")
end
end
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))

View File

@ -21,10 +21,16 @@ module ConditionalRelease
include Deletion
belongs_to :rule, required: true
belongs_to :root_account, :class_name => "Account"
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
before_create :set_root_account_id
def set_root_account_id
self.root_account_id ||= rule.root_account_id
end
delegate :course_id, to: :rule
acts_as_list :scope => {:rule => self, :deleted_at => nil}

View File

@ -0,0 +1,151 @@
#
# 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/>.
module ConditionalRelease
module Stats
class << self
def students_per_range(rule, include_trend_data = false)
assignment_ids = [ rule.trigger_assignment_id ]
assignment_ids += rule.assignment_set_associations.pluck(:assignment_id) if include_trend_data
sub_attrs = [:id, :user_id, :assignment_id, :score]
all_submission_data = rule.course.submissions.where(:assignment_id => assignment_ids).
pluck(*sub_attrs).map{|r| sub_attrs.zip(r).to_h}.sort_by{|s| s[:user_id]} # turns plucked rows into hashes
assignments_by_id = rule.course.assignments.where(:id => assignment_ids).to_a.index_by(&:id)
users_by_id = User.where(:id => all_submission_data.map{|s| s[:user_id]}.uniq).to_a.index_by(&:id)
trigger_submissions = all_submission_data.select{|s| s[:assignment_id] == rule.trigger_assignment_id}
# { user_id => [Submission] }
follow_on_submissions_hash = {}
if include_trend_data
student_ids = trigger_submissions.map{|s| s[:user_id]}
all_previous_assignment_ids = AssignmentSetAction.current_assignments(student_ids, rule.assignment_sets).
preload(assignment_set: :assignment_set_associations).
each_with_object({}) { |action, acc| acc[action.student_id] = action.assignment_set.assignment_set_associations.map(&:assignment_id) }
student_ids.each do |student_id|
previous_assignment_ids = all_previous_assignment_ids[student_id]
follow_on_submissions_hash[student_id] = previous_assignment_ids ?
all_submission_data.select{|s| s[:user_id] == student_id && previous_assignment_ids.include?(s[:assignment_id])} : []
end
end
ranges = rule.scoring_ranges.map { |sr| { scoring_range: sr, size: 0, students: [] } }
trigger_submissions.each do |submission|
next unless submission
user_id = submission[:user_id]
raw_score = submission[:score]
assignment = assignments_by_id[submission[:assignment_id]]
score = percent_from_points(raw_score, assignment.points_possible)
next unless score
user = users_by_id[user_id]
user_details = nil
ranges.each do |b|
if b[:scoring_range].contains_score score
user_details ||= assignment.anonymize_students? ?
{:name => t('Anonymous User')} :
{
:id => user.id,
:name => user.short_name,
:avatar_image_url => AvatarHelper.avatar_url_for_user(user, nil, root_account: rule.root_account)
}
student_record = {
score: score,
submission_id: submission[:id],
user: user_details
}
if include_trend_data
student_record[:trend] = compute_trend_from_submissions(score, follow_on_submissions_hash[user_id], assignments_by_id)
end
b[:size] += 1
b[:students] << student_record
end
end
end
{ rule: rule, ranges: ranges, enrolled: users_by_id.count }
end
def student_details(rule, student_id)
previous_assignment = AssignmentSetAction.current_assignments(student_id, rule.assignment_sets).take
follow_on_assignment_ids = previous_assignment ?
previous_assignment.assignment_set.assignment_set_associations.pluck(:assignment_id) :
[]
possible_assignment_ids = follow_on_assignment_ids + [rule.trigger_assignment_id]
submissions_by_assignment_id = rule.course.submissions.where(:assignment_id => possible_assignment_ids, :user_id => student_id).to_a.index_by(&:assignment_id)
assignments_by_id = rule.course.assignments.where(:id => possible_assignment_ids).to_a.index_by(&:id)
trigger_assignment = assignments_by_id[rule.trigger_assignment_id]
trigger_submission = submissions_by_assignment_id[rule.trigger_assignment_id]
trigger_points = trigger_submission.score if trigger_submission
trigger_points_possible = trigger_assignment.points_possible if trigger_assignment
trigger_score = percent_from_points(trigger_points, trigger_points_possible)
{
rule: rule,
trigger_assignment: assignment_detail(trigger_assignment, trigger_submission),
follow_on_assignments: follow_on_assignment_ids.map do |id|
assignment_detail(
assignments_by_id[id],
submissions_by_assignment_id[id],
trend_score: trigger_score
)
end
}
end
private
def assignment_detail(assignment, submission, trend_score: nil)
score = submission ? percent_from_points(submission.score, assignment.points_possible) : nil
detail = {
assignment: {id: assignment.id, name: assignment.title, submission_types: assignment.submission_types},
submission: {id: submission.id, score: submission.score, grade: submission.grade, submitted_at: submission.submitted_at},
score: score
}
detail[:trend] = compute_trend(trend_score, score) if trend_score
detail
end
def compute_trend(old_score, new_score_or_scores)
new_scores = Array.wrap(new_score_or_scores).compact
return unless old_score && new_scores.present?
average = new_scores.reduce(&:+) / new_scores.length
percentage_points_improvement = average - old_score
return 1 if percentage_points_improvement >= 0.03
return -1 if percentage_points_improvement <= -0.03
0
end
def compute_trend_from_submissions(score, submissions, assignments_by_id)
return unless submissions.present?
new_scores = submissions.map do |s|
percent_from_points(s[:score], assignments_by_id[s[:assignment_id]].points_possible)
end
compute_trend(score, new_scores)
end
def percent_from_points(points, points_possible)
return points.to_f / points_possible.to_f if points.present? && points_possible.to_f.nonzero?
return points.to_f / 100 if points.present? # mirror Canvas rule
end
end
end
end

View File

@ -215,6 +215,9 @@ class Course < ActiveRecord::Base
class_name: "Auditors::ActiveRecord::GradeChangeRecord",
dependent: :destroy
has_many :conditional_release_rules, inverse_of: :course, class_name: "ConditionalRelease::Rule", dependent: :destroy
prepend Profile::Association
before_save :assign_uuid

View File

@ -2259,6 +2259,20 @@ CanvasRails::Application.routes.draw do
put 'media_objects/:media_object_id/media_tracks', action: 'update', as: :update_media_tracks
end
scope(controller: 'conditional_release/rules') do
# TODO: can rearrange so assignment is in path if desired once we're no longer maintaining backwards compat
get 'courses/:course_id/mastery_paths/rules', action: 'index'
get 'courses/:course_id/mastery_paths/rules/:id', action: 'show'
post 'courses/:course_id/mastery_paths/rules', action: 'create'
put 'courses/:course_id/mastery_paths/rules/:id', action: 'update'
delete 'courses/:course_id/mastery_paths/rules/:id', action: 'destroy'
end
scope(controller: 'conditional_release/stats') do
# TODO: can rearrange so assignment is in path if desired once we're no longer maintaining backwards compat
get 'courses/:course_id/mastery_paths/stats/students_per_range', action: 'students_per_range'
get 'courses/:course_id/mastery_paths/stats/student_details', action: 'student_details'
end
end
# this is not a "normal" api endpoint in the sense that it is not documented or

View File

@ -0,0 +1,402 @@
#
# 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_relative '../../api_spec_helper'
require_dependency "conditional_release/rules_controller"
module ConditionalRelease
describe RulesController, type: :request do
before(:once) do
course_with_teacher(:active_all => true)
@user = @teacher
@assignment = @course.assignments.create!(:title => "an assignment")
end
def verify_positions_for(rule)
rule.scoring_ranges.each.with_index(1) do |range, range_idx|
expect(range.position).to be range_idx
range.assignment_sets.each.with_index(1) do |set, set_idx|
expect(set.position).to be set_idx
set.assignment_set_associations.each.with_index(1) do |asg, asg_idx|
expect(asg.position).to be asg_idx
end
end
end
end
describe 'GET index' do
before(:once) do
create :rule_with_scoring_ranges,
course: @course,
trigger_assignment: @assignment
create :rule_with_scoring_ranges,
course: @course,
trigger_assignment: @assignment,
assignment_count: 0
create :rule_with_scoring_ranges, course: @course
other_course = Course.create!
create :rule_with_scoring_ranges, course: other_course
@url = "/api/v1/courses/#{@course.id}/mastery_paths/rules"
@base_params = {
:controller => 'conditional_release/rules',
:action => 'index',
:format => 'json',
:course_id => @course.id.to_s,
}
end
it 'requires authorization' do
@user = user_factory
api_call(:get, @url, @base_params, {}, {}, {:expected_status => 401})
end
it 'returns all rules for a course' do
json = api_call(:get, @url, @base_params, {}, {}, {:expected_status => 200})
expect(json.length).to eq 3
end
it 'allows students to view' do
student_in_course(:course => @course, :active_all => true)
json = api_call(:get, @url, @base_params, {}, {}, {:expected_status => 200})
expect(json.length).to eq 3
end
it 'filters based on assignment id' do
json = api_call(:get, @url, @base_params.merge(trigger_assignment: @assignment.id), {}, {}, {:expected_status => 200})
expect(json.length).to eq 2
end
it 'does not include scoring ranges by default' do
json = api_call(:get, @url, @base_params, {}, {}, {:expected_status => 200})
expect(json[0]).not_to have_key 'scoring_ranges'
end
it 'includes scoring ranges and assignments when requested' do
json = api_call(:get, @url, @base_params.merge(include: ['all']), {}, {}, {:expected_status => 200})
ranges_json = json[0]['scoring_ranges']
expect(ranges_json.length).to eq(2)
sets_json = ranges_json.last['assignment_sets']
expect(sets_json.length).to eq(1)
expect(sets_json.last['assignment_set_associations'].length).to eq(2)
end
it 'includes only rules with assignments when "active" requested' do
json = api_call(:get, @url, @base_params.merge(active: true), {}, {}, {:expected_status => 200})
expect(json.length).to eq(2)
end
end
describe 'GET show' do
before :once do
@rule = create :rule_with_scoring_ranges,
course: @course,
scoring_range_count: 2,
assignment_set_count: 2,
assignment_count: 3
@url = "/api/v1/courses/#{@course.id}/mastery_paths/rules/#{@rule.id}"
@base_params = {
:controller => 'conditional_release/rules',
:action => 'show',
:format => 'json',
:course_id => @course.id.to_s,
:id => @rule.id.to_s
}
end
it 'fails for deleted rule' do
@rule.destroy
api_call(:get, @url, @base_params, {}, {}, {:expected_status => 404})
end
it 'does not show scoring ranges by default' do
json = api_call(:get, @url, @base_params, {}, {}, {:expected_status => 200})
expect(json['scoring_ranges']).to be_nil
end
it 'does show assignments when asked' do
json = api_call(:get, @url, @base_params.merge(include: ['all']), {}, {}, {:expected_status => 200})
ranges_json = json['scoring_ranges']
expect(ranges_json.length).to eq(2)
sets_json = ranges_json.last['assignment_sets']
expect(sets_json.length).to eq(2)
expect(sets_json.last['assignment_set_associations'].length).to eq(3)
end
it 'shows assignments in order' do
first_assoc = @rule.scoring_ranges.first.assignment_sets.first.assignment_set_associations.first
last_assoc = @rule.scoring_ranges.last.assignment_sets.last.assignment_set_associations.last
first_assoc.move_to_bottom
last_assoc.move_to_top
json = api_call(:get, @url, @base_params.merge(include: ['all']), {}, {}, {:expected_status => 200})
expect(json['scoring_ranges'].last['assignment_sets'].last['assignment_set_associations'].first['id']).to eq last_assoc.id
expect(json['scoring_ranges'].first['assignment_sets'].first['assignment_set_associations'].last['id']).to eq first_assoc.id
ranges_json = json['scoring_ranges']
ranges_json.each.with_index(1) do |range, range_idx|
expect(range['position']).to eq(range_idx)
range['assignment_sets'].each.with_index(1) do |set, set_idx|
expect(set['position']).to eq(set_idx)
set['assignment_set_associations'].each.with_index(1) do |asg, asg_idx|
expect(asg['position']).to eq(asg_idx)
end
end
end
end
end
describe 'POST create' do
before :once do
@url = "/api/v1/courses/#{@course.id}/mastery_paths/rules"
@base_params = {
:controller => 'conditional_release/rules',
:action => 'create',
:format => 'json',
:course_id => @course.id.to_s,
:trigger_assignment_id => @assignment.id
}
end
it 'requires management rights' do
student_in_course(:course => @course)
@user = @student
api_call(:post, @url, @base_params, {}, {}, {:expected_status => 401})
end
it 'creates successfully' do
json = api_call(:post, @url, @base_params, {}, {}, {:expected_status => 200})
rule = @course.conditional_release_rules.find(json['id'])
expect(rule.trigger_assignment).to eq @assignment
end
it 'creates with scoring range and assignments' do
ranges = Array.new(3) do |range_pos|
assignment_sets = Array.new(2) do |set_pos|
associations = Array.new(3) do |assoc_pos|
assignment = @course.assignments.create!
{'position' => assoc_pos + 1, 'assignment_id' => assignment.id}
end
{'position' => set_pos + 1, 'assignment_set_associations' => associations}
end
{'position' => range_pos + 1, 'lower_bound' => 65, 'upper_bound' => 95, 'assignment_sets' => assignment_sets}
end
json = api_call(:post, @url, @base_params.merge('scoring_ranges' => ranges), {}, {}, {:expected_status => 200})
rule = @course.conditional_release_rules.find(json['id'])
expect(rule.scoring_ranges.length).to eq(3)
expect(rule.scoring_ranges.last.assignment_sets.length).to eq(2)
expect(rule.scoring_ranges.last.assignment_sets.last.assignment_set_associations.length).to eq(3)
expect(rule.assignment_set_associations.length).to eq(18)
verify_positions_for rule
end
it 'does not create with invalid scoring range' do
expect {
api_call(:post, @url, @base_params.merge('scoring_ranges' => [{foo: 3}]), {}, {}, {:expected_status => 400})
}.not_to change { Rule.count }
end
it 'does not create with invalid assignment' do
sr = {'lower_bound' => 65, 'upper_bound' => 95}
sr['assignment_sets'] = [ { assignment_set_associations: [ { foo: 3 } ] } ]
expect {
api_call(:post, @url, @base_params.merge('scoring_ranges' => [sr]), {}, {}, {:expected_status => 400})
}.not_to change { Rule.count }
end
it 'does not create with trigger assignment in other course' do
other_course = Course.create!
other_assignment = other_course.assignments.create!
expect {
api_call(:post, @url, @base_params.merge(:trigger_assignment_id => other_assignment.id), {}, {}, {:expected_status => 400})
}.not_to change { Rule.count }
end
it 'does not create with assignment in other course' do
other_course = Course.create!
other_assignment = other_course.assignments.create!
sr = {'lower_bound' => 65, 'upper_bound' => 95}
sr['assignment_sets'] = [ { assignment_set_associations: [ { assignment_id: other_assignment.id } ] } ]
expect {
api_call(:post, @url, @base_params.merge('scoring_ranges' => [sr]), {}, {}, {:expected_status => 400})
}.not_to change { Rule.count }
end
end
describe 'PUT update' do
before :once do
@rule = create :rule, course: @course, trigger_assignment: @assignment
@url = "/api/v1/courses/#{@course.id}/mastery_paths/rules/#{@rule.id}"
@other_assignment = @course.assignments.create!
@base_params = {
:controller => 'conditional_release/rules',
:action => 'update',
:format => 'json',
:course_id => @course.id.to_s,
:id => @rule.id.to_s,
:trigger_assignment_id => @other_assignment.id
}
end
it 'requires management rights' do
student_in_course(:course => @course)
@user = @student
api_call(:put, @url, @base_params, {}, {}, {:expected_status => 401})
end
it 'fails for deleted rule' do
@rule.destroy
api_call(:put, @url, @base_params, {}, {}, {:expected_status => 404})
end
it 'updates the trigger_assignment' do
json = api_call(:put, @url, @base_params, {}, {}, {:expected_status => 200})
expect(json['trigger_assignment_id']).to eq @other_assignment.id
expect(@rule.reload.trigger_assignment).to eq @other_assignment
end
it 'does not allow invalid rule' do
api_call(:put, @url, @base_params.merge(:trigger_assignment_id => 'doh'), {}, {}, {:expected_status => 400})
expect(@rule.reload.trigger_assignment).to eq @assignment
end
it 'updates with scoring ranges' do
rule = create :rule_with_scoring_ranges,
course: @course,
scoring_range_count: 2,
assignment_count: 3
range = rule.scoring_ranges[0]
range.upper_bound = 99
rule_params = rule.as_json(include: :scoring_ranges, include_root: false)
api_call(:put, "/api/v1/courses/#{@course.id}/mastery_paths/rules/#{rule.id}",
@base_params.with_indifferent_access.merge(rule_params), {}, {}, {:expected_status => 200})
rule.reload
range.reload
expect(rule.scoring_ranges.count).to eq(2) # didn't add ranges
expect(rule.scoring_ranges.include?(range)).to be true
expect(range.upper_bound).to eq(99)
expect(range.assignment_set_associations.count).to eq(3) # didn't delete assignments when not specified
end
it 'updates removes scoring ranges' do
rule = create :rule_with_scoring_ranges,
course: @course,
scoring_range_count: 2
rule_params = rule.as_json(include: :scoring_ranges, include_root: false)
rule_params['scoring_ranges'].shift
api_call(:put, "/api/v1/courses/#{@course.id}/mastery_paths/rules/#{rule.id}",
@base_params.with_indifferent_access.merge(rule_params), {}, {}, {:expected_status => 200})
rule.reload
expect(rule.scoring_ranges.count).to be(1)
end
it 'updates with assignments in order' do
rule = create :rule_with_scoring_ranges,
course: @course,
scoring_range_count: 2,
assignment_set_count: 2,
assignment_count: 1
rule_params = rule.as_json(include: { scoring_ranges: {include: { assignment_sets: {include: :assignment_set_associations} }} }, include_root: false)
changed_assignment = @course.assignments.create!
rule_params['scoring_ranges'][1]['assignment_sets'][0]['assignment_set_associations'][0]['assignment_id'] = changed_assignment.id
# replace one assignment with another
deleted_assignment_id = rule_params['scoring_ranges'][0]['assignment_sets'][0]['assignment_set_associations'][0]['assignment_id']
new_assignment = @course.assignments.create!
rule_params['scoring_ranges'][0]['assignment_sets'][0]['assignment_set_associations'] = [ {assignment_id: new_assignment.id} ]
api_call(:put, "/api/v1/courses/#{@course.id}/mastery_paths/rules/#{rule.id}",
@base_params.with_indifferent_access.merge(rule_params), {}, {}, {:expected_status => 200})
rule.reload
changed_assoc = rule.assignment_set_associations.where(:assignment_id => changed_assignment.id).take
expect(changed_assoc).not_to be nil
new_assoc = rule.assignment_set_associations.where(:assignment_id => new_assignment.id).take
expect(new_assoc).not_to be nil
deleted_assoc = rule.assignment_set_associations.where(:assignment_id => deleted_assignment_id).take
expect(deleted_assoc).to be nil
expect(rule.assignment_set_associations.count).to be 4
verify_positions_for rule
end
it 'updates with assignments in rearranged order' do
rule = create :rule_with_scoring_ranges,
course: @course,
scoring_range_count: 1,
assignment_set_count: 1,
assignment_count: 3
rule_params = rule.as_json(include_root: false, include: { scoring_ranges: {include: { assignment_sets: {include: :assignment_set_associations} }} })
assignments = rule_params['scoring_ranges'][0]['assignment_sets'][0]['assignment_set_associations']
# Rearrange them
assignments[0], assignments[1], assignments[2] = assignments[2], assignments[0], assignments[1]
api_call(:put, "/api/v1/courses/#{@course.id}/mastery_paths/rules/#{rule.id}",
@base_params.with_indifferent_access.merge(rule_params), {}, {}, {:expected_status => 200})
# Refresh the Rule and make sure no assignments were added
rule.reload
expect(rule.assignment_set_associations.count).to be 3
# Check that the rules have been sorted to match the order received
expect(rule.assignment_set_associations.pluck(:id)).to eq assignments.map { |asg| asg['id'] }
# And that their positions are correctly updated
verify_positions_for rule
end
end
describe 'DELETE destroy' do
before :once do
@rule = create :rule, course: @course
@url = "/api/v1/courses/#{@course.id}/mastery_paths/rules/#{@rule.id}"
@base_params = {
:controller => 'conditional_release/rules',
:action => 'destroy',
:format => 'json',
:course_id => @course.id.to_s,
:id => @rule.id.to_s
}
end
it 'requires management rights' do
student_in_course(:course => @course)
@user = @student
api_call(:delete, @url, @base_params, {}, {}, {:expected_status => 401})
end
it 'deletes a rule' do
api_call(:delete, @url, @base_params, {}, {}, {:expected_status => 200})
expect(@rule.reload.deleted_at).to be_present
expect(Rule.active.where(:id => @rule.id).exists?).to eq false
end
it 'fails for non-existent rule' do
@rule.destroy
api_call(:delete, @url, @base_params, {}, {}, {:expected_status => 404})
end
end
end
end

View File

@ -0,0 +1,111 @@
#
# 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_relative '../../api_spec_helper'
require_dependency "conditional_release/stats_controller"
module ConditionalRelease
describe StatsController, type: :request do
before(:once) do
course_with_teacher(:active_all => true)
end
before do
user_session(@teacher)
end
context 'rules stats' do
before(:once) do
@rule = create :rule, course: @course
end
describe 'GET students_per_range' do
before :once do
@url = "/api/v1/courses/#{@course.id}/mastery_paths/stats/students_per_range"
@base_params = {
:controller => 'conditional_release/stats',
:action => 'students_per_range',
:format => 'json',
:course_id => @course.id.to_s,
:trigger_assignment => @rule.trigger_assignment_id,
}
end
it 'requires grade viewing rights' do
student_in_course(:course => @course, :active_all => true)
api_call(:get, @url, @base_params, {}, {}, {:expected_status => 401})
end
it 'shows stats for export' do
expect(Stats).to receive(:students_per_range).with(@rule, false).and_return [0,1,2]
json = api_call(:get, @url, @base_params, {}, {}, {:expected_status => 200})
expect(json).to eq [0,1,2]
end
it 'includes trend if requested' do
expect(Stats).to receive(:students_per_range).with(@rule, true).and_return [0,1,2]
json = api_call(:get, @url, @base_params.merge(:include => 'trends'), {}, {}, {:expected_status => 200})
expect(json).to eq [0,1,2]
end
it 'requires trigger_assignment' do
json = api_call(:get, @url, @base_params.except(:trigger_assignment), {}, {}, {:expected_status => 400})
expect(json['message']).to eq "trigger_assignment required"
end
end
describe 'GET student_details' do
before :once do
@url = "/api/v1/courses/#{@course.id}/mastery_paths/stats/student_details"
student_in_course(:course => @course, :active_all => true)
@user = @teacher
@base_params = {
:controller => 'conditional_release/stats',
:action => 'student_details',
:format => 'json',
:course_id => @course.id.to_s,
:trigger_assignment => @rule.trigger_assignment_id,
:student_id => @student.id
}
end
it 'requires grade viewing rights' do
@user = @student
api_call(:get, @url, @base_params, {}, {}, {:expected_status => 401})
end
it 'requires a student id' do
json = api_call(:get, @url, @base_params.except(:student_id), {}, {}, {:expected_status => 400})
expect(json['message']).to eq "student_id required"
end
it 'requires trigger_assignment' do
json = api_call(:get, @url, @base_params.except(:trigger_assignment), {}, {}, {:expected_status => 400})
expect(json['message']).to eq "trigger_assignment required"
end
it 'calls into stats' do
expect(Stats).to receive(:student_details).with(@rule, @student.id.to_s).and_return([1,2,3])
json = api_call(:get, @url, @base_params, {}, {}, {:expected_status => 200})
expect(json).to eq [1,2,3]
end
end
end
end
end

View File

@ -18,7 +18,10 @@
FactoryBot.define do
factory :assignment_set_association, class: ConditionalRelease::AssignmentSetAssociation do
association :assignment_set
assignment
root_account_id { Account.default.id }
before(:create) do |assmt_set_assoc, _evaluator|
assmt_set_assoc.assignment ||= assmt_set_assoc.assignment_set.scoring_range.rule.course.assignments.create!
end
end
end

View File

@ -21,7 +21,10 @@ FactoryBot.define do
factory :rule, class: ConditionalRelease::Rule do
root_account_id { Account.default.id }
course :factory => :course
trigger_assignment :factory => :assignment
before(:create) do |rule, _evaluator|
rule.trigger_assignment ||= rule.course.assignments.create!
end
factory :rule_with_scoring_ranges do
transient do

View File

@ -0,0 +1,248 @@
#
# 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/stats"
module ConditionalRelease
describe Stats do
before :once do
@course = course_factory(:active_all => true)
@students = n_students_in_course(4, :course => @course)
@rule = create :rule, course: @course
@sr1 = create :scoring_range_with_assignments, rule: @rule, upper_bound: nil, lower_bound: 0.7, assignment_set_count: 2, assignment_count: 5
@sr2 = create :scoring_range_with_assignments, rule: @rule, upper_bound: 0.7, lower_bound: 0.4, assignment_set_count: 2, assignment_count: 5
@sr3 = create :scoring_range_with_assignments, rule: @rule, upper_bound: 0.4, lower_bound: nil, assignment_set_count: 2, assignment_count: 5
@as1 = @sr1.assignment_sets.first
@as2 = @sr2.assignment_sets.first
@trigger = @rule.trigger_assignment
@a1, @a2, @a3, @a4, @a5 = @as1.assignment_set_associations.to_a.map(&:assignment)
@b1, @b2, @b3, @b4, @b5 = @as2.assignment_set_associations.to_a.map(&:assignment)
end
def expected_assignment_set(user_ids, assignment_set)
user_ids.each do |id|
AssignmentSetAction.create_from_sets(assignment_set, [], student_id: id, action: 'assign', source: 'select_assignment_set')
end
end
describe 'students_per_range' do
# turning the student ids from 1, 2, 3, etc to real user ids
def get_student(idx)
@students[idx - 1]
end
def get_student_ids(indexes)
indexes.map{|idx| get_student(idx).id}
end
# admittedly this is terrible but rewriting every spec would be too so just stuff the formerly "mock" data into the db
def set_user_submissions(user_idx, user_name, submissions)
student = get_student(user_idx)
student.update_attribute(:short_name, user_name)
submissions.map do |data|
assignment, score, points_possible = data
Assignment.where(:id => assignment).update_all(:points_possible => points_possible)
Submission.where(:assignment_id => assignment, :user_id => student).update_all(:score => score)
end
end
def set_trigger_submissions
set_user_submissions(1, 'foo', [[@trigger, 10, 100]])
set_user_submissions(2, 'bar', [[@trigger, 20, 100]])
set_user_submissions(3, 'baz', [[@trigger, 50, 100]])
set_user_submissions(4, 'bat', [])
end
it 'sums up assignments' do
set_trigger_submissions
rollup = Stats.students_per_range(@rule, false).with_indifferent_access
expect(rollup[:enrolled]).to eq 4
expect(rollup[:ranges][0][:size]).to eq 0
expect(rollup[:ranges][1][:size]).to eq 1
expect(rollup[:ranges][2][:size]).to eq 2
expect(rollup[:ranges][2][:students].map {|s| s[:user][:id]}).to match_array get_student_ids([1, 2])
end
it 'does not include trend data' do
set_trigger_submissions
rollup = Stats.students_per_range(@rule, false).with_indifferent_access
expect(rollup.dig(:ranges, 2, :students, 0)).not_to have_key 'trend'
end
it 'treats 0 points possible as /100' do
set_trigger_submissions
@trigger.update_attribute(:points_possible, 0)
rollup = Stats.students_per_range(@rule, false).with_indifferent_access
expect(rollup[:enrolled]).to eq 4
expect(rollup[:ranges][0][:size]).to eq 0
expect(rollup[:ranges][1][:size]).to eq 1
expect(rollup[:ranges][2][:size]).to eq 2
end
context 'with trend data' do
let(:trends) { @rollup.dig(:ranges, 0, :students).map{ |s| s[:trend] } }
it 'has trend == nil if no follow on assignments have been completed' do
set_user_submissions(1, 'foo', [[@trigger, 32, 40]])
rollup = Stats.students_per_range(@rule, true).with_indifferent_access
expect(rollup.dig(:ranges, 0, :students, 0)).to have_key 'trend'
expect(rollup.dig(:ranges, 0, :students, 0, :trend)).to eq nil
end
it 'returns the correct trend for a single follow on assignment' do
set_user_submissions(1, 'foo', [[@trigger, 30, 40], [@a1, 4, 4]])
set_user_submissions(2, 'bar', [[@trigger, 30, 40], [@a1, 3, 4]])
set_user_submissions(3, 'baz', [[@trigger, 30, 40], [@a1, 2, 4]])
expected_assignment_set(get_student_ids([1, 2, 3]), @as1)
@rollup = Stats.students_per_range(@rule, true).with_indifferent_access
expect(trends).to eq [1, 0, -1]
end
it 'averages the follow on assignments based on percent' do
set_user_submissions(1, 'foo', [[@trigger, 8, 10], [@a1, 3500, 5000], [@a2, 5, 5]])
set_user_submissions(2, 'bar', [[@trigger, 9, 10], [@a1, 5000, 5000], [@a2, 4, 5]])
expected_assignment_set(get_student_ids([1, 2]), @as1)
@rollup = Stats.students_per_range(@rule, true).with_indifferent_access
expect(trends).to eq [1, 0]
end
it 'averages over a large number of assignments' do
set_user_submissions(1, 'foo', [[@trigger, 8, 10], [@a1, 3900, 5000], [@a2, 5, 5], [@a3, 9, 10], [@a4, 12, 1000], [@a5, 3.2, 3]])
expected_assignment_set(get_student_ids([1]), @as1)
@rollup = Stats.students_per_range(@rule, true).with_indifferent_access
expect(trends).to eq [-1]
end
it 'ignores assignments outside of assigned set' do
set_user_submissions(1, 'foo', [[@trigger, 80, 100], [@a1, 75, 100], [@b1, 5, 5], [@b2, 10, 10], [@b3, 1000, 1000], [@b4, 3, 3]])
expected_assignment_set(get_student_ids([1]), @as1)
@rollup = Stats.students_per_range(@rule, true).with_indifferent_access
expect(trends).to eq [-1]
end
end
end
describe 'student_details' do
before :once do
@student_id = @students.first.id
end
def set_assignments(points_possible_per_id = nil)
ids = [@trigger.id] + @rule.assignment_set_associations.pluck(:assignment_id)
ids.each do |id|
points_possible = 100
points_possible = points_possible_per_id[id] if points_possible_per_id
Assignment.where(:id => id).update_all(:title => "assn #{id}", :points_possible => points_possible)
end
end
def set_submissions(submissions)
submissions.map do |data|
assignment, score, points_possible = data
Assignment.where(:id => assignment).update_all(:points_possible => points_possible)
Submission.where(:assignment_id => assignment, :user_id => @student_id).update_all(:score => score)
end
end
it 'includes assignments from the correct scoring range' do
set_assignments
set_submissions [[@trigger, 90, 100], [@a3, 80, 100], [@b1, 45, 100]]
expected_assignment_set([@student_id], @as1)
details = Stats.student_details(@rule, @student_id).with_indifferent_access
expect(details.dig(:trigger_assignment, :score)).to eq 0.9
expect(details[:follow_on_assignments].map {|f| f[:assignment][:id]}).to match_array [@a1, @a2, @a3, @a4, @a5].map(&:id)
expect(details[:follow_on_assignments].map {|f| f[:score]}).to match_array [nil, nil, 0.8, nil, nil]
end
it 'matches assignment info and submission info' do
set_assignments
set_submissions [[@trigger, 50, 100], [@b1, 3, 100], [@b2, 88, 100], [@b4, 93, 100]]
expected_assignment_set([@student_id], @as2)
details = Stats.student_details(@rule, @student_id).with_indifferent_access
details_by_id = details[:follow_on_assignments].each_with_object({}) {|f, acc| acc[f.dig(:assignment, :id)] = f }
expect(details_by_id.map {|k,v| [k, v.dig(:submission, :score)] }).to match_array [
[@b1.id, 3], [@b2.id, 88], [@b3.id, nil], [@b4.id, 93], [@b5.id, nil]
]
end
it 'includes score and trend data' do
set_assignments
set_submissions [[@trigger, 50, 100], [@b1, 3, 5], [@b2, 1, 20], [@b4, 0, 0]]
expected_assignment_set([@student_id], @as2)
details = Stats.student_details(@rule, @student_id).with_indifferent_access
details[:follow_on_assignments].each do |detail|
expect(detail).to have_key :score
expect(detail).to have_key :trend
end
end
context 'trends per assignment' do
before do
@rule.scoring_ranges.destroy_all
@sr = create :scoring_range_with_assignments, assignment_count: 1, rule: @rule, upper_bound: nil, lower_bound: 0
@trigger = @rule.trigger_assignment
@follow_on = @sr.assignment_set_associations.first.assignment
end
def check_trend(orig_score, orig_points_possible, new_score, new_points_possible, expected_trend)
set_assignments({ @trigger => orig_points_possible, @follow_on => new_points_possible})
set_submissions [[@trigger, orig_score, orig_points_possible], [@follow_on, new_score, new_points_possible]]
expected_assignment_set([@student_id], @sr.assignment_sets.first)
details = Stats.student_details(@rule, @student_id).with_indifferent_access
trend = details.dig(:follow_on_assignments, 0, :trend)
expect(trend).to eq(expected_trend), "expected #{orig_score}/#{orig_points_possible}:#{new_score}/#{new_points_possible} => #{expected_trend}, got #{trend}"
end
it 'trends upward if new percentage at least 3 % points higher of base percentage' do
check_trend(100, 100, 103, 100, 1)
check_trend(7, 35, 23, 100, 1)
check_trend(1, 2, 528, 995, 1)
end
it 'trends downward if score at least 3 % points lower than base score' do
check_trend(100, 100, 97, 100, -1)
check_trend(7, 35, 17, 100, -1)
check_trend(1, 2, 467, 995, -1)
end
it 'trends stable if score within 3 % points of base score' do
check_trend(100, 100, 102, 100, 0)
check_trend(100, 100, 98, 100, 0)
check_trend(7, 35, 22, 100, 0)
check_trend(7, 35, 19, 100, 0)
check_trend(1, 2, 512, 995, 0)
check_trend(1, 2, 480, 995, 0)
end
it 'trends nil if follow-on score is not present' do
check_trend(100, 100, nil, 0, nil)
end
end
end
end
end