diff --git a/app/assets/javascripts/class_enrollments.js b/app/assets/javascripts/class_enrollments.js index 7a128547..1c6186da 100644 --- a/app/assets/javascripts/class_enrollments.js +++ b/app/assets/javascripts/class_enrollments.js @@ -2,24 +2,91 @@ $(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)){ + 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"); + } + else{ + this.checked = false; } } } } + else{ + var disapproved_label = this.getAttribute('data_disapproved'); + changeSituationInput(target_row,disapproved_label); + } + } + }); + 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(){ + placeholder = this.getAttribute('grade_placeholder'); + $(this).attr('placeholder',placeholder); + }); + $(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); + 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){ + var approved_label = this.getAttribute('data_approved'); + changeSituationInput($(this),approved_label); + } + else{ + var disapproved_label = this.getAttribute('data_disapproved'); + changeSituationInput($(this),disapproved_label); + } + } + }); + $(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); + 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 3085c8cb..d04f1e9c 100644 --- a/app/assets/stylesheets/active_scaffold_overrides.scss +++ b/app/assets/stylesheets/active_scaffold_overrides.scss @@ -351,4 +351,11 @@ 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; +} +.grade-input{ + text-align: right; } \ No newline at end of file diff --git a/app/controllers/class_enrollments_controller.rb b/app/controllers/class_enrollments_controller.rb index f999e58c..15ea16e6 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/controllers/concerns/shared_xls_concern.rb b/app/controllers/concerns/shared_xls_concern.rb index 9ce35735..1f6526e8 100644 --- a/app/controllers/concerns/shared_xls_concern.rb +++ b/app/controllers/concerns/shared_xls_concern.rb @@ -44,4 +44,29 @@ 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) + raise ArgumentError, "Invalid upload" unless file.is_a?(ActionDispatch::Http::UploadedFile) + + original_filename = file.original_filename.to_s + valid_filename = original_filename.end_with?(".xlsx") && + !original_filename.include?("/") && + !original_filename.include?("\\") + raise ArgumentError, "Invalid file name" unless valid_filename + + grades = {} + 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" + 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 a601130d..30421123 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/helpers/concerns/class_enrollment_helper_concern.rb b/app/helpers/concerns/class_enrollment_helper_concern.rb index f8d03360..fa30f876 100644 --- a/app/helpers/concerns/class_enrollment_helper_concern.rb +++ b/app/helpers/concerns/class_enrollment_helper_concern.rb @@ -31,36 +31,21 @@ 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 }) - 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", + 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) end @@ -121,6 +106,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 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 00000000..027f3dea --- /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 %> +

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

+
+
+ + <%= file_field_tag :spreadsheet, required: true %> + <%= 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 f768c099..1c267761 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 34bd4f4e..b1324a54 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" diff --git a/config/routes.rb b/config/routes.rb index e0e877e3..80f43ba7 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 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 00000000..ac7f49c7 --- /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 + 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 + 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 ba72a7e1..f5587371 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")