feat(groupe_instructeurs): import instructeurs in unrouted procedure with a proper CSV

This commit is contained in:
Eric Leroy-Terquem 2023-01-10 14:36:56 +01:00
parent 3aa7793ed3
commit 25df48f255
10 changed files with 184 additions and 38 deletions

View file

@ -200,15 +200,17 @@ module Administrateurs
def import def import
if procedure.publiee_or_close? if procedure.publiee_or_close?
if !CSV_ACCEPTED_CONTENT_TYPES.include?(group_csv_file.content_type) && !CSV_ACCEPTED_CONTENT_TYPES.include?(marcel_content_type) if !CSV_ACCEPTED_CONTENT_TYPES.include?(csv_file.content_type) && !CSV_ACCEPTED_CONTENT_TYPES.include?(marcel_content_type)
flash[:alert] = "Importation impossible : veuillez importer un fichier CSV" flash[:alert] = "Importation impossible : veuillez importer un fichier CSV"
elsif group_csv_file.size > CSV_MAX_SIZE elsif csv_file.size > CSV_MAX_SIZE
flash[:alert] = "Importation impossible : le poids du fichier est supérieur à #{number_to_human_size(CSV_MAX_SIZE)}" flash[:alert] = "Importation impossible : le poids du fichier est supérieur à #{number_to_human_size(CSV_MAX_SIZE)}"
else else
file = group_csv_file.read file = csv_file.read
base_encoding = CharlockHolmes::EncodingDetector.detect(file) base_encoding = CharlockHolmes::EncodingDetector.detect(file)
if params[:group_csv_file]
groupes_emails = ACSV::CSV.new_for_ruby3(file.encode("UTF-8", base_encoding[:encoding], invalid: :replace, replace: ""), headers: true, header_converters: :downcase) groupes_emails = ACSV::CSV.new_for_ruby3(file.encode("UTF-8", base_encoding[:encoding], invalid: :replace, replace: ""), headers: true, header_converters: :downcase)
.map { |r| r.to_h.slice('groupe', 'email') } .map { |r| r.to_h.slice('groupe', 'email') }
@ -217,9 +219,27 @@ module Administrateurs
if groupes_emails_has_keys.blank? if groupes_emails_has_keys.blank?
flash[:alert] = "Importation impossible, veuillez importer un csv #{view_context.link_to('suivant ce modèle', "/csv/#{I18n.locale}/import-groupe-test.csv")}" flash[:alert] = "Importation impossible, veuillez importer un csv #{view_context.link_to('suivant ce modèle', "/csv/#{I18n.locale}/import-groupe-test.csv")}"
else else
add_instructeurs_and_get_errors = InstructeursImportService.import(procedure, groupes_emails) add_instructeurs_and_get_errors = InstructeursImportService.import_groupes(procedure, groupes_emails)
if add_instructeurs_and_get_errors.empty? if add_instructeurs_and_get_errors.blank?
flash[:notice] = "La liste des instructeurs a été importée avec succès"
else
flash[:alert] = "Import terminé. Cependant les emails suivants ne sont pas pris en compte: #{add_instructeurs_and_get_errors.join(', ')}"
end
end
elsif params[:instructeurs_csv_file]
instructors_emails = ACSV::CSV.new_for_ruby3(file.encode("UTF-8", base_encoding[:encoding], invalid: :replace, replace: ""), headers: true, header_converters: :downcase)
.map(&:to_h)
instructors_emails_has_key = instructors_emails.first.has_key?("email") && !instructors_emails.first.keys.many?
if instructors_emails_has_key.blank?
flash[:alert] = "Importation impossible, veuillez importer un csv #{view_context.link_to('suivant ce modèle', "/csv/import-instructeurs-test.csv")}"
else
add_instructeurs_and_get_errors = InstructeursImportService.import_instructeurs(procedure, instructors_emails)
if add_instructeurs_and_get_errors.blank?
flash[:notice] = "La liste des instructeurs a été importée avec succès" flash[:notice] = "La liste des instructeurs a été importée avec succès"
else else
flash[:alert] = "Import terminé. Cependant les emails suivants ne sont pas pris en compte: #{add_instructeurs_and_get_errors.join(', ')}" flash[:alert] = "Import terminé. Cependant les emails suivants ne sont pas pris en compte: #{add_instructeurs_and_get_errors.join(', ')}"
@ -229,6 +249,7 @@ module Administrateurs
redirect_to admin_procedure_groupe_instructeurs_path(procedure) redirect_to admin_procedure_groupe_instructeurs_path(procedure)
end end
end end
end
def export_groupe_instructeurs def export_groupe_instructeurs
groupe_instructeurs = procedure.groupe_instructeurs groupe_instructeurs = procedure.groupe_instructeurs
@ -298,12 +319,12 @@ module Administrateurs
(all - assigned).sort (all - assigned).sort
end end
def group_csv_file def csv_file
params[:group_csv_file] params[:group_csv_file] || params[:instructeurs_csv_file]
end end
def marcel_content_type def marcel_content_type
Marcel::MimeType.for(group_csv_file.read, name: group_csv_file.original_filename, declared_type: group_csv_file.content_type) Marcel::MimeType.for(csv_file.read, name: csv_file.original_filename, declared_type: csv_file.content_type)
end end
def instructeurs_self_management_enabled_params def instructeurs_self_management_enabled_params

