Skip to content

Commit 29f42cc

Browse files
committed
Add student import to new admin students page
1 parent c9b71e8 commit 29f42cc

File tree

5 files changed

+299
-0
lines changed

5 files changed

+299
-0
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,7 @@
6161
Library/
6262

6363
.idea
64+
65+
# Ignore macOS metadata files
66+
.DS_Store
67+
**/.DS_Store

app/controllers/admin_v2/students_controller.rb

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,24 @@ def add_transaction
111111
end
112112
end
113113

114+
def import
115+
return redirect_with_missing_file_error if params[:csv_file].blank?
116+
117+
begin
118+
results = BulkStudentImportService.import_from_csv(params[:csv_file].path)
119+
redirect_with_import_results(results)
120+
rescue CSV::MalformedCSVError => e
121+
redirect_to admin_v2_students_path, alert: "Invalid CSV format: #{e.message}"
122+
end
123+
end
124+
125+
def template
126+
send_data BulkStudentImportService.generate_csv_template,
127+
filename: "student_import_template.csv",
128+
type: "text/csv",
129+
disposition: "attachment"
130+
end
131+
114132
private
115133

116134
def set_discarded_student
@@ -153,6 +171,62 @@ def validate_transaction_params
153171
errors << t("admin_v2.students.add_transaction.errors.reason_blank") if transaction_reason.blank?
154172
errors
155173
end
174+
175+
def redirect_with_missing_file_error
176+
redirect_to admin_v2_students_path, alert: "Please select a CSV file"
177+
end
178+
179+
def redirect_with_import_results(results)
180+
return redirect_with_no_results_error if results.empty?
181+
182+
created, skipped, failed = partition_results(results)
183+
success_messages = build_success_messages(created, skipped)
184+
185+
if failed.any?
186+
redirect_with_mixed_results(success_messages, failed)
187+
else
188+
redirect_to admin_v2_students_path, notice: success_messages.join(". ")
189+
end
190+
end
191+
192+
def redirect_with_no_results_error
193+
redirect_to admin_v2_students_path, alert: "No students found in CSV file"
194+
end
195+
196+
def partition_results(results)
197+
[
198+
results.select(&:created?),
199+
results.select(&:skipped?),
200+
results.select(&:failed?)
201+
]
202+
end
203+
204+
def build_success_messages(created, skipped)
205+
messages = []
206+
messages << build_created_message(created) if created.any?
207+
messages << build_skipped_message(skipped) if skipped.any?
208+
messages
209+
end
210+
211+
def build_created_message(created)
212+
usernames = created.map { |item| item.student.username }
213+
"Successfully created #{created.count} students: #{usernames.join(', ')}"
214+
end
215+
216+
def build_skipped_message(skipped)
217+
"Skipped #{skipped.count} existing usernames"
218+
end
219+
220+
def redirect_with_mixed_results(success_messages, failed)
221+
error_messages = failed.map { |item| "Row #{item.line_number}: #{item.error_message}" }
222+
alert_message = "#{failed.count} errors occurred: #{error_messages.join(', ')}"
223+
224+
if success_messages.any?
225+
redirect_to admin_v2_students_path, notice: success_messages.join(". "), alert: alert_message
226+
else
227+
redirect_to admin_v2_students_path, alert: alert_message
228+
end
229+
end
156230
end
157231
# rubocop:enable Metrics/ClassLength
158232
end

