Repository navigation
Admin csv import #120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Admin csv import #120
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
1a836ae
Adds new route and controller for csv template. Successfully download…
amcheers 58ceaf8
Adds route, controller, and service to implement CSV upload and resou…
amcheers e8614b6
WIP, moves error message hash and resource arrays to instance variabl…
amcheers 3053d4d
WIP Successfully imported test csv, extracted records, created and as…
amcheers 42530f8
(WIP) Refactors StudentCsvImporter service, still needs route update,…
amcheers 83eb255
Adds fixtures and tests around Csv controller, and import service.
amcheers a2dc317
Refactors controller and import service, updates route to include sch…
amcheers 4b9e974
fix: require existing program in student CSV import
sean-dickinson 3eeb6a9
refactor: define CSV routes without module scope
sean-dickinson 0a3953e
chore: remove unrelated tool-versions and lockfile changes
sean-dickinson 7ad3e17
test: inline CSV data for controller invariants
sean-dickinson File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 = [ | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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! | ||
|
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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.