View file

@ -1,5 +1,5 @@
class InstructeursImportService class InstructeursImportService
def self.import(procedure, groupes_emails) def self.import_groupes(procedure, groupes_emails)
created_at = Time.zone.now created_at = Time.zone.now
updated_at = Time.zone.now updated_at = Time.zone.now
@ -20,21 +20,30 @@ class InstructeursImportService
target_groupes = procedure.reload.groupe_instructeurs target_groupes = procedure.reload.groupe_instructeurs
target_emails = groupes_emails.map { |groupe_email| groupe_email["email"] }.uniq target_instructeurs = find_or_create_instructeurs(admins, groupes_emails)
existing_emails = Instructeur.where(user: { email: target_emails }).pluck(:email)
missing_emails = target_emails - existing_emails
missing_emails.each { |email| create_instructeur(admins, email) }
target_instructeurs = User.where(email: target_emails).map(&:instructeur)
groupes_emails.each do |groupe_email| groupes_emails.each do |groupe_email|
gi = target_groupes.find { |g| g.label == groupe_email['groupe'] } gi = target_groupes.find { |g| g.label == groupe_email['groupe'] }
instructeur = target_instructeurs.find { |i| i.email == groupe_email['email'] } add_instructeur_to_groupe(target_instructeurs, groupe_email, gi)
if !gi.instructeurs.include?(instructeur)
gi.instructeurs << instructeur
end end
errors
end
def self.import_instructeurs(procedure, emails)
admins = procedure.administrateurs
instructeurs_emails, error_instructeurs_emails = emails
.map { |instructeur_email| { "email" => instructeur_email["email"].present? ? instructeur_email["email"].gsub(/[[:space:]]/, '').downcase : nil } }
.partition { |instructeur_email| Devise.email_regexp.match?(instructeur_email['email']) }
errors = error_instructeurs_emails.map { |instructeur_email| instructeur_email['email'] }
target_instructeurs = find_or_create_instructeurs(admins, instructeurs_emails)
instructeurs_emails.each do |instructeur_email|
gi = procedure.defaut_groupe_instructeur
add_instructeur_to_groupe(target_instructeurs, instructeur_email, gi)
end end
errors errors
@ -42,6 +51,16 @@ class InstructeursImportService
private private
def self.find_or_create_instructeurs(administrateurs, instructeurs_emails)
target_emails = instructeurs_emails.map { |instructeur_email| instructeur_email["email"] }.uniq
existing_emails = Instructeur.where(user: { email: target_emails }).pluck(:email)
missing_emails = target_emails - existing_emails
missing_emails.each { |email| create_instructeur(administrateurs, email) }
User.where(email: target_emails).map(&:instructeur)
end
def self.create_instructeur(administrateurs, email) def self.create_instructeur(administrateurs, email)
user = User.create_or_promote_to_instructeur( user = User.create_or_promote_to_instructeur(
email, email,
@ -51,4 +70,12 @@ class InstructeursImportService
user.invite! user.invite!
user.instructeur user.instructeur
end end
def self.add_instructeur_to_groupe(target_instructeurs, instructeur_email, gi)
instructeur = target_instructeurs.find { |i| i.email == instructeur_email['email'] }
if !gi.instructeurs.include?(instructeur)
gi.instructeurs << instructeur
end
end
end end

View file

@ -28,11 +28,13 @@
= form_tag import_admin_procedure_groupe_instructeurs_path(procedure), method: :post, multipart: true, class: "mt-4 form" do = form_tag import_admin_procedure_groupe_instructeurs_path(procedure), method: :post, multipart: true, class: "mt-4 form" do
= label_tag t('.csv_import.title') = label_tag t('.csv_import.title')
%p.notice %p.notice
= t('.csv_import.notice_1') = procedure.routing_enabled? ? t('.csv_import.routing_enabled.notice_1') : t('.csv_import.routing_disabled.notice_1')
%p.notice %p.notice
= t('.csv_import.notice_2', csv_max_size: number_to_human_size(csv_max_size)) = t('.csv_import.notice_2', csv_max_size: number_to_human_size(csv_max_size))
%p.mt-2.mb-2= link_to t('.csv_import.download_exemple'), "/csv/#{I18n.locale}/import-groupe-test.csv" - sample_file_path = procedure.routing_enabled? ? "/csv/#{I18n.locale}/import-groupe-test.csv" : "/csv/import-instructeurs-test.csv"
= file_field_tag :group_csv_file, required: true, accept: 'text/csv', size: "1" %p.mt-2.mb-2= link_to t('.csv_import.download_exemple'), sample_file_path
- csv_params = procedure.routing_enabled? ? :group_csv_file : :instructeurs_csv_file
= file_field_tag csv_params, required: true, accept: 'text/csv', size: "1"
= submit_tag t('.csv_import.import_file'), class: 'button primary send', data: { disable_with: "Envoi..." } = submit_tag t('.csv_import.import_file'), class: 'button primary send', data: { disable_with: "Envoi..." }
- else - else
%p.mt-4.form.font-weight-bold.mb-2.text-lg %p.mt-4.form.font-weight-bold.mb-2.text-lg

View file

@ -41,7 +41,10 @@ en:
notice: This group will be a choice from the list "%{routing_criteria_name}" notice: This group will be a choice from the list "%{routing_criteria_name}"
csv_import: csv_import:
title: CSV Import title: CSV Import
routing_enabled:
notice_1: The csv file must have 2 columns (Group, Email) and be separated by commas. The import does not overwrite existing groups and instructors. notice_1: The csv file must have 2 columns (Group, Email) and be separated by commas. The import does not overwrite existing groups and instructors.
routing_disabled:
notice_1: The csv file must have 1 column with instructors emails.
notice_2: The size of the file must be less than %{csv_max_size}. notice_2: The size of the file must be less than %{csv_max_size}.
download_exemple: Download sample CSV file download_exemple: Download sample CSV file
import_file: Import file import_file: Import file

View file

@ -47,8 +47,11 @@ fr:
notice: Ce groupe sera un choix de la liste "%{routing_criteria_name}" notice: Ce groupe sera un choix de la liste "%{routing_criteria_name}"
csv_import: csv_import:
title: Importer par CSV title: Importer par CSV
notice_1: Le fichier csv doit comporter 2 colonnes (Groupe, Email) et être séparé par des virgules. Si vous n'avez pas créé de groupe, entrez « défaut » dans la colonne Groupe pour chaque instructeur. Limport nécrase pas les groupes et les instructeurs existants. La modification du fichier csv ne sopère que pour lajout de nouveaux instructeurs. La suppression dun instructeur sopère manuellement en cliquant sur le bouton « retirer ». routing_enabled:
notice_2: Le poids du fichier doit être inférieur à %{csv_max_size} notice_1: Le fichier csv doit comporter 2 colonnes (Groupe, Email) et être séparé par des virgules.
routing_disabled:
notice_1: Le fichier csv doit comporter 1 seule colonne (Email) avec une adresse email d'instructeur par ligne.
notice_2: Limport nécrase pas les groupes et les instructeurs existants. La modification du fichier csv ne sopère que pour lajout de nouveaux instructeurs. La suppression dun instructeur sopère manuellement en cliquant sur le bouton « retirer ». Le poids du fichier doit être inférieur à %{csv_max_size}.
download_exemple: Télécharger lexemple de fichier CSV download_exemple: Télécharger lexemple de fichier CSV
import_file: Importer le fichier import_file: Importer le fichier
import_file_procedure_not_published: Limport dinstructeurs par fichier CSV est disponible une fois la démarche publiée import_file_procedure_not_published: Limport dinstructeurs par fichier CSV est disponible une fois la démarche publiée

View file

@ -0,0 +1,5 @@
Email
camilia@gouv.fr
kara@gouv.fr
simon@gouv.fr
pauline@gouv.fr
1 Email
2 camilia@gouv.fr
3 kara@gouv.fr
4 simon@gouv.fr
5 pauline@gouv.fr

View file

@ -476,6 +476,65 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do
end end
end end
describe '#add_instructeurs_via_csv_file' do
let(:procedure_non_routee) { create(:procedure, :published, :for_individual, administrateurs: [admin]) }
subject do
post :import, params: { procedure_id: procedure_non_routee.id, instructeurs_csv_file: csv_file }
end
context 'when the csv file is less than 1 mo and content type text/csv' do
let(:csv_file) { fixture_file_upload('spec/fixtures/files/instructeurs-file.csv', 'text/csv') }
before { subject }
it { expect(response.status).to eq(302) }
it { expect(procedure_non_routee.instructeurs.pluck(:email)).to match_array(["kara@beta-gouv.fr", "philippe@mail.com", "lisa@gouv.fr"]) }
it { expect(flash.alert).to be_present }
it { expect(flash.alert).to eq("Import terminé. Cependant les emails suivants ne sont pas pris en compte: eric") }
end
context 'when the csv file has more than one column' do
let(:csv_file) { fixture_file_upload('spec/fixtures/files/groupe-instructeur.csv', 'text/csv') }
before { subject }
it { expect(response.status).to eq(302) }
it { expect(flash.alert).to be_present }
it { expect(flash.alert).to eq("Importation impossible, veuillez importer un csv <a href=\"/csv/import-instructeurs-test.csv\">suivant ce modèle</a>") }
end
context 'when the file content type is application/vnd.ms-excel' do
let(:csv_file) { fixture_file_upload('spec/fixtures/files/valid-instructeurs-file.csv', "application/vnd.ms-excel") }
before { subject }
it { expect(procedure_non_routee.instructeurs.pluck(:email)).to match_array(["kara@beta-gouv.fr", "philippe@mail.com", "lisa@gouv.fr"]) }
it { expect(flash.notice).to be_present }
it { expect(flash.notice).to eq("La liste des instructeurs a été importée avec succès") }
end
context 'when the csv file length is more than 1 mo' do
let(:csv_file) { fixture_file_upload('spec/fixtures/files/groupe-instructeur.csv', 'text/csv') }
before do
allow_any_instance_of(ActionDispatch::Http::UploadedFile).to receive(:size).and_return(3.megabytes)
subject
end
it { expect(flash.alert).to be_present }
it { expect(flash.alert).to eq("Importation impossible : le poids du fichier est supérieur à 1 Mo") }
end
context 'when the file content type is not accepted' do
let(:csv_file) { fixture_file_upload('spec/fixtures/files/french-flag.gif', 'image/gif') }
before { subject }
it { expect(flash.alert).to be_present }
it { expect(flash.alert).to eq("Importation impossible : veuillez importer un fichier CSV") }
end
end
describe '#export_groupe_instructeurs' do describe '#export_groupe_instructeurs' do
let(:procedure) { create(:procedure, :published) } let(:procedure) { create(:procedure, :published) }
let(:gi_1_2) { procedure.groupe_instructeurs.create(label: 'groupe instructeur 1 2') } let(:gi_1_2) { procedure.groupe_instructeurs.create(label: 'groupe instructeur 1 2') }

View file

@ -0,0 +1,5 @@
Email
kara@beta-gouv.fr
philippe@mail.com
lisa@gouv.fr
eric
1 Email
2 kara@beta-gouv.fr
3 philippe@mail.com
4 lisa@gouv.fr
5 eric

View file

@ -0,0 +1,4 @@
Email
kara@beta-gouv.fr
philippe@mail.com
lisa@gouv.fr
1 Email
2 kara@beta-gouv.fr
3 philippe@mail.com
4 lisa@gouv.fr

View file

@ -1,5 +1,5 @@
describe InstructeursImportService do describe InstructeursImportService do
describe '#import' do describe '#import_groupes' do
let(:procedure) { create(:procedure) } let(:procedure) { create(:procedure) }
let(:procedure_groupes) do let(:procedure_groupes) do
@ -9,7 +9,7 @@ describe InstructeursImportService do
.to_h .to_h
end end
subject { described_class.import(procedure, lines) } subject { described_class.import_groupes(procedure, lines) }
context 'nominal case' do context 'nominal case' do
let(:lines) do let(:lines) do
@ -20,7 +20,7 @@ describe InstructeursImportService do
] ]
end end
it 'imports' do it 'imports groupes' do
errors = subject errors = subject
expect(procedure_groupes.keys).to contain_exactly("Auvergne Rhone-Alpes", "Occitanie", "défaut") expect(procedure_groupes.keys).to contain_exactly("Auvergne Rhone-Alpes", "Occitanie", "défaut")
@ -126,4 +126,21 @@ describe InstructeursImportService do
end end
end end
end end
describe '#import_instructeurs' do
let(:procedure_non_routee) { create(:procedure) }
subject { described_class.import_instructeurs(procedure_non_routee, emails) }
context 'nominal case' do
let(:emails) { [{ "email" => "john@lennon.fr" }, { "email" => "paul@mccartney.uk" }, { "email" => "ringo@starr.uk" }] }
it 'imports instructeurs' do
errors = subject
expect(procedure_non_routee.defaut_groupe_instructeur.instructeurs.pluck(:email)).to contain_exactly("john@lennon.fr", "paul@mccartney.uk", "ringo@starr.uk")
expect(errors).to match_array([])
end
end
end
end end