app/views/admin_v2/students/index.html.erb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
<div class="flex justify-between items-center mb-4">
77
<h3 class="text-lg font-semibold text-gray-900">Students</h3>
88
<div class="flex gap-2">
9+
<%= link_to "Template", template_admin_v2_students_path,
10+
class: "inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" %>
11+
<%= link_to "Import Students", "#",
12+
onclick: "document.getElementById('import-modal').classList.remove('hidden'); return false;",
13+
class: "inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" %>
914
<%= link_to "New Student", new_admin_v2_student_path,
1015
class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" %>
1116
</div>
@@ -90,3 +95,47 @@
9095
</div>
9196
</div>
9297
</div>
98+
99+
<!-- Import Modal -->
100+
<div id="import-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" aria-labelledby="modal-title" role="dialog" aria-modal="true">
101+
<!-- Background overlay -->
102+
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onclick="document.getElementById('import-modal').classList.add('hidden');"></div>
103+
104+
<!-- Modal content -->
105+
<div class="relative bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 z-50">
106+
<div class="px-6 py-4">
107+
<div class="flex justify-between items-center mb-4">
108+
<h3 class="text-lg font-medium text-gray-900" id="modal-title">
109+
Import Students from CSV
110+
</h3>
111+
<button type="button" class="text-gray-400 hover:text-gray-500" onclick="document.getElementById('import-modal').classList.add('hidden');">
112+
<span class="sr-only">Close</span>
113+
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
114+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
115+
</svg>
116+
</button>
117+
</div>
118+
119+
<p class="text-sm text-gray-500 mb-4">
120+
Upload a CSV file with columns: <strong>classroom_id, username</strong>
121+
</p>
122+
123+
<%= form_with url: import_admin_v2_students_path, multipart: true, local: true do |form| %>
124+
<div class="mb-4">
125+
<%= form.file_field :csv_file,
126+
accept: ".csv",
127+
class: "block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 focus:outline-none p-2" %>
128+
</div>
129+
<div class="flex justify-end gap-3">
130+
<button type="button"
131+
onclick="document.getElementById('import-modal').classList.add('hidden');"
132+
class="inline-flex justify-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
133+
Cancel
134+
</button>
135+
<%= form.submit "Import Students",
136+
class: "inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
137+
</div>
138+
<% end %>
139+
</div>
140+
</div>
141+
</div>

config/routes.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@
8989
resources :school_years
9090
resources :stocks
9191
resources :students do
92+
collection do
93+
post :import
94+
get :template
95+
end
9296
member do
9397
patch :restore
9498
post :add_transaction

