From 183823346ba975696c2bcea54b9a9b1ae6b88a62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Monteiro?= Date: Tue, 5 May 2026 23:14:33 -0300 Subject: [PATCH 01/10] #587: hide grade fields when course has no score --- app/assets/javascripts/class_enrollments.js | 8 ++++++++ app/assets/stylesheets/active_scaffold_overrides.scss | 4 ++++ app/controllers/class_enrollments_controller.rb | 2 ++ app/helpers/concerns/class_enrollment_helper_concern.rb | 1 + 4 files changed, 15 insertions(+) diff --git a/app/assets/javascripts/class_enrollments.js b/app/assets/javascripts/class_enrollments.js index 7a1285479..7874d1f45 100644 --- a/app/assets/javascripts/class_enrollments.js +++ b/app/assets/javascripts/class_enrollments.js @@ -22,4 +22,12 @@ $(function() { } } }); + $(document).on('as:action_success', function() { + $('.class_enrollments-sub-form').each(function() { + var subform = $(this); + if (subform.find('[course_has_grade="false"]').length>0){ + subform.addClass('hide-score-column'); + } + }); + }); }); diff --git a/app/assets/stylesheets/active_scaffold_overrides.scss b/app/assets/stylesheets/active_scaffold_overrides.scss index 3085c8cb3..0e74e7821 100644 --- a/app/assets/stylesheets/active_scaffold_overrides.scss +++ b/app/assets/stylesheets/active_scaffold_overrides.scss @@ -351,4 +351,8 @@ small._default_value { .active-scaffold li.form-element dd { padding: 6px 0; +} +.hide-score-column .grade-column, +.hide-score-column .grade_not_count_in_gpr-column{ + display: none !important; } \ No newline at end of file diff --git a/app/controllers/class_enrollments_controller.rb b/app/controllers/class_enrollments_controller.rb index f999e58c4..15ea16e66 100644 --- a/app/controllers/class_enrollments_controller.rb +++ b/app/controllers/class_enrollments_controller.rb @@ -35,6 +35,8 @@ class ClassEnrollmentsController < ApplicationController format: :i18n_number, i18n_options: { format_as: "grade" } } + config.columns[:grade].css_class = "grade-column" + config.columns[:grade_not_count_in_gpr].css_class = "grade_not_count_in_gpr-column" config.columns[:situation].form_ui = :select config.columns[:situation].options = { options: ClassEnrollment::SITUATIONS, diff --git a/app/helpers/concerns/class_enrollment_helper_concern.rb b/app/helpers/concerns/class_enrollment_helper_concern.rb index f8d03360e..955b3c3e7 100644 --- a/app/helpers/concerns/class_enrollment_helper_concern.rb +++ b/app/helpers/concerns/class_enrollment_helper_concern.rb @@ -59,6 +59,7 @@ def custom_disapproved_by_absence_form_column(record, options) end def custom_grade_form_column(record, options) + return "" if !record.course_has_grade options = options.merge({ maxlength: 5, class: "grade-input numeric-input text-input" }) From 2e3a23f14e2300a51d48a9d9b420ae9f3c755f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Monteiro?= Date: Thu, 7 May 2026 17:23:12 -0300 Subject: [PATCH 02/10] #587: add input mask for grade field --- app/assets/javascripts/class_enrollments.js | 18 ++++++++++++++++++ .../stylesheets/active_scaffold_overrides.scss | 3 +++ 2 files changed, 21 insertions(+) diff --git a/app/assets/javascripts/class_enrollments.js b/app/assets/javascripts/class_enrollments.js index 7874d1f45..4eb057d60 100644 --- a/app/assets/javascripts/class_enrollments.js +++ b/app/assets/javascripts/class_enrollments.js @@ -22,12 +22,30 @@ $(function() { } } }); + $(document).on('focus','.grade-input',function(){ + $(this).attr('placeholder','0,0'); + }); + $(document).on('blur','.grade-input',function(){ + $(this).attr('placeholder',''); + }); + $(document).on('input', '.grade-input', function() { + var digits = $(this).val().replace(/\D/g, ''); + if (digits === '') return; + var number = parseInt(digits, 10); + var formatted = (number / 10).toFixed(1).replace('.', ','); + $(this).val(formatted); + }); $(document).on('as:action_success', function() { $('.class_enrollments-sub-form').each(function() { var subform = $(this); if (subform.find('[course_has_grade="false"]').length>0){ subform.addClass('hide-score-column'); } + else{ + subform.find('.grade-input').val(function(i,value){ + return value.replace('.',','); + }); + } }); }); }); diff --git a/app/assets/stylesheets/active_scaffold_overrides.scss b/app/assets/stylesheets/active_scaffold_overrides.scss index 0e74e7821..d04f1e9c7 100644 --- a/app/assets/stylesheets/active_scaffold_overrides.scss +++ b/app/assets/stylesheets/active_scaffold_overrides.scss @@ -355,4 +355,7 @@ small._default_value { .hide-score-column .grade-column, .hide-score-column .grade_not_count_in_gpr-column{ display: none !important; +} +.grade-input{ + text-align: right; } \ No newline at end of file From 4ddc4d5ab45ec3c96515c66683a4a905b8e7799f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Monteiro?= Date: Thu, 7 May 2026 19:38:05 -0300 Subject: [PATCH 03/10] #587: auto-update situation combobox based on minimum_grade_for_approval --- app/assets/javascripts/class_enrollments.js | 17 +++++++++++++++++ .../concerns/class_enrollment_helper_concern.rb | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/class_enrollments.js b/app/assets/javascripts/class_enrollments.js index 4eb057d60..ec08f7564 100644 --- a/app/assets/javascripts/class_enrollments.js +++ b/app/assets/javascripts/class_enrollments.js @@ -22,6 +22,13 @@ $(function() { } } }); + function changeSituationInput(context,situation){ + var row = context.closest('tr'); + var situationSelect = row.find('.situation-input'); + if(situationSelect.val() != situation){ + situationSelect.val(situation).trigger('change'); + } + } $(document).on('focus','.grade-input',function(){ $(this).attr('placeholder','0,0'); }); @@ -34,6 +41,16 @@ $(function() { var number = parseInt(digits, 10); var formatted = (number / 10).toFixed(1).replace('.', ','); $(this).val(formatted); + var minimum_grade = this.getAttribute('minimum_grade_for_approval'); + var actual_grade = parseFloat(formatted.replace(',','.')); + if(actual_grade <= 10){ /* sanity check */ + if(actual_grade >= minimum_grade){ + changeSituationInput($(this),"Aprovado"); + } + else{ + changeSituationInput($(this),"Reprovado"); + } + } }); $(document).on('as:action_success', function() { $('.class_enrollments-sub-form').each(function() { diff --git a/app/helpers/concerns/class_enrollment_helper_concern.rb b/app/helpers/concerns/class_enrollment_helper_concern.rb index 955b3c3e7..3643cd99e 100644 --- a/app/helpers/concerns/class_enrollment_helper_concern.rb +++ b/app/helpers/concerns/class_enrollment_helper_concern.rb @@ -61,7 +61,8 @@ def custom_disapproved_by_absence_form_column(record, options) def custom_grade_form_column(record, options) return "" if !record.course_has_grade options = options.merge({ - maxlength: 5, class: "grade-input numeric-input text-input" + maxlength: 5, class: "grade-input numeric-input text-input", + minimum_grade_for_approval: (CustomVariable.minimum_grade_for_approval.to_f / 10.0) }) text_field(:record, :grade_to_view, options) end From b9e29b60263f4153f2c0d691366736ac06606b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Monteiro?= Date: Fri, 8 May 2026 13:27:56 -0300 Subject: [PATCH 04/10] #587: set grade to grade_of_disapproval_for_absence when disapproved by absence --- app/assets/javascripts/class_enrollments.js | 29 +++++++++++-------- .../class_enrollment_helper_concern.rb | 26 ++--------------- 2 files changed, 19 insertions(+), 36 deletions(-) diff --git a/app/assets/javascripts/class_enrollments.js b/app/assets/javascripts/class_enrollments.js index ec08f7564..c610e3b02 100644 --- a/app/assets/javascripts/class_enrollments.js +++ b/app/assets/javascripts/class_enrollments.js @@ -2,24 +2,29 @@ $(function() { $(document).on('change', '.disapproved_by_absence-input', function() { if(this.checked){ var course_has_grade = this.getAttribute("course_has_grade"); + var target_row = $(this).closest("tr"); if (course_has_grade == "true") { var grade_of_disapproval_for_absence = this.getAttribute("grade_of_disapproval_for_absence"); - grade_of_disapproval_for_absence = grade_of_disapproval_for_absence.replace(/\s+/g, '').replace(",", "."); if (grade_of_disapproval_for_absence != "") { - grade_of_disapproval_for_absence = parseFloat(grade_of_disapproval_for_absence); - var class_enrollments_id = this.getAttribute("class_enrollments_id"); - var grade = document.getElementById( "record_grade_" + class_enrollments_id ).value; - grade = grade.replace(/\s+/g, '').replace(",", "."); - if (grade == "") { - document.getElementById("record_grade_" + class_enrollments_id).value = grade_of_disapproval_for_absence; - } else { - grade = parseFloat(grade); - if( grade > grade_of_disapproval_for_absence ){ - document.getElementById("record_grade_" + class_enrollments_id).value = grade_of_disapproval_for_absence; + var grade = target_row.find(".grade-input").val(); + if (grade == "" || parseFloat(grade)== 0){ + target_row.find(".grade-input").val(grade_of_disapproval_for_absence).trigger("input"); + } + else if (parseFloat(grade) != parseFloat(grade_of_disapproval_for_absence)){ + var msg = "Já existe uma nota digitada ("+grade.replace('.',',')+"). "+ + "Deseja sobrescrevê-la com a nota de reprovação por falta (" + grade_of_disapproval_for_absence.replace('.',',') + ")?"; + if(confirm(msg)){ + target_row.find(".grade-input").val(grade_of_disapproval_for_absence).trigger("input"); + } + else{ + this.checked = false; } } } } + else{ + changeSituationInput(target_row,"Reprovado"); + } } }); function changeSituationInput(context,situation){ @@ -30,7 +35,7 @@ $(function() { } } $(document).on('focus','.grade-input',function(){ - $(this).attr('placeholder','0,0'); + $(this).attr('placeholder','_,_'); }); $(document).on('blur','.grade-input',function(){ $(this).attr('placeholder',''); diff --git a/app/helpers/concerns/class_enrollment_helper_concern.rb b/app/helpers/concerns/class_enrollment_helper_concern.rb index 3643cd99e..52bf4a4ea 100644 --- a/app/helpers/concerns/class_enrollment_helper_concern.rb +++ b/app/helpers/concerns/class_enrollment_helper_concern.rb @@ -33,35 +33,13 @@ def custom_disapproved_by_absence_form_column(record, options) }", course_has_grade: "#{record.course_has_grade}" }) - if record.course_has_grade - grade_of_disapproval_for_absence = ( - (!CustomVariable.grade_of_disapproval_for_absence) || - (CustomVariable.grade_of_disapproval_for_absence.nil?) - ) ? nil : CustomVariable.grade_of_disapproval_for_absence.to_f / 10.0 - options = options.merge( - onchange: " - if( - (this.checked) && - (document.getElementById('record_grade_#{ - record.course_class_id - }_class_enrollments_#{record.id}').value.trim() == '') - ){ - document.getElementById('record_grade_#{ - record.course_class_id - }_class_enrollments_#{record.id}').value = '#{ - grade_of_disapproval_for_absence - }'; - } - " - ) - end check_box(:record, :disapproved_by_absence_to_view, options) end def custom_grade_form_column(record, options) return "" if !record.course_has_grade options = options.merge({ - maxlength: 5, class: "grade-input numeric-input text-input", + maxlength: 5, class: "grade-input numeric-input text-input", minimum_grade_for_approval: (CustomVariable.minimum_grade_for_approval.to_f / 10.0) }) text_field(:record, :grade_to_view, options) @@ -123,6 +101,6 @@ def custom_field_attributes(column, record) if can?(:post_grades, record) && cannot?(:update_all_fields, record) return { style: "display:none;" } end - return { style: "width: 190px;" } if controller_name != "class_enrollments" + { style: "width: 190px;" } if controller_name != "class_enrollments" end end From 4b3fe28e9ba832735019e0649c51a2b473faef10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Monteiro?= Date: Fri, 8 May 2026 13:31:21 -0300 Subject: [PATCH 05/10] #587: add arrow key navigation between grade fields --- app/assets/javascripts/class_enrollments.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/assets/javascripts/class_enrollments.js b/app/assets/javascripts/class_enrollments.js index c610e3b02..0da160e39 100644 --- a/app/assets/javascripts/class_enrollments.js +++ b/app/assets/javascripts/class_enrollments.js @@ -57,6 +57,22 @@ $(function() { } } }); + $(document).on('keydown','.grade-input',function(e){ + if (e.which == 40 || e.which == 38){ + var subform = $(this).closest('.class_enrollments-sub-form'); + var gradeInputs = subform.find('.grade-input:visible'); + var index = gradeInputs.index(this); + var nextIndex = (e.which==40) ? index + 1 : index - 1; + if(nextIndex >= 0 && nextIndex < gradeInputs.length){ + var nextEl = gradeInputs.eq(nextIndex); + nextEl.focus(); + /* 10ms timeout to make sure the select() works properly */ + setTimeout(function(){ + nextEl.select(); + }, 10); + } + } + }); $(document).on('as:action_success', function() { $('.class_enrollments-sub-form').each(function() { var subform = $(this); From c8334dc5d15d15715219a91f81d484655eae01c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Monteiro?= Date: Fri, 8 May 2026 21:59:10 -0300 Subject: [PATCH 06/10] #587: add xlsx grade import for course classes --- .../concerns/shared_xls_concern.rb | 17 ++++++++++ app/controllers/course_classes_controller.rb | 32 +++++++++++++++++++ .../course_classes/import_grades_xls.html.erb | 10 ++++++ config/routes.rb | 3 +- 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 app/views/course_classes/import_grades_xls.html.erb diff --git a/app/controllers/concerns/shared_xls_concern.rb b/app/controllers/concerns/shared_xls_concern.rb index 9ce357352..14dc19628 100644 --- a/app/controllers/concerns/shared_xls_concern.rb +++ b/app/controllers/concerns/shared_xls_concern.rb @@ -44,4 +44,21 @@ def scholarship_status(class_enrollment) key = class_enrollment.enrollment.has_active_scholarship_now? ? "active_scholarship_true" : "active_scholarship_false" I18n.t("xls_content.course_class.summary.#{key}") end + + def parse_grades_xlsx(file) + grades = {} + Zip::File.open(file) do |zip| + sheet = zip.find_entry("xl/worksheets/sheet1.xml") + xml = Nokogiri::XML(sheet.get_input_stream.read) + ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" + xml.xpath("//xmlns:row", "xmlns" => ns).each_with_index do |row, index| + next if index == 0 + enrollment_number = row.at_xpath('xmlns:c[@r[starts-with(., "B")]]//xmlns:v', "xmlns" => ns)&.text + grade_node = row.at_xpath('xmlns:c[@r[starts-with(., "E")]]//xmlns:v', "xmlns" => ns) + next if enrollment_number.nil? + grades[enrollment_number] = grade_node&.text + end + end + grades + end end diff --git a/app/controllers/course_classes_controller.rb b/app/controllers/course_classes_controller.rb index a601130db..304211237 100644 --- a/app/controllers/course_classes_controller.rb +++ b/app/controllers/course_classes_controller.rb @@ -35,6 +35,11 @@ class CourseClassesController < ApplicationController page: true, type: :member, parameters: { format: :xlsx } + config.action_links.add "import_grades_xls", + label: "".html_safe, + type: :member, + position: :replace, + crud_type: :update config.list.sorting = { name: "ASC", id: "DESC" } @@ -150,6 +155,33 @@ def summary_xls end end + def import_grades_xls + @course_class = CourseClass.find(params[:id]) + if request.post? && params[:spreadsheet].present? + file = params[:spreadsheet] + minimum_grade_for_approval = CustomVariable.minimum_grade_for_approval + grades = parse_grades_xlsx(file) + grades.each do |id, grade| + enrollment = Enrollment.find_by(enrollment_number: id) + class_enrollment = @course_class.class_enrollments.find_by(enrollment: enrollment) + if class_enrollment + class_enrollment.grade = grade + if class_enrollment.grade.to_i >= minimum_grade_for_approval + class_enrollment.situation = ClassEnrollment::APPROVED + else + class_enrollment.situation = ClassEnrollment::DISAPPROVED + end + class_enrollment.save + end + end + flash[:info] = "Notas importadas com sucesso!" + return redirect_to course_classes_path + end + respond_to do |format| + format.html { render layout: false if request.xhr? } + end + end + protected def before_update_save(record) return unless diff --git a/app/views/course_classes/import_grades_xls.html.erb b/app/views/course_classes/import_grades_xls.html.erb new file mode 100644 index 000000000..7608271a7 --- /dev/null +++ b/app/views/course_classes/import_grades_xls.html.erb @@ -0,0 +1,10 @@ +<%= form_tag import_grades_xls_course_class_path(@course_class), multipart: true, class: "as_form update", "data-loading": true do %> +

