canvas-lms/app/controllers/gradebooks_controller.rb

484 lines
22 KiB
Ruby

#
# Copyright (C) 2012 - 2014 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/>.
#
class GradebooksController < ApplicationController
include ActionView::Helpers::NumberHelper
include GradebooksHelper
include KalturaHelper
include Api::V1::AssignmentGroup
include Api::V1::Submission
include Api::V1::CustomGradebookColumn
before_filter :require_context
before_filter :require_user, only: %w(speed_grader speed_grader_settings)
batch_jobs_in_actions :only => :update_submission, :batch => { :priority => Delayed::LOW_PRIORITY }
add_crumb(proc { t '#crumbs.grades', "Grades" }) { |c| c.send :named_context_url, c.instance_variable_get("@context"), :context_grades_url }
before_filter { |c| c.active_tab = "grades" }
def grade_summary
@presenter = GradeSummaryPresenter.new(@context, @current_user, params[:id])
# do this as the very first thing, if the current user is a teacher in the course and they are not trying to view another user's grades, redirect them to the gradebook
if @presenter.user_needs_redirection?
return redirect_to polymorphic_url([@context, 'gradebook'])
end
if !@presenter.student || !@presenter.student_enrollment
return authorized_action(nil, @current_user, :permission_fail)
end
if authorized_action(@presenter.student_enrollment, @current_user, :read_grades)
log_asset_access("grades:#{@context.asset_string}", "grades", "other")
if @presenter.student
add_crumb(@presenter.student_name, named_context_url(@context, :context_student_grades_url, @presenter.student_id))
Shackles.activate(:slave) do
#run these queries on the slave database for speed
@presenter.assignments
@presenter.groups_assignments = groups_as_assignments(@presenter.groups, :out_of_final => true, :exclude_total => @context.hide_final_grades?)
@presenter.submissions
@presenter.submission_counts
@presenter.assignment_stats
end
submissions_json = @presenter.submissions.map { |s|
{
'assignment_id' => s.assignment_id,
'score' => s.grants_right?(@current_user, :read_grade)? s.score : nil
}
}
ags_json = light_weight_ags_json(@presenter.groups, {student: @presenter.student})
js_env submissions: submissions_json,
assignment_groups: ags_json,
group_weighting_scheme: @context.group_weighting_scheme,
show_total_grade_as_points: @context.settings[:show_total_grade_as_points],
grading_scheme: @context.grading_standard.try(:data) || GradingStandard.default_grading_standard,
student_outcome_gradebook_enabled: @context.feature_enabled?(:student_outcome_gradebook),
student_id: @presenter.student_id
render :action => 'grade_summary'
else
render :action => 'grade_summary_list'
end
end
end
def light_weight_ags_json(assignment_groups, opts={})
assignment_groups.map do |ag|
assignments = ag.visible_assignments(opts[:student] || @current_user).map do |a|
{
:id => a.id,
:submission_types => a.submission_types_array,
:points_possible => a.points_possible,
}
end
{
:id => ag.id,
:rules => ag.rules_hash({stringify_json_ids: true}),
:group_weight => ag.group_weight,
:assignments => assignments,
}
end
end
def grading_rubrics
@rubric_contexts = @context.rubric_contexts(@current_user)
if params[:context_code]
context = @rubric_contexts.detect{|r| r[:context_code] == params[:context_code] }
@rubric_context = @context
if context
@rubric_context = Context.find_by_asset_string(params[:context_code])
end
@rubric_associations = @context.sorted_rubrics(@current_user, @rubric_context)
render :json => @rubric_associations.map{ |r| r.as_json(methods: [:context_name], include: {:rubric => {:include_root => false}}) }
else
render :json => @rubric_contexts
end
end
def attendance
@enrollment = @context.all_student_enrollments.find_by_user_id(params[:user_id]) if params[:user_id].present?
@enrollment ||= @context.all_student_enrollments.find_by_user_id(@current_user.id) if !@context.grants_right?(@current_user, session, :manage_grades)
add_crumb t(:crumb, 'Attendance')
if !@enrollment && @context.grants_right?(@current_user, session, :manage_grades)
@assignments = @context.assignments.active.where(:submission_types => 'attendance').all
@students = @context.students_visible_to(@current_user).order_by_sortable_name
@at_least_one_due_at = @assignments.any?{|a| a.due_at }
# Find which assignment group most attendance items belong to,
# it'll be a better guess for default assignment group than the first
# in the list...
@default_group_id = @assignments.to_a.inject(Hash.new(0)){|h,a| h[a.assignment_group_id] += 1; h}.sort_by{|id, cnt| cnt }.reverse.first[0] rescue nil
elsif @enrollment && @enrollment.grants_right?(@current_user, session, :read_grades)
@assignments = @context.assignments.active.where(:submission_types => 'attendance').all
@students = @context.students_visible_to(@current_user).order_by_sortable_name
@submissions = @context.submissions.find_all_by_user_id(@enrollment.user_id)
@user = @enrollment.user
render :action => "student_attendance"
# render student_attendance, optional params[:assignment_id] to highlight and scroll to that particular assignment
else
flash[:notice] = t('notices.unauthorized', "You are not authorized to view attendance for this course")
redirect_to named_context_url(@context, :context_url)
# redirect
end
end
def show
if authorized_action(@context, @current_user, [:manage_grades, :view_all_grades])
respond_to do |format|
format.html {
set_js_env
case @current_user.preferred_gradebook_version
when "2"
render :action => "gradebook2"
return
when "srgb"
render :action => "screenreader"
return
end
}
format.csv {
cancel_cache_buster
Shackles.activate(:slave) do
send_data(
@context.gradebook_to_csv(
:user => @current_user,
:include_priors => value_to_boolean(params[:include_priors]),
:include_sis_id => @context.grants_any_right?(@current_user, session, :read_sis, :manage_sis)
),
:type => "text/csv",
:filename => t('grades_filename', "Grades").gsub(/ /, "_") + "-" + @context.name.to_s.gsub(/ /, "_") + ".csv",
:disposition => "attachment"
)
end
}
end
end
end
def gradebook2
redirect_to action: :show
end
def set_js_env
@gradebook_is_editable = @context.grants_right?(@current_user, session, :manage_grades)
per_page = Setting.get('api_max_per_page', '50').to_i
teacher_notes = @context.custom_gradebook_columns.not_deleted.where(:teacher_notes=> true).first
ag_includes = [:assignments]
ag_includes << :assignment_visibility if @context.feature_enabled?(:differentiated_assignments)
js_env :GRADEBOOK_OPTIONS => {
:chunk_size => Setting.get('gradebook2.submissions_chunk_size', '35').to_i,
:assignment_groups_url => api_v1_course_assignment_groups_url(@context, :include => ag_includes, :override_assignment_dates => "false"),
:sections_url => api_v1_course_sections_url(@context),
:students_url => api_v1_course_enrollments_url(@context, :include => [:avatar_url], :type => ['StudentEnrollment', 'StudentViewEnrollment'], :per_page => per_page),
:students_url_with_concluded_enrollments => api_v1_course_enrollments_url(@context, :include => [:avatar_url], :type => ['StudentEnrollment', 'StudentViewEnrollment'], :state => ['active', 'invited', 'completed'], :per_page => per_page),
:submissions_url => api_v1_course_student_submissions_url(@context, :grouped => '1'),
:outcome_links_url => api_v1_course_outcome_group_links_url(@context),
:outcome_rollups_url => api_v1_course_outcome_rollups_url(@context, :per_page => 100),
:change_grade_url => api_v1_course_assignment_submission_url(@context, ":assignment", ":submission"),
:context_url => named_context_url(@context, :context_url),
:download_assignment_submissions_url => named_context_url(@context, :context_assignment_submissions_url, "{{ assignment_id }}", :zip => 1),
:re_upload_submissions_url => named_context_url(@context, :submissions_upload_context_gradebook_url, "{{ assignment_id }}"),
:context_id => @context.id,
:context_code => @context.asset_string,
:context_integration_id => @context.integration_id,
:group_weighting_scheme => @context.group_weighting_scheme,
:grading_standard => @context.grading_standard_enabled? && (@context.grading_standard.try(:data) || GradingStandard.default_grading_standard),
:course_is_concluded => @context.completed?,
:gradebook_is_editable => @gradebook_is_editable,
:setting_update_url => api_v1_course_settings_url(@context),
:show_total_grade_as_points => @context.settings[:show_total_grade_as_points],
:publish_to_sis_enabled => @context.allows_grade_publishing_by(@current_user) && @gradebook_is_editable,
:publish_to_sis_url => context_url(@context, :context_details_url, :anchor => 'tab-grade-publishing'),
:speed_grader_enabled => @context.allows_speed_grader?,
:draft_state_enabled => @context.feature_enabled?(:draft_state),
:differentiated_assignments_enabled => @context.feature_enabled?(:differentiated_assignments),
:outcome_gradebook_enabled => @context.feature_enabled?(:outcome_gradebook),
:custom_columns_url => api_v1_course_custom_gradebook_columns_url(@context),
:custom_column_url => api_v1_course_custom_gradebook_column_url(@context, ":id"),
:custom_column_data_url => api_v1_course_custom_gradebook_column_data_url(@context, ":id", per_page: per_page),
:custom_column_datum_url => api_v1_course_custom_gradebook_column_datum_url(@context, ":id", ":user_id"),
:reorder_custom_columns_url => api_v1_custom_gradebook_columns_reorder_url(@context),
:teacher_notes => teacher_notes && custom_gradebook_column_json(teacher_notes, @current_user, session),
:change_gradebook_version_url => context_url(@context, :change_gradebook_version_context_gradebook_url, :version => 2),
:sis_app_url => Setting.get('sis_app_url', nil),
:sis_app_token => Setting.get('sis_app_token', nil)
}
end
def history
if authorized_action(@context, @current_user, :manage_grades)
#
# Temporary disabling of this page for large courses
# We need some reworking of the gradebook history to allow using it
# in large courses in a performant manner. Until that happens, we're
# disabling it over a certain threshold.
#
submissions_count = @context.submissions.count
submissions_limit = Setting.get('gradebook_history_submission_count_threshold', '0').to_i
if submissions_limit == 0 || submissions_count <= submissions_limit
# TODO this whole thing could go a LOT faster if you just got ALL the versions of ALL the submissions in this course then did a ruby sort_by day then grader
@days = SubmissionList.days(@context)
end
respond_to do |format|
format.html
end
end
end
def update_submission
if authorized_action(@context, @current_user, :manage_grades)
if params[:submissions].blank? && params[:submission].blank?
render nothing: true, status: 400
return
end
submissions = if params[:submissions]
params[:submissions].values
else
[params[:submission]]
end
valid_user_ids = Set.new(@context.students_visible_to(@current_user).pluck(:id))
submissions.select! { |s| valid_user_ids.include? s[:user_id].to_i }
users = @context.students.uniq.find(submissions.map { |s| s[:user_id] })
.index_by(&:id)
assignments = @context.assignments.active.find(submissions.map { |s|
s[:assignment_id]
}).index_by(&:id)
@submissions = []
submissions.compact.each do |submission|
@assignment = assignments[submission[:assignment_id].to_i]
@user = users[submission[:user_id].to_i]
submission[:grader] = @current_user
submission.delete :comment_attachments
if params[:attachments]
attachments = []
params[:attachments].each do |idx, attachment|
attachment[:user] = @current_user
attachments << @assignment.attachments.create(attachment)
end
submission[:comment_attachments] = attachments
end
begin
# if it's a percentage graded assignment, we need to ensure there's a
# percent sign on the end. eventually this will probably be done in
# the javascript.
if @assignment.grading_type == "percent" && submission[:grade] && submission[:grade] !~ /%\z/
submission[:grade] = "#{submission[:grade]}%"
end
submission[:dont_overwrite_grade] = value_to_boolean(params[:dont_overwrite_grades])
@submissions += @assignment.grade_student(@user, submission)
rescue => e
@error_message = e.to_s
end
end
@submissions = @submissions.reverse.uniq.reverse
@submissions = nil if submissions.empty? # no valid submissions
respond_to do |format|
if @submissions && !@error_message#&& !@submission.errors || @submission.errors.empty?
flash[:notice] = t('notices.updated', 'Assignment submission was successfully updated.')
format.html { redirect_to course_gradebook_url(@assignment.context) }
format.json {
render :json => @submissions.map{ |s| s.as_json(Submission.json_serialization_full_parameters) }, :status => :created, :location => course_gradebook_url(@assignment.context)
}
format.text {
render :json => @submissions.map{ |s| s.as_json(Submission.json_serialization_full_parameters) }, :status => :created, :location => course_gradebook_url(@assignment.context),
:as_text => true
}
else
flash[:error] = t('errors.submission_failed', "Submission was unsuccessful: %{error}", :error => @error_message || t('errors.submission_failed_default', 'Submission Failed'))
format.html { render :action => "show", :course_id => @assignment.context.id }
format.json { render :json => {:errors => {:base => @error_message}}, :status => :bad_request }
format.text { render :json => {:errors => {:base => @error_message}}, :status => :bad_request }
end
end
end
end
def submissions_zip_upload
unless @context.allows_gradebook_uploads?
flash[:error] = t('errors.not_allowed', "This course does not allow score uploads.")
redirect_to named_context_url(@context, :context_assignment_url, @assignment.id)
return
end
@assignment = @context.assignments.active.find(params[:assignment_id])
if !params[:submissions_zip] || params[:submissions_zip].is_a?(String)
flash[:error] = t('errors.missing_file', "Could not find file to upload")
redirect_to named_context_url(@context, :context_assignment_url, @assignment.id)
return
end
@comments, @failures = @assignment.generate_comments_from_files(params[:submissions_zip].path, @current_user)
flash[:notice] = t('notices.uploaded',
{ :one => "Files and comments created for 1 submission",
:other => "Files and comments created for %{count} submissions" },
:count => @comments.length)
end
def speed_grader
if !@context.allows_speed_grader?
flash[:notice] = t(:speed_grader_disabled, 'SpeedGrader is disabled for this course')
return redirect_to(course_gradebook_path(@context))
end
return unless authorized_action(@context, @current_user, [:manage_grades, :view_all_grades])
@assignment = @context.assignments.active.find(params[:assignment_id])
if @context.feature_enabled?(:draft_state) && @assignment.unpublished?
flash[:notice] = t(:speedgrader_enabled_only_for_published_content,
'Speedgrader is enabled only for published content.')
return redirect_to polymorphic_url([@context, @assignment])
end
respond_to do |format|
format.html do
@headers = false
@outer_frame = true
log_asset_access("speed_grader:#{@context.asset_string}", "grades", "other")
env = {
:CONTEXT_ACTION_SOURCE => :speed_grader,
:settings_url => speed_grader_settings_course_gradebook_path,
}
if @assignment.quiz
env[:quiz_history_url] = course_quiz_history_path @context.id,
@assignment.quiz.id,
:user_id => "{{user_id}}"
end
append_sis_data(env)
js_env(env)
render :action => "speed_grader"
end
format.json do
render :json => @assignment.speed_grader_json(@current_user, service_enabled?(:avatars))
end
end
end
def speed_grader_settings
grade_by_question = value_to_boolean(params[:enable_speedgrader_grade_by_question])
@current_user.preferences[:enable_speedgrader_grade_by_question] = grade_by_question
@current_user.save!
render nothing: true
end
def blank_submission
@headers = false
render :action => "blank_submission"
end
def change_gradebook_version
@current_user.preferences[:gradebook_version] = params[:version]
@current_user.save!
redirect_to polymorphic_url([@context, 'gradebook'])
end
def groups_as_assignments(groups=nil, options = {})
groups ||= @context.assignment_groups.active
percentage = lambda do |weight|
# find the smallest precision necessary to capture up to two digits of
# significant decimals, but to avoid unnecessary zeros on the end. (so we
# can have 100%, but still have 33.33%, for example)
precision = sprintf('%.2f', weight % 1).sub(/^(?:0|1)\.(\d?[1-9])?0*$/, '\1').length
number_to_percentage(weight, :precision => precision)
end
points_possible =
(@context.group_weighting_scheme == "percent") ?
(options[:out_of_final] ?
lambda{ |group| t(:out_of_final, "%{weight} of Final", :weight => percentage[group.group_weight]) } :
lambda{ |group| percentage[group.group_weight] }) :
lambda{ |group| nil }
groups = groups.map { |group|
OpenObject.build('assignment',
:id => 'group-' + group.id.to_s,
:rules => group.rules,
:title => group.name,
:points_possible => points_possible[group],
:hard_coded => true,
:special_class => 'group_total',
:assignment_group_id => group.id,
:group_weight => group.group_weight,
:asset_string => "group_total_#{group.id}")
}
groups << OpenObject.build('assignment',
:id => 'final-grade',
:title => t('titles.total', 'Total'),
:points_possible => (options[:out_of_final] ? '' : percentage[100]),
:hard_coded => true,
:special_class => 'final_grade',
:asset_string => "final_grade_column") unless options[:exclude_total]
groups = [] if options[:exclude_total] && groups.length == 1
groups
end
def set_gradebook_warnings(groups, assignments)
@assignments_in_bad_groups = Set.new
if @context.group_weighting_scheme == "percent"
assignments_by_group = assignments.group_by(&:assignment_group_id)
bad_groups = groups.select do |group|
group_assignments = assignments_by_group[group.id] || []
points_in_group = group_assignments.map(&:points_possible).compact.sum
points_in_group.zero?
end
bad_group_ids = bad_groups.map(&:id)
bad_assignment_ids = assignments_by_group.
slice(*bad_group_ids).
values.
flatten
@assignments_in_bad_groups.replace bad_assignment_ids
warning = t('invalid_assignment_groups_warning',
{:one => "Score does not include %{groups} because " \
"it has no points possible",
:other => "Score does not include %{groups} because " \
"they have no points possible"},
:groups => bad_groups.map(&:name).to_sentence,
:count => bad_groups.size)
else
if assignments.all? { |a| (a.points_possible || 0).zero? }
warning = t(:no_assignments_have_points_warning,
"Can't compute score until an assignment " \
"has points possible")
end
end
js_env :total_grade_warning => warning if warning
end
private :set_gradebook_warnings
def assignment_groups_json(opts={})
assignment_scope = AssignmentGroup.assignment_scope_for_draft_state(@context)
@context.assignment_groups.active.includes(assignment_scope).map { |g|
assignment_group_json(g, @current_user, session, ['assignments'], {
stringify_json_ids: opts[:stringify_json_ids] || stringify_json_ids?
})
}
end
end