canvas-lms/app/controllers/submissions_api_controller.rb

322 lines
14 KiB
Ruby

#
# Copyright (C) 2011 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.
class SubmissionsApiController < ApplicationController
before_filter :get_course_from_section, :require_context
include Api::V1::Submission
# @API List assignment submissions
#
# Get all existing submissions for an assignment.
#
# @argument include[] ["submission_history"|"submission_comments"|"rubric_assessment"|"assignment"] Associations to include with the group.
#
# Fields include:
# assignment_id:: The unique identifier for the assignment.
# user_id:: The id of the user who submitted the assignment.
# submitted_at:: The timestamp when the assignment was submitted, if an actual submission has been made.
# score:: The raw score for the assignment submission.
# attempt:: If multiple submissions have been made, this is the attempt number.
# body:: The content of the submission, if it was submitted directly in a text field.
# grade:: The grade for the submission, translated into the assignment grading scheme (so a letter grade, for example).
# grade_matches_current_submission:: A boolean flag which is false if the student has re-submitted since the submission was last graded.
# preview_url:: Link to the URL in canvas where the submission can be previewed. This will require the user to log in.
# submitted_at:: Timestamp when the submission was made.
# url:: If the submission was made as a URL.
def index
if authorized_action(@context, @current_user, :manage_grades)
@assignment = @context.assignments.active.find(params[:assignment_id])
@submissions = @assignment.submissions.all(
:conditions => { :user_id => visible_user_ids })
includes = Array(params[:include])
result = @submissions.map { |s| submission_json(s, @assignment, @current_user, session, @context, includes) }
render :json => result.to_json
end
end
# @API List submissions for multiple assignments
#
# Get all existing submissions for a given set of students and assignments.
#
# @argument student_ids[] List of student ids to return submissions for. At least one is required.
# @argument assignment_ids[] List of assignments to return submissions for. If none are given, submissions for all assignments are returned.
# @argument grouped If this argument is present, the response will be grouped by student, rather than a flat array of submissions.
# @argument include[] ["submission_history"|"submission_comments"|"rubric_assessment"|"assignment"|"total_scores"] 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 authorized_action(@context, @current_user, :manage_grades)
raise ActiveRecord::RecordNotFound if params[:student_ids].blank?
student_ids = map_user_ids(params[:student_ids]).map(&:to_i) & visible_user_ids
return render(:json => []) if student_ids.blank?
includes = Array(params[:include])
assignment_scope = @context.assignments.active
requested_assignment_ids = Array(params[:assignment_ids]).map(&:to_i)
if requested_assignment_ids.present?
assignment_scope = assignment_scope.scoped(:conditions => { 'assignments.id' => requested_assignment_ids })
end
assignments = assignment_scope.all
assignments_hash = {}
assignments.each { |a| assignments_hash[a.id] = a }
# sadly hackish -- see User.submissions_for_given_assignments
Api.assignment_ids_for_students_api = assignments.map(&:id)
sql_includes = { :user => [] }
sql_includes[:user] << :submissions_for_given_assignments unless assignments.empty?
scope = (@section || @context).student_enrollments.scoped(
:include => sql_includes,
:conditions => { 'users.id' => student_ids })
result = scope.map do |enrollment|
student = enrollment.user
hash = { :user_id => student.id, :submissions => [] }
student.submissions_for_given_assignments.each do |submission|
# we've already got all the assignments loaded, so bypass AR loading
# here and just give the submission its assignment
submission.assignment = assignments_hash[submission.assignment_id]
hash[:submissions] << submission_json(submission, submission.assignment, @current_user, session, @context, includes)
end unless assignments.empty?
if includes.include?('total_scores') && params[:grouped].present?
hash.merge!(
:computed_final_score => enrollment.computed_final_score,
:computed_current_score => enrollment.computed_current_score)
end
hash
end
unless params[:grouped].present?
result = result.inject([]) { |arr, user_info| arr.concat(user_info[:submissions]) }
end
render :json => result
end
end
# @API Get a single submission
#
# Get a single submission, based on user id.
#
# @argument include[] ["submission_history"|"submission_comments"|"rubric_assessment"] Associations to include with the group.
def show
@assignment = @context.assignments.active.find(params[:assignment_id])
@user = get_user_considering_section(params[:id])
@submission = @assignment.submission_for_student(@user)
if authorized_action(@submission, @current_user, :read)
includes = Array(params[:include])
render :json => submission_json(@submission, @assignment, @current_user, session, @context, includes).to_json
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
# we don't check quota when uploading a file for assignment submission
if authorized_action(@assignment, @current_user, permission)
api_attachment_preflight(@user, request, :check_quota => false)
end
end
# @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] 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] 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] ["audio"|"video"] The type of media comment being added.
#
# @argument submission[posted_grade] 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 rubric_assessment 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:
#
# rubric_assessment[criterion_id][points]:: The points awarded for this row.
# rubric_assessment[criterion_id][comments]:: Comments to add for this row.
#
# 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[:id])
authorized = false
if params[:submission] || params[:rubric_assessment]
authorized = authorized_action(@context, @current_user, :manage_grades)
else
@submission = @assignment.find_or_create_submission(@user)
authorized = authorized_action(@submission, @current_user, :comment)
end
if authorized
submission = {}
if params[:submission].is_a?(Hash)
submission[:grade] = params[:submission].delete(:posted_grade)
end
if submission[:grade]
@submission = @assignment.grade_student(@user, submission).first
else
@submission ||= @assignment.find_or_create_submission(@user)
end
assessment = params[:rubric_assessment]
if assessment.is_a?(Hash) && @assignment.rubric_association
# 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?(Hash)
comment = {
:comment => comment[:text_comment], :author => @current_user }.merge(
comment.slice(:media_comment_id, :media_comment_type, :group_comment)
).with_indifferent_access
@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
render :json => submission_json(@submission, @assignment, @current_user, session, @context, %w(submission_comments)).to_json
end
end
def map_user_ids(user_ids)
Api.map_ids(user_ids, User, @domain_root_account)
end
def get_user_considering_section(user_id)
scope = @context.students_visible_to(@current_user)
if @section
scope = scope.scoped(:conditions => { 'enrollments.course_section_id' => @section.id })
end
api_find(scope, user_id)
end
def visible_user_ids
scope = if @section
@context.enrollments_visible_to(@current_user, :section_ids => [@section.id])
else
@context.enrollments_visible_to(@current_user)
end
scope.all(:select => :user_id).map(&:user_id)
end
end