506 lines
19 KiB
Ruby
506 lines
19 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#
|
|
# Copyright (C) 2012 - 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 Api::V1::AssignmentOverride
|
|
include Api::V1::Json
|
|
|
|
def assignment_override_json(override, visible_users = nil)
|
|
fields = [:id, :assignment_id, :title]
|
|
fields.concat([:due_at, :all_day, :all_day_date]) if override.due_at_overridden
|
|
fields << :unlock_at if override.unlock_at_overridden
|
|
fields << :lock_at if override.lock_at_overridden
|
|
api_json(override, @current_user, session, :only => fields).tap do |json|
|
|
case override.set_type
|
|
when 'ADHOC'
|
|
json[:student_ids] = if override.preloaded_student_ids
|
|
override.preloaded_student_ids
|
|
else
|
|
if visible_users.present?
|
|
override.assignment_override_students.where(user_id: visible_users).pluck(:user_id)
|
|
else
|
|
override.assignment_override_students.pluck(:user_id)
|
|
end
|
|
end
|
|
when 'Group'
|
|
json[:group_id] = override.set_id
|
|
when 'CourseSection'
|
|
json[:course_section_id] = override.set_id
|
|
when 'Noop'
|
|
json[:noop_id] = override.set_id
|
|
end
|
|
end
|
|
end
|
|
|
|
def assignment_overrides_json(overrides, user = nil)
|
|
visible_users_ids = ::AssignmentOverride.visible_enrollments_for(overrides.compact, user).select(:user_id)
|
|
# we most likely already have the student_ids preloaded here because of overridden_for, but just in case
|
|
if overrides.any? { |ov| ov.present? && ov.set_type == 'ADHOC' && !ov.preloaded_student_ids }
|
|
AssignmentOverrideApplicator.preload_student_ids_for_adhoc_overrides(overrides.select { |ov| ov.set_type == 'ADHOC' }, visible_users_ids)
|
|
end
|
|
overrides.map { |override| assignment_override_json(override, visible_users_ids) if override }
|
|
end
|
|
|
|
def assignment_override_collection(assignment, include_students = false)
|
|
overrides = AssignmentOverrideApplicator.overrides_for_assignment_and_user(assignment, @current_user)
|
|
if include_students
|
|
ActiveRecord::Associations::Preloader.new.preload(overrides, :assignment_override_students)
|
|
end
|
|
overrides
|
|
end
|
|
|
|
def find_assignment_override(assignment, set_or_id)
|
|
find_assignment_overrides(assignment, [set_or_id])[0]
|
|
end
|
|
|
|
def find_assignment_overrides(assignment, sets_or_ids)
|
|
overrides = assignment_override_collection(assignment)
|
|
sets_or_ids.map do |set_or_id|
|
|
filter_assignment_overrides(overrides, set_or_id)
|
|
end
|
|
end
|
|
|
|
def filter_assignment_overrides(overrides, set_or_id)
|
|
return nil if overrides.empty? || set_or_id.nil?
|
|
|
|
case set_or_id
|
|
when CourseSection, Group
|
|
overrides.detect do |o|
|
|
o.set_type == set_or_id.class.to_s &&
|
|
o.set_id == set_or_id.id # maybe FIXME
|
|
end
|
|
else
|
|
overrides.detect { |o| o.id == set_or_id.to_i }
|
|
end
|
|
end
|
|
|
|
def find_group(assignment, group_id, group_category_id = nil)
|
|
scope = Group.active.where(:context_type => 'Course').where("group_category_id IS NOT NULL")
|
|
if assignment
|
|
scope = scope.where(
|
|
context_id: assignment.context_id,
|
|
group_category_id: (group_category_id || assignment.group_category_id)
|
|
)
|
|
end
|
|
group = scope.find(group_id)
|
|
raise ActiveRecord::RecordNotFound unless group.grants_right?(@current_user, session, :read)
|
|
|
|
group
|
|
end
|
|
|
|
def find_section(context, section_id)
|
|
scope = CourseSection.active
|
|
scope = scope.where(:course_id => context) if context
|
|
section = api_find(scope, section_id)
|
|
raise ActiveRecord::RecordNotFound unless section.grants_right?(@current_user, session, :read)
|
|
|
|
section
|
|
end
|
|
|
|
def interpret_assignment_override_data(assignment, data, set_type = nil)
|
|
data ||= {}
|
|
return {}, ["invalid override data"] unless data.is_a?(Hash) || data.is_a?(ActionController::Parameters)
|
|
|
|
# validate structure of parameters
|
|
override_data = {}
|
|
errors = []
|
|
|
|
if !set_type && data[:student_ids]
|
|
set_type = 'ADHOC'
|
|
end
|
|
|
|
if set_type == 'ADHOC' && data[:student_ids]
|
|
# require the ids to be a list
|
|
student_ids = data[:student_ids]
|
|
if !student_ids.is_a?(Array) || student_ids.empty?
|
|
errors << "invalid student_ids #{student_ids.inspect}"
|
|
students = []
|
|
else
|
|
# look up all students since the assignment will affect all current and
|
|
# previous students in the course on this override and not just what the
|
|
# teacher can see that were sent in the request object
|
|
students = api_find_all(assignment.context.all_students, student_ids)
|
|
students = students.distinct if students.is_a?(ActiveRecord::Relation)
|
|
students = students.uniq if students.is_a?(Array)
|
|
|
|
# make sure they were all valid
|
|
found_ids = students.map { |s|
|
|
[
|
|
s.id.to_s,
|
|
s.global_id.to_s,
|
|
("sis_login_id:#{s.pseudonym.unique_id}" if s.pseudonym),
|
|
("hex:sis_login_id:#{s.pseudonym.unique_id.to_s.unpack('H*')}" if s.pseudonym),
|
|
("sis_user_id:#{s.pseudonym.sis_user_id}" if s.pseudonym && s.pseudonym.sis_user_id),
|
|
("hex:sis_user_id:#{s.pseudonym.sis_user_id.to_s.unpack('H*')}" if s.pseudonym && s.pseudonym.sis_user_id)
|
|
]
|
|
}.flatten.compact
|
|
bad_ids = student_ids.map(&:to_s) - found_ids
|
|
errors << "unknown student ids: #{bad_ids.inspect}" unless bad_ids.empty?
|
|
end
|
|
override_data[:students] = students
|
|
end
|
|
|
|
if !set_type && data.key?(:group_id)
|
|
group_category_id = assignment.group_category_id || assignment.discussion_topic.try(:group_category_id)
|
|
if !group_category_id
|
|
# don't recognize group_id for non-group assignments
|
|
errors << "group_id is not valid for non-group assignments"
|
|
else
|
|
set_type = 'Group'
|
|
# look up the group
|
|
begin
|
|
group = find_group(assignment, data[:group_id], group_category_id)
|
|
rescue ActiveRecord::RecordNotFound
|
|
errors << "unknown group id #{data[:group_id].inspect}"
|
|
end
|
|
override_data[:group] = group
|
|
end
|
|
end
|
|
|
|
if !set_type && data.key?(:course_section_id)
|
|
set_type = 'CourseSection'
|
|
|
|
# look up the section
|
|
begin
|
|
section = find_section(assignment.context, data[:course_section_id])
|
|
rescue ActiveRecord::RecordNotFound
|
|
errors << "unknown section id #{data[:course_section_id].inspect}"
|
|
end
|
|
override_data[:section] = section
|
|
end
|
|
|
|
if !set_type && data.key?(:noop_id)
|
|
set_type = 'Noop'
|
|
override_data[:noop_id] = data[:noop_id]
|
|
end
|
|
|
|
errors << "one of student_ids, group_id, or course_section_id is required" if !set_type && errors.empty?
|
|
|
|
if %w(ADHOC Noop).include?(set_type) && data.key?(:title)
|
|
override_data[:title] = data[:title]
|
|
end
|
|
|
|
# collect override values
|
|
[:due_at, :unlock_at, :lock_at].each do |field|
|
|
next unless data.key?(field)
|
|
|
|
begin
|
|
if data[field].blank?
|
|
# override value of nil/'' is meaningful
|
|
override_data[field] = nil
|
|
elsif (value = Time.zone.parse(data[field].to_s))
|
|
override_data[field] = value
|
|
else
|
|
errors << "invalid #{field} #{data[field].inspect}"
|
|
end
|
|
rescue
|
|
errors << "invalid #{field} #{data[field].inspect}"
|
|
end
|
|
end
|
|
|
|
errors = nil if errors.empty?
|
|
return override_data, errors
|
|
end
|
|
|
|
def check_property(object, prop, present, errors, message)
|
|
object.each_with_index.reject do |element, i|
|
|
if element[prop].present? != present
|
|
errors[i] ||= []
|
|
errors[i] << message
|
|
end
|
|
end
|
|
end
|
|
|
|
# receives data of shape [{ id: 2, assignment_id: 1, ...update_params }, ... ]
|
|
# responds with data of shape [{ assignment: model, override: model, ...update_params }, ...]
|
|
def interpret_batch_assignment_overrides_data(course, assignment_overrides_data, for_update)
|
|
return nil, ['no assignment override data present'] unless assignment_overrides_data.present?
|
|
return nil, ['must specify an array of overrides'] unless assignment_overrides_data.is_a? Array
|
|
|
|
all_errors = Array.new(assignment_overrides_data.length)
|
|
|
|
check_property(assignment_overrides_data, 'assignment_id', true, all_errors, 'must specify an assignment id')
|
|
if for_update
|
|
check_property(assignment_overrides_data, 'id', true, all_errors, 'must specify an override id')
|
|
else
|
|
check_property(assignment_overrides_data, 'id', false, all_errors, 'may not specify an override id')
|
|
end
|
|
return nil, all_errors unless all_errors.compact.blank?
|
|
|
|
grouped = assignment_overrides_data.group_by { |o| o['assignment_id'] }
|
|
assignments = course.active_assignments.where(id: grouped.keys).preload(:assignment_overrides)
|
|
overrides = grouped.map do |assignment_id, overrides_data|
|
|
assignment = assignments.find { |a| a.id.to_s == assignment_id.to_s }
|
|
find_assignment_overrides(assignment, overrides_data.map { |o| o['id'] }) if assignment
|
|
end.flatten.compact if for_update
|
|
|
|
interpreted = assignment_overrides_data.each_with_index.map do |override_data, i|
|
|
assignment = assignments.find { |a| a.id.to_s == override_data['assignment_id'].to_s }
|
|
unless assignment.present?
|
|
all_errors[i] = ['assignment not found']
|
|
next
|
|
end
|
|
if for_update
|
|
override = overrides.find { |o| o.id.to_s == override_data['id'].to_s }
|
|
unless override.present?
|
|
all_errors[i] = ['override not found']
|
|
next
|
|
end
|
|
set_type = override.set_type
|
|
end
|
|
|
|
update_data, errors = interpret_assignment_override_data(assignment, override_data, set_type)
|
|
if errors
|
|
all_errors[i] = errors
|
|
next
|
|
end
|
|
update_data['assignment'] = assignment
|
|
update_data['override'] = override if for_update
|
|
update_data
|
|
end
|
|
all_errors = nil if all_errors.compact.blank?
|
|
[interpreted, all_errors]
|
|
end
|
|
|
|
def update_assignment_override_without_save(override, override_data)
|
|
if override_data.key?(:noop_id)
|
|
override.set = nil
|
|
override.set_type = 'Noop'
|
|
override.set_id = override_data[:noop_id]
|
|
override.title = override_data[:title]
|
|
end
|
|
|
|
if override_data.key?(:students)
|
|
override.set = nil
|
|
override.set_type = 'ADHOC'
|
|
|
|
defunct_student_ids = override.new_record? ?
|
|
Set.new :
|
|
override.assignment_override_students.map(&:user_id).to_set
|
|
|
|
override.changed_student_ids = Set.new
|
|
|
|
override_data[:students].each do |student|
|
|
if defunct_student_ids.include?(student.id)
|
|
defunct_student_ids.delete(student.id)
|
|
else
|
|
# link will be saved with the override
|
|
link = override.assignment_override_students.build
|
|
link.workflow_state = 'active'
|
|
link.assignment_override = override
|
|
link.user = student
|
|
override.changed_student_ids << student.id
|
|
end
|
|
end
|
|
|
|
unless defunct_student_ids.empty?
|
|
override.changed_student_ids.merge(defunct_student_ids)
|
|
override.assignment_override_students
|
|
.where(:user_id => defunct_student_ids.to_a)
|
|
.in_batches
|
|
.delete_all
|
|
end
|
|
end
|
|
|
|
if override_data.key?(:group)
|
|
override.set = override_data[:group]
|
|
end
|
|
|
|
if override_data.key?(:section)
|
|
override.set = override_data[:section]
|
|
end
|
|
|
|
if override.set_type == 'ADHOC'
|
|
override.title = override_data[:title] ||
|
|
(override_data[:students] && override.title_from_students(override_data[:students])) ||
|
|
override.title
|
|
end
|
|
|
|
[:due_at, :unlock_at, :lock_at].each do |field|
|
|
if override_data.key?(field)
|
|
override.send("override_#{field}", override_data[field])
|
|
else
|
|
override.send("clear_#{field}_override")
|
|
end
|
|
end
|
|
end
|
|
|
|
def update_assignment_override(override, override_data, updating_user: nil)
|
|
DueDateCacher.with_executing_user(updating_user) do
|
|
override_changed = false
|
|
override.transaction do
|
|
update_assignment_override_without_save(override, override_data)
|
|
override_changed = override.changed? || override.changed_student_ids.present?
|
|
override.save! if override_changed
|
|
end
|
|
if override_changed
|
|
if override.set_type == 'ADHOC' && override.changed_student_ids.present?
|
|
override.assignment.run_if_overrides_changed_later!(
|
|
student_ids: override.changed_student_ids.to_a,
|
|
updating_user: updating_user
|
|
)
|
|
else
|
|
override.assignment.run_if_overrides_changed_later!(updating_user: updating_user)
|
|
end
|
|
end
|
|
end
|
|
|
|
true
|
|
rescue ActiveRecord::RecordInvalid
|
|
false
|
|
end
|
|
|
|
# updates only the selected overrides; compare with
|
|
# batch_update_assignment_overrides below, which updates
|
|
# all overrides for assignment
|
|
def update_assignment_overrides(overrides, overrides_data, updating_user: nil)
|
|
overrides.zip(overrides_data).each do |override, data|
|
|
update_assignment_override_without_save(override, data)
|
|
end
|
|
return false if overrides.find(&:invalid?).present?
|
|
|
|
AssignmentOverride.transaction do
|
|
overrides.each(&:save!)
|
|
end
|
|
overrides.map(&:assignment).uniq.each do |assignment|
|
|
assignment.run_if_overrides_changed_later!(updating_user: updating_user)
|
|
end
|
|
rescue ActiveRecord::RecordInvalid
|
|
false
|
|
end
|
|
|
|
def invisible_users_and_overrides_for_user(context, user, existing_overrides)
|
|
# get the student overrides the user can't see and ensure those overrides are included
|
|
visible_user_ids = context.enrollments_visible_to(user).select(:user_id)
|
|
invisible_user_ids = context.enrollments.where.not(:user_id => visible_user_ids).distinct.pluck(:user_id)
|
|
invisible_override_ids = existing_overrides.select { |ov|
|
|
ov.set_type == 'ADHOC' &&
|
|
!ov.visible_student_overrides(visible_user_ids)
|
|
}.map(&:id)
|
|
return invisible_user_ids, invisible_override_ids
|
|
end
|
|
|
|
def update_override_with_invisible_data(override_params, override, invisible_override_ids, invisible_user_ids)
|
|
return override_params = override if invisible_override_ids.include?(override.id)
|
|
|
|
# add back in the invisible students for this override if any found
|
|
hidden_ids = override.assignment_override_students.where(user_id: invisible_user_ids).pluck(:user_id)
|
|
unless hidden_ids.empty?
|
|
override_params[:student_ids] = (override_params[:student_ids] + hidden_ids)
|
|
overrides_size = override_params[:student_ids].size
|
|
override_params[:title] = t({ one: '1 student', other: "%{count} students" }, count: overrides_size)
|
|
end
|
|
end
|
|
|
|
def prepare_assignment_overrides_for_batch_update(assignment, overrides_params, user)
|
|
existing_overrides = assignment.assignment_overrides.active
|
|
invisible_user_ids, invisible_override_ids = invisible_users_and_overrides_for_user(
|
|
assignment.context, user, existing_overrides
|
|
)
|
|
|
|
override_param_ids = invisible_override_ids + overrides_params.map { |ov| ov[:id].to_i }
|
|
split_overrides = existing_overrides.group_by do |override|
|
|
override_param_ids.include?(override.id) ? :keep : :delete
|
|
end
|
|
|
|
overrides_to_keep = split_overrides[:keep] || []
|
|
overrides_to_delete = split_overrides[:delete] || []
|
|
|
|
ActiveRecord::Associations::Preloader.new.preload(overrides_to_keep, :assignment_override_students)
|
|
|
|
override_errors = []
|
|
overrides_to_save = overrides_params.map do |override_params|
|
|
override = get_override_from_params(override_params, assignment, overrides_to_keep)
|
|
update_override_with_invisible_data(override_params, override, invisible_override_ids, invisible_user_ids)
|
|
|
|
data, errors = interpret_assignment_override_data(assignment, override_params, override.set_type)
|
|
if errors
|
|
override_errors << errors.join(',')
|
|
else
|
|
update_assignment_override_without_save(override, data)
|
|
end
|
|
override
|
|
end
|
|
|
|
overrides_to_create, overrides_to_update = overrides_to_save.partition(&:new_record?)
|
|
|
|
{
|
|
overrides_to_create: overrides_to_create,
|
|
overrides_to_update: overrides_to_update,
|
|
overrides_to_delete: overrides_to_delete,
|
|
override_errors: override_errors
|
|
}
|
|
end
|
|
|
|
def perform_batch_update_assignment_overrides(assignment, prepared_overrides, updating_user: nil)
|
|
prepared_overrides[:override_errors].each do |error|
|
|
assignment.errors.add(:base, error)
|
|
end
|
|
|
|
raise ActiveRecord::RecordInvalid.new(assignment) if assignment.errors.any?
|
|
|
|
if prepared_overrides[:overrides_to_delete].any?
|
|
assignment.assignment_overrides.where(id: prepared_overrides[:overrides_to_delete]).destroy_all
|
|
end
|
|
|
|
raise ActiveRecord::RecordInvalid.new(assignment) unless assignment.valid?
|
|
|
|
prepared_overrides[:overrides_to_create].each(&:save!)
|
|
prepared_overrides[:overrides_to_update].each(&:save!)
|
|
|
|
@overrides_affected = prepared_overrides[:overrides_to_delete].size +
|
|
prepared_overrides[:overrides_to_create].size + prepared_overrides[:overrides_to_update].size
|
|
|
|
assignment.touch # invalidate cached list of overrides for the assignment
|
|
assignment.assignment_overrides.reset # unload the obsolete association
|
|
assignment.run_if_overrides_changed_later!(updating_user: updating_user)
|
|
end
|
|
|
|
def batch_update_assignment_overrides(assignment, overrides_params, user)
|
|
prepared_overrides = prepare_assignment_overrides_for_batch_update(assignment, overrides_params, user)
|
|
perform_batch_update_assignment_overrides(assignment, prepared_overrides, updating_user: user)
|
|
end
|
|
|
|
def get_override_from_params(override_params, assignment, potential_overrides)
|
|
override = potential_overrides.detect { |ov| ov.id == override_params[:id].to_i }
|
|
return override if override
|
|
|
|
case assignment
|
|
when Assignment
|
|
AssignmentOverride.new(assignment_id: assignment.id, dont_touch_assignment: true)
|
|
when Quizzes::Quiz
|
|
AssignmentOverride.new(quiz_id: assignment.id, dont_touch_assignment: true)
|
|
end
|
|
end
|
|
private :get_override_from_params
|
|
|
|
def deserialize_overrides(overrides)
|
|
if overrides.is_a?(Hash) || overrides.is_a?(ActionController::Parameters)
|
|
return unless overrides.keys.all? { |k| k.to_i.to_s == k.to_s }
|
|
|
|
indices = overrides.keys.sort_by(&:to_i)
|
|
return unless indices.map(&:to_i) == (0...indices.size).to_a
|
|
|
|
overrides = indices.map { |index| overrides[index] }
|
|
else
|
|
overrides
|
|
end
|
|
end
|
|
end
|