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 d02d347..9538c98 100644 --- a/Gemfile +++ b/Gemfile @@ -42,6 +42,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 cd4ddf2..ddcdbeb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -114,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) @@ -434,6 +435,7 @@ DEPENDENCIES brakeman bundler-audit capybara + csv debug faker (~> 3.8) herb @@ -496,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 new file mode 100644 index 0000000..8a40fb7 --- /dev/null +++ b/app/controllers/admin/csv_controller.rb @@ -0,0 +1,42 @@ + +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 + 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] + if csv_file.present? + csv = CSV.read(csv_file.path, headers: true) + #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: #{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 + 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..f066c6d --- /dev/null +++ b/app/services/student_csv_importer.rb @@ -0,0 +1,106 @@ + + +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 classrooms, teachers, and students" + + + @csv.each_with_index do |row, index| + next if row.blank? + + classroom = Classroom.new( + school_id: @school_id, + name: row['Class Name'], + ) + + @error_messages[:classrooms][index] = classroom.errors.full_messages if classroom.invalid? + @classrooms << classroom + + + # 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'], + grade_level: row['Grade Level'], + school_id: @school_id, + ) + + student.classroom = classroom + + @students << student + + teacher = Teacher.new( + name: row['Teacher'], + school_id: @school_id, + ) + + @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!) + 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 + + 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 + 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}" + + 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) + 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 2b6d6f1..3eee2d8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,6 +23,12 @@ resources :links, shallow: true, except: %i[index show] end resources :classroom_modules, only: %i[update] + + end + + namespace :admin do + get :csv_template, to: "csv#download" + post :csv_import, to: "csv#import" end root to: "schools#index" 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",