diff --git a/Gemfile b/Gemfile index 4ff30afd..0db124df 100644 --- a/Gemfile +++ b/Gemfile @@ -11,6 +11,7 @@ gem 'importmap-rails', '~> 2.1' gem "omniauth", "~> 2.1" gem "omniauth-google-oauth2", '~> 1.2' gem "omniauth-rails_csrf_protection", '~> 1.0' +gem 'pdf-reader' gem 'pg', '~> 1.5' gem 'puma', '~> 6.6' gem 'rollbar', '~> 3.6' diff --git a/Gemfile.lock b/Gemfile.lock index 3ffe3c2c..66ac7bc9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,7 @@ GEM remote: https://rubygems.org/ specs: + Ascii85 (2.0.1) actioncable (8.0.0.1) actionpack (= 8.0.0.1) activesupport (= 8.0.0.1) @@ -74,6 +75,7 @@ GEM uri (>= 0.13.1) addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) + afm (0.2.2) ast (2.4.2) base64 (0.2.0) bcrypt (3.1.20) @@ -100,7 +102,7 @@ GEM concurrent-ruby (1.3.5) connection_pool (2.5.0) crass (1.0.6) - date (3.4.1) + date (3.4.0) devise (4.9.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -109,7 +111,7 @@ GEM warden (~> 1.2.3) diff-lcs (1.5.1) docile (1.4.1) - dotenv (3.1.7) + dotenv (3.1.6) drb (2.2.1) ed25519 (1.3.0) erubi (1.13.1) @@ -126,6 +128,7 @@ GEM net-http (>= 0.5.0) globalid (1.2.1) activesupport (>= 6.1) + hashery (2.1.2) hashie (5.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) @@ -134,14 +137,13 @@ GEM activesupport (>= 6.0.0) railties (>= 6.0.0) io-console (0.8.0) - irb (1.15.1) - pp (>= 0.6.0) + irb (1.14.3) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.10.1) + json (2.9.1) jwt (2.10.1) base64 - kamal (2.5.2) + kamal (2.4.0) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) @@ -159,7 +161,7 @@ GEM letter_opener (1.10.0) launchy (>= 2.2, < 4) logger (1.6.5) - loofah (2.24.0) + loofah (2.23.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -177,14 +179,14 @@ GEM bigdecimal (~> 3.1) net-http (0.6.0) uri - net-imap (0.5.6) + net-imap (0.5.1) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-scp (4.1.0) + net-scp (4.0.0) net-ssh (>= 2.6.5, < 8.0.0) net-sftp (4.0.0) net-ssh (>= 5.0.0, < 8.0.0) @@ -192,7 +194,7 @@ GEM net-protocol net-ssh (7.3.0) nio4r (2.7.4) - nokogiri (1.18.2) + nokogiri (1.17.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) oauth2 (2.0.9) @@ -220,29 +222,31 @@ GEM orm_adapter (0.5.0) ostruct (0.6.1) parallel (1.26.3) - parser (3.3.7.1) + parser (3.3.7.0) ast (~> 2.4.1) racc + pdf-reader (2.13.0) + Ascii85 (>= 1.0, < 3.0, != 2.0.0) + afm (~> 0.2.1) + hashery (~> 2.0) + ruby-rc4 + ttfunk pg (1.5.9) - pp (0.6.2) - prettyprint - prettyprint (0.2.0) - psych (5.2.3) + psych (5.2.2) date stringio public_suffix (5.0.4) puma (6.6.0) nio4r (~> 2.0) racc (1.8.1) - rack (3.1.10) + rack (3.1.8) rack-protection (4.1.1) base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) - rack-session (2.1.0) - base64 (>= 0.1.0) + rack-session (2.0.0) rack (>= 3.0.0) - rack-test (2.2.0) + rack-test (2.1.0) rack (>= 1.3) rackup (2.2.1) rack (>= 3) @@ -277,7 +281,7 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) - rdoc (6.12.0) + rdoc (6.10.0) psych (>= 4.0.0) regexp_parser (2.10.0) reline (0.6.0) @@ -286,8 +290,8 @@ GEM actionpack (>= 5.2) railties (>= 5.2) rexml (3.4.0) - rollbar (3.6.1) - rspec-core (3.13.3) + rollbar (3.6.0) + rspec-core (3.13.2) rspec-support (~> 3.13.0) rspec-expectations (3.13.3) diff-lcs (>= 1.2.0, < 2.0) @@ -295,7 +299,7 @@ GEM rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (7.1.1) + rspec-rails (7.1.0) actionpack (>= 7.0) activesupport (>= 7.0) railties (>= 7.0) @@ -303,28 +307,29 @@ GEM rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-support (3.13.2) - rubocop (1.71.2) + rspec-support (3.13.1) + rubocop (1.71.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.38.0, < 2.0) + rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.38.0) + rubocop-ast (1.37.0) parser (>= 3.3.1.0) rubocop-performance (1.23.1) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.29.1) + rubocop-rails (2.29.0) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.52.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (1.13.0) + ruby-rc4 (0.1.5) rubyzip (2.4.1) securerandom (0.4.1) selenium-webdriver (4.28.0) @@ -351,9 +356,8 @@ GEM actionpack (>= 6.1) activesupport (>= 6.1) sprockets (>= 3.0.0) - sshkit (1.24.0) + sshkit (1.23.2) base64 - logger net-scp (>= 1.1.2) net-sftp (>= 2.1.2) net-ssh (>= 2.8.0) @@ -362,7 +366,9 @@ GEM railties (>= 6.0.0) stringio (3.1.2) thor (1.3.2) - timeout (0.4.3) + timeout (0.4.2) + ttfunk (1.8.0) + bigdecimal (~> 3.1) turbo-rails (2.0.11) actionpack (>= 6.0.0) railties (>= 6.0.0) @@ -405,6 +411,7 @@ DEPENDENCIES omniauth (~> 2.1) omniauth-google-oauth2 (~> 1.2) omniauth-rails_csrf_protection (~> 1.0) + pdf-reader pg (~> 1.5) puma (~> 6.6) rails (~> 8.0.0) diff --git a/app/assets/stylesheets/_utility.css b/app/assets/stylesheets/_utility.css index 22b595fc..f70dea16 100644 --- a/app/assets/stylesheets/_utility.css +++ b/app/assets/stylesheets/_utility.css @@ -6,6 +6,100 @@ color: #19a819 !important; } +.color-red { + color: #ff0000 !important; +} + .d-none { display: none !important; } + +.mt-0 { margin-top: 0rem !important; } +.mt-1 { margin-top: 0.5rem !important; } +.mt-2 { margin-top: 1rem !important; } +.mt-3 { margin-top: 1.5rem !important; } +.mt-4 { margin-top: 2rem !important; } +.mt-5 { margin-top: 2.5rem !important; } +.mt-6 { margin-top: 3rem !important; } + +.mr-0 { margin-right: 0rem !important; } +.mr-1 { margin-right: 0.5rem !important; } +.mr-2 { margin-right: 1rem !important; } +.mr-3 { margin-right: 1.5rem !important; } +.mr-4 { margin-right: 2rem !important; } +.mr-5 { margin-right: 2.5rem !important; } +.mr-6 { margin-right: 3rem !important; } + +.mb-0 { margin-bottom: 0rem !important; } +.mb-1 { margin-bottom: 0.5rem !important; } +.mb-2 { margin-bottom: 1rem !important; } +.mb-3 { margin-bottom: 1.5rem !important; } +.mb-4 { margin-bottom: 2rem !important; } +.mb-5 { margin-bottom: 2.5rem !important; } +.mb-6 { margin-bottom: 3rem !important; } + +.ml-0 { margin-left: 0rem !important; } +.ml-1 { margin-left: 0.5rem !important; } +.ml-2 { margin-left: 1rem !important; } +.ml-3 { margin-left: 1.5rem !important; } +.ml-4 { margin-left: 2rem !important; } +.ml-5 { margin-left: 2.5rem !important; } +.ml-6 { margin-left: 3rem !important; } + +.mx-0 { + margin-left: 0rem !important; + margin-right: 0rem !important; +} +.mx-1 { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; +} +.mx-2 { + margin-left: 1rem !important; + margin-right: 1rem !important; +} +.mx-3 { + margin-left: 1.5rem !important; + margin-right: 1.5rem !important; +} +.mx-4 { + margin-left: 2rem !important; + margin-right: 2rem !important; +} +.mx-5 { + margin-left: 2.5rem !important; + margin-right: 2.5rem !important; +} +.mx-6 { + margin-left: 3rem !important; + margin-right: 3rem !important; +} + +.my-0 { + margin-top: 0rem !important; + margin-bottom: 0rem !important; +} +.my-1 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; +} +.my-2 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; +} +.my-3 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; +} +.my-4 { + margin-top: 2rem !important; + margin-bottom: 2rem !important; +} +.my-5 { + margin-top: 2.5rem !important; + margin-bottom: 2.5rem !important; +} +.my-6 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; +} diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 1d4967c4..a091091c 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -222,6 +222,11 @@ body { padding-left: 0px; } +.subtle-text { + font-size: 0.7em; + color: #666; +} + .show-password-button { margin-right: -20px; } diff --git a/app/controllers/academic_histories_controller.rb b/app/controllers/academic_histories_controller.rb new file mode 100644 index 00000000..04d8156f --- /dev/null +++ b/app/controllers/academic_histories_controller.rb @@ -0,0 +1,45 @@ +require 'pdf-reader' + +class AcademicHistoriesController < ApplicationController + def index + redirect_to new_academic_history_path + end + + def new + end + + def create + file = params[:file] + @failed_entries = [] + @successful_entries = [] + + if file && file.content_type == 'application/pdf' + AcademicHistory::PdfProcessor.new(file).each do |entry| + save_academic_entry(entry) if entry.approved? + end + else + flash[:error] = 'Please upload a valid PDF file' + end + render :new + end + + private + + def save_academic_entry(entry) + subject_match = Subject.where("lower(unaccent(name)) = lower(unaccent(?))", entry.name) + subject_match = subject_match.select { |subject| subject.credits == entry.credits.to_i } + active_subjects = subject_match.select { |subject| !subject.inactive? } + if subject_match.length == 1 + save_subject(subject_match.first) + elsif active_subjects.length == 1 + save_subject(active_subjects.first) + else + @failed_entries << entry + end + end + + def save_subject(subject) + current_student.force_add_subject(subject) + @successful_entries << subject + end +end diff --git a/app/models/base_student.rb b/app/models/base_student.rb index 86a3382c..b3d7a371 100644 --- a/app/models/base_student.rb +++ b/app/models/base_student.rb @@ -11,6 +11,12 @@ def add(approvable) end end + def force_add_subject(subject) + ids << subject.exam.id if subject.exam + ids << subject.course.id if subject.course + save! + end + def remove(approvable) ids.delete(approvable.id) if !approvable.is_exam? && approvable.subject.exam.present? && ids.include?(approvable.subject.exam.id) diff --git a/app/views/academic_histories/new.html.erb b/app/views/academic_histories/new.html.erb new file mode 100644 index 00000000..14338f26 --- /dev/null +++ b/app/views/academic_histories/new.html.erb @@ -0,0 +1,35 @@ +
Carga tu escolaridad para marcar automaticamente las materias que ya cursaste.
+1) Entrar a Bedelias e iniciar session
+2) Entrar a "Estudiante" -> "Escolaridad" -> "Solicitar Escolaridad"
+3) Seleccionar tu carrera y "No" en "Resultados Intermedios"
+<%= form_with url: academic_histories_path, method: :post, local: true, html: { multipart: true } do |form| %> +<%= @successful_entries.length %> Materias marcadas correctamente
+ <% @successful_entries.each do |entry| %> +<%= entry.code %> - <%= entry.name %>
+ <% end %> + <% end %> + <% if @failed_entries&.present? %> +<%= @failed_entries.length %> Materias no encontradas
+ <% @failed_entries.each do |entry| %> +<%= entry.name %>
+ <% end %> + <% end %> ++La escolaridad no incluye codigos de materias, por lo que en caso de multiples candidatos con el mismo nombre, se marca la materia mas reciente y actualmente activa +
diff --git a/app/views/shared/_user_menu.html.erb b/app/views/shared/_user_menu.html.erb index 0a607bba..512096ab 100644 --- a/app/views/shared/_user_menu.html.erb +++ b/app/views/shared/_user_menu.html.erb @@ -29,6 +29,12 @@ Editar Perfil <% end %> + <%= link_to new_academic_history_path, class: "mdc-deprecated-list-item", tabindex: 0 do %> + + + Importar Materias + + <% end %> <%= link_to destroy_user_session_path, method: :delete, class: "mdc-deprecated-list-item", tabindex: 0 do %> diff --git a/config/routes.rb b/config/routes.rb index cfd51b15..b8464fc4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -30,6 +30,8 @@ resources :current_optional_subjects, only: :index + resources :academic_histories, only: [:new, :create, :index] + resources :reviews, only: [:create, :destroy] resources :subject_plans, only: [:index, :create, :destroy], param: :subject_id diff --git a/lib/academic_history/pdf_processor.rb b/lib/academic_history/pdf_processor.rb new file mode 100644 index 00000000..2d179a31 --- /dev/null +++ b/lib/academic_history/pdf_processor.rb @@ -0,0 +1,46 @@ +require 'pdf-reader' + +module AcademicHistory + class PdfProcessor + include Enumerable + + AcademicEntry = Struct.new(:name, :credits, :number_of_failures, :date_of_completion, :grade) do + def approved? + grade != '***' + end + end + + def initialize(file) + @reader = PDF::Reader.new(file.path) + end + + def each + reader.pages.each do |page| + page.text.split("\n").each do |line| + line.match(subject_regex) do |match| + yield AcademicEntry.new(match[1], match[2], match[3], match[4], match[5]) + end + end + end + end + + # SubjectName Credits NumberOfFailures DateOfCompletion Concept + def subject_regex + /\s*(.*?)\s+(\d+)\s+(\d+)\s+(#{date_regex.source})\s+(#{concept_regex.source})/ + end + + # The date can be either a date in the format DD/MM/YYYY or ********** + def date_regex + /\*{10}|\d\d\/\d\d\/\d\d\d\d/ + end + + # The concept can be either a number from 0 to 12, S/N or *** + def concept_regex + /Aceptable|Bueno|Muy Bueno|Excelente|S\/C|\*{3}/ + end + + private + + attr_reader :reader + end +end diff --git a/spec/lib/academic_history/pdf_processor_spec.rb b/spec/lib/academic_history/pdf_processor_spec.rb new file mode 100644 index 00000000..20896668 --- /dev/null +++ b/spec/lib/academic_history/pdf_processor_spec.rb @@ -0,0 +1,79 @@ +require 'rails_helper' + +RSpec.describe AcademicHistory::PdfProcessor, type: :lib do + # rubocop:disable Layout/LineLength + let(:pdf_mock) { + [ + double('page', text: " Test Subject 1 10 0 20/02/2024 Aceptable"), + double('page', text: " Test Subject 2 9 0 20/02/2024 Bueno"), + double('page', text: " Test Subject 3 8 0 20/02/2024 Muy Bueno"), + double('page', text: " Test Subject 4 7 0 20/02/2024 Excelente"), + double('page', text: " Test Subject 5 6 0 20/02/2024 S/C"), + double('page', text: " Failed Subject 1 8 1 ********** ***") + ] + } + # rubocop:enable Layout/LineLength + + let(:academic_entries) { described_class::AcademicEntry } + + describe '.process' do + let(:file) { double('file', path: 'path') } + let(:reader) { double('reader') } + let(:academic_entries_list) { + [ + academic_entries.new('Test Subject 1', '10', '0', '20/02/2024', 'Aceptable'), + academic_entries.new('Test Subject 2', '9', '0', '20/02/2024', 'Bueno'), + academic_entries.new('Test Subject 3', '8', '0', '20/02/2024', 'Muy Bueno'), + academic_entries.new('Test Subject 4', '7', '0', '20/02/2024', 'Excelente'), + academic_entries.new('Test Subject 5', '6', '0', '20/02/2024', 'S/C'), + academic_entries.new('Failed Subject 1', '8', '1', '**********', '***') + ] + } + + before do + allow(PDF::Reader).to receive(:new).with('path').and_return(reader) + allow(reader).to receive(:pages).and_return(pdf_mock) + end + + it 'returns a list of academic entries' do + expect(described_class.new(file).to_a).to all(be_an(academic_entries)) + end + + it 'parses the text from the pdf file' do + expect(described_class.new(file).to_a).to eq(academic_entries_list) + end + end + + describe 'academic entry' do + let(:entry) { academic_entries.new('Test Subject', '10', '0', '20/02/2024', 'Aceptable') } + let(:failed_entry) { academic_entries.new('Failed Subject', '10', '1', '**********', '***') } + + it 'has a name' do + expect(entry.name).to eq('Test Subject') + end + + it 'has a number of credits' do + expect(entry.credits).to eq('10') + end + + it 'has a number of failures' do + expect(entry.number_of_failures).to eq('0') + end + + it 'has a date of completion' do + expect(entry.date_of_completion).to eq('20/02/2024') + end + + it 'has a grade' do + expect(entry.grade).to eq('Aceptable') + end + + it 'is approved if the grade is not ***' do + expect(entry).to be_approved + end + + it 'is not approved if the grade is ***' do + expect(failed_entry).not_to be_approved + end + end +end