Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ gem "thruster", require: false
gem "image_processing", "~> 2.2"
gem "ruby-vips", "~> 2.0"

gem "csv"

group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
Expand Down
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ GEM
cruise (0.3.0-x86_64-darwin)
cruise (0.3.0-x86_64-linux-gnu)
cruise (0.3.0-x86_64-linux-musl)
csv (3.3.6)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
Expand Down Expand Up @@ -436,6 +437,7 @@ DEPENDENCIES
brakeman
bundler-audit
capybara
csv
debug
faker (~> 3.8)
herb
Expand Down Expand Up @@ -499,6 +501,7 @@ CHECKSUMS
cruise (0.3.0-x86_64-darwin) sha256=5dcf048c20cfcaf208cdcdd6ddfd4410acb431ff18a84bd7441bba0c8b8d6b83
cruise (0.3.0-x86_64-linux-gnu) sha256=a7bdd7c72307a450a9d37f3c7a90c118230ea6122663b32b8c8e52cad32adfa8
cruise (0.3.0-x86_64-linux-musl) sha256=54ed6809f1d476bf99a3043bc256cca0efed8bbc9df93a55f4e49eac2bbcb7f4
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6
dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
Expand Down
68 changes: 68 additions & 0 deletions app/controllers/admin/csv_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
require "csv"

class Admin::CsvController < AdminController
class CSVHeadersError < StandardError; end
class StudentBulkImportError < StandardError; end
class ClassroomBulkImportError < StandardError; end

IMPORT_ERRORS = [
Comment thread
sean-dickinson marked this conversation as resolved.
CSVHeadersError,
StudentBulkImportError,
ClassroomBulkImportError,
CSV::MalformedCSVError,
StudentCsvImporter::InvalidClassroomError,
StudentCsvImporter::InvalidProgramError,
StudentCsvImporter::InvalidStudentError,
StudentCsvImporter::InvalidTeacherError
].freeze

CSV_HEADERS = [ "Student First Name", "Student Last Name", "Grade Level", "Class Name", "Teacher", "Teacher Email",
"Program", "Program Level" ].freeze

before_action :set_school

def download
csv_data = CSV.generate do |csv|
csv << CSV_HEADERS
end

send_data csv_data,
filename: "students-#{Date.today}.csv",
type: "text/csv; charset=utf-8",
disposition: "attachment"
end

def import
csv_file = params[:file]
return redirect_to school_students_path(@school), alert: "Choose a CSV file to import." if csv_file.blank?

csv = CSV.read(csv_file.path, headers: true)
validate_rows!(csv)
StudentCsvImporter.new(csv: csv, school_id: @school.id).import

redirect_to school_students_path(@school), notice: "Students were successfully imported."
rescue *IMPORT_ERRORS => error
redirect_to school_students_path(@school), alert: error.message
end

private
def set_school
@school = School.find(params.expect(:school_id))
end

def validate_rows!(csv)
raise CSVHeadersError, "Headers must match CSV headers" unless CSV_HEADERS == csv.headers

csv.each do |row|
raise CSVHeadersError, "Row must have same number of columns as headers" unless CSV_HEADERS.length == row.length

if @school.students.exists?(first_name: row["Student First Name"], last_name: row["Student Last Name"])
raise StudentBulkImportError, "Student: #{row["Student First Name"]} #{row["Student Last Name"]} already exists"
end

if @school.classrooms.exists?(name: row["Class Name"])
raise ClassroomBulkImportError, "Classroom: #{row["Class Name"]} already exists"
end
end
end
end
124 changes: 124 additions & 0 deletions app/services/student_csv_importer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@


class StudentCsvImporter
class InvalidClassroomError < StandardError; end
class InvalidProgramError < StandardError; end
class InvalidStudentError < StandardError; end
class InvalidTeacherError < StandardError; end

def initialize(csv:, school_id:)
@csv = csv
@school_id = school_id
@error_messages = {
classrooms: {},
teachers: {},
students: {}
}
@classrooms = {}
@teachers = {}
@students = []
@row_indexes = {}
end


def import
@csv.each_with_index do |row, index|
next if row.blank?

teacher = find_or_build_teacher(row)
classroom = find_or_build_classroom(row, teacher)

student = Student.new(
first_name: row["Student First Name"],
last_name: row["Student Last Name"],
grade_level: row["Grade Level"],
school_id: @school_id,
classroom: classroom
)

collect_errors(:teachers, teacher, index)
collect_errors(:classrooms, classroom, index)
collect_errors(:students, student, index)

@students << student
end

save_records
end



private

def save_records
ActiveRecord::Base.transaction do
raise_validation_errors!

save_all(@teachers.values, InvalidTeacherError)
save_all(@classrooms.values, InvalidClassroomError)
save_all(@students, InvalidStudentError)

create_program_associations
end
end

# A uniqueness collision between two rows of the same file survives `valid?`,
# because neither record is persisted yet. Re-raise it in the shape the
# validation errors already use so callers only have to handle one thing.
def save_all(records, error_class)
records.each do |record|
record.save!
rescue ActiveRecord::RecordInvalid => error
raise error_class, { @row_indexes[record.object_id] => error.record.errors.full_messages }.to_s
end
end

def create_program_associations
@csv.each do |row|
teacher = @teachers[row["Teacher"]]
classroom = @classrooms[[ teacher.name, row["Class Name"] ]]

program = find_existing_program!(row["Program"])

classroom.classroom_programs.create_or_find_by!(program: program, level: row["Program Level"])
end
end

def find_existing_program!(name)
Program.find_by!(name: name)
rescue ActiveRecord::RecordNotFound
raise InvalidProgramError, "Program: #{name} does not exist"
end

def collect_errors(type, record, index)
@row_indexes[record.object_id] ||= index

return if record.valid?

@error_messages[type][index] = record.errors.full_messages
end

def raise_validation_errors!
Comment thread
sean-dickinson marked this conversation as resolved.
raise InvalidClassroomError, @error_messages[:classrooms].to_s if @error_messages[:classrooms].any?
raise InvalidStudentError, @error_messages[:students].to_s if @error_messages[:students].any?
raise InvalidTeacherError, @error_messages[:teachers].to_s if @error_messages[:teachers].any?
end

def find_or_build_teacher(row)
@teachers[row["Teacher"]] ||= Teacher.new(
name: row["Teacher"],
email: row["Teacher Email"],
school_id: @school_id
)
end

def find_or_build_classroom(row, teacher)
key = [ teacher.name, row["Class Name"] ]

@classrooms[key] ||= Classroom.new(
school_id: @school_id,
name: row["Class Name"],
teacher: teacher
)
end
end
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
member { get :schedule }
end
resources :teachers, shallow: true, except: [ :show ]
get :csv_template, to: "admin/csv#download"
post :csv_import, to: "admin/csv#import"
end
resources :content_modules, except: [ :show ] do
resources :links, shallow: true, except: %i[index show]
Expand Down
Loading
Loading