test/controllers/admin_v2/students_controller_test.rb

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,5 +329,173 @@ class StudentsControllerTest < ActionDispatch::IntegrationTest
329329
assert_redirected_to root_path
330330
assert_equal "Access denied. Admin privileges required.", flash[:alert]
331331
end
332+
333+
# Import/Template tests
334+
test "template should download CSV template" do
335+
get template_admin_v2_students_path
336+
337+
assert_response :success
338+
assert_equal "text/csv", response.media_type
339+
assert_equal "attachment; filename=\"student_import_template.csv\"", response.headers["Content-Disposition"]
340+
assert_match(/classroom_id,username/, response.body)
341+
end
342+
343+
test "import should create students from valid CSV" do
344+
csv_content = "classroom_id,username\n#{@classroom1.id},import_student1\n#{@classroom2.id},import_student2"
345+
csv_file = Tempfile.new(["test_import", ".csv"])
346+
csv_file.write(csv_content)
347+
csv_file.rewind
348+
349+
assert_difference("Student.count", 2) do
350+
post import_admin_v2_students_path, params: {
351+
csv_file: fixture_file_upload(csv_file.path, "text/csv")
352+
}
353+
end
354+
355+
csv_file.close
356+
csv_file.unlink
357+
358+
assert_redirected_to admin_v2_students_path
359+
assert_match(/Successfully created 2 students/, flash[:notice])
360+
assert_match(/import_student1/, flash[:notice])
361+
assert_match(/import_student2/, flash[:notice])
362+
end
363+
364+
test "import should skip existing students" do
365+
csv_content = "classroom_id,username\n#{@classroom1.id},student1\n#{@classroom2.id},new_student"
366+
csv_file = Tempfile.new(["test_import", ".csv"])
367+
csv_file.write(csv_content)
368+
csv_file.rewind
369+
370+
assert_difference("Student.count", 1) do
371+
post import_admin_v2_students_path, params: {
372+
csv_file: fixture_file_upload(csv_file.path, "text/csv")
373+
}
374+
end
375+
376+
csv_file.close
377+
csv_file.unlink
378+
379+
assert_redirected_to admin_v2_students_path
380+
assert_match(/Successfully created 1 students/, flash[:notice])
381+
assert_match(/Skipped 1 existing usernames/, flash[:notice])
382+
end
383+
384+
test "import should handle errors and show line numbers" do
385+
csv_content = "classroom_id,username\n999,invalid_student\n#{@classroom1.id},valid_student"
386+
csv_file = Tempfile.new(["test_import", ".csv"])
387+
csv_file.write(csv_content)
388+
csv_file.rewind
389+
390+
post import_admin_v2_students_path, params: {
391+
csv_file: fixture_file_upload(csv_file.path, "text/csv")
392+
}
393+
394+
csv_file.close
395+
csv_file.unlink
396+
397+
assert_redirected_to admin_v2_students_path
398+
assert_match(/errors occurred/, flash[:alert])
399+
assert_match(/Row 2:/, flash[:alert])
400+
end
401+
402+
test "import should reject missing file" do
403+
assert_no_difference("Student.count") do
404+
post import_admin_v2_students_path
405+
end
406+
407+
assert_redirected_to admin_v2_students_path
408+
assert_equal "Please select a CSV file", flash[:alert]
409+
end
410+
411+
test "import should handle malformed CSV" do
412+
csv_content = "classroom_id,username\n1,\"unclosed quote\n2,another_row"
413+
csv_file = Tempfile.new(["test_import", ".csv"])
414+
csv_file.write(csv_content)
415+
csv_file.rewind
416+
417+
assert_no_difference("Student.count") do
418+
post import_admin_v2_students_path, params: {
419+
csv_file: fixture_file_upload(csv_file.path, "text/csv")
420+
}
421+
end
422+
423+
csv_file.close
424+
csv_file.unlink
425+
426+
assert_redirected_to admin_v2_students_path
427+
assert_match(/Invalid CSV format/, flash[:alert])
428+
end
429+
430+
test "import should handle empty CSV" do
431+
csv_content = "classroom_id,username\n"
432+
csv_file = Tempfile.new(["test_import", ".csv"])
433+
csv_file.write(csv_content)
434+
csv_file.rewind
435+
436+
assert_no_difference("Student.count") do
437+
post import_admin_v2_students_path, params: {
438+
csv_file: fixture_file_upload(csv_file.path, "text/csv")
439+
}
440+
end
441+
442+
csv_file.close
443+
csv_file.unlink
444+
445+
assert_redirected_to admin_v2_students_path
446+
assert_equal "No students found in CSV file", flash[:alert]
447+
end
448+
449+
test "import should show both success and error messages" do
450+
csv_content = "classroom_id,username\n#{@classroom1.id},import_success1\n999,import_fail\n#{@classroom2.id},import_success2"
451+
csv_file = Tempfile.new(["test_import", ".csv"])
452+
csv_file.write(csv_content)
453+
csv_file.rewind
454+
455+
post import_admin_v2_students_path, params: {
456+
csv_file: fixture_file_upload(csv_file.path, "text/csv")
457+
}
458+
459+
csv_file.close
460+
csv_file.unlink
461+
462+
assert_redirected_to admin_v2_students_path
463+
assert_match(/Successfully created 2 students/, flash[:notice])
464+
assert_match(/1 errors occurred/, flash[:alert])
465+
end
466+
467+
test "non-admin cannot access template" do
468+
sign_out(@admin)
469+
teacher = create(:teacher)
470+
sign_in(teacher)
471+
472+
get template_admin_v2_students_path
473+
474+
assert_redirected_to root_path
475+
assert_equal "Access denied. Admin privileges required.", flash[:alert]
476+
end
477+
478+
test "non-admin cannot import students" do
479+
sign_out(@admin)
480+
teacher = create(:teacher)
481+
sign_in(teacher)
482+
483+
csv_content = "classroom_id,username\n#{@classroom1.id},import_student"
484+
csv_file = Tempfile.new(["test_import", ".csv"])
485+
csv_file.write(csv_content)
486+
csv_file.rewind
487+
488+
assert_no_difference("Student.count") do
489+
post import_admin_v2_students_path, params: {
490+
csv_file: fixture_file_upload(csv_file.path, "text/csv")
491+
}
492+
end
493+
494+
csv_file.close
495+
csv_file.unlink
496+
497+
assert_redirected_to root_path
498+
assert_equal "Access denied. Admin privileges required.", flash[:alert]
499+
end
332500
end
333501
end

0 commit comments

Comments
 (0)