Importar Notas para <%= @course_class.label_with_course %>

+
+
+ + <%= file_field_tag :spreadsheet, required: true %> + <%= submit_tag "Fazer Upload", class: "submit",style: "margin: 0;" ,data: {disable_with: "Enviando..."} %> +
+
+<% end %> diff --git a/config/routes.rb b/config/routes.rb index e0e877e35..80f43ba78 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -63,6 +63,7 @@ member do get "summary_pdf" get "summary_xls" + match "import_grades_xls", via: [:get, :post] end collection do get "class_schedule_pdf" @@ -157,7 +158,7 @@ record_select_routes end - + resources :scholarship_durations do concerns :active_scaffold From 8601d536de2e01b1573eeb6bc43f49f84198cefd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Monteiro?= Date: Sat, 9 May 2026 00:11:00 -0300 Subject: [PATCH 07/10] #587: add i18n support for grade import and usability features --- app/assets/javascripts/class_enrollments.js | 15 +++++++++------ .../concerns/class_enrollment_helper_concern.rb | 7 ++++++- .../course_classes/import_grades_xls.html.erb | 6 +++--- config/locales/class_enrollment.pt-BR.yml | 3 +++ config/locales/course_class.pt-BR.yml | 7 +++++++ 5 files changed, 28 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/class_enrollments.js b/app/assets/javascripts/class_enrollments.js index 0da160e39..1c6186daa 100644 --- a/app/assets/javascripts/class_enrollments.js +++ b/app/assets/javascripts/class_enrollments.js @@ -11,8 +11,7 @@ $(function() { target_row.find(".grade-input").val(grade_of_disapproval_for_absence).trigger("input"); } else if (parseFloat(grade) != parseFloat(grade_of_disapproval_for_absence)){ - var msg = "Já existe uma nota digitada ("+grade.replace('.',',')+"). "+ - "Deseja sobrescrevê-la com a nota de reprovação por falta (" + grade_of_disapproval_for_absence.replace('.',',') + ")?"; + msg = this.getAttribute('overwrite_confirm_msg').replace('actual_grade',grade.replace('.',',')).replace('grade_for_disapproval',grade_of_disapproval_for_absence.replace('.',',')); if(confirm(msg)){ target_row.find(".grade-input").val(grade_of_disapproval_for_absence).trigger("input"); } @@ -23,7 +22,8 @@ $(function() { } } else{ - changeSituationInput(target_row,"Reprovado"); + var disapproved_label = this.getAttribute('data_disapproved'); + changeSituationInput(target_row,disapproved_label); } } }); @@ -35,7 +35,8 @@ $(function() { } } $(document).on('focus','.grade-input',function(){ - $(this).attr('placeholder','_,_'); + placeholder = this.getAttribute('grade_placeholder'); + $(this).attr('placeholder',placeholder); }); $(document).on('blur','.grade-input',function(){ $(this).attr('placeholder',''); @@ -50,10 +51,12 @@ $(function() { var actual_grade = parseFloat(formatted.replace(',','.')); if(actual_grade <= 10){ /* sanity check */ if(actual_grade >= minimum_grade){ - changeSituationInput($(this),"Aprovado"); + var approved_label = this.getAttribute('data_approved'); + changeSituationInput($(this),approved_label); } else{ - changeSituationInput($(this),"Reprovado"); + var disapproved_label = this.getAttribute('data_disapproved'); + changeSituationInput($(this),disapproved_label); } } }); diff --git a/app/helpers/concerns/class_enrollment_helper_concern.rb b/app/helpers/concerns/class_enrollment_helper_concern.rb index 52bf4a4ea..fa30f8761 100644 --- a/app/helpers/concerns/class_enrollment_helper_concern.rb +++ b/app/helpers/concerns/class_enrollment_helper_concern.rb @@ -31,7 +31,9 @@ def custom_disapproved_by_absence_form_column(record, options) CustomVariable.grade_of_disapproval_for_absence.nil? ? nil : CustomVariable.grade_of_disapproval_for_absence.to_f / 10.0 }", - course_has_grade: "#{record.course_has_grade}" + course_has_grade: "#{record.course_has_grade}", + overwrite_confirm_msg: I18n.t("activerecord.attributes.class_enrollment.confirm_grade_overwrite"), + data_disapproved: ClassEnrollment::DISAPPROVED }) check_box(:record, :disapproved_by_absence_to_view, options) end @@ -40,6 +42,9 @@ def custom_grade_form_column(record, options) return "" if !record.course_has_grade options = options.merge({ maxlength: 5, class: "grade-input numeric-input text-input", + data_approved: ClassEnrollment::APPROVED, + data_disapproved: ClassEnrollment::DISAPPROVED, + grade_placeholder: I18n.t("activerecord.attributes.class_enrollment.placeholder_grade"), minimum_grade_for_approval: (CustomVariable.minimum_grade_for_approval.to_f / 10.0) }) text_field(:record, :grade_to_view, options) diff --git a/app/views/course_classes/import_grades_xls.html.erb b/app/views/course_classes/import_grades_xls.html.erb index 7608271a7..027f3deaa 100644 --- a/app/views/course_classes/import_grades_xls.html.erb +++ b/app/views/course_classes/import_grades_xls.html.erb @@ -1,10 +1,10 @@ <%= form_tag import_grades_xls_course_class_path(@course_class), multipart: true, class: "as_form update", "data-loading": true do %> -

Importar Notas para <%= @course_class.label_with_course %>

+

<%= I18n.t("xls_content.course_class.import_grades_xls_title", course: @course_class.label_with_course) %>

- + <%= file_field_tag :spreadsheet, required: true %> - <%= submit_tag "Fazer Upload", class: "submit",style: "margin: 0;" ,data: {disable_with: "Enviando..."} %> + <%= submit_tag I18n.t("xls_content.course_class.upload_button"), class: "submit",style: "margin: 0;" ,data: {disable_with: I18n.t("xls_content.course_class.upload_loading")} %>
<% end %> diff --git a/config/locales/class_enrollment.pt-BR.yml b/config/locales/class_enrollment.pt-BR.yml index f768c0994..1c2677615 100644 --- a/config/locales/class_enrollment.pt-BR.yml +++ b/config/locales/class_enrollment.pt-BR.yml @@ -35,6 +35,9 @@ pt-BR: scholarship_durations_active: "Possui bolsa?" advisor: "Orientador" has_advisor: "Possui orientador?" + placeholder_grade: "_,_" + confirm_grade_overwrite: "Já existe uma nota digitada (actual_grade). Deseja substituí-la com a nota de reprovação por falta (grade_for_disapproval)?" + errors: models: diff --git a/config/locales/course_class.pt-BR.yml b/config/locales/course_class.pt-BR.yml index 34bd4f4e6..b1324a54d 100644 --- a/config/locales/course_class.pt-BR.yml +++ b/config/locales/course_class.pt-BR.yml @@ -56,6 +56,13 @@ pt-BR: xls_content: course_class: + spreadsheet_label: "Planilha (.xlsx)" + upload_button: "Fazer Upload" + upload_loading: "Enviando..." + import_grades_xls_label: "Importar Notas" + import_grades_xls_title: "Importar notas para %{course}" + import_grades_xls_success: "Notas importadas com sucesso!" + import_grades_xls_error: "Erro ao importar planilha." summary: attendance: "Freq S/I" student_email: "Email" From 2d043836de3ae0295a6e37f21ba8ca0b9e9aea7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Monteiro?= Date: Mon, 11 May 2026 07:21:27 -0300 Subject: [PATCH 08/10] #587: add test for grade xlsx import parser and update tests to expect input mask --- .../concerns/shared_xls_concern_spec.rb | 39 +++++++++++++++++++ spec/features/class_enrollments_spec.rb | 4 +- 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 spec/controllers/concerns/shared_xls_concern_spec.rb diff --git a/spec/controllers/concerns/shared_xls_concern_spec.rb b/spec/controllers/concerns/shared_xls_concern_spec.rb new file mode 100644 index 000000000..febcecb1d --- /dev/null +++ b/spec/controllers/concerns/shared_xls_concern_spec.rb @@ -0,0 +1,39 @@ +# Copyright (c) Universidade Federal Fluminense (UFF). +# This file is part of SAPOS. Please, consult the license terms in the LICENSE file. + +require "spec_helper" + +RSpec.describe SharedXlsConcern, type: :concern do + include SharedXlsConcern + + describe "parse_grades_xlsx" do + let(:enrollment1) { FactoryBot.build(:enrollment, enrollment_number: "101") } + let(:enrollment2) { FactoryBot.build(:enrollment, enrollment_number: "102") } + let(:class_enrollment1) { FactoryBot.build(:class_enrollment, enrollment: enrollment1, grade: 87) } + let(:class_enrollment2) { FactoryBot.build(:class_enrollment, enrollment: enrollment2, grade: nil) } + + let(:xlsx_file) do + file = Tempfile.new(["test", ".xlsx"]) + file.binmode + file.write(render_course_classes_summary_xls([class_enrollment1, class_enrollment2])) + file.rewind + file.path + end + + it "returns a hash with enrollment numbers as keys" do + result = parse_grades_xlsx(xlsx_file) + expect(result).to be_a(Hash) + expect(result.keys).to include("101", "102") + end + + it "returns the grade value for filled cells" do + result = parse_grades_xlsx(xlsx_file) + expect(result["101"]).to eq("8.7") + end + + it "returns nil for empty grade cells" do + result = parse_grades_xlsx(xlsx_file) + expect(result["102"]).to be_nil + end + end +end \ No newline at end of file diff --git a/spec/features/class_enrollments_spec.rb b/spec/features/class_enrollments_spec.rb index ba72a7e15..f55873717 100644 --- a/spec/features/class_enrollments_spec.rb +++ b/spec/features/class_enrollments_spec.rb @@ -142,7 +142,7 @@ it "should have a disapproved by absence widget" do page.send_keys :escape find(:css, "#record_disapproved_by_absence_").set(true) - expect(page).to have_field("Nota", with: "1") + expect(page).to have_field("Nota", with: "1,0") end it "should have a justification_grade_not_count_in_gpr without a label" do @@ -171,7 +171,7 @@ it "should be able to edit student" do page.driver.browser.action.send_keys(:escape).perform within(".as_form") do - fill_in "Nota", with: "6" + fill_in "Nota", with: "60" end click_button_and_wait "Atualizar" expect(page).to have_css("td.grade_label-column", text: "6.0") From 21d35c7091b07c43a1904099a3aa148c74493684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Monteiro?= <86133458+jjoaopb@users.noreply.github.com> Date: Mon, 11 May 2026 07:38:05 -0300 Subject: [PATCH 09/10] #587: fix security issue in xlsx import Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- app/controllers/concerns/shared_xls_concern.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/controllers/concerns/shared_xls_concern.rb b/app/controllers/concerns/shared_xls_concern.rb index 14dc19628..69dd254a5 100644 --- a/app/controllers/concerns/shared_xls_concern.rb +++ b/app/controllers/concerns/shared_xls_concern.rb @@ -46,8 +46,16 @@ def scholarship_status(class_enrollment) end def parse_grades_xlsx(file) + raise ArgumentError, "Invalid upload" unless file.is_a?(ActionDispatch::Http::UploadedFile) + + original_filename = file.original_filename.to_s + valid_filename = /\A[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)?\.xlsx\z/.match?(original_filename) && + !original_filename.include?("/") && + !original_filename.include?("\\") + raise ArgumentError, "Invalid file name" unless valid_filename + grades = {} - Zip::File.open(file) do |zip| + Zip::File.open(file.tempfile.path) do |zip| sheet = zip.find_entry("xl/worksheets/sheet1.xml") xml = Nokogiri::XML(sheet.get_input_stream.read) ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" From d56e26b00dc0597296429bfe530539358762568b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Monteiro?= Date: Mon, 11 May 2026 08:06:48 -0300 Subject: [PATCH 10/10] #587: fix xlsx import parser test --- app/controllers/concerns/shared_xls_concern.rb | 2 +- spec/controllers/concerns/shared_xls_concern_spec.rb | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/controllers/concerns/shared_xls_concern.rb b/app/controllers/concerns/shared_xls_concern.rb index 69dd254a5..1f6526e85 100644 --- a/app/controllers/concerns/shared_xls_concern.rb +++ b/app/controllers/concerns/shared_xls_concern.rb @@ -49,7 +49,7 @@ def parse_grades_xlsx(file) raise ArgumentError, "Invalid upload" unless file.is_a?(ActionDispatch::Http::UploadedFile) original_filename = file.original_filename.to_s - valid_filename = /\A[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)?\.xlsx\z/.match?(original_filename) && + valid_filename = original_filename.end_with?(".xlsx") && !original_filename.include?("/") && !original_filename.include?("\\") raise ArgumentError, "Invalid file name" unless valid_filename diff --git a/spec/controllers/concerns/shared_xls_concern_spec.rb b/spec/controllers/concerns/shared_xls_concern_spec.rb index febcecb1d..ac7f49c7d 100644 --- a/spec/controllers/concerns/shared_xls_concern_spec.rb +++ b/spec/controllers/concerns/shared_xls_concern_spec.rb @@ -13,11 +13,11 @@ let(:class_enrollment2) { FactoryBot.build(:class_enrollment, enrollment: enrollment2, grade: nil) } let(:xlsx_file) do - file = Tempfile.new(["test", ".xlsx"]) - file.binmode - file.write(render_course_classes_summary_xls([class_enrollment1, class_enrollment2])) - file.rewind - file.path + tempfile = Tempfile.new(["test", ".xlsx"]) + tempfile.binmode + tempfile.write(render_course_classes_summary_xls([class_enrollment1, class_enrollment2])) + tempfile.rewind + ActionDispatch::Http::UploadedFile.new(tempfile: tempfile,filename: "test.xlsx") end it "returns a hash with enrollment numbers as keys" do