1085 lines
46 KiB
Ruby
1085 lines
46 KiB
Ruby
#
|
|
# Copyright (C) 2011 - 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/>.
|
|
#
|
|
|
|
# @API Submissions
|
|
#
|
|
# API for accessing and updating submissions for an assignment. The submission
|
|
# id in these URLs is the id of the student in the course, there is no separate
|
|
# submission id exposed in these APIs.
|
|
#
|
|
# All submission actions can be performed with either the course id, or the
|
|
# course section id. SIS ids can be used, prefixed by "sis_course_id:" or
|
|
# "sis_section_id:" as described in the API documentation on SIS IDs.
|
|
#
|
|
# @model Submission
|
|
# {
|
|
# "id": "Submission",
|
|
# "description": "",
|
|
# "properties": {
|
|
# "assignment_id": {
|
|
# "description": "The submission's assignment id",
|
|
# "example": 23,
|
|
# "type": "integer"
|
|
# },
|
|
# "assignment": {
|
|
# "description": "The submission's assignment (see the assignments API) (optional)",
|
|
# "example": "Assignment",
|
|
# "type": "string"
|
|
# },
|
|
# "course": {
|
|
# "description": "The submission's course (see the course API) (optional)",
|
|
# "example": "Course",
|
|
# "type": "string"
|
|
# },
|
|
# "attempt": {
|
|
# "description": "This is the submission attempt number.",
|
|
# "example": 1,
|
|
# "type": "integer"
|
|
# },
|
|
# "body": {
|
|
# "description": "The content of the submission, if it was submitted directly in a text field.",
|
|
# "example": "There are three factors too...",
|
|
# "type": "string"
|
|
# },
|
|
# "grade": {
|
|
# "description": "The grade for the submission, translated into the assignment grading scheme (so a letter grade, for example).",
|
|
# "example": "A-",
|
|
# "type": "string"
|
|
# },
|
|
# "grade_matches_current_submission": {
|
|
# "description": "A boolean flag which is false if the student has re-submitted since the submission was last graded.",
|
|
# "example": true,
|
|
# "type": "boolean"
|
|
# },
|
|
# "html_url": {
|
|
# "description": "URL to the submission. This will require the user to log in.",
|
|
# "example": "http://example.com/courses/255/assignments/543/submissions/134",
|
|
# "type": "string"
|
|
# },
|
|
# "preview_url": {
|
|
# "description": "URL to the submission preview. This will require the user to log in.",
|
|
# "example": "http://example.com/courses/255/assignments/543/submissions/134?preview=1",
|
|
# "type": "string"
|
|
# },
|
|
# "score": {
|
|
# "description": "The raw score",
|
|
# "example": 13.5,
|
|
# "type": "number"
|
|
# },
|
|
# "submission_comments": {
|
|
# "description": "Associated comments for a submission (optional)",
|
|
# "type": "array",
|
|
# "items": { "$ref": "SubmissionComment" }
|
|
# },
|
|
# "submission_type": {
|
|
# "description": "The types of submission ex: ('online_text_entry'|'online_url'|'online_upload'|'media_recording')",
|
|
# "example": "online_text_entry",
|
|
# "type": "string",
|
|
# "allowableValues": {
|
|
# "values": [
|
|
# "online_text_entry",
|
|
# "online_url",
|
|
# "online_upload",
|
|
# "media_recording"
|
|
# ]
|
|
# }
|
|
# },
|
|
# "submitted_at": {
|
|
# "description": "The timestamp when the assignment was submitted",
|
|
# "example": "2012-01-01T01:00:00Z",
|
|
# "type": "datetime"
|
|
# },
|
|
# "url": {
|
|
# "description": "The URL of the submission (for 'online_url' submissions).",
|
|
# "type": "string"
|
|
# },
|
|
# "user_id": {
|
|
# "description": "The id of the user who created the submission",
|
|
# "example": 134,
|
|
# "type": "integer"
|
|
# },
|
|
# "grader_id": {
|
|
# "description": "The id of the user who graded the submission. This will be null for submissions that haven't been graded yet. It will be a positive number if a real user has graded the submission and a negative number if the submission was graded by a process (e.g. Quiz autograder and autograding LTI tools). Specifically autograded quizzes set grader_id to the negative of the quiz id. Submissions autograded by LTI tools set grader_id to the negative of the tool id.",
|
|
# "example": 86,
|
|
# "type": "integer"
|
|
# },
|
|
# "graded_at" : {
|
|
# "example": "2012-01-02T03:05:34Z",
|
|
# "type": "datetime"
|
|
# },
|
|
# "user": {
|
|
# "description": "The submissions user (see user API) (optional)",
|
|
# "example": "User",
|
|
# "type": "string"
|
|
# },
|
|
# "late": {
|
|
# "description": "Whether the submission was made after the applicable due date",
|
|
# "example": false,
|
|
# "type": "boolean"
|
|
# },
|
|
# "assignment_visible": {
|
|
# "description": "Whether the assignment is visible to the user who submitted the assignment. Submissions where `assignment_visible` is false no longer count towards the student's grade and the assignment can no longer be accessed by the student. `assignment_visible` becomes false for submissions that do not have a grade and whose assignment is no longer assigned to the student's section.",
|
|
# "example": true,
|
|
# "type": "boolean"
|
|
# },
|
|
# "excused": {
|
|
# "description": "Whether the assignment is excused. Excused assignments have no impact on a user's grade.",
|
|
# "example": true,
|
|
# "type": "boolean"
|
|
# },
|
|
# "missing": {
|
|
# "description": "Whether the assignment is missing.",
|
|
# "example": true,
|
|
# "type": "boolean"
|
|
# },
|
|
# "late_policy_status": {
|
|
# "description": "The status of the submission in relation to the late policy. Can be late, missing, none, or null.",
|
|
# "example": "missing",
|
|
# "type": "string"
|
|
# },
|
|
# "points_deducted": {
|
|
# "description": "The amount of points automatically deducted from the score by the missing/late policy for a late or missing assignment.",
|
|
# "example": 12.3,
|
|
# "type": "number"
|
|
# },
|
|
# "seconds_late": {
|
|
# "description": "The amount of time, in seconds, that an submission is late by.",
|
|
# "example": 300,
|
|
# "type": "number"
|
|
# },
|
|
# "workflow_state": {
|
|
# "description": "The current state of the submission",
|
|
# "example": "submitted",
|
|
# "type": "string",
|
|
# "allowableValues": {
|
|
# "values": [
|
|
# "graded",
|
|
# "submitted",
|
|
# "unsubmitted",
|
|
# "pending_review"
|
|
# ]
|
|
# }
|
|
# }
|
|
# }
|
|
# }
|
|
#
|
|
class SubmissionsApiController < ApplicationController
|
|
before_action :get_course_from_section, :require_context, :require_user
|
|
batch_jobs_in_actions :only => [:update], :batch => { :priority => Delayed::LOW_PRIORITY }
|
|
|
|
include Api::V1::Progress
|
|
include Api::V1::Submission
|
|
|
|
# @API List assignment submissions
|
|
#
|
|
# A paginated list of all existing submissions for an assignment.
|
|
#
|
|
# @argument include[] [String, "submission_history"|"submission_comments"|"rubric_assessment"|"assignment"|"visibility"|"course"|"user"|"group"]
|
|
# Associations to include with the group. "group" will add group_id and group_name.
|
|
#
|
|
# @argument grouped [Boolean]
|
|
# If this argument is true, the response will be grouped by student groups.
|
|
#
|
|
# @response_field assignment_id The unique identifier for the assignment.
|
|
# @response_field user_id The id of the user who submitted the assignment.
|
|
# @response_field grader_id The id of the user who graded the submission. This will be null for submissions that haven't been graded yet. It will be a positive number if a real user has graded the submission and a negative number if the submission was graded by a process (e.g. Quiz autograder and autograding LTI tools). Specifically autograded quizzes set grader_id to the negative of the quiz id. Submissions autograded by LTI tools set grader_id to the negative of the tool id.
|
|
# @response_field canvadoc_document_id The id for the canvadoc document associated with this submission, if it was a file upload.
|
|
# @response_field submitted_at The timestamp when the assignment was submitted, if an actual submission has been made.
|
|
# @response_field score The raw score for the assignment submission.
|
|
# @response_field attempt If multiple submissions have been made, this is the attempt number.
|
|
# @response_field body The content of the submission, if it was submitted directly in a text field.
|
|
# @response_field grade The grade for the submission, translated into the assignment grading scheme (so a letter grade, for example).
|
|
# @response_field grade_matches_current_submission A boolean flag which is false if the student has re-submitted since the submission was last graded.
|
|
# @response_field preview_url Link to the URL in canvas where the submission can be previewed. This will require the user to log in.
|
|
# @response_field url If the submission was made as a URL.
|
|
# @response_field late Whether the submission was made after the applicable due date.
|
|
# @response_field assignment_visible Whether this assignment is visible to the user who submitted the assignment.
|
|
# @response_field workflow_state The current status of the submission. Possible values: “submitted”, “unsubmitted”, “graded”, “pending_review”
|
|
#
|
|
# @returns [Submission]
|
|
def index
|
|
if authorized_action(@context, @current_user, [:manage_grades, :view_all_grades])
|
|
@assignment = @context.assignments.active.find(params[:assignment_id])
|
|
includes = Array.wrap(params[:include])
|
|
|
|
student_ids = if value_to_boolean(params[:grouped])
|
|
# this provides one assignment object(and
|
|
# submission object within), per user group
|
|
@assignment.representatives(@current_user).map(&:id)
|
|
else
|
|
@context.apply_enrollment_visibility(@context.student_enrollments,
|
|
@current_user, section_ids)
|
|
.pluck(:user_id)
|
|
end
|
|
submissions = @assignment.submissions.where(user_id: student_ids).preload(:originality_reports)
|
|
submissions = submissions.preload(:quiz_submission) if @assignment.quiz?
|
|
|
|
json = if includes.include?("visibility")
|
|
bulk_process_submissions_for_visibility(submissions, includes)
|
|
else
|
|
submissions = submissions.order(:user_id)
|
|
|
|
submissions = submissions.preload(:group) if includes.include?("group")
|
|
|
|
submissions = Api.paginate(submissions, self,
|
|
polymorphic_url([:api_v1, @section || @context, @assignment, :submissions]))
|
|
bulk_load_attachments_and_previews(submissions)
|
|
|
|
submissions.map do |s|
|
|
s.visible_to_user = true
|
|
submission_json(s, @assignment, @current_user, session, @context, includes, params)
|
|
end
|
|
end
|
|
|
|
render :json => json
|
|
end
|
|
end
|
|
|
|
# @API List submissions for multiple assignments
|
|
#
|
|
# A paginated list of all existing submissions for a given set of students and assignments.
|
|
#
|
|
# @argument student_ids[] [String]
|
|
# List of student ids to return submissions for. If this argument is
|
|
# omitted, return submissions for the calling user. Students may only list
|
|
# their own submissions. Observers may only list those of associated
|
|
# students. The special id "all" will return submissions for all students
|
|
# in the course/section as appropriate.
|
|
#
|
|
# @argument assignment_ids[] [String]
|
|
# List of assignments to return submissions for. If none are given,
|
|
# submissions for all assignments are returned.
|
|
#
|
|
# @argument grouped [Boolean]
|
|
# If this argument is present, the response will be grouped by student,
|
|
# rather than a flat array of submissions.
|
|
#
|
|
# @argument post_to_sis [Boolean]
|
|
# If this argument is set to true, the response will only include
|
|
# submissions for assignments that have the post_to_sis flag set to true and
|
|
# user enrollments that were added through sis.
|
|
#
|
|
# @argument grading_period_id [Integer]
|
|
# The id of the grading period in which submissions are being requested
|
|
# (Requires grading periods to exist on the account)
|
|
#
|
|
# @argument workflow_state [String, "submitted"|"unsubmitted"|"graded"|"pending_review"]
|
|
# The current status of the submission
|
|
#
|
|
# @argument enrollment_state [String, "active"|"concluded"|]
|
|
# The current state of the enrollments. If omitted will include all
|
|
# enrollments that are not deleted.
|
|
#
|
|
# @argument order [String, "id"|"graded_at"]
|
|
# The order submissions will be returned in. Defaults to "id". Doesn't
|
|
# affect results for "grouped" mode.
|
|
#
|
|
# @argument order_direction [String, "ascending"|"descending"]
|
|
# Determines whether ordered results are retured in ascending or descending
|
|
# order. Defaults to "ascending". Doesn't affect results for "grouped" mode.
|
|
#
|
|
# @argument include[] [String, "submission_history"|"submission_comments"|"rubric_assessment"|"assignment"|"total_scores"|"visibility"|"course"|"user"]
|
|
# Associations to include with the group. `total_scores` requires the
|
|
# `grouped` argument.
|
|
#
|
|
# @example_response
|
|
# # Without grouped:
|
|
#
|
|
# [
|
|
# { "assignment_id": 100, grade: 5, "user_id": 1, ... },
|
|
# { "assignment_id": 101, grade: 6, "user_id": 2, ... }
|
|
#
|
|
# # With grouped:
|
|
#
|
|
# [
|
|
# {
|
|
# "user_id": 1,
|
|
# "submissions": [
|
|
# { "assignment_id": 100, grade: 5, ... },
|
|
# { "assignment_id": 101, grade: 6, ... }
|
|
# ]
|
|
# }
|
|
# ]
|
|
def for_students
|
|
if params[:student_ids].try(:include?, 'all')
|
|
all = true
|
|
else
|
|
student_ids = map_user_ids(params[:student_ids] || []).map(&:to_i)
|
|
student_ids << @current_user.id if student_ids.empty?
|
|
end
|
|
|
|
can_view_all = @context.grants_any_right?(@current_user, session, :manage_grades, :view_all_grades)
|
|
if all && can_view_all
|
|
# this is a scope, and will generate subqueries
|
|
student_ids = @context.apply_enrollment_visibility(@context.all_student_enrollments, @current_user, section_ids).select(:user_id)
|
|
elsif can_view_all
|
|
visible_student_ids = @context.apply_enrollment_visibility(@context.all_student_enrollments, @current_user, section_ids).pluck(:user_id)
|
|
inaccessible_students = student_ids - visible_student_ids
|
|
unless inaccessible_students.empty?
|
|
return render_unauthorized_action
|
|
end
|
|
else
|
|
# can view observees
|
|
allowed_student_ids = @context.observer_enrollments
|
|
.where(:user_id => @current_user.id, :workflow_state => 'active')
|
|
.where("associated_user_id IS NOT NULL")
|
|
.pluck(:associated_user_id)
|
|
|
|
# can view self?
|
|
if @context.grants_right?(@current_user, session, :read_grades)
|
|
allowed_student_ids << @current_user.id
|
|
end
|
|
return render_unauthorized_action if allowed_student_ids.empty?
|
|
|
|
if all
|
|
student_ids = allowed_student_ids
|
|
else
|
|
# if any student_ids exist that the current_user shouldn't have access to, return an error
|
|
# (student looking at other students, observer looking at student out of their scope)
|
|
inaccessible_students = student_ids - allowed_student_ids
|
|
return render_unauthorized_action unless inaccessible_students.empty?
|
|
end
|
|
end
|
|
|
|
if student_ids.is_a?(Array) && student_ids.length > Api.max_per_page
|
|
return render json: { error: 'too many students' }, status: 400
|
|
end
|
|
|
|
if (enrollment_state = params[:enrollment_state].presence)
|
|
case enrollment_state
|
|
when 'active'
|
|
student_ids = (@section || @context).all_student_enrollments.active_by_date.where(user_id: student_ids).select(:user_id)
|
|
when 'concluded'
|
|
student_ids = (@section || @context).all_student_enrollments.completed_by_date.where(user_id: student_ids).select(:user_id)
|
|
else
|
|
return render json: {error: 'invalid enrollment_state'}, status: :bad_request
|
|
end
|
|
end
|
|
|
|
if value_to_boolean(params[:post_to_sis])
|
|
if student_ids.is_a?(Array)
|
|
student_ids = (@section || @context).all_student_enrollments.
|
|
where(user_id: student_ids).where.not(sis_batch_id: nil).select(:user_id)
|
|
else
|
|
student_ids = student_ids.where.not(sis_batch_id: nil)
|
|
end
|
|
end
|
|
|
|
includes = Array(params[:include])
|
|
|
|
assignment_scope = @context.assignments.published
|
|
assignment_scope = assignment_scope.where(post_to_sis: true) if value_to_boolean(params[:post_to_sis])
|
|
requested_assignment_ids = Array(params[:assignment_ids]).map(&:to_i)
|
|
if requested_assignment_ids.present?
|
|
assignment_scope = assignment_scope.where(:id => requested_assignment_ids)
|
|
end
|
|
|
|
if params[:grading_period_id].present?
|
|
assignments = GradingPeriod.active.find(params[:grading_period_id]).assignments(assignment_scope)
|
|
else
|
|
assignments = assignment_scope.to_a
|
|
end
|
|
|
|
if requested_assignment_ids.present? && (requested_assignment_ids - assignments.map(&:id)).present?
|
|
return render json: { error: 'invalid assignment ids requested'}, status: :forbidden
|
|
end
|
|
|
|
assignment_visibilities = {}
|
|
assignment_visibilities = AssignmentStudentVisibility.users_with_visibility_by_assignment(course_id: @context.id, user_id: student_ids, assignment_id: assignments.map(&:id))
|
|
|
|
# unless teacher, filter assignments down to only assignments current user can see
|
|
unless @context.grants_any_right?(@current_user, :read_as_admin, :manage_grades, :manage_assignments)
|
|
assignments = assignments.select{ |a| (assignment_visibilities.fetch(a.id,[]) & student_ids).any?}
|
|
end
|
|
|
|
# preload with stuff already in memory
|
|
assignments.each { |a| a.context = @context }
|
|
assignments_hash = assignments.index_by(&:id)
|
|
|
|
if params[:grouped].present?
|
|
scope = (@section || @context).all_student_enrollments.
|
|
eager_load(:user => :pseudonyms).
|
|
where("users.id" => student_ids)
|
|
|
|
submissions_scope = Submission.active.where(user_id: student_ids, assignment_id: assignments)
|
|
if params[:workflow_state].present?
|
|
submissions_scope = submissions_scope.where(:workflow_state => params[:workflow_state])
|
|
end
|
|
submissions = submissions_scope.preload(:originality_reports, :quiz_submission).to_a
|
|
bulk_load_attachments_and_previews(submissions)
|
|
submissions_for_user = submissions.group_by(&:user_id)
|
|
|
|
seen_users = Set.new
|
|
result = []
|
|
show_sis_info = context.grants_any_right?(@current_user, :read_sis, :manage_sis)
|
|
scope.each do |enrollment|
|
|
student = enrollment.user
|
|
next if seen_users.include?(student.id)
|
|
seen_users << student.id
|
|
hash = { :user_id => student.id, :section_id => enrollment.course_section_id, :submissions => [] }
|
|
|
|
pseudonym = SisPseudonym.for(student, context)
|
|
if pseudonym && show_sis_info
|
|
hash[:integration_id] = pseudonym.integration_id
|
|
hash[:sis_user_id] = pseudonym.sis_user_id
|
|
end
|
|
|
|
student_submissions = submissions_for_user[student.id] || []
|
|
student_submissions = student_submissions.select{ |s|
|
|
assignment_visibilities.fetch(s.assignment_id, []).include?(s.user_id) || can_view_all
|
|
}
|
|
|
|
if assignments.present?
|
|
student_submissions.each do |submission|
|
|
# we've already got all the assignments loaded, so bypass AR loading
|
|
# here and just give the submission its assignment
|
|
next unless (assignment = assignments_hash[submission.assignment_id])
|
|
submission.assignment = assignment
|
|
submission.user = student
|
|
|
|
visible_assignments = assignment_visibilities.fetch(submission.user_id, [])
|
|
submission.visible_to_user = visible_assignments.include? submission.assignment_id
|
|
hash[:submissions] << submission_json(submission, submission.assignment, @current_user, session, @context, includes, params)
|
|
end
|
|
end
|
|
if includes.include?('total_scores')
|
|
hash.merge!(
|
|
:computed_final_score => enrollment.computed_final_score,
|
|
:computed_current_score => enrollment.computed_current_score
|
|
)
|
|
end
|
|
result << hash
|
|
end
|
|
else
|
|
order_by = params[:order] == "graded_at" ? "graded_at" : :id
|
|
order_direction = params[:order_direction] == "descending" ? "desc nulls last" : "asc"
|
|
order = "#{order_by} #{order_direction}"
|
|
submissions = @context.submissions.except(:order).where(user_id: student_ids).order(order)
|
|
submissions = submissions.where(:assignment_id => assignments)
|
|
submissions = submissions.where(:workflow_state => params[:workflow_state]) if params[:workflow_state].present?
|
|
submissions = submissions.preload(:user, :originality_reports, :quiz_submission)
|
|
|
|
submissions = Api.paginate(submissions, self, polymorphic_url([:api_v1, @section || @context, :student_submissions]))
|
|
Submission.bulk_load_versioned_attachments(submissions)
|
|
Version.preload_version_number(submissions)
|
|
result = submissions.select{ |s|
|
|
assignment_visibilities.fetch(s.assignment_id, []).include?(s.user_id) || can_view_all
|
|
}.map { |s|
|
|
s.assignment = assignments_hash[s.assignment_id]
|
|
visible_assignments = assignment_visibilities.fetch(s.user_id, [])
|
|
s.visible_to_user = visible_assignments.include? s.assignment_id
|
|
submission_json(s, s.assignment, @current_user, session, @context, includes, params)
|
|
}
|
|
end
|
|
|
|
render :json => result
|
|
end
|
|
|
|
# @API Get a single submission
|
|
#
|
|
# Get a single submission, based on user id.
|
|
#
|
|
# @argument include[] [String, "submission_history"|"submission_comments"|"rubric_assessment"|"visibility"|"course"|"user"]
|
|
# Associations to include with the group.
|
|
def show
|
|
@assignment = @context.assignments.active.find(params[:assignment_id])
|
|
@user = get_user_considering_section(params[:user_id])
|
|
@submission = @assignment.submission_for_student(@user)
|
|
bulk_load_attachments_and_previews([@submission])
|
|
|
|
if authorized_action(@submission, @current_user, :read)
|
|
if @context.grants_any_right?(@current_user, :read_as_admin, :manage_grades, :manage_assignments) ||
|
|
@submission.assignment_visible_to_user?(@current_user)
|
|
includes = Array(params[:include])
|
|
@submission.visible_to_user = includes.include?("visibility") ? @assignment.visible_to_user?(@submission.user) : true
|
|
render :json => submission_json(@submission, @assignment, @current_user, session, @context, includes, params)
|
|
else
|
|
@unauthorized_message = t('#application.errors.submission_unauthorized', "You cannot access this submission.")
|
|
return render_unauthorized_action
|
|
end
|
|
end
|
|
end
|
|
|
|
# @API Upload a file
|
|
#
|
|
# Upload a file to a submission.
|
|
#
|
|
# This API endpoint is the first step in uploading a file to a submission as a student.
|
|
# See the {file:file_uploads.html File Upload Documentation} for details on the file upload workflow.
|
|
#
|
|
# The final step of the file upload workflow will return the attachment data,
|
|
# including the new file id. The caller can then POST to submit the
|
|
# +online_upload+ assignment with these file ids.
|
|
#
|
|
def create_file
|
|
@assignment = @context.assignments.active.find(params[:assignment_id])
|
|
@user = get_user_considering_section(params[:user_id])
|
|
permission = @assignment.submission_types.include?("online_upload") ? :submit : :nothing
|
|
# rationale for allowing other user ids at all: eventually, you'll be able
|
|
# to use this api for uploading an attachment to a submission comment.
|
|
# teachers will be able to do that for any submission they can grade, so
|
|
# they need to be able to specify the target user.
|
|
permission = :nothing if @user != @current_user
|
|
if authorized_action(@assignment, @current_user, permission)
|
|
api_attachment_preflight(
|
|
@user, request,
|
|
check_quota: false, # we don't check quota when uploading a file for assignment submission
|
|
folder: @user.submissions_folder(@context) # organize attachment into the course submissions folder
|
|
)
|
|
end
|
|
end
|
|
|
|
# @model RubricAssessment
|
|
# {
|
|
# "id" : "RubricAssessment",
|
|
# "required": ["criterion_id"],
|
|
# "properties": {
|
|
# "criterion_id": {
|
|
# "description": "The ID of the quiz question.",
|
|
# "example": 1,
|
|
# "type": "integer",
|
|
# "format": "int64"
|
|
# },
|
|
# }
|
|
# }
|
|
#
|
|
#
|
|
# @API Grade or comment on a submission
|
|
#
|
|
# Comment on and/or update the grading for a student's assignment submission.
|
|
# If any submission or rubric_assessment arguments are provided, the user
|
|
# must have permission to manage grades in the appropriate context (course or
|
|
# section).
|
|
#
|
|
# @argument comment[text_comment] [String]
|
|
# Add a textual comment to the submission.
|
|
#
|
|
# @argument comment[group_comment] [Boolean]
|
|
# Whether or not this comment should be sent to the entire group (defaults
|
|
# to false). Ignored if this is not a group assignment or if no text_comment
|
|
# is provided.
|
|
#
|
|
# @argument comment[media_comment_id] [String]
|
|
# Add an audio/video comment to the submission. Media comments can be added
|
|
# via this API, however, note that there is not yet an API to generate or
|
|
# list existing media comments, so this functionality is currently of
|
|
# limited use.
|
|
#
|
|
# @argument comment[media_comment_type] [String, "audio"|"video"]
|
|
# The type of media comment being added.
|
|
#
|
|
# @argument comment[file_ids][] [Integer]
|
|
# Attach files to this comment that were previously uploaded using the
|
|
# Submission Comment API's files action
|
|
#
|
|
# @argument include[visibility] [String]
|
|
# Whether this assignment is visible to the owner of the submission
|
|
#
|
|
# @argument submission[posted_grade] [String]
|
|
# Assign a score to the submission, updating both the "score" and "grade"
|
|
# fields on the submission record. This parameter can be passed in a few
|
|
# different formats:
|
|
#
|
|
# points:: A floating point or integral value, such as "13.5". The grade
|
|
# will be interpreted directly as the score of the assignment.
|
|
# Values above assignment.points_possible are allowed, for awarding
|
|
# extra credit.
|
|
# percentage:: A floating point value appended with a percent sign, such as
|
|
# "40%". The grade will be interpreted as a percentage score on the
|
|
# assignment, where 100% == assignment.points_possible. Values above 100%
|
|
# are allowed, for awarding extra credit.
|
|
# letter grade:: A letter grade, following the assignment's defined letter
|
|
# grading scheme. For example, "A-". The resulting score will be the high
|
|
# end of the defined range for the letter grade. For instance, if "B" is
|
|
# defined as 86% to 84%, a letter grade of "B" will be worth 86%. The
|
|
# letter grade will be rejected if the assignment does not have a defined
|
|
# letter grading scheme. For more fine-grained control of scores, pass in
|
|
# points or percentage rather than the letter grade.
|
|
# "pass/complete/fail/incomplete":: A string value of "pass" or "complete"
|
|
# will give a score of 100%. "fail" or "incomplete" will give a score of
|
|
# 0.
|
|
#
|
|
# Note that assignments with grading_type of "pass_fail" can only be
|
|
# assigned a score of 0 or assignment.points_possible, nothing inbetween. If
|
|
# a posted_grade in the "points" or "percentage" format is sent, the grade
|
|
# will only be accepted if the grade equals one of those two values.
|
|
#
|
|
# @argument submission[excuse] [Boolean]
|
|
# Sets the "excused" status of an assignment.
|
|
#
|
|
# @argument submission[late_policy_status] [String]
|
|
# Sets the late policy status to either "late", "missing", "none", or null.
|
|
#
|
|
# @argument submission[seconds_late_override] [Integer]
|
|
# Sets the seconds late if late policy status is "late"
|
|
#
|
|
# @argument rubric_assessment [RubricAssessment]
|
|
# Assign a rubric assessment to this assignment submission. The
|
|
# sub-parameters here depend on the rubric for the assignment. The general
|
|
# format is, for each row in the rubric:
|
|
#
|
|
# The points awarded for this row.
|
|
# rubric_assessment[criterion_id][points]
|
|
#
|
|
# Comments to add for this row.
|
|
# rubric_assessment[criterion_id][comments]
|
|
#
|
|
#
|
|
# For example, if the assignment rubric is (in JSON format):
|
|
# !!!javascript
|
|
# [
|
|
# {
|
|
# 'id': 'crit1',
|
|
# 'points': 10,
|
|
# 'description': 'Criterion 1',
|
|
# 'ratings':
|
|
# [
|
|
# { 'description': 'Good', 'points': 10 },
|
|
# { 'description': 'Poor', 'points': 3 }
|
|
# ]
|
|
# },
|
|
# {
|
|
# 'id': 'crit2',
|
|
# 'points': 5,
|
|
# 'description': 'Criterion 2',
|
|
# 'ratings':
|
|
# [
|
|
# { 'description': 'Complete', 'points': 5 },
|
|
# { 'description': 'Incomplete', 'points': 0 }
|
|
# ]
|
|
# }
|
|
# ]
|
|
#
|
|
# Then a possible set of values for rubric_assessment would be:
|
|
# rubric_assessment[crit1][points]=3&rubric_assessment[crit2][points]=5&rubric_assessment[crit2][comments]=Well%20Done.
|
|
def update
|
|
@assignment = @context.assignments.active.find(params[:assignment_id])
|
|
@user = get_user_considering_section(params[:user_id])
|
|
|
|
@submission = @assignment.all_submissions.find_or_create_by!(user: @user)
|
|
|
|
authorized = if params[:submission] || params[:rubric_assessment]
|
|
authorized_action(@submission, @current_user, :grade)
|
|
else
|
|
authorized_action(@submission, @current_user, :comment)
|
|
end
|
|
|
|
if authorized
|
|
submission = { grader: @current_user }
|
|
if params[:submission].is_a?(ActionController::Parameters)
|
|
submission[:grade] = params[:submission].delete(:posted_grade)
|
|
submission[:excuse] = params[:submission].delete(:excuse)
|
|
if params[:submission].key?(:late_policy_status)
|
|
submission[:late_policy_status] = params[:submission].delete(:late_policy_status)
|
|
end
|
|
if params[:submission].key?(:seconds_late_override)
|
|
submission[:seconds_late_override] = params[:submission].delete(:seconds_late_override)
|
|
end
|
|
submission[:provisional] = value_to_boolean(params[:submission][:provisional])
|
|
submission[:final] = value_to_boolean(params[:submission][:final]) && @context.grants_right?(@current_user, :moderate_grades)
|
|
if params[:submission][:submission_type] == 'basic_lti_launch' && (!@submission.has_submission? || @submission.submission_type == 'basic_lti_launch')
|
|
submission[:submission_type] = params[:submission][:submission_type]
|
|
submission[:url] = params[:submission][:url]
|
|
end
|
|
end
|
|
if submission[:grade] || submission[:excuse]
|
|
begin
|
|
@submissions = @assignment.grade_student(@user, submission)
|
|
rescue Assignment::GradeError => e
|
|
logger.info "GRADES: grade_student failed because '#{e.message}'"
|
|
return render json: { error: e.to_s }, status: 400
|
|
end
|
|
@submission = @submissions.first
|
|
else
|
|
@submission = @assignment.find_or_create_submission(@user) if @submission.new_record?
|
|
@submissions ||= [@submission]
|
|
end
|
|
if submission.key?(:late_policy_status) || submission.key?(:seconds_late_override)
|
|
@submission.late_policy_status = submission[:late_policy_status] if submission.key?(:late_policy_status)
|
|
if @submission.late_policy_status == 'late' && submission[:seconds_late_override].present?
|
|
@submission.seconds_late_override = submission[:seconds_late_override]
|
|
end
|
|
@submission.save!
|
|
end
|
|
|
|
assessment = params[:rubric_assessment]
|
|
if assessment.is_a?(ActionController::Parameters) && @assignment.rubric_association
|
|
if (assessment.keys & @assignment.rubric_association.rubric.criteria_object.map{|c| c.id.to_s}).empty?
|
|
return render :json => {:message => "invalid rubric_assessment"}, :status => :bad_request
|
|
end
|
|
|
|
# prepend each key with "criterion_", which is required by the current
|
|
# RubricAssociation#assess code.
|
|
assessment.keys.each do |crit_name|
|
|
assessment["criterion_#{crit_name}"] = assessment.delete(crit_name)
|
|
end
|
|
|
|
@rubric_assessment = @assignment.rubric_association.assess(
|
|
assessor: @current_user,
|
|
user: @user,
|
|
artifact: @submission,
|
|
assessment: assessment.merge(assessment_type: 'grading')
|
|
)
|
|
end
|
|
|
|
comment = params[:comment]
|
|
if comment.is_a?(ActionController::Parameters)
|
|
admin_in_context = !@context_enrollment || @context_enrollment.admin?
|
|
comment = {
|
|
comment: comment[:text_comment],
|
|
author: @current_user,
|
|
hidden: @assignment.muted? && admin_in_context
|
|
}.merge(
|
|
comment.permit(:media_comment_id, :media_comment_type, :group_comment).to_unsafe_h
|
|
).with_indifferent_access
|
|
comment[:provisional] = value_to_boolean(submission[:provisional])
|
|
if (file_ids = params[:comment][:file_ids])
|
|
attachments = Attachment.where(id: file_ids).to_a
|
|
attachable = attachments.all? { |a|
|
|
a.grants_right?(@current_user, :attach_to_submission_comment)
|
|
}
|
|
unless attachable
|
|
render_unauthorized_action
|
|
return
|
|
end
|
|
attachments.each { |a| a.ok_for_submission_comment = true }
|
|
comment[:attachments] = attachments
|
|
end
|
|
@assignment.update_submission(@submission.user, comment)
|
|
end
|
|
# We need to reload because some of this stuff is getting set on the
|
|
# submission without going through the model instance -- it'd be nice to
|
|
# fix this at some point.
|
|
@submission.reload
|
|
bulk_load_attachments_and_previews([@submission])
|
|
|
|
includes = %w(submission_comments)
|
|
includes.concat(Array.wrap(params[:include]) & ['visibility'])
|
|
includes << 'provisional_grades' if submission[:provisional]
|
|
|
|
visiblity_included = includes.include?("visibility")
|
|
if visiblity_included
|
|
user_ids = @submissions.map(&:user_id)
|
|
users_with_visibility = AssignmentStudentVisibility.where(course_id: @context, assignment_id: @assignment, user_id: user_ids).pluck(:user_id).to_set
|
|
end
|
|
json = submission_json(@submission, @assignment, @current_user, session, @context, includes, params)
|
|
|
|
includes.delete("submission_comments")
|
|
Version.preload_version_number(@submissions)
|
|
json[:all_submissions] = @submissions.map do |submission|
|
|
if visiblity_included
|
|
submission.visible_to_user = users_with_visibility.include?(submission.user_id)
|
|
end
|
|
|
|
submission_json(submission, @assignment, @current_user, session, @context, includes, params)
|
|
end
|
|
render :json => json
|
|
end
|
|
end
|
|
|
|
# @API List gradeable students
|
|
#
|
|
# A paginated list of students eligible to submit the assignment. The caller must have permission to view grades.
|
|
#
|
|
# Section-limited instructors will only see students in their own sections.
|
|
#
|
|
# returns [UserDisplay]
|
|
def gradeable_students
|
|
if authorized_action(@context, @current_user, [:manage_grades, :view_all_grades])
|
|
@assignment = @context.assignments.active.find(params[:assignment_id])
|
|
includes = Array(params[:include])
|
|
student_scope = context.students_visible_to(@current_user, include: :inactive)
|
|
student_scope = @assignment.students_with_visibility(student_scope)
|
|
student_scope = student_scope.order(:id)
|
|
students = Api.paginate(student_scope, self, api_v1_course_assignment_gradeable_students_url(@context, @assignment))
|
|
if (include_pg = includes.include?('provisional_grades'))
|
|
return unless authorized_action(@context, @current_user, :moderate_grades)
|
|
submissions = @assignment.submissions.where(user_id: students).preload(:provisional_grades).index_by(&:user_id)
|
|
selections = @assignment.moderated_grading_selections.where(student_id: students).index_by(&:student_id)
|
|
end
|
|
render :json => students.map { |student|
|
|
json = user_display_json(student, @context)
|
|
if include_pg
|
|
selection = selections[student.id]
|
|
json.merge!(in_moderation_set: selection.present?,
|
|
selected_provisional_grade_id: selection && selection.selected_provisional_grade_id)
|
|
sub = submissions[student.id]
|
|
pg_list = if sub
|
|
submission_provisional_grades_json(sub, @assignment, @current_user, includes)
|
|
else
|
|
[]
|
|
end
|
|
json.merge!({ provisional_grades: pg_list })
|
|
end
|
|
json
|
|
}
|
|
end
|
|
end
|
|
|
|
# @API List multiple assignments gradeable students
|
|
#
|
|
# @argument assignment_ids[] [String]
|
|
# Assignments being requested
|
|
#
|
|
# A paginated list of students eligible to submit a list of assignments. The caller must have
|
|
# permission to view grades for the requested course.
|
|
#
|
|
# Section-limited instructors will only see students in their own sections.
|
|
#
|
|
# @example_response
|
|
# A [UserDisplay] with an extra assignment_ids field to indicate what assignments
|
|
# that user can submit
|
|
#
|
|
# [
|
|
# {
|
|
# "id": 2,
|
|
# "display_name": "Display Name",
|
|
# "avatar_image_url": "http://avatar-image-url.jpeg",
|
|
# "html_url": "http://canvas.com",
|
|
# "assignment_ids": [1, 2, 3]
|
|
# }
|
|
# ]
|
|
def multiple_gradeable_students
|
|
if authorized_action(@context, @current_user, [:manage_grades, :view_all_grades])
|
|
assignment_ids = Array(params[:assignment_ids])
|
|
|
|
student_scope = context.students_visible_to(@current_user, include: :inactive)
|
|
student_scope = student_scope.
|
|
preload(:assignment_student_visibilities).
|
|
joins(:assignment_student_visibilities).
|
|
where(:assignment_student_visibilities =>
|
|
{
|
|
:assignment_id => assignment_ids,
|
|
:course_id => @context.id
|
|
}).
|
|
distinct.
|
|
order(:id)
|
|
|
|
students = Api.paginate(student_scope, self, api_v1_multiple_assignments_gradeable_students_url(@context))
|
|
|
|
student_displays = students.map do |student|
|
|
user_display = user_display_json(student, @context)
|
|
user_display['assignment_ids'] = student.assignment_student_visibilities.
|
|
select { |visibility| assignment_ids.include?(visibility.assignment_id.to_s) }.
|
|
map(&:assignment_id)
|
|
user_display
|
|
end
|
|
|
|
render :json => student_displays
|
|
end
|
|
end
|
|
|
|
# @API Grade or comment on multiple submissions
|
|
#
|
|
# Update the grading and comments on multiple student's assignment
|
|
# submissions in an asynchronous job.
|
|
#
|
|
# The user must have permission to manage grades in the appropriate context
|
|
# (course or section).
|
|
#
|
|
# @argument grade_data[<student_id>][posted_grade] [String]
|
|
# See documentation for the posted_grade argument in the
|
|
# {api:SubmissionsApiController#update Submissions Update} documentation
|
|
#
|
|
# @argument grade_data[<student_id>][excuse] [Boolean]
|
|
# See documentation for the excuse argument in the
|
|
# {api:SubmissionsApiController#update Submissions Update} documentation
|
|
#
|
|
# @argument grade_data[<student_id>][rubric_assessment] [RubricAssessment]
|
|
# See documentation for the rubric_assessment argument in the
|
|
# {api:SubmissionsApiController#update Submissions Update} documentation
|
|
#
|
|
# @argument grade_data[<student_id>][text_comment] [String]
|
|
# @argument grade_data[<student_id>][group_comment] [Boolean]
|
|
# @argument grade_data[<student_id>][media_comment_id] [String]
|
|
# @argument grade_data[<student_id>][media_comment_type] [String, "audio"|"video"]
|
|
# @argument grade_data[<student_id>][file_ids][] [Integer]
|
|
# See documentation for the comment[] arguments in the
|
|
# {api:SubmissionsApiController#update Submissions Update} documentation
|
|
# @argument grade_data[<student_id>][assignment_id] [Integer]
|
|
# Specifies which assignment to grade. This argument is not necessary when
|
|
# using the assignment-specific endpoints.
|
|
#
|
|
# @example_request
|
|
#
|
|
# curl 'https://<canvas>/api/v1/courses/1/assignments/2/submissions/update_grades' \
|
|
# -X POST \
|
|
# -F 'grade_data[3][posted_grade]=88' \
|
|
# -F 'grade_data[4][posted_grade]=95' \
|
|
# -H "Authorization: Bearer <token>"
|
|
#
|
|
# @returns Progress
|
|
def bulk_update
|
|
grade_data = params[:grade_data].to_unsafe_h
|
|
unless grade_data.is_a?(Hash) && grade_data.present?
|
|
return render :json => "'grade_data' parameter required", :status => :bad_request
|
|
end
|
|
|
|
# singular case doesn't require the user to pass an assignment_id in
|
|
# grade_data, so we do it for them
|
|
if params[:assignment_id]
|
|
grade_data = {params[:assignment_id] => grade_data}
|
|
end
|
|
|
|
assignment_ids = grade_data.keys
|
|
@assignments = @context.assignments.active.find(assignment_ids)
|
|
|
|
unless @assignments.all?(&:published?) &&
|
|
@context.grants_right?(@current_user, session, :manage_grades)
|
|
return render_unauthorized_action
|
|
end
|
|
|
|
progress = Submission.queue_bulk_update(@context, @section, @current_user, grade_data)
|
|
render :json => progress_json(progress, @current_user, session)
|
|
end
|
|
|
|
# @API Mark submission as read
|
|
#
|
|
# No request fields are necessary.
|
|
#
|
|
# On success, the response will be 204 No Content with an empty body.
|
|
#
|
|
# @example_request
|
|
#
|
|
# curl 'https://<canvas>/api/v1/courses/<course_id>/assignments/<assignment_id>/submissions/<user_id>/read.json' \
|
|
# -X PUT \
|
|
# -H "Authorization: Bearer <token>" \
|
|
# -H "Content-Length: 0"
|
|
def mark_submission_read
|
|
change_topic_read_state("read")
|
|
end
|
|
|
|
# @API Mark submission as unread
|
|
#
|
|
# No request fields are necessary.
|
|
#
|
|
# On success, the response will be 204 No Content with an empty body.
|
|
#
|
|
# @example_request
|
|
#
|
|
# curl 'https://<canvas>/api/v1/courses/<course_id>/assignments/<assignment_id>/submissions/<user_id>/read.json' \
|
|
# -X DELETE \
|
|
# -H "Authorization: Bearer <token>"
|
|
def mark_submission_unread
|
|
change_topic_read_state("unread")
|
|
end
|
|
|
|
def map_user_ids(user_ids)
|
|
Api.map_ids(user_ids, User, @domain_root_account, @current_user)
|
|
end
|
|
|
|
# @API Submission Summary
|
|
#
|
|
# Returns the number of submissions for the given assignment based on gradeable students
|
|
# that fall into three categories: graded, ungraded, not submitted.
|
|
#
|
|
# @example_response
|
|
# {
|
|
# "graded": 5,
|
|
# "ungraded": 10,
|
|
# "not_submitted": 42
|
|
# }
|
|
def submission_summary
|
|
if authorized_action(@context, @current_user, [:manage_grades, :view_all_grades])
|
|
@assignment = @context.assignments.active.find(params[:assignment_id])
|
|
student_scope = @context.students_visible_to(@current_user)
|
|
.where("enrollments.type<>'StudentViewEnrollment'").distinct
|
|
student_scope = @assignment.students_with_visibility(student_scope)
|
|
student_ids = student_scope.pluck(:id)
|
|
|
|
graded = @context.submissions.graded.where(user_id: student_ids, assignment_id: @assignment).count
|
|
ungraded = @context.submissions.
|
|
needs_grading.having_submission.
|
|
where(user_id: student_ids, assignment_id: @assignment, excused: [nil, false]).
|
|
count
|
|
not_submitted = student_ids.count - graded - ungraded
|
|
|
|
render json: {graded: graded, ungraded: ungraded, not_submitted: not_submitted}
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def change_topic_read_state(new_state)
|
|
@assignment = @context.assignments.active.find(params[:assignment_id])
|
|
@user = get_user_considering_section(params[:user_id])
|
|
@submission = @assignment.submissions.find_or_create_by!(user: @user)
|
|
|
|
render_state_change_result @submission.change_read_state(new_state, @current_user)
|
|
end
|
|
|
|
# the result of several state change functions are the following:
|
|
# nil - no current user
|
|
# true - state is already set to the requested state
|
|
# participant with errors - something went wrong with the participant
|
|
# participant with no errors - the change went through
|
|
# this function renders a 204 No Content for a success, or a Bad Request
|
|
# for failure with participant errors if there are any
|
|
def render_state_change_result(result)
|
|
if result == true || result.try(:errors).blank?
|
|
head :no_content
|
|
else
|
|
render :json => result.try(:errors) || {}, :status => :bad_request
|
|
end
|
|
end
|
|
|
|
def get_user_considering_section(user_id)
|
|
students = @context.students_visible_to(@current_user, include: :priors)
|
|
if @section
|
|
students = students.where(:enrollments => { :course_section_id => @section })
|
|
end
|
|
api_find(students, user_id)
|
|
end
|
|
|
|
def section_ids
|
|
@section ? [@section.id] : nil
|
|
end
|
|
|
|
def bulk_load_attachments_and_previews(submissions)
|
|
Submission.bulk_load_versioned_attachments(submissions)
|
|
attachments = submissions.flat_map &:versioned_attachments
|
|
ActiveRecord::Associations::Preloader.new.preload(attachments,
|
|
[:canvadoc, :crocodoc_document])
|
|
Version.preload_version_number(submissions)
|
|
end
|
|
|
|
def bulk_process_submissions_for_visibility(submissions_scope, includes)
|
|
result = []
|
|
|
|
submissions_scope.find_in_batches(batch_size: 100) do |submission_batch|
|
|
bulk_load_attachments_and_previews(submission_batch)
|
|
user_ids = submission_batch.map(&:user_id)
|
|
users_with_visibility = AssignmentStudentVisibility.where(
|
|
course_id: @context,
|
|
assignment_id: @assignment,
|
|
user_id: user_ids
|
|
).pluck(:user_id).to_set
|
|
|
|
submission_array = submission_batch.map do |submission|
|
|
submission.visible_to_user = users_with_visibility.include?(submission.user_id)
|
|
submission_json(submission, @assignment, @current_user, session, @context, includes, params)
|
|
end
|
|
|
|
result.concat(submission_array)
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
end
|