canvas-lms/spec/lib/gradebook_importer_spec.rb

2050 lines
79 KiB
Ruby

# frozen_string_literal: true
#
# 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/>.
#
require_relative '../spec_helper'
require 'csv'
describe GradebookImporter do
let(:gradebook_user) do
teacher = User.create!
course_with_teacher(user: teacher, course: @course)
teacher
end
context "construction" do
let!(:gradebook_course) { course_model }
it "requires a context, usually a course" do
user = user_model
progress = Progress.create!(tag: "test", context: @user)
upload = GradebookUpload.new
expect { GradebookImporter.new(upload) }
.to raise_error(ArgumentError, "Must provide a valid context for this gradebook.")
upload = GradebookUpload.create!(course: gradebook_course, user: gradebook_user, progress: progress)
expect { GradebookImporter.new(upload, valid_gradebook_contents, user, progress) }
.not_to raise_error
end
it "stores the context and make it available" do
new_gradebook_importer
expect(@gi.context).to be_is_a(Course)
end
it "requires the contents of an upload" do
progress = Progress.create!(tag: "test", context: @user)
upload = GradebookUpload.create!(course: gradebook_course, user: gradebook_user, progress: progress)
expect { GradebookImporter.new(upload) }
.to raise_error(ArgumentError, "Must provide attachment.")
end
it "handles points possible being sorted in weird places" do
importer_with_rows(
'Student,ID,Section,Assignment 1,Final Score',
'"Blend, Bill",6,My Course,-,',
'Points Possible,,,10,',
'"Farner, Todd",4,My Course,-,'
)
expect(@gi.assignments.length).to eq 1
expect(@gi.assignments.first.points_possible).to eq 10
expect(@gi.students.length).to eq 2
end
it "handles muted line and being sorted in weird places" do
importer_with_rows(
'Student,ID,Section,Assignment 1,Final Score',
'"Blend, Bill",6,My Course,-,',
'Points Possible,,,10,',
', ,,Muted,',
'"Farner, Todd",4,My Course,-,'
)
expect(@gi.assignments.length).to eq 1
expect(@gi.assignments.first.points_possible).to eq 10
expect(@gi.students.length).to eq 2
end
it "ignores the line denoting manually posted assignments if present" do
importer_with_rows(
'Student,ID,Section,Assignment 1,Final Score',
'Points Possible,,,10,',
', ,,Manual Posting,',
'"Blend, Bill",6,My Course,-,',
'"Farner, Todd",4,My Course,-,'
)
expect(@gi.students.length).to eq 2
end
it "expects and deals with invalid upload files" do
user = user_model
progress = Progress.create!(tag: "test", context: @user)
upload = GradebookUpload.new
upload = GradebookUpload.create!(course: gradebook_course, user: gradebook_user, progress: progress)
expect do
GradebookImporter.create_from(progress, upload, user, invalid_gradebook_contents)
end.to raise_error(Delayed::RetriableError)
end
it "ignores the line denoting manually posted anonymous assignments if present" do
importer_with_rows(
'Student,ID,Section,Assignment 1,Final Score',
'Points Possible,,,10,',
', ,,Manual Posting (scores cachés aux instructeurs),',
'"Blend, Bill",6,My Course,-,',
'"Farner, Todd",4,My Course,-,'
)
expect(@gi.students.length).to eq 2
end
context 'when dealing with a file containing semicolon field separators' do
context 'with interspersed commas to throw you off' do
before(:each) do
@rows = [
'Student;ID;Section;Aufgabe 1;Aufgabe 2;Final Score',
'Points Possible;;;10000,54;100,00;',
'"Merkel 1,0, Angela";1;Mein Kurs;123,4;57,4%;',
'"Einstein 1,1, Albert";2;Mein Kurs;1.234,5;4.200,3%;',
'"Curie, Marie";3;Mein Kurs;12.34,5;4.20.0,3%;',
'"Planck, Max";4;Mein Kurs;-1.234,50;-4.200,30%;',
'"Bohr, Neils";5;Mein Kurs;1.234.5;4.200.3%;',
'"Dirac, Paul";6;Mein Kurs;1,234,5;4,200,3%;'
]
importer_with_rows(*@rows)
end
it 'parses out assignments only' do
expect(@gi.assignments.length).to eq 2
end
it 'parses out points_possible correctly' do
expect(@gi.assignments.first.points_possible).to eq(10_000.54)
end
it 'parses out students correctly' do
expect(@gi.students.length).to eq 6
end
it 'does not reformat numbers that are part of strings' do
expect(@gi.students.first.name).to eq('Merkel 1,0, Angela')
end
it 'normalizes pure numbers' do
expected_grades = %w[123.4 1234.5 1234.5 -1234.50 1234.5 1234.5]
actual_grades = @gi.upload.gradebook.fetch('students').map { |student| student.fetch('submissions').first.fetch('grade') }
expect(actual_grades).to match_array(expected_grades)
end
it 'normalizes percentages' do
expected_grades = %w[57.4% 4200.3% 4200.3% -4200.30% 4200.3% 4200.3%]
actual_grades = @gi.upload.gradebook.fetch('students').map { |student| student.fetch('submissions').second.fetch('grade') }
expect(actual_grades).to match_array(expected_grades)
end
end
context 'without any interspersed commas' do
before(:each) do
@rows = [
'Student;ID;Section;Aufgabe 1;Aufgabe 2;Final Score',
'Points Possible;;;10000,54;100,00;',
'"Angela Merkel";1;Mein Kurs;123,4;57,4%;',
'"Albert Einstein";2;Mein Kurs;1.234,5;4.200,3%;',
'"Marie Curie";3;Mein Kurs;12.34,5;4.20.0,3%;',
'"Max Planck";4;Mein Kurs;-1.234,50;-4.200,30%;',
'"Neils Bohr";5;Mein Kurs;1.234.5;4.200.3%;',
'"Paul Dirac";6;Mein Kurs;1,234,5;4,200,3%;'
]
importer_with_rows(*@rows)
end
it 'parses out assignments only' do
expect(@gi.assignments.length).to eq 2
end
it 'parses out points_possible correctly' do
expect(@gi.assignments.first.points_possible).to eq(10_000.54)
end
it 'parses out students correctly' do
expect(@gi.students.length).to eq 6
end
it 'does not reformat numbers that are part of strings' do
expect(@gi.students.first.name).to eq('Angela Merkel')
end
it 'normalizes pure numbers' do
expected_grades = %w[123.4 1234.5 1234.5 -1234.50 1234.5 1234.5]
actual_grades = @gi.upload.gradebook.fetch('students').map { |student| student.fetch('submissions').first.fetch('grade') }
expect(actual_grades).to match_array(expected_grades)
end
it 'normalizes percentages' do
expected_grades = %w[57.4% 4200.3% 4200.3% -4200.30% 4200.3% 4200.3%]
actual_grades = @gi.upload.gradebook.fetch('students').map { |student| student.fetch('submissions').second.fetch('grade') }
expect(actual_grades).to match_array(expected_grades)
end
end
end
context "when dealing with a file containing comma field separators" do
let(:rows) do
[
'Student,ID,Section,Assignment 1,Assignment 2,Final Score',
'Points Possible,,,1000,50000,',
'C. Iulius Caesar,1,,123.43,"45,678.12",99%',
'Cn. Pompeius Magnus,2,,"123,32","45.678,23",99%',
]
end
let(:importer) { importer_with_rows(rows) }
let(:students) { importer.upload.gradebook.fetch('students') }
context "with values that use a period as a decimal separator" do
let(:grades) { students.first.fetch('submissions').map { |submission| submission.fetch('grade') } }
it "normalizes values with no thousands separator" do
expect(grades.first).to eq "123.43"
end
it "normalizes values using a comma as the thousands separator" do
expect(grades.second).to eq "45678.12"
end
end
context "with values that use a comma as the decimal separator" do
let(:grades) { students.second.fetch('submissions').map { |submission| submission.fetch('grade') } }
it "normalizes values with no thousands separator" do
expect(grades.first).to eq "123.32"
end
it "normalizes values using a period as the thousands separator" do
expect(grades.second).to eq "45678.23"
end
end
end
it "creates a GradebookUpload" do
new_gradebook_importer
expect(GradebookUpload.where(course_id: @course, user_id: @user)).not_to be_empty
end
context "when attachment and gradebook_upload is provided" do
let(:attachment) do
a = attachment_model
file = Tempfile.new("gradebook.csv")
file.puts("'Student,ID,Section,Assignment 1,Final Score'\n")
file.puts("\"Blend, Bill\",6,My Course,-,\n")
file.close
allow(a).to receive(:open).and_return(file)
return a
end
let(:progress) { Progress.create!(tag: "test", context: gradebook_user) }
let(:upload) do
GradebookUpload.create!(course: gradebook_course, user: gradebook_user, progress: progress)
end
let(:importer) { new_gradebook_importer(attachment, upload, gradebook_user, progress) }
it "hangs onto the provided model for streaming" do
expect(importer.attachment).to eq(attachment)
end
it "nils out contents when using an attachment (saves on memory to not parse all at once)" do
expect(importer.contents).to be_nil
end
it "keeps the provided upload rather than creating a new one" do
expect(importer.upload).to eq(upload)
end
it "sets the uploads course as the importer context" do
expect(importer.context).to eq(gradebook_course)
end
end
end
context "User lookup" do
it "Lookups with either Student Name, ID, SIS User ID, or SIS Login ID" do
course_model
student_in_course(:name => "Some Name", active_all: true)
@u1 = @user
user_with_pseudonym(:active_all => true)
@user.pseudonym.sis_user_id = "SISUSERID"
@user.pseudonym.save!
student_in_course(user: @user, active_all: true)
@u2 = @user
user_with_pseudonym(:active_all => true, :username => "something_that_has_not_been_taken")
student_in_course(user: @user, active_all: true)
@u3 = @user
user_with_pseudonym(:active_all => true, :username => "inactive_login")
@user.pseudonym.destroy
student_in_course(user: @user, active_all: true)
@u4 = @user
user_with_pseudonym(:active_all => true, :username => "inactive_login")
@user.pseudonym.destroy
@user.pseudonyms.create!(:unique_id => 'active_login', :account => Account.default)
student_in_course(user: @user, active_all: true)
@u5 = @user
uploaded_csv = CSV.generate do |csv|
csv << ["Student", "ID", "SIS User ID", "SIS Login ID", "Section", "Assignment 1"]
csv << [" Points Possible", "", "", "", ""]
csv << [@u1.name, "", "", "", "", 99]
csv << ["", "", @u2.pseudonym.sis_user_id, "", "", 99]
csv << ["", "", "", @u3.pseudonym.unique_id, "", 99]
csv << ["", "", "", 'inactive_login', "", 99]
csv << ["", "", "", 'active_login', "", 99]
csv << ["", "", "bogusSISid", "", "", 99]
end
importer_with_rows(uploaded_csv)
hash = @gi.as_json
expect(hash[:students][0][:id]).to eq @u1.id
expect(hash[:students][0][:previous_id]).to eq @u1.id
expect(hash[:students][0][:name]).to eql(@u1.name)
expect(hash[:students][1][:id]).to eq @u2.id
expect(hash[:students][1][:previous_id]).to eq @u2.id
expect(hash[:students][2][:id]).to eq @u3.id
expect(hash[:students][2][:previous_id]).to eq @u3.id
# Looking up by login, but there are no active pseudonyms for u4
expect(hash[:students][3][:id]).to be < 0
expect(hash[:students][3][:previous_id]).to be_nil
expect(hash[:students][4][:id]).to eq @u5.id
expect(hash[:students][4][:previous_id]).to eq @u5.id
expect(hash[:students][5][:id]).to be < 0
expect(hash[:students][5][:previous_id]).to be_nil
end
it "Lookups by root account" do
course_model
student_in_course(name: "Some Name", active_all: true)
@u1 = @user
account2 = Account.create!
p = @u1.pseudonyms.create!(account: account2, unique_id: 'uniqueid')
p.sis_user_id = 'SISUSERID'
p.save!
expect(Account).to receive(:find_by_domain).with('account2').and_return(account2)
uploaded_csv = CSV.generate do |csv|
csv << ["Student", "ID", "SIS User ID", "SIS Login ID", "Root Account", "Section", "Assignment 1"]
csv << [" Points Possible", "", "", "", "", ""]
csv << ["", "", @u1.pseudonym.sis_user_id, "", "account2", "", 99]
end
importer_with_rows(uploaded_csv)
hash = @gi.as_json
expect(hash[:students][0][:id]).to eq @u1.id
expect(hash[:students][0][:previous_id]).to eq @u1.id
expect(hash[:students][0][:name]).to eql(@u1.name)
end
it "ignores integration_id when present" do
course_model
student_in_course(name: "Some Name", active_all: true)
u1 = @user
uploaded_csv = CSV.generate do |csv|
csv << ["Student", "ID", "SIS User ID", "SIS Login ID", "Integration ID", "Section", "Assignment 1"]
csv << [" Points Possible", "", "", "", "", ""]
csv << ["", u1.id, "", "", "", "", 99]
end
importer_with_rows(uploaded_csv)
hash = @gi.as_json
expect(hash[:students][0][:id]).to eq u1.id
expect(hash[:students][0][:previous_id]).to eq u1.id
expect(hash[:students][0][:name]).to eql(u1.name)
end
it "allows ids that look like numbers" do
course_model
user_with_pseudonym(:active_all => true)
@user.pseudonym.sis_user_id = "0123456"
@user.pseudonym.save!
student_in_course(user: @user, active_all: true)
@u0 = @user
# user with an sis-id that is a number
user_with_pseudonym(:active_all => true, :username => "octal_ud")
@user.pseudonym.destroy
@user.pseudonyms.create!(:unique_id => '0231163', :account => Account.default)
student_in_course(user: @user, active_all: true)
@u1 = @user
uploaded_csv = CSV.generate do |csv|
csv << ["Student", "ID", "SIS User ID", "SIS Login ID", "Section", "Assignment 1"]
csv << [" Points Possible", "", "", "", ""]
csv << ["", "", "0123456", "", "", 99]
csv << ["", "", "", "0231163", "", 99]
end
importer_with_rows(uploaded_csv)
hash = @gi.as_json
expect(hash[:students][0][:id]).to eq @u0.id
expect(hash[:students][0][:previous_id]).to eq @u0.id
expect(hash[:students][1][:id]).to eq @u1.id
expect(hash[:students][1][:previous_id]).to eq @u1.id
end
it "fails and updates progress if invalid header row" do
uploaded_csv = CSV.generate do |csv|
csv << ["", "", "0123456", "", "", 99]
csv << ["", "", "", "0231163", "", 99]
end
importer_with_rows(uploaded_csv)
@progress.reload
expect(@progress).to be_failed
expect(@progress.message).to eq 'Invalid header row'
end
end
it "strips leading and trailing spaces from grades" do
rows = [
"Student;ID;Section;Aufgabe 1;Aufgabe 2;Final Score",
"Points Possible;;;10000,54;100,00;",
"'Merkel, Angela';1;Mein Kurs; 123,4;57,4%;",
"'Einstein, Albert';2;Mein Kurs;1234,5 ;4.200,3%;"
]
importer = importer_with_rows(*rows)
grades = importer.upload.gradebook.fetch("students").map { |s| s.fetch("submissions").first.fetch("grade") }
expect(grades).to match_array ["123,4", "1234,5"]
end
it "parses new and existing assignments" do
course_model
@assignment1 = @course.assignments.create!(:name => 'Assignment 1')
@assignment3 = @course.assignments.create!(:name => 'Assignment 3')
importer_with_rows(
'Student,ID,Section,Assignment 1,Assignment 2',
'Some Student,,,,'
)
expect(@gi.assignments.length).to eq 2
expect(@gi.assignments.first).to eq @assignment1
expect(@gi.assignments.last.title).to eq 'Assignment 2'
expect(@gi.assignments.last).to be_new_record
expect(@gi.assignments.last.id).to be < 0
expect(@gi.missing_assignments).to eq [@assignment3]
end
GradebookImporter::NON_ASSIGNMENT_COLUMN_HEADERS.each do |header|
it "does not parse assignments with name that matches #{header}" do
course = course_model
@assignment1 = course.assignments.create!(name: 'Assignment 1')
@assignment2 = course.assignments.create!(name: header)
importer_with_rows(
"Student,ID,Section,Assignment 1,#{header}",
'Some Student,,,,10'
)
expect(@gi.assignments).to eq [@assignment1]
end
end
it "parses assignments correctly with existing custom columns" do
course_model
@assignment1 = @course.assignments.create! name: 'Assignment 1'
@assignment3 = @course.assignments.create! name: 'Assignment 3'
@custom_column1 = @course.custom_gradebook_columns.create! title: "Custom Column 1"
importer_with_rows(
'Student,ID,Section,Custom Column 1,Assignment 1,Assignment 2',
'Some Student,,,,,'
)
expect(@gi.assignments.length).to eq 2
expect(@gi.assignments.first).to eq @assignment1
expect(@gi.assignments.last.title).to eq 'Assignment 2'
expect(@gi.assignments.last).to be_new_record
expect(@gi.assignments.last.id).to be < 0
expect(@gi.missing_assignments).to eq [@assignment3]
end
it "parses CSVs with the SIS Login ID column" do
course = course_model
user = user_model
progress = Progress.create!(tag: "test", context: @user)
upload = GradebookUpload.create!(course: course, user: @user, progress: progress)
importer = GradebookImporter.new(
upload, valid_gradebook_contents_with_sis_login_id, user, progress
)
expect { importer.parse! }.not_to raise_error
end
it "parses CSVs with semicolons" do
course = course_model
user = user_model
progress = Progress.create!(tag: "test", context: @user)
upload = GradebookUpload.create!(course: course, user: @user, progress: progress)
new_gradebook_importer(
attachment_with_rows(
'Student;ID;Section;An Assignment',
'A Student;1;Section 13;2',
'Another Student;2;Section 13;10'
),
upload,
user,
progress
)
expect(upload.gradebook["students"][1]["name"]).to eql 'Another Student'
end
it "parses CSVs with commas" do
course = course_model
user = user_model
progress = Progress.create!(tag: "test", context: @user)
upload = GradebookUpload.create!(course: course, user: @user, progress: progress)
new_gradebook_importer(
attachment_with_rows(
'Student,ID,Section,An Assignment',
'A Student,1,Section 13,2',
'Another Student,2,Section 13,10'
),
upload,
user,
progress
)
expect(upload.gradebook["students"][1]["name"]).to eql 'Another Student'
end
it "parses arbitrarily ordered assignments" do
course = course_model
group1 = course.assignment_groups.create!(name: "first group", position: 1)
group2 = course.assignment_groups.create!(name: "second group", position: 2)
group3 = course.assignment_groups.create!(name: "third group", position: 3)
assignment1 = course.assignments.create!(name: "Assignment 1", assignment_group: group1)
assignment2 = course.assignments.create!(name: "Assignment 2", assignment_group: group2)
assignment3 = course.assignments.create!(name: "Assignment 3", assignment_group: group3)
importer_with_rows(
'Student,ID,Section,Assignment 2,Assignment 3,Assignment 1',
'Student 1,,,,,'
)
expect(@gi.assignments).to include(assignment1, assignment2, assignment3)
end
it "does not include missing assignments if no new assignments" do
course_model
@assignment1 = @course.assignments.create!(:name => 'Assignment 1')
@assignment3 = @course.assignments.create!(:name => 'Assignment 3')
importer_with_rows(
'Student,ID,Section,Assignment 1',
'Some Student,,,'
)
expect(@gi.assignments).to eq [@assignment1]
expect(@gi.missing_assignments).to eq []
end
it "does not include assignments with no changes" do
course_model
@assignment1 = @course.assignments.create!(:name => 'Assignment 1', :points_possible => 10)
importer_with_rows(
"Student,ID,Section,Assignment 1"
)
expect(@gi.assignments).to eq []
expect(@gi.missing_assignments).to eq []
end
it "doesn't include readonly assignments" do
course_model
@assignment1 = @course.assignments.create!(:name => 'Assignment 1', :points_possible => 10)
@assignment1 = @course.assignments.create!(:name => 'Assignment 2', :points_possible => 10)
importer_with_rows(
'Student,ID,Section,Assignment 1,Readonly,Assignment 2',
' Points Possible,,,,(read only),'
)
expect(@gi.assignments).to eq []
expect(@gi.missing_assignments).to eq []
end
it "includes assignments that changed only in points possible" do
course_model
@assignment1 = @course.assignments.create!(:name => 'Assignment 1', :points_possible => 10)
importer_with_rows(
"Student,ID,Section,Assignment 1",
"Points Possible,,,20"
)
expect(@gi.assignments).to eq [@assignment1]
expect(@gi.assignments.first).to be_changed
expect(@gi.assignments.first.points_possible).to eq 20
end
it "does not create assignments for the totals columns after assignments" do
course_model
@assignment1 = @course.assignments.create!(:name => 'Assignment 1', :points_possible => 10)
importer_with_rows(
"Student,ID,Section,Assignment 1,Current Points,Final Points,Current Score,Final Score,Final Grade",
"Points Possible,,,20,,,,,"
)
expect(@gi.assignments).to eq [@assignment1]
expect(@gi.missing_assignments).to be_empty
end
it "does not create assignments for arbitrarily placed totals columns" do
course_model
@assignment1 = @course.assignments.create!(:name => 'Assignment 1', :points_possible => 10)
@assignment2 = @course.assignments.create!(:name => 'Assignment 2', :points_possible => 10)
importer_with_rows(
"Student,ID,Section,Final Score,Assignment 1,Current Points,Assignment 2,Final Points,Current Score,Final Grade",
"Points Possible,,,(read only),20,(read only),20,,,"
)
expect(@gi.assignments).to include(@assignment1, @assignment2)
expect(@gi.assignments.map(&:title)).to_not include('Final Score', 'Current Points')
expect(@gi.missing_assignments).to be_empty
end
it "does not create assignments for unposted columns" do
course_model
@assignment1 = @course.assignments.create!(:name => 'Assignment 1', :points_possible => 10)
importer_with_rows(
"Student,ID,Section,Assignment 1,Current Points,Final Points,Unposted Current Score," \
"Unposted Final Score,Unposted Final Grade",
"Points Possible,,,20,,,,,"
)
expect(@gi.assignments).to eq [@assignment1]
expect(@gi.missing_assignments).to be_empty
end
describe "override columns" do
it "does not create assignments for the Override Score or Override Grade column" do
course_model
@assignment1 = @course.assignments.create!(name: 'Assignment 1', points_possible: 10)
importer_with_rows(
"Student,ID,Section,Assignment 1,Current Points,Final Points,Override Score,Override Grade",
"Points Possible,,,20,,,,"
)
aggregate_failures do
expect(@gi.assignments).to eq [@assignment1]
expect(@gi.missing_assignments).to be_empty
end
end
end
it "parses new and existing users" do
course_with_student(active_all: true)
@student1 = @student
e = student_in_course
e.update_attribute :workflow_state, 'completed'
concluded_student = @student
@student2 = user_factory
@course.enroll_student(@student2)
importer_with_rows(
"Student,ID,Section,Assignment 1",
",#{@student1.id},,10",
"New Student,,,12",
",#{concluded_student.id},,10"
)
expect(@gi.students.length).to eq 2 # doesn't include concluded_student
expect(@gi.students.first).to eq @student1
expect(@gi.students.last).to be_new_record
expect(@gi.students.last.id).to be < 0
expect(@gi.missing_students).to eq [@student2]
end
it "does not include assignments that don't have any grade changes" do
course_with_student
course_with_teacher(course: @course)
@assignment1 = @course.assignments.create!(:name => 'Assignment 1', :points_possible => 10)
@assignment1.grade_student(@student, grade: 10, grader: @teacher)
importer_with_rows(
"Student,ID,Section,Assignment 1",
",#{@student.id},,10"
)
expect(@gi.assignments).to eq []
end
it "checks for score changes at a precision of 2 decimal places" do
course_with_student
course_with_teacher(course: @course)
@assignment1 = @course.assignments.create!(name: 'Assignment 1', points_possible: 10)
@assignment1.grade_student(@student, grade: 10.987, grader: @teacher)
importer_with_rows(
"Student,ID,Section,Assignment 1",
",#{@student.id},,10.99"
)
expect(@gi.assignments).to eq []
end
it "includes assignments that the grade changed for an existing user" do
course_with_student(active_all: true)
@assignment1 = @course.assignments.create!(:name => 'Assignment 1', :points_possible => 10)
@assignment1.grade_student(@student, grade: 8, grader: @teacher)
importer_with_rows(
"Student,ID,Section,Assignment 1",
",#{@student.id},,10"
)
expect(@gi.assignments).to eq [@assignment1]
submission = @gi.upload.gradebook.fetch('students').first.fetch('submissions').first
expect(submission['original_grade']).to eq '8.0'
expect(submission['grade']).to eq '10'
expect(submission['assignment_id']).to eq @assignment1.id
end
context "anonymous assignments" do
before(:each) do
@student = User.create!
course_with_student(user: @student, active_all: true)
@assignment = @course.assignments.create!(name: "Assignment 1", anonymous_grading: true, points_possible: 10)
@assignment.grade_student(@student, grade: 8, grader: @teacher)
end
it "does not include grade changes for anonymous unposted assignments" do
importer_with_rows(
"Student,ID,Section,Assignment 1",
",#{@student.id},,10"
)
expect(@gi.assignments).to be_empty
end
it "includes grade changes for anonymous posted assignments" do
@assignment.post_submissions
importer_with_rows(
"Student,ID,Section,Assignment 1",
",#{@student.id},,10"
)
expect(@gi.assignments).not_to be_empty
end
end
context "custom gradebook columns" do
let(:uploaded_custom_columns) { @gi.upload.gradebook["custom_columns"] }
let(:uploaded_student_custom_column_data) do
student_data = @gi.upload.gradebook["students"].first
student_data["custom_column_data"]
end
before do
@student = User.create!
course_with_student(course: @course, user: @student, active_enrollment: true)
@course.custom_gradebook_columns.create!({ title: "CustomColumn1", read_only: false })
@course.custom_gradebook_columns.create!({ title: "CustomColumn2", read_only: false })
end
it "includes non read only custom columns" do
importer_with_rows(
"Student,ID,Section,CustomColumn1,CustomColumn2,Assignment 1",
",#{@student.id},,test 1,test 2,10"
)
col = @gi.upload.gradebook.fetch('custom_columns').map do |custom_column|
custom_column.fetch('title')
end
expect(col).to eq ['CustomColumn1', 'CustomColumn2']
end
it "excludes read only custom columns" do
@course.custom_gradebook_columns.create!({ title: "CustomColumn3", read_only: true })
importer_with_rows(
"Student,ID,Section,CustomColumn1,CustomColumn2,CustomColumn3,Assignment 1",
",#{@student.id},,test 1,test 2,test 3,10"
)
col = @gi.upload.gradebook.fetch('custom_columns').find { |custom_column| custom_column.fetch('title') == 'CustomColumn3' }
expect(col).to eq nil
end
it "excludes hidden custom columns" do
@course.custom_gradebook_columns.create!({ title: "CustomColumn3", workflow_state: :hidden })
importer_with_rows(
"Student,ID,Section,CustomColumn1,CustomColumn2,CustomColumn3,Assignment 1",
",#{@student.id},,test 1,test 2,test 3,10"
)
col = @gi.upload.gradebook.fetch('custom_columns').find { |custom_column| custom_column.fetch('title') == 'CustomColumn3' }
expect(col).to eq nil
end
GradebookImporter::GRADEBOOK_IMPORTER_RESERVED_NAMES.each do |reserved_column|
it "excludes custom columns with reserved importer column #{reserved_column}" do
# The custom columns have a validation to prevent this, but since this was allowed for a long time, we will skip
# the validation that blocks us from creating bad column names.
build_col = @course.custom_gradebook_columns.build({ title: reserved_column, read_only: false })
build_col.save!(validate: false)
importer_with_rows(
"Student,ID,Section,CustomColumn1,CustomColumn2,#{reserved_column},Assignment 1",
",#{@student.id},,test 1,test 2,test 3,10"
)
col = @gi.upload.gradebook.fetch('custom_columns').find { |custom_column| custom_column.fetch('title') == reserved_column }
expect(col).to eq nil
end
end
it "expects custom column datum from non read only columns" do
importer_with_rows(
"Student,ID,Section,CustomColumn1,CustomColumn2,Assignment 1",
",#{@student.id},,test 1,test 2,10"
)
col = @gi.upload.gradebook.fetch('students').first.fetch('custom_column_data').map { |custom_column| custom_column.fetch('new_content') }
expect(col).to eq ['test 1', 'test 2']
end
it "does not capture custom columns that are not included in the import" do
importer_with_rows(
"Student,ID,Section,CustomColumn2,Assignment 1",
",#{@student.id},,test 2,10"
)
expect(uploaded_custom_columns).not_to include(hash_including(title: "CustomColumn1"))
end
it "does not attempt to change the values of custom columns that are not included in the import" do
importer_with_rows(
"Student,ID,Section,CustomColumn2,Assignment 1",
",#{@student.id},,test 2,10"
)
column = @course.custom_gradebook_columns.find_by(title: 'CustomColumn1')
expect(uploaded_student_custom_column_data).not_to include(hash_including(column_id: column.id))
end
it "captures new values even if custom columns are in different positions" do
importer_with_rows(
"Student,ID,Section,CustomColumn2,CustomColumn1,Assignment 1",
",#{@student.id},,test 2,test 1,10"
)
column = @course.custom_gradebook_columns.find_by(title: 'CustomColumn2')
column_datum = uploaded_student_custom_column_data.detect { |datum| datum['column_id'] == column.id }
expect(column_datum["new_content"]).to eq "test 2"
end
context "with a deleted custom column" do
before(:each) do
@course.custom_gradebook_columns.find_by(title: "CustomColumn1").destroy
end
it "omits deleted custom columns when they are included in the import" do
importer_with_rows(
"Student,ID,Section,CustomColumn1,CustomColumn2,Assignment 1",
",#{@student.id},,test 1,test 2,10"
)
expect(uploaded_custom_columns.pluck(:title)).not_to include("CustomColumn1")
end
it "ignores deleted custom columns when they are not included in the import" do
importer_with_rows(
"Student,ID,Section,CustomColumn2,Assignment 1",
",#{@student.id},,test 2,10"
)
expect(uploaded_custom_columns.pluck(:title)).not_to include("CustomColumn1")
end
it "supplies the expected new values for non-deleted columns" do
importer_with_rows(
"Student,ID,Section,CustomColumn2,Assignment 1",
",#{@student.id},,NewCustomColumnValue,10"
)
expect(uploaded_student_custom_column_data.first["new_content"]).to eq "NewCustomColumnValue"
end
it "supplies the expected current values for non-deleted columns" do
active_column = @course.custom_gradebook_columns.find_by(title: "CustomColumn2")
active_column.custom_gradebook_column_data.create!(user_id: @student.id, content: "OldCustomColumnValue")
importer_with_rows(
"Student,ID,Section,CustomColumn2,Assignment 1",
",#{@student.id},,NewCustomColumnValue,10"
)
expect(uploaded_student_custom_column_data.first["current_content"]).to eq "OldCustomColumnValue"
end
end
end
context "to_json" do
before do
course_model
end
let(:hash) { new_gradebook_importer.as_json }
let(:student) { hash[:students].first }
let(:submission) { student[:submissions].first }
let(:assignment) { hash[:assignments].first }
describe "simplified json output" do
let(:top_level_keys) do
%i{
assignments custom_columns missing_objects original_submissions
students unchanged_assignments warning_messages
}
end
let(:student_keys) { %i{custom_column_data id last_name_first name previous_id submissions} }
it "has only the specified keys" do
expect(hash.keys).to match_array(top_level_keys)
end
it "a student only has specified keys" do
expect(student.keys).to match_array(student_keys)
end
context "when importing override scores is enabled" do
before(:each) do
Account.site_admin.enable_feature!(:import_override_scores_in_gradebook)
@course.enable_feature!(:final_grades_override)
@course.allow_final_grade_override = true
@course.save!
end
it "includes the override_scores key at the top level" do
expect(hash.keys).to match_array(top_level_keys + [:override_scores])
end
it "include the override_scores key for students" do
expect(student.keys).to match_array(student_keys + [:override_scores])
end
end
it "a submission only has specified keys" do
keys = ["assignment_id", "grade", "gradeable", "original_grade"]
expect(submission.keys.sort).to eql(keys)
end
it "an assignment only has specified keys" do
keys = [:grading_type, :id, :points_possible, :previous_id,
:title]
expect(assignment.keys.sort).to eql(keys)
end
end
end
context "moderated assignments" do
let(:course) { course_model }
let(:user) do
user = User.create!
course.enroll_teacher(user).accept!
user
end
let(:progress) { Progress.create!(tag: "test", context: user) }
before :each do
@existing_moderated_assignment = Assignment.create!(
context: course,
name: 'An Assignment',
moderated_grading: true,
grader_count: 1
)
end
it "allows importing grades of assignments when user is final grader" do
@existing_moderated_assignment.update!(final_grader: user)
upload = GradebookUpload.create!(course: course, user: user, progress: progress)
new_gradebook_importer(
attachment_with_rows(
'Student;ID;Section;An Assignment',
'A Student;1;Section 13;2',
'Another Student;2;Section 13;10'
),
upload,
user,
progress
)
expect(upload.gradebook["students"][1]["submissions"][0]["gradeable"]).to be true
end
it "does not allow importing grades of assignments when user is not final grader" do
upload = GradebookUpload.create!(course: course, user: user, progress: progress)
new_gradebook_importer(
attachment_with_rows(
'Student;ID;Section;An Assignment',
'A Student;1;Section 13;2',
'Another Student;2;Section 13;10'
),
upload,
user,
progress
)
expect(upload.gradebook["students"][1]["submissions"][0]["gradeable"]).to be false
end
end
context "differentiated assignments" do
def setup_DA
course_with_teacher(active_all: true)
@section_one = @course.course_sections.create!(name: 'Section One')
@section_two = @course.course_sections.create!(name: 'Section Two')
@student_one = student_in_section(@section_one)
@student_two = student_in_section(@section_two)
@assignment_one = assignment_model(course: @course, title: "a1")
@assignment_two = assignment_model(course: @course, title: "a2")
differentiated_assignment(assignment: @assignment_one, course_section: @section_one)
differentiated_assignment(assignment: @assignment_two, course_section: @section_two)
end
before :once do
setup_DA
end
it "ignores submissions for students without visibility" do
@assignment_one.grade_student(@student_one, grade: "3", grader: @teacher)
@assignment_two.grade_student(@student_two, grade: "3", grader: @teacher)
importer_with_rows(
"Student,ID,Section,a1,a2",
",#{@student_one.id},#{@section_one.id},7,9",
",#{@student_two.id},#{@section_two.id},7,9"
)
json = @gi.as_json
expect(json[:students][0][:submissions][0]["grade"]).to eq "7"
expect(json[:students][0][:submissions][1]["grade"]).to eq ""
expect(json[:students][1][:submissions][0]["grade"]).to eq ""
expect(json[:students][1][:submissions][1]["grade"]).to eq "9"
end
it "does not break the creation of new assignments" do
importer_with_rows(
"Student,ID,Section,a1,a2,a3",
"#{@student_one.name},#{@student_one.id},,1,2,3"
)
expect(@gi.assignments.last.title).to eq 'a3'
expect(@gi.assignments.last).to be_new_record
expect(@gi.assignments.last.id).to be < 0
submissions = @gi.as_json[:students][0][:submissions]
expect(submissions.length).to eq(2)
expect(submissions.first["grade"]).to eq "1"
expect(submissions.last["grade"]).to eq "3"
end
end
context "with grading periods" do
before(:once) do
account = Account.default
@course = account.courses.create!
@teacher = User.create!
course_with_teacher(course: @course, user: @teacher, active_enrollment: true)
group = account.grading_period_groups.create!
group.enrollment_terms << @course.enrollment_term
@now = Time.zone.now
@closed_period = group.grading_periods.create!(
title: "Closed Period",
start_date: 3.months.ago(@now),
end_date: 1.month.ago(@now),
close_date: 1.month.ago(@now)
)
@active_period = group.grading_periods.create!(
title: "Active Period",
start_date: 1.month.ago(@now),
end_date: 2.months.from_now(@now),
close_date: 2.months.from_now(@now)
)
@closed_assignment = @course.assignments.create!(
name: "Assignment in closed period",
points_possible: 10,
due_at: date_in_closed_period
)
@open_assignment = @course.assignments.create!(
name: "Assignment in open period",
points_possible: 10,
due_at: date_in_open_period
)
end
let(:assignments) { @gi.as_json[:assignments] }
let(:date_in_open_period) { 1.month.from_now(@now) }
let(:date_in_closed_period) { 2.months.ago(@now) }
let(:student_submissions) { @gi.as_json[:students][0][:submissions] }
context "uploading submissions for existing assignments" do
context "assignments without overrides" do
before(:once) do
@student = User.create!
course_with_student(course: @course, user: @student, active_enrollment: true)
end
it "excludes entire assignments if no submissions for the assignment are being uploaded" do
importer_with_rows(
"Student,ID,Section,Assignment in closed period,Assignment in open period",
",#{@student.id},,5,5",
)
assignment_ids = assignments.map { |a| a[:id] }
expect(assignment_ids).to_not include @closed_assignment.id
end
it "includes assignments if there is at least one submission in the assignment being uploaded" do
importer_with_rows(
"Student,ID,Section,Assignment in closed period,Assignment in open period",
",#{@student.id},,5,5",
)
assignment_ids = assignments.map { |a| a[:id] }
expect(assignment_ids).to include @open_assignment.id
end
context "submissions already exist" do
before(:once) do
Timecop.freeze(@closed_period.end_date - 1.day) do
@closed_assignment.grade_student(@student, grade: 8, grader: @teacher)
end
@open_assignment.grade_student(@student, grade: 8, grader: @teacher)
end
it "does not include submissions that fall in closed grading periods" do
importer_with_rows(
"Student,ID,Section,Assignment in closed period,Assignment in open period",
",#{@student.id},,5,5",
)
assignment_ids = student_submissions.map { |s| s['assignment_id'] }
expect(assignment_ids).to_not include @closed_assignment.id
end
it "includes submissions that do not fall in closed grading periods" do
importer_with_rows(
"Student,ID,Section,Assignment in closed period,Assignment in open period",
",#{@student.id},,5,5",
)
assignment_ids = student_submissions.map { |s| s['assignment_id'] }
expect(assignment_ids).to include @open_assignment.id
end
end
context "submissions do not already exist" do
it "does not include submissions that will fall in closed grading periods" do
importer_with_rows(
"Student,ID,Section,Assignment in closed period,Assignment in open period",
",#{@student.id},,5,5",
)
expect(student_submissions.map { |s| s['assignment_id'] }).to_not include @closed_assignment.id
end
it "includes submissions that will not fall in closed grading periods" do
importer_with_rows(
"Student,ID,Section,Assignment in closed period,Assignment in open period",
",#{@student.id},,5,5",
)
expect(student_submissions.map { |s| s['assignment_id'] }).to include @open_assignment.id
end
end
it "marks excused submission as 'EX' even if 'ex' is not capitalized" do
importer_with_rows(
"Student,ID,Section,Assignment in closed period,Assignment in open period",
",#{@student.id},,,eX",
)
expect(student_submissions.first.fetch('grade')).to eq 'EX'
end
end
context "assignments with overrides" do
before(:once) do
section_one = @course.course_sections.create!(name: 'Section One')
@student = student_in_section(section_one)
# set up overrides such that the student has a due date in an open grading period
# for @closed_assignment and a due date in a closed grading period for @open_assignment
@override_in_open_grading_period = @closed_assignment.assignment_overrides.create! do |override|
override.set = section_one
override.due_at_overridden = true
override.due_at = date_in_open_period
end
@open_assignment.assignment_overrides.create! do |override|
override.set = section_one
override.due_at_overridden = true
override.due_at = date_in_closed_period
end
end
it "excludes entire assignments if there are no submissions in the assignment" \
"being uploaded that are gradeable" do
@override_in_open_grading_period.update_attribute(:due_at, date_in_closed_period)
importer_with_rows(
"Student,ID,Section,Assignment in closed period,Assignment in open period",
",#{@student.id},,5,5"
)
assignment_ids = assignments.map { |a| a[:id] }
expect(assignment_ids).not_to include @closed_assignment.id
end
it "includes assignments if there is at least one submission in the assignment" \
"being uploaded that is gradeable (it does not fall in a closed grading period)" do
importer_with_rows(
"Student,ID,Section,Assignment in closed period,Assignment in open period",
",#{@student.id},,5,5"
)
assignment_ids = assignments.map { |a| a[:id] }
expect(assignment_ids).to include @closed_assignment.id
end
context "submissions already exist" do
before(:once) do
Timecop.freeze(@closed_period.end_date - 1.day) do
@closed_assignment.grade_student(@student, grade: 8, grader: @teacher)
@open_assignment.grade_student(@student, grade: 8, grader: @teacher)
end
end
it "does not include submissions that fall in closed grading periods" do
importer_with_rows(
"Student,ID,Section,Assignment in closed period,Assignment in open period",
",#{@student.id},,5,5"
)
assignment_ids = student_submissions.map { |s| s['assignment_id'] }
expect(assignment_ids).not_to include @open_assignment.id
end
it "includes submissions that do not fall in closed grading periods" do
importer_with_rows(
"Student,ID,Section,Assignment in closed period,Assignment in open period",
",#{@student.id},,5,5"
)
assignment_ids = student_submissions.map { |s| s['assignment_id'] }
expect(assignment_ids).to include @closed_assignment.id
end
end
context "submissions do not already exist" do
it "does not include submissions that will fall in closed grading periods" do
importer_with_rows(
"Student,ID,Section,Assignment in closed period,Assignment in open period",
",#{@student.id},,5,5"
)
assignment_ids = student_submissions.map { |s| s['assignment_id'] }
expect(assignment_ids).to_not include @open_assignment.id
end
it "includes submissions that will not fall in closed grading periods" do
importer_with_rows(
"Student,ID,Section,Assignment in closed period,Assignment in open period",
",#{@student.id},,5,5"
)
assignment_ids = student_submissions.map { |s| s['assignment_id'] }
expect(assignment_ids).to include @closed_assignment.id
end
end
end
end
context "uploading submissions for new assignments" do
before(:once) do
@student = User.create!
course_with_student(course: @course, user: @student, active_enrollment: true)
end
it "does not create a new assignment if the last grading period is closed" do
@active_period.destroy!
importer_with_rows(
"Student,ID,Section,Some new assignment",
",#{@student.id},,5",
)
expect(assignments.count).to eq(0)
end
it "creates a new assignment if the last grading period is not closed" do
importer_with_rows(
"Student,ID,Section,Some new assignment",
",#{@student.id},,5",
)
expect(assignments.count).to eq(1)
end
end
end
describe "#translate_pass_fail" do
let(:account) { Account.default }
let(:course) { Course.create! account: account }
let(:student) do
student = User.create
student
end
let(:assignment) do
course.assignments.create!(:name => 'Assignment 1',
:grading_type => "pass_fail",
:points_possible => 6)
end
let(:assignments) { [assignment] }
let(:students) { [student] }
let(:progress) { Progress.create tag: "test", context: student }
let(:gradebook_upload) { GradebookUpload.create!(course: course, user: student, progress: progress) }
let(:importer) { GradebookImporter.new(gradebook_upload, "", student, progress) }
it "translates positive score in gradebook_importer_assignments grade to complete" do
gradebook_importer_assignments = { student.id => [{ "grade" => "3", "original_grade" => "" }] }
importer.translate_pass_fail(assignments, students, gradebook_importer_assignments)
grade = gradebook_importer_assignments.fetch(student.id).first['grade']
expect(grade).to eq "complete"
end
it "translates positive grade in gradebook_importer_assignments original_grade to complete" do
gradebook_importer_assignments = { student.id => [{ "grade" => "", "original_grade" => "5" }] }
importer.translate_pass_fail(assignments, students, gradebook_importer_assignments)
original_grade = gradebook_importer_assignments.fetch(student.id).first['original_grade']
expect(original_grade).to eq "complete"
end
it "translates 0 grade in gradebook_importer_assignments grade to incomplete" do
gradebook_importer_assignments = { student.id => [{ "grade" => "0", "original_grade" => "" }] }
importer.translate_pass_fail(assignments, students, gradebook_importer_assignments)
grade = gradebook_importer_assignments.fetch(student.id).first['grade']
expect(grade).to eq "incomplete"
end
it "translates 0 grade in gradebook_importer_assignments original_grade to incomplete" do
gradebook_importer_assignments = { student.id => [{ "grade" => "", "original_grade" => "0" }] }
importer.translate_pass_fail(assignments, students, gradebook_importer_assignments)
original_grade = gradebook_importer_assignments.fetch(student.id).first['original_grade']
expect(original_grade).to eq "incomplete"
end
it "doesn't change empty string grade in gradebook_importer_assignments grade" do
gradebook_importer_assignments = { student.id => [{ "grade" => "", "original_grade" => "" }] }
importer.translate_pass_fail(assignments, students, gradebook_importer_assignments)
grade = gradebook_importer_assignments.fetch(student.id).first['grade']
expect(grade).to eq ""
end
it "doesn't change empty string grade in gradebook_importer_assignments original_grade" do
gradebook_importer_assignments = { student.id => [{ "grade" => "", "original_grade" => "" }] }
importer.translate_pass_fail(assignments, students, gradebook_importer_assignments)
original_grade = gradebook_importer_assignments.fetch(student.id).first['original_grade']
expect(original_grade).to eq ""
end
end
describe "importing submissions as excused from CSV" do
let(:account) { Account.default }
let(:course) { Course.create! account: account }
let(:student) { User.create! }
let(:teacher) do
teacher = User.create!
course.enroll_teacher(teacher).accept!
teacher
end
let(:assignment) do
course.assignments.create!(
name: "Assignment 1",
grading_type: "pass_fail",
points_possible: 10
)
end
let(:assignments) { [assignment] }
let(:students) { [student] }
let(:progress) { Progress.create! tag: "test", context: student }
let(:gradebook_upload) { GradebookUpload.create!(course: course, user: student, progress: progress) }
let(:importer) { GradebookImporter.new(gradebook_upload, "", student, progress) }
it "changes incomplete submission to excused when marked as 'EX' in CSV" do
gradebook_importer_assignments = { student.id => [{ "grade" => "EX", "original_grade" => "incomplete" }] }
importer.translate_pass_fail(assignments, students, gradebook_importer_assignments)
grade = gradebook_importer_assignments.fetch(student.id).first['grade']
expect(grade).to eq "EX"
end
it "changes complete submission to excused when marked as 'EX' in CSV" do
gradebook_importer_assignments = { student.id => [{ "grade" => "EX", "original_grade" => "complete" }] }
importer.translate_pass_fail(assignments, students, gradebook_importer_assignments)
grade = gradebook_importer_assignments.fetch(student.id).first['grade']
expect(grade).to eq "EX"
end
it "changes empty string grade to excused when marked as 'EX' in CSV" do
gradebook_importer_assignments = { student.id => [{ "grade" => "EX", "original_grade" => "" }] }
importer.translate_pass_fail(assignments, students, gradebook_importer_assignments)
grade = gradebook_importer_assignments.fetch(student.id).first['grade']
expect(grade).to eq "EX"
end
it "changes 0 grade to complete when marked as positive" do
gradebook_importer_assignments = { student.id => [{ "grade" => "8", "original_grade" => "0" }] }
importer.translate_pass_fail(assignments, students, gradebook_importer_assignments)
grade = gradebook_importer_assignments.fetch(student.id).first["grade"]
expect(grade).to eq "complete"
end
it "changes points assignment to excused when marked as 'EX' in CSV" do
course.assignments.create!(
name: "Assignment 2",
grading_type: "points",
points_possible: 10
)
gradebook_importer_assignments = { student.id => [{ "grade" => "EX", "original_grade" => "8" }] }
grade = gradebook_importer_assignments.fetch(student.id).first['grade']
expect(grade).to eq "EX"
end
it "changes incomplete submission to excused when marked as 'ex' in CSV" do
gradebook_importer_assignments = { student.id => [{ "grade" => "ex", "original_grade" => "incomplete" }] }
importer.translate_pass_fail(assignments, students, gradebook_importer_assignments)
grade = gradebook_importer_assignments.fetch(student.id).first['grade']
expect(grade).to eq "EX"
end
it "changes complete submission to excused when marked as 'eX' in CSV" do
gradebook_importer_assignments = { student.id => [{ "grade" => "eX", "original_grade" => "complete" }] }
importer.translate_pass_fail(assignments, students, gradebook_importer_assignments)
grade = gradebook_importer_assignments.fetch(student.id).first['grade']
expect(grade).to eq "EX"
end
it "changes empty string grade to excused when marked as 'ex' in CSV" do
gradebook_importer_assignments = { student.id => [{ "grade" => "ex", "original_grade" => "" }] }
importer.translate_pass_fail(assignments, students, gradebook_importer_assignments)
grade = gradebook_importer_assignments.fetch(student.id).first['grade']
expect(grade).to eq "EX"
end
it "changes points assignment to excused when marked as 'Ex' in CSV" do
course.assignments.create!(
name: "Assignment 3",
grading_type: "points",
points_possible: 10
)
course.enroll_student(student, enrollment_state: "active")
upload = GradebookUpload.create!(course: course, user: teacher, progress: progress)
importer = new_gradebook_importer(
attachment_with_rows(
'Student;ID;Section;Assignment 3',
"A Student;#{student.id};Section 13;Ex",
),
upload,
teacher,
progress
)
json = importer.as_json
expect(json[:students][0][:submissions][0]["grade"]).to eq "EX"
end
end
describe "override score changes" do
before(:once) do
Account.site_admin.enable_feature!(:import_override_scores_in_gradebook)
course_model
@course.enable_feature!(:final_grades_override)
@course.allow_final_grade_override = true
@course.save!
end
let(:student_with_override) { User.create!(name: "Cyrus") }
let(:student_without_override) { User.create!(name: "Ophilia") }
before(:each) do
@course.enroll_student(student_with_override, enrollment_state: "active")
@course.enroll_student(student_without_override, enrollment_state: "active")
# Run the grade calculator so Score objects get created
@course.recompute_student_scores(run_immediately: true)
student_with_override.enrollments.first.find_score.update!(override_score: 50.54)
end
it "recognizes changes to override scores" do
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score",
"Cyrus,#{student_with_override.id},My Course,0,60"
)
output = importer.as_json
aggregate_failures do
expect(output[:students].length).to eq 1
expect(output[:students].first.dig(:override_scores, 0, :current_score)).to eq "50.54"
expect(output[:students].first.dig(:override_scores, 0, :new_score)).to eq "60"
expect(output[:students].first.dig(:override_scores, 0, :grading_period_id)).to eq nil
end
end
it "recognizes newly-added override scores" do
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score",
"Ophilia,#{student_without_override.id},My Course,0,70"
)
output = importer.as_json
aggregate_failures do
expect(output[:students].length).to eq 1
expect(output[:students].first.dig(:override_scores, 0, :current_score)).to eq nil
expect(output[:students].first.dig(:override_scores, 0, :new_score)).to eq "70"
expect(output[:students].first.dig(:override_scores, 0, :grading_period_id)).to eq nil
end
end
it "recognizes when override scores are removed" do
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score",
"Cyrus,#{student_with_override.id},My Course,0,"
)
output = importer.as_json
aggregate_failures do
expect(output[:students].length).to eq 1
expect(output[:students].first.dig(:override_scores, 0, :current_score)).to eq "50.54"
expect(output[:students].first.dig(:override_scores, 0, :new_score)).to eq nil
expect(output[:students].first.dig(:override_scores, 0, :grading_period_id)).to eq nil
end
end
it "compares scores with a maximum precision of two decimal places" do
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score",
"Cyrus,#{student_with_override.id},My Course,0,50.5432"
)
output = importer.as_json
aggregate_failures do
expect(output[:students]).to be_empty
end
end
it "returns no records when there are no override score changes" do
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score",
"Cyrus,#{student_with_override.id},My Course,0,50.54"
)
output = importer.as_json
aggregate_failures do
expect(output[:students]).to be_empty
end
end
it "returns records for all students if at least one student's grade changed" do
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score",
"Cyrus,#{student_with_override.id},My Course,0,50.54",
"Ophilia,#{student_without_override.id},My Course,0,60"
)
output = importer.as_json
aggregate_failures do
expect(output[:students].length).to eq 2
expect(output[:students].first.dig(:override_scores, 0, :current_score)).to eq "50.54"
expect(output[:students].first.dig(:override_scores, 0, :new_score)).to eq "50.54"
expect(output[:students].first.dig(:override_scores, 0, :grading_period_id)).to eq nil
expect(output[:students].second.dig(:override_scores, 0, :current_score)).to eq nil
expect(output[:students].second.dig(:override_scores, 0, :new_score)).to eq "60"
expect(output[:students].second.dig(:override_scores, 0, :grading_period_id)).to eq nil
end
end
it "ignores students with concluded enrollments" do
student_with_override.enrollments.first.conclude
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score",
"Cyrus,#{student_with_override.id},My Course,0,10"
)
output = importer.as_json
aggregate_failures do
expect(output[:students]).to be_empty
end
end
it "produces an empty result if there are no students" do
student_with_override.enrollments.first.conclude
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score"
)
output = importer.as_json
aggregate_failures do
expect(output[:students]).to be_empty
end
end
it "ignores the 'Override Grade' column even if a grading scheme is active" do
@course.grading_standard_enabled = true
@course.save!
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Grade",
"Cyrus,#{student_with_override.id},My Course,0,A+"
)
output = importer.as_json
expect(output[:students]).to be_empty
end
context "for a course with grading periods" do
before(:each) do
enrollment_term = @course.root_account.enrollment_terms.create!
@course.update!(enrollment_term: enrollment_term)
grading_period_group = @course.root_account.grading_period_groups.create!
grading_period_group.enrollment_terms << enrollment_term
now = Time.zone.now
grading_period_group.grading_periods.create!(
close_date: now,
end_date: now,
start_date: 1.week.ago(now),
title: "First GP"
)
grading_period_group.grading_periods.create!(
close_date: 1.week.from_now(now),
end_date: 1.week.from_now(now),
start_date: now,
title: "Second GP"
)
end
let(:first_grading_period) { @course.root_account.grading_period_groups.first.grading_periods.first }
let(:second_grading_period) { @course.root_account.grading_period_groups.first.grading_periods.second }
it "handles override score changes for specific grading periods" do
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score (First GP)",
"Cyrus,#{student_with_override.id},My Course,0,70"
)
output = importer.as_json
overrides = output[:students].first[:override_scores]
aggregate_failures do
expect(overrides.length).to eq 1
expect(overrides.first[:grading_period_id]).to eq first_grading_period.id
expect(overrides.first[:new_score]).to eq "70"
end
end
it "handles multiple grading periods and course scores in the same input" do
first_grading_period_score = student_with_override.enrollments.first.find_score({ grading_period_id: first_grading_period.id })
first_grading_period_score.update!(override_score: 40.0)
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score (First GP),Override Score (Second GP),Override Score",
"Cyrus,#{student_with_override.id},My Course,0,70,60,100"
)
output = importer.as_json
overrides = output[:students].first[:override_scores]
aggregate_failures do
expect(overrides.length).to eq 3
course_change = overrides.detect { |override| override[:grading_period_id].nil? }
expect(course_change[:current_score]).to eq "50.54"
expect(course_change[:new_score]).to eq "100"
first_period_change = overrides.detect { |override| override[:grading_period_id] == first_grading_period.id }
expect(first_period_change[:current_score]).to eq "40.0"
expect(first_period_change[:new_score]).to eq "70"
second_period_change = overrides.detect { |override| override[:grading_period_id] == second_grading_period.id }
expect(second_period_change[:current_score]).to eq nil
expect(second_period_change[:new_score]).to eq "60"
end
end
it "filters out any grading periods with no changed override scores" do
first_grading_period_score = student_with_override.enrollments.first.find_score({ grading_period_id: first_grading_period.id })
first_grading_period_score.update!(override_score: 40.0)
# Make changes to First GP and the course score; leave second GP alone
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score (First GP),Override Score (Second GP),Override Score",
"Cyrus,#{student_with_override.id},My Course,0,70,,100",
"Ophilia,#{student_without_override.id},My Course,0,70,,"
)
output = importer.as_json
aggregate_failures do
expect(output[:students].length).to eq 2
student1_overrides = output[:students].first[:override_scores]
expect(student1_overrides.length).to eq 2
student1_course_change = student1_overrides.detect { |override| override[:grading_period_id].nil? }
expect(student1_course_change[:current_score]).to eq "50.54"
expect(student1_course_change[:new_score]).to eq "100"
student1_gp_change = student1_overrides.detect { |override| override[:grading_period_id] == first_grading_period.id }
expect(student1_gp_change[:current_score]).to eq "40.0"
expect(student1_gp_change[:new_score]).to eq "70"
student2_overrides = output[:students].second[:override_scores]
expect(student2_overrides.length).to eq 2
student2_course_change = student2_overrides.detect { |override| override[:grading_period_id].nil? }
expect(student2_course_change[:current_score]).to eq nil
expect(student2_course_change[:new_score]).to eq nil
student2_gp_change = student2_overrides.detect { |override| override[:grading_period_id] == first_grading_period.id }
expect(student2_gp_change[:current_score]).to eq nil
expect(student2_gp_change[:new_score]).to eq "70"
end
end
it "ignores grading periods whose title it does not recognize" do
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score (Unknown GP)",
"Cyrus,#{student_with_override.id},My Course,0,40"
)
output = importer.as_json
expect(output[:students]).to be_empty
end
it "ignores malformed 'Override Score' headers" do
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score (zzzzzz",
"Cyrus,#{student_with_override.id},My Course,0,40"
)
output = importer.as_json
expect(output[:students]).to be_empty
end
it "treats an 'empty' grading period title as a course score" do
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score ()",
"Cyrus,#{student_with_override.id},My Course,0,50"
)
output = importer.as_json
aggregate_failures do
expect(output[:students].length).to eq 1
expect(output[:students].first.dig(:override_scores, 0, :current_score)).to eq "50.54"
expect(output[:students].first.dig(:override_scores, 0, :new_score)).to eq "50"
expect(output[:students].first.dig(:override_scores, 0, :grading_period_id)).to eq nil
end
end
end
it "handles changes to assignments and override scores in the same file" do
importer = importer_with_rows(
"Student,ID,Section,Assignment 1,Final Score,Override Score",
"Cyrus,#{student_with_override.id},My Course,20,0,60",
"Ophilia,#{student_without_override.id},My Course,40,0,"
)
output = importer.as_json
aggregate_failures do
expect(output[:students].length).to eq 2
student_with_override_data = output[:students].detect { |student| student[:id] == student_with_override.id }
expect(student_with_override_data[:submissions].length).to eq 1
expect(student_with_override_data.dig(:submissions, 0, "grade")).to eq "20"
expect(student_with_override_data[:override_scores].length).to eq 1
expect(student_with_override_data.dig(:override_scores, 0, :new_score)).to eq "60"
student_without_override_data = output[:students].detect { |student| student[:id] == student_without_override.id }
expect(student_without_override_data[:submissions].length).to eq 1
expect(student_without_override_data.dig(:submissions, 0, "grade")).to eq "40"
expect(student_without_override_data[:override_scores].length).to eq 1
expect(student_without_override_data.dig(:override_scores, 0, :new_score)).to eq nil
end
end
it "ignores changes to override scores if the feature flag is turned off" do
Account.site_admin.disable_feature!(:import_override_scores_in_gradebook)
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score",
"Cyrus,#{student_with_override.id},My Course,0,60"
)
output = importer.as_json
expect(output[:students]).to be_empty
end
it "ignores changes to override scores if the course does not allow override grades" do
@course.allow_final_grade_override = false
@course.save!
importer = importer_with_rows(
"Student,ID,Section,Final Score,Override Score",
"Cyrus,#{student_with_override.id},My Course,0,60"
)
output = importer.as_json
expect(output[:students]).to be_empty
end
describe "override score json" do
let(:grading_period_group) do
group = Factories::GradingPeriodGroupHelper.new.create_for_account(@course.account)
Factories::GradingPeriodHelper.new.create_presets_for_group(group, :past, :current)
group
end
let(:grading_period_1) { grading_period_group.grading_periods.first }
let(:grading_period_2) { grading_period_group.grading_periods.second }
before(:each) do
@course.enrollment_term.update!(grading_period_group: grading_period_group)
end
describe "top-level override score content" do
it "sets 'includes_course_scores' to true if course-level override scores have changed" do
importer = importer_with_rows(
"Student,ID,Section,Assignment 1,Final Score,Override Score",
"Cyrus,#{student_with_override.id},My Course,20,0,60",
"Ophilia,#{student_without_override.id},My Course,40,0,"
)
output = importer.as_json
expect(output[:override_scores][:includes_course_scores]).to eq true
end
it "sets 'includes_course_scores' to false if no course-level override scores have changed" do
importer = importer_with_rows(
"Student,ID,Section,Assignment 1,Final Score,Override Score",
"Cyrus,#{student_with_override.id},My Course,20,0,50.54",
"Ophilia,#{student_without_override.id},My Course,40,0,"
)
output = importer.as_json
expect(output[:override_scores][:includes_course_scores]).to eq false
end
it "includes JSON for all grading periods with changes" do
importer = importer_with_rows(
"Student,ID,Section,Assignment 1,Final Score,Override Score (#{grading_period_1.title})",
"Cyrus,#{student_with_override.id},My Course,20,0,99",
"Ophilia,#{student_without_override.id},My Course,40,0,98"
)
output = importer.as_json
expect(output[:override_scores][:grading_periods].pluck(:id)).to contain_exactly(grading_period_1.id)
end
it "is not included if importing override grades is not enabled" do
@course.allow_final_grade_override = false
@course.save!
importer = importer_with_rows(
"Student,ID,Section,Assignment 1,Final Score,Override Score",
"Cyrus,#{student_with_override.id},My Course,20,0,100",
"Ophilia,#{student_without_override.id},My Course,40,0,100"
)
output = importer.as_json
expect(output).not_to have_key(:override_scores)
end
end
describe "per-student override score changes" do
it "includes all course-level override scores if any course score has changed" do
importer = importer_with_rows(
"Student,ID,Section,Assignment 1,Final Score,Override Score",
"Cyrus,#{student_with_override.id},My Course,20,0,50.54",
"Ophilia,#{student_without_override.id},My Course,40,0,80.23"
)
output = importer.as_json
changed_record = output[:students].detect { |student| student[:id] == student_without_override.id }
unchanged_record = output[:students].detect { |student| student[:id] == student_with_override.id }
aggregate_failures do
expect(changed_record[:override_scores].length).to eq 1
expect(changed_record[:override_scores].first[:current_score]).to eq nil
expect(changed_record[:override_scores].first[:new_score]).to eq "80.23"
expect(changed_record[:override_scores].first[:grading_period_id]).to eq nil
expect(unchanged_record[:override_scores].length).to eq 1
expect(unchanged_record[:override_scores].first[:current_score]).to eq "50.54"
expect(unchanged_record[:override_scores].first[:new_score]).to eq "50.54"
expect(unchanged_record[:override_scores].first[:grading_period_id]).to eq nil
end
end
it "omits course-level override scores if there are no changes" do
importer = importer_with_rows(
"Student,ID,Section,Assignment 1,Final Score,Override Score",
"Cyrus,#{student_with_override.id},My Course,20,0,50.54",
"Ophilia,#{student_without_override.id},My Course,40,0,"
)
output = importer.as_json
override_scores_by_student = output[:students].map { |student| student[:override_scores] }
expect(override_scores_by_student).to all(be_empty)
end
it "includes all override scores for a grading period if any score has changed" do
importer = importer_with_rows(
"Student,ID,Section,Assignment 1,Final Score,Override Score (#{grading_period_2.title})",
"Cyrus,#{student_with_override.id},My Course,20,0,",
"Ophilia,#{student_without_override.id},My Course,40,0,90"
)
output = importer.as_json
changed_record = output[:students].detect { |student| student[:id] == student_without_override.id }
unchanged_record = output[:students].detect { |student| student[:id] == student_with_override.id }
aggregate_failures do
expect(changed_record[:override_scores].length).to eq 1
expect(changed_record[:override_scores].first[:current_score]).to eq nil
expect(changed_record[:override_scores].first[:new_score]).to eq "90"
expect(changed_record[:override_scores].first[:grading_period_id]).to eq grading_period_2.id
expect(unchanged_record[:override_scores].length).to eq 1
expect(unchanged_record[:override_scores].first[:current_score]).to eq nil
expect(unchanged_record[:override_scores].first[:new_score]).to eq nil
expect(unchanged_record[:override_scores].first[:grading_period_id]).to eq grading_period_2.id
end
end
it "omits override scores for a grading period if there are no changes" do
importer = importer_with_rows(
"Student,ID,Section,Assignment 1,Final Score,Override Score (#{grading_period_2.title})",
"Cyrus,#{student_with_override.id},My Course,20,0,",
"Ophilia,#{student_without_override.id},My Course,40,0,"
)
output = importer.as_json
override_scores_by_student = output[:students].map { |student| student[:override_scores] }
expect(override_scores_by_student).to all(be_empty)
end
it "keeps all scores if there is an unknown student in the CSV" do
importer = importer_with_rows(
"Student,ID,Section,Assignment 1,Final Score,Override Score (#{grading_period_2.title})",
"Cyrus,#{student_with_override.id},My Course,20,0,",
"Ophilia,#{student_without_override.id},My Course,40,0,",
"Olberic,#{student_without_override.id},My Course,40,0,99"
)
output = importer.as_json
override_scores_by_student = output[:students].map { |student| student[:override_scores] }
aggregate_failures do
expect(override_scores_by_student.length).to eq 3
expect(override_scores_by_student.map(&:length)).to all(eq(1))
end
end
it "works as expected if no override score column is included in the import" do
expect {
importer_with_rows(
"Student,ID,Section,Assignment 1,Final Score",
"Cyrus,#{student_with_override.id},My Course,20,0",
"Ophilia,#{student_without_override.id},My Course,40,0"
)
}.not_to raise_error
end
end
end
end
def new_gradebook_importer(attachment = valid_gradebook_contents, upload = nil, user = gradebook_user, progress = nil)
@user = user
@progress = progress || Progress.create!(tag: "test", context: @user)
upload ||= GradebookUpload.create!(course: @course, user: @user, progress: @progress)
if attachment.is_a?(String)
attachment = attachment_with_rows(attachment)
end
@gi = GradebookImporter.new(upload, attachment, @user, @progress)
@gi.parse!
@gi
end
def valid_gradebook_contents
attachment_with_file(File.join(File.dirname(__FILE__), %w(.. fixtures gradebooks basic_course.csv)))
end
def valid_gradebook_contents_with_sis_login_id
attachment_with_file(File.join(File.dirname(__FILE__), %w(.. fixtures gradebooks basic_course_with_sis_login_id.csv)))
end
def invalid_gradebook_contents
attachment_with_file(File.join(File.dirname(__FILE__), %w(.. fixtures gradebooks wat.csv)))
end
def attachment_with
a = attachment_model
file = Tempfile.new("gradebook_import.csv")
yield file
file.close
allow(a).to receive(:open).and_return(file)
a
end
def attachment_with_file(path)
contents = File.read(path)
attachment_with do |tempfile|
tempfile.write(contents)
end
end
def attachment_with_rows(*rows)
attachment_with do |tempfile|
rows.each do |row|
tempfile.puts(row)
end
end
end
def importer_with_rows(*rows)
new_gradebook_importer(attachment_with_rows(rows))
end
end