2050 lines
79 KiB
Ruby
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
|