From 2e2430ed6f51fb747a854f775f930f68df7ae79d Mon Sep 17 00:00:00 2001 From: Ahmad Cheers Date: Fri, 28 Aug 2026 11:27:38 -0400 Subject: [PATCH 1/4] Adds new route and controller for csv template. Successfully downloads template with correct headers when endpoint is hit. --- app/controllers/admin/csv_controller.rb | 21 +++++++++++++++++++++ config/routes.rb | 5 +++++ 2 files changed, 26 insertions(+) create mode 100644 app/controllers/admin/csv_controller.rb diff --git a/app/controllers/admin/csv_controller.rb b/app/controllers/admin/csv_controller.rb new file mode 100644 index 0000000..d214fff --- /dev/null +++ b/app/controllers/admin/csv_controller.rb @@ -0,0 +1,21 @@ + +require 'csv' + +class Admin::CsvController < ApplicationController + CSV_HEADERS = ["Student First Name", "Student Last Name", "Grade Level", "Class Name", "Teacher", "Program", "Program Level"].freeze + + 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 upload + + end +end \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 832848c..9afd1e4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,6 +15,11 @@ resources :links, shallow: true end resources :classroom_modules, only: %i[update] + + end + + namespace :admin do + get :csv_template, to: "csv#download" end root to: "schools#index" From 0f85f14e1ce571186f7853b6512b576462bfca71 Mon Sep 17 00:00:00 2001 From: Ahmad Cheers Date: Sat, 29 Aug 2026 15:58:48 -0400 Subject: [PATCH 2/4] Adds route, controller, and service to implement CSV upload and resource creation ability. Adds csv gem. --- .tool-versions | 1 + Gemfile | 2 + Gemfile.lock | 4 ++ app/controllers/admin/csv_controller.rb | 25 +++++++- app/services/student_csv_importer.rb | 77 +++++++++++++++++++++++++ config/routes.rb | 1 + 6 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 .tool-versions create mode 100644 app/services/student_csv_importer.rb diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..05913ea --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 4.0.6 diff --git a/Gemfile b/Gemfile index 54e97c2..25a7b51 100644 --- a/Gemfile +++ b/Gemfile @@ -41,6 +41,8 @@ gem "thruster", require: false gem "image_processing", "~> 2.0" 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" diff --git a/Gemfile.lock b/Gemfile.lock index 827044c..24c2543 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -107,6 +107,7 @@ GEM concurrent-ruby (1.3.8) connection_pool (3.0.2) crass (1.0.7) + csv (3.3.6) date (3.5.1) debug (1.11.1) irb (~> 1.10) @@ -399,6 +400,7 @@ PLATFORMS arm-linux-musl arm64-darwin-23 arm64-darwin-24 + arm64-darwin-25 x86_64-linux x86_64-linux-gnu x86_64-linux-musl @@ -410,6 +412,7 @@ DEPENDENCIES brakeman bundler-audit capybara + csv debug faker (~> 3.8) image_processing (~> 2.0) @@ -464,6 +467,7 @@ CHECKSUMS concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d diff --git a/app/controllers/admin/csv_controller.rb b/app/controllers/admin/csv_controller.rb index d214fff..6405533 100644 --- a/app/controllers/admin/csv_controller.rb +++ b/app/controllers/admin/csv_controller.rb @@ -1,7 +1,12 @@ require 'csv' +class CSVHeadersError < StandardError; end +class StudentBulkImportError < StandardError; end +class ClassroomBulkImportError < StandardError; end + class Admin::CsvController < ApplicationController + skip_forgery_protection only: :import CSV_HEADERS = ["Student First Name", "Student Last Name", "Grade Level", "Class Name", "Teacher", "Program", "Program Level"].freeze def download @@ -15,7 +20,25 @@ def download disposition: "attachment" end - def upload + def import + csv_file = params[:file] + if csv_file.present? + csv = CSV.read(csv_file.path, headers: true) + puts "CSV File is present" + puts "CSV Headers: #{csv.headers}" + #check if headers are equal to CSV_HEADERS and return with Headers must match CSV headers error if not + raise CSVHeadersError, "Headers must match CSV headers" unless CSV_HEADERS == csv.headers + #iterate through each row and validate that each row has the same number of columns as the headers + #if not, return with Row must have same number of columns as headers error + puts "Checking CSV Header validity" + csv.each do |row| + raise CSVHeadersError, "Row must have same number of columns as headers" unless CSV_HEADERS.length == row.length + raise StudentBulkImportError, "Student already exists" if Student.find_by(first_name: row["Student First Name"], last_name: row["Student Last Name"]) + raise ClassroomBulkImportError, "Classroom already exists" if Classroom.find_by(name: row["Class Name"]) + end + # pass file to importer + StudentCsvImporter.new(csv: csv, school_id: 1).import + end end end \ No newline at end of file diff --git a/app/services/student_csv_importer.rb b/app/services/student_csv_importer.rb new file mode 100644 index 0000000..dad25c0 --- /dev/null +++ b/app/services/student_csv_importer.rb @@ -0,0 +1,77 @@ +class InvalidClassroomError < StandardError; end +class InvalidStudentError < StandardError; end + +class StudentCsvImporter + def initialize(csv:, school_id:) + @csv = csv + @school_id = school_id + end + + + def import + puts "Importing students" + error_messages = {students: {}, classrooms: {}} # key = csv row, value = row data + students = [] + classrooms = [] + + @csv.each_with_index do |row, index| + next if row.blank? + + #Instantiate but don't persist Classroom record + #If record is invalid push error message into error messages hash + #If any classroom records are invalid raise error and return error messages hash to user + #Loop over classrooms array and save! classroom records if error_messages[:classrooms] is empty + classroom = Classroom.new( + school_id: @school_id, + name: row['Classroom Name'], + teacher: row['Teacher'], + ) + error_messages[:classrooms][index] = classroom.errors.full_messages if classroom.invalid? + classrooms << classroom + + + # Classrooms must exist before checking validity of student records + student = Student.new( + first_name: row['Student First Name'], + last_name: row['Student Last Name'], + grade_level: row['Grade Level'], + school_id: @school_id, + ) + + students << student + + end + + if error_messages[:classrooms].empty? + classrooms.each(&:save!) + elsif error_messages[:classrooms].any? Raise InvalidClassroomError, error_messages[:classrooms] + end + + students.each { |student| error_messages[:students][index] = student.errors.full_messages if student.invalid? } + + puts "CSV Passed Validations... Creating School Records" + puts "Are error messages empty?: #{error_messages.empty?}" + puts "Error Messages: #{error_messages}" + if error_messages[:students].empty? + create_school_records + elsif error_messages[:students].any? Raise InvalidStudentError, error_messages[:students] + end + end + + def create_school_records + #school_id should be passed in from the url params + #Iterate CSV rows + @csv.each do |row| + #Extract and apply teacher column when creating classroom + classroom = Classroom.find_by!(school_id: school_id, teacher: row['Teacher'], name: row['Classroom Name']) + puts "Found Classroom: #{classroom.name}" + #Extract and apply uuid column when creating classroom + #Extract and apply program column when creating classroom + classroom.programs.create_or_find_by!(name: row['Program'], level: row['Program Level']) + puts "Created Program: #{classroom.programs.first.name}" + #Extract and apply program level column when creating classroom + classroom.students.create_or_find_by!(first_name: row['Student First Name'], last_name: row['Student Last Name'], grade_level: row['Grade Level'], school_id: @school_id) + puts "Created Student: #{classroom.students.first.first_name}" + end + end +end \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 9afd1e4..e8ce431 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,6 +20,7 @@ namespace :admin do get :csv_template, to: "csv#download" + post :csv_import, to: "csv#import" end root to: "schools#index" From f298e3dd84a2e1eee7d00d41994d1dd68bc7c427 Mon Sep 17 00:00:00 2001 From: Ahmad Cheers Date: Sun, 30 Aug 2026 10:33:50 -0400 Subject: [PATCH 3/4] WIP, moves error message hash and resource arrays to instance variables, wraps persistence of resources in a transaction block, refactors errors to inherit from ActiveRecord::Rollback. --- Gemfile.lock | 4 +- app/controllers/admin/csv_controller.rb | 4 +- app/services/student_csv_importer.rb | 75 +++++++++++++++++-------- package-lock.json | 1 + 4 files changed, 55 insertions(+), 29 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e731095..ddcdbeb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -107,7 +107,6 @@ GEM concurrent-ruby (1.3.8) connection_pool (3.0.2) crass (1.0.7) - csv (3.3.6) cruise (0.2.0) cruise (0.2.0-aarch64-linux-gnu) cruise (0.2.0-aarch64-linux-musl) @@ -115,6 +114,7 @@ GEM cruise (0.2.0-arm64-darwin) cruise (0.2.0-x86_64-linux-gnu) cruise (0.2.0-x86_64-linux-musl) + csv (3.3.6) date (3.5.1) debug (1.11.1) irb (~> 1.10) @@ -491,7 +491,6 @@ CHECKSUMS concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 - csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 cruise (0.2.0) sha256=ffdbc6f9fa7b6071dbf2c7435bf887d41a822fe9b6ab422fa8a8456c0f034d71 cruise (0.2.0-aarch64-linux-gnu) sha256=87a5a3fbda8070b4a531b84161c7c5acbcc54f62e57b9ffa6960df61e9b9c2c3 cruise (0.2.0-aarch64-linux-musl) sha256=9f14c9a00aeeb67d75a29dd3d3bd4ce60bef23d148c35e525461e5990b986d25 @@ -499,6 +498,7 @@ CHECKSUMS cruise (0.2.0-arm64-darwin) sha256=fb3e9b265868e077dd754b4a430ab14d8abb209281c61c906b74f326b797a10a cruise (0.2.0-x86_64-linux-gnu) sha256=3d16f6a6a3409f2cae5dbdb3fb322389fa61a6a18d80a2df50fc4c807f1f0fd9 cruise (0.2.0-x86_64-linux-musl) sha256=2be5c5f2fb474ff5a4f42e3421dbb27fb439008a53697f7a6a3d2f3ec2de711c + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d diff --git a/app/controllers/admin/csv_controller.rb b/app/controllers/admin/csv_controller.rb index 6405533..8a40fb7 100644 --- a/app/controllers/admin/csv_controller.rb +++ b/app/controllers/admin/csv_controller.rb @@ -24,8 +24,6 @@ def import csv_file = params[:file] if csv_file.present? csv = CSV.read(csv_file.path, headers: true) - puts "CSV File is present" - puts "CSV Headers: #{csv.headers}" #check if headers are equal to CSV_HEADERS and return with Headers must match CSV headers error if not raise CSVHeadersError, "Headers must match CSV headers" unless CSV_HEADERS == csv.headers @@ -35,7 +33,7 @@ def import csv.each do |row| raise CSVHeadersError, "Row must have same number of columns as headers" unless CSV_HEADERS.length == row.length raise StudentBulkImportError, "Student already exists" if Student.find_by(first_name: row["Student First Name"], last_name: row["Student Last Name"]) - raise ClassroomBulkImportError, "Classroom already exists" if Classroom.find_by(name: row["Class Name"]) + raise ClassroomBulkImportError, "Classroom: #{row["Class Name"]} already exists" if Classroom.find_by(name: row["Class Name"]) end # pass file to importer StudentCsvImporter.new(csv: csv, school_id: 1).import diff --git a/app/services/student_csv_importer.rb b/app/services/student_csv_importer.rb index dad25c0..cd552b7 100644 --- a/app/services/student_csv_importer.rb +++ b/app/services/student_csv_importer.rb @@ -1,18 +1,23 @@ -class InvalidClassroomError < StandardError; end -class InvalidStudentError < StandardError; end + class StudentCsvImporter + class InvalidClassroomError < ActiveRecord::Rollback; end + class InvalidStudentError < ActiveRecord::Rollback; end + class InvalidTeacherError < ActiveRecord::Rollback; end + def initialize(csv:, school_id:) @csv = csv @school_id = school_id + @error_messages = { students: {}, classrooms: {}, teachers: {} } + @students = [] + @classrooms = [] + @teachers = [] end def import - puts "Importing students" - error_messages = {students: {}, classrooms: {}} # key = csv row, value = row data - students = [] - classrooms = [] + puts "Importing classrooms, teachers, and students" + @csv.each_with_index do |row, index| next if row.blank? @@ -23,11 +28,11 @@ def import #Loop over classrooms array and save! classroom records if error_messages[:classrooms] is empty classroom = Classroom.new( school_id: @school_id, - name: row['Classroom Name'], - teacher: row['Teacher'], + name: row['Class Name'], ) - error_messages[:classrooms][index] = classroom.errors.full_messages if classroom.invalid? - classrooms << classroom + + @error_messages[:classrooms][index] = classroom.errors.full_messages if classroom.invalid? + @classrooms << classroom # Classrooms must exist before checking validity of student records @@ -38,23 +43,43 @@ def import school_id: @school_id, ) - students << student + student.classroom = classroom - end + @students << student + + teacher = Teacher.new( + name: row['Teacher'], + school_id: @school_id, + ) + + @error_messages[:teachers][index] = teacher.errors.full_messages if teacher.invalid? + @teachers << teacher - if error_messages[:classrooms].empty? - classrooms.each(&:save!) - elsif error_messages[:classrooms].any? Raise InvalidClassroomError, error_messages[:classrooms] end - students.each { |student| error_messages[:students][index] = student.errors.full_messages if student.invalid? } - - puts "CSV Passed Validations... Creating School Records" - puts "Are error messages empty?: #{error_messages.empty?}" - puts "Error Messages: #{error_messages}" - if error_messages[:students].empty? - create_school_records - elsif error_messages[:students].any? Raise InvalidStudentError, error_messages[:students] + ActiveRecord::Base.transaction do + if @error_messages[:classrooms].empty? + @classrooms.each(&:save!) + else + raise InvalidClassroomError.new(error_messages[:classrooms]) + end + + if @error_messages[:teachers].empty? + @teachers.each(&:save!) + else + raise InvalidTeacherError, @error_messages[:teachers] + end + + @students.each_with_index { |student, index| @error_messages[:students][index] = student.errors.full_messages if student.invalid? } + + puts "CSV Passed Validations... Creating School Records" + + if @error_messages[:students].empty? + create_school_records + else + raise InvalidStudentError, error_messages[:students] + end + end end @@ -63,7 +88,9 @@ def create_school_records #Iterate CSV rows @csv.each do |row| #Extract and apply teacher column when creating classroom - classroom = Classroom.find_by!(school_id: school_id, teacher: row['Teacher'], name: row['Classroom Name']) + teacher = Teacher.find_by!(school_id: @school_id, name: row['Teacher']) + + classroom = Classroom.find_by!(school_id: @school_id, teacher_id: teacher.id, name: row['Class Name']) puts "Found Classroom: #{classroom.name}" #Extract and apply uuid column when creating classroom #Extract and apply program column when creating classroom diff --git a/package-lock.json b/package-lock.json index 58a0c91..37484af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -343,6 +343,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", From dae4cdfeaed20a725c881a61d5e2afed152d443c Mon Sep 17 00:00:00 2001 From: Ahmad Cheers Date: Sun, 30 Aug 2026 15:23:21 -0400 Subject: [PATCH 4/4] WIP Successfully imported test csv, extracted records, created and associated records. Needs cleanup and refactoring. --- app/services/student_csv_importer.rb | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/app/services/student_csv_importer.rb b/app/services/student_csv_importer.rb index cd552b7..f066c6d 100644 --- a/app/services/student_csv_importer.rb +++ b/app/services/student_csv_importer.rb @@ -22,10 +22,6 @@ def import @csv.each_with_index do |row, index| next if row.blank? - #Instantiate but don't persist Classroom record - #If record is invalid push error message into error messages hash - #If any classroom records are invalid raise error and return error messages hash to user - #Loop over classrooms array and save! classroom records if error_messages[:classrooms] is empty classroom = Classroom.new( school_id: @school_id, name: row['Class Name'], @@ -35,7 +31,7 @@ def import @classrooms << classroom - # Classrooms must exist before checking validity of student records + # Student must be associated with a Classroom before checking validity of Student records student = Student.new( first_name: row['Student First Name'], last_name: row['Student Last Name'], @@ -53,10 +49,16 @@ def import ) @error_messages[:teachers][index] = teacher.errors.full_messages if teacher.invalid? + teacher.classrooms << classroom @teachers << teacher - end + save_records + + + end + + def save_records ActiveRecord::Base.transaction do if @error_messages[:classrooms].empty? @classrooms.each(&:save!) @@ -92,9 +94,9 @@ def create_school_records classroom = Classroom.find_by!(school_id: @school_id, teacher_id: teacher.id, name: row['Class Name']) puts "Found Classroom: #{classroom.name}" - #Extract and apply uuid column when creating classroom - #Extract and apply program column when creating classroom - classroom.programs.create_or_find_by!(name: row['Program'], level: row['Program Level']) + + program = Program.create_or_find_by!(name: row['Program']) + classroom.classroom_programs.create_or_find_by!(program: program, level: row['Program Level']) puts "Created Program: #{classroom.programs.first.name}" #Extract and apply program level column when creating classroom classroom.students.create_or_find_by!(first_name: row['Student First Name'], last_name: row['Student Last Name'], grade_level: row['Grade Level'], school_id: @school_id)