allowed_attempts enforcement

* Adds a validation on the backend to
  prevent a new submission when necessary
* Adds indicators to the UI that are shown
  when allowed_attempts are set on an
  assignment

Test Plan:

* Set allowed_attempts and extra_attempts for an
  assignment and student, using the instructions from
  the test plan on PFS-11440
* Validate that the UI is updated to show Attempts and
  Allowed Attempts in the following locations:
  - Student assignment submission page
  - Teacher assignment page
  - Student Assignment Details page
* Create multiple submissions as a student until you
  reach the max allowed for that student. Validate that
  the "Submit Assignment" button is now disabled.
* Use the API to try and submit an assignment (since
  the UI won't let you):
  https://canvas.instructure.com/doc/api/submissions.html#method.submissions.create
  Validate that the submission errors out and is not created.
* Validate that if "allowed_attempts" is not set on an assignment,
  that there are no changes to the UI on the pages listed above.

refs PFS-11441

Change-Id: I76a81d4d3c58ca5266645a3cb7b3d3cbc5bedb22
Reviewed-on: https://gerrit.instructure.com/173320
Tested-by: Jenkins
Product-Review: Bryce Stevenson <bstevenson@instructure.com>
QA-Review: Marisa Jense <mjense@instructure.com>
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
This commit is contained in:
Nate Collings 2018-11-26 12:46:14 -07:00 committed by Nathan Collings
parent 248fae8293
commit 579a702b8d
9 changed files with 315 additions and 6 deletions

View File

@ -65,7 +65,13 @@ module AssignmentsHelper
if assignment.expects_submission? && can_do(assignment, user, :submit)
submit_text = user_submission.try(:has_submission?) ? I18n.t("Re-submit Assignment") : I18n.t("Submit Assignment")
late = user_submission.try(:late?) ? "late" : ""
link_to(submit_text, '#', :role => "button", :class => "Button Button--primary submit_assignment_link #{late}")
link_to(
submit_text,
'#',
:role => "button",
:class => "Button Button--primary submit_assignment_link #{late}",
:disabled => user_submission && user_submission.attempts_left == 0,
)
end
end

View File

@ -58,6 +58,8 @@ class Submission < ActiveRecord::Base
}.freeze
}.freeze
SUBMISSION_TYPES_GOVERNED_BY_ALLOWED_ATTEMPTS = %w[online_upload online_url online_text_entry].freeze
attr_readonly :assignment_id
attr_accessor :visible_to_user,
:skip_grade_calc,
@ -119,6 +121,7 @@ class Submission < ActiveRecord::Base
validates :late_policy_status, inclusion: ['none', 'missing', 'late'], allow_nil: true
validate :ensure_grader_can_grade
validate :extra_attempts_can_only_be_set_on_online_uploads
validate :ensure_attempts_are_in_range
scope :active, -> { where("submissions.workflow_state <> 'deleted'") }
scope :for_enrollments, -> (enrollments) { where(user_id: enrollments.select(:user_id)) }
@ -1518,14 +1521,26 @@ class Submission < ActiveRecord::Base
def extra_attempts_can_only_be_set_on_online_uploads
return true unless changes.key?("extra_attempts") && assignment
allowed_submission_types = %w[online_upload online_url online_text_entry]
return true if (assignment.submission_types.split(",") & allowed_submission_types).any?
return true if (assignment.submission_types.split(",") & SUBMISSION_TYPES_GOVERNED_BY_ALLOWED_ATTEMPTS).any?
error_msg = 'extra_attempts can only be set on submissions for an assignment with a type of online_upload, online_url, or online_text_entry'
error_msg = 'can only be set on submissions for an assignment with a type of online_upload, online_url, or online_text_entry'
errors.add(:extra_attempts, error_msg)
false
end
def attempts_left
return nil if self.assignment.allowed_attempts.nil? || self.assignment.allowed_attempts < 0
[0, self.assignment.allowed_attempts + (self.extra_attempts || 0) - (self.attempt || 0)].max
end
def ensure_attempts_are_in_range
return true unless changes.key?("submitted_at") && assignment
return true unless (assignment.submission_types.split(",") & SUBMISSION_TYPES_GOVERNED_BY_ALLOWED_ATTEMPTS).any?
return true if attempts_left.nil? || attempts_left > 0
errors.add(:attempt, 'you have reached the maximum number of allowed attempts for this assignment')
false
end
def can_autograde?
result = GRADE_STATUS_MESSAGES_MAP[can_autograde_symbolic_status]
result ||= { status: false, message: I18n.t('Cannot autograde at this time') }

View File

@ -62,6 +62,15 @@
}
.submission-details-header__info {
margin-top: $ic-sp / 3;
display: flex;
.submission-details-header__attempts_info {
margin: 0px 0px 0px 40px;
> span {
margin: 0px 20px 0px 0px;
}
}
}
.submission-details-header__time {
margin-#{direction(right)}: $ic-sp / 4;

View File

@ -139,7 +139,7 @@
padding-#{direction(right)}: 0.5em;
}
.value {
padding-#{direction(right)}: 2.5em;
padding-#{direction(right)}: 2.0em;
}
}
.form-horizontal.display-only .control-group {

View File

@ -151,6 +151,16 @@
</li>
<% end %>
<% end %>
<% if @assignment.allowed_attempts&.> 0 %>
<li>
<span class='title'><%= t :attempts, 'Attempts' %></span>
<span class='value'><%= @current_user_submission&.attempt || 0 %></span>
</li>
<li>
<span class='title'><%= t :allowed_attempts, 'Allowed Attempts' %></span>
<span class='value'><%= @assignment.allowed_attempts + (@current_user_submission&.extra_attempts || 0) %></span>
</li>
<% end %>
<%= render :partial => "shared/available_dates", :locals => {:association => @assignment} %>
<div class="clear"></div>
</ul>
@ -192,6 +202,14 @@
</div>
</div>
<% end -%>
<% if @assignment.allowed_attempts&.> 0 %>
<div class="control-group">
<div class="control-label"><%= t :allowed_attempts, 'Allowed Attempts' %></div>
<div class="controls">
<span class="value"><%= @assignment.allowed_attempts %></span>
</div>
</div>
<% end -%>
<% if turnitin_active? %>
<div class="control-group">
<div class="control-label"><%= t :turnitin, 'Turnitin' %></div>

View File

@ -165,6 +165,16 @@
<span class="submission-missing-pill"></span>
<% end %>
<% end %>
<% if @assignment.allowed_attempts&.> 0 %>
<div class="submission-details-header__attempts_info">
<span class="bold"><%= t :attempts, 'Attempts' %></span>
<span><%= @submission.attempt || 0 %></span>
</div>
<div class="submission-details-header__attempts_info">
<span class="bold"><%= t :allowed_attempts, 'Allowed Attempts' %></span>
<span><%= @assignment.allowed_attempts + (@submission.extra_attempts || 0) %></span>
</div>
<% end %>
</div>
</div>
<div class="ic-Action-header__Secondary ic-Action-header__Secondary--auto">
@ -175,7 +185,7 @@
</a>
<% end %>
<% if @assignment.expects_submission? && can_do(@assignment, @current_user, :submit) && @current_user == @submission.user %>
<a href="<%= context_url(@context, :context_assignment_url, @assignment.id) %>#submit" class="Button Button--primary" >
<a href="<%= context_url(@context, :context_assignment_url, @assignment.id) %>#submit" class="Button Button--primary" <%= 'disabled' if @submission&.attempts_left == 0 %>>
<%= @submission.has_submission? ? t('links.resubmit', "Re-submit Assignment") : t('links.submit', "Submit Assignment") %>
</a>
<% end %>

View File

@ -119,4 +119,43 @@ describe AssignmentsHelper do
expect(turnitin_active?).to be_falsey
end
end
describe "#assignment_submission_button" do
before do
student_in_course
assignment_model(course: @course)
@assignment.update_attribute(:submission_types, "online_upload")
allow(self).to receive(:can_do).and_return true
end
context "the submission has 0 attempts left" do
it "returns a disabled button" do
@assignment.update_attribute(:allowed_attempts, 2)
submission = @assignment.submissions.find_by!(user_id: @student)
submission.update_attribute(:attempt, 2)
button = assignment_submission_button(@assignment, @student, submission)
expect(button["disabled"]).to eq("disabled")
end
end
context "the submission has > 0 attempts left" do
it "returns an enabled button" do
@assignment.update_attribute(:allowed_attempts, 2)
submission = @assignment.submissions.find_by!(user_id: @student)
submission.update_attribute(:attempt, 1)
button = assignment_submission_button(@assignment, @student, submission)
expect(button["disabled"]).to be_nil
end
end
context "the submission has unlimited attempts" do
it "returns an enabled button" do
@assignment.update_attribute(:allowed_attempts, -1)
submission = @assignment.submissions.find_by!(user_id: @student)
submission.update_attribute(:attempt, 3)
button = assignment_submission_button(@assignment, @student, submission)
expect(button["disabled"]).to be_nil
end
end
end
end

View File

@ -5853,4 +5853,110 @@ describe Submission do
end
end
end
describe '#ensure_attempts_are_in_range' do
let(:submission) { @assignment.submissions.first }
context 'the assignment is of a type that is restricted by attempts' do
before do
@assignment.allowed_attempts = 10
@assignment.submission_types = 'online_upload'
@assignment.save!
end
context 'attempts_left <= 0' do
before do
submission.attempt = 10
submission.save!
end
context 'the submitted_at changed' do
it 'is invalid' do
submission.submitted_at = Time.zone.now
expect(submission).to_not be_valid
end
end
context 'the submitted_at did not change' do
it 'is valid' do
expect(submission).to be_valid
end
end
end
end
context 'the assignment is of a type that is not restricted by attempts' do
before do
@assignment.allowed_attempts = 10
@assignment.submission_types = 'online_discussion'
@assignment.save!
submission.attempt = 10
submission.save!
end
it 'is valid' do
expect(submission).to be_valid
end
end
end
describe '#attempts_left' do
let(:submission) { @assignment.submissions.first }
context 'allowed_attempts is set to a number > 0 on the assignment' do
before do
@assignment.allowed_attempts = 10
@assignment.submission_types = 'online_upload'
@assignment.save!
end
context 'the submission has extra_attempts set to a value > 0' do
it 'returns assignment.allowed_attempts + submission.extra_attempts - submission.attempt' do
submission.extra_attempts = 12
submission.attempt = 6
submission.save!
expect(submission.attempts_left).to eq(10 + 12 - 6)
end
it 'correctly recalculates when allowed_attempts and extra_attempts change' do
submission.extra_attempts = 12
submission.attempt = 22
submission.save!
expect(submission.attempts_left).to eq(0)
@assignment.allowed_attempts = 11
@assignment.save!
expect(submission.attempts_left).to eq(1)
submission.extra_attempts = 13
submission.save!
expect(submission.attempts_left).to eq(2)
end
it 'will never return negative values' do
submission.attempt = 1000
submission.save!
expect(submission.attempts_left).to eq(0)
end
end
context 'the submission has extra_attempts set to nil' do
it 'returns allowed_attempts from the assignment' do
submission.extra_attempts = nil
submission.attempt = 6
submission.save!
expect(submission.attempts_left).to eq(10 - 6)
end
end
end
context 'allowed_attempts is set to nil or -1 on the assignment' do
it 'returns nil' do
@assignment.allowed_attempts = nil
@assignment.save!
expect(submission.attempts_left).to be_nil
@assignment.allowed_attempts = -1
@assignment.save!
expect(submission.attempts_left).to be_nil
end
end
end
end

View File

@ -0,0 +1,106 @@
#
# Copyright (C) 2018 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
require_relative '../common'
require_relative '../helpers/assignments_common'
describe "allowed_attempts feature for assignments" do
include_context "in-process server selenium tests"
include AssignmentsCommon
context "as a student" do
before do
course_with_student_logged_in
end
describe "the assignments page" do
context "with allowed_attempts on the assignment" do
before do
@assignment = @course.assignments.create!({ name: "Test Assignment", allowed_attempts: 2 })
@assignment.update_attribute(:submission_types, "online_text_entry")
end
it "prevents submitting if the student has exceeded the max number of attempts" do
submission = @assignment.submit_homework(@student, { body: "blah" })
submission.update_attributes(attempt: 2, submission_type: "online_text_entry")
get "/courses/#{@course.id}/assignments/#{@assignment.id}"
expect(f(".student-assignment-overview")).to include_text("Allowed Attempts")
expect(f('.submit_assignment_link')).to be_disabled
end
it "allows submitting if the student has not exceeded the max number of attempts" do
get "/courses/#{@course.id}/assignments/#{@assignment.id}"
expect(f(".student-assignment-overview")).to include_text("Allowed Attempts")
expect(f('.submit_assignment_link')).to_not be_disabled
end
end
context "without allowed_attempts on the assignment" do
before do
@assignment = @course.assignments.create!({ name: "Test Assignment", allowed_attempts: -1 })
@assignment.update_attribute(:submission_types, "online_text_entry")
@assignment.submit_homework(@student, { body: "blah" })
end
it "does not show the attempt data" do
get "/courses/#{@course.id}/assignments/#{@assignment.id}"
expect(f(".student-assignment-overview")).to_not include_text("Allowed Attempts")
expect(f('.submit_assignment_link')).to_not be_disabled
end
end
end
describe "the assignments detail page" do
context "with allowed_attempts on the assignment" do
before do
@assignment = @course.assignments.create!({ name: "Test Assignment", allowed_attempts: 2 })
@assignment.update_attribute(:submission_types, "online_text_entry")
@submission = @assignment.submit_homework(@student, { body: "blah" })
@submission.update_attributes(submission_type: "online_text_entry")
end
it "prevents submitting if the student has exceeded the max number of attempts" do
@submission.update_attributes(attempt: 2)
get "/courses/#{@course.id}/assignments/#{@assignment.id}/submissions/#{@student.id}"
expect(f(".submission-details-header__info")).to include_text("Allowed Attempts")
expect(fln("Re-submit Assignment")).to be_disabled
end
it "allows submitting if the student has not exceeded the max number of attempts" do
get "/courses/#{@course.id}/assignments/#{@assignment.id}/submissions/#{@student.id}"
expect(f(".submission-details-header__info")).to include_text("Allowed Attempts")
expect(fln("Re-submit Assignment")).to_not be_disabled
end
end
context "without allowed_attempts on the assignment" do
before do
@assignment = @course.assignments.create!({ name: "Test Assignment", allowed_attempts: -1 })
@assignment.update_attribute(:submission_types, "online_text_entry")
@submission = @assignment.submit_homework(@student, { body: "blah" })
@submission.update_attributes(attempt: 2, submission_type: "online_text_entry")
end
it "does not show the attempt data and allows submitting" do
get "/courses/#{@course.id}/assignments/#{@assignment.id}/submissions/#{@student.id}"
expect(f(".submission-details-header__info")).to_not include_text("Allowed Attempts")
expect(fln("Re-submit Assignment")).to_not be_disabled
end
end
end
end
end