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:
parent
f9328de0ca
commit
900d0e3cf5
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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').
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue