commit
a098003ba8
13 changed files with 169 additions and 26 deletions
|
@ -54,6 +54,10 @@
|
||||||
margin-top: 2 * $default-spacer;
|
margin-top: 2 * $default-spacer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mt-4 {
|
||||||
|
margin-top: 4 * $default-spacer;
|
||||||
|
}
|
||||||
|
|
||||||
.mb-2 {
|
.mb-2 {
|
||||||
margin-bottom: 2 * $default-spacer;
|
margin-bottom: 2 * $default-spacer;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
module NewAdministrateur
|
module NewAdministrateur
|
||||||
class GroupeInstructeursController < AdministrateurController
|
class GroupeInstructeursController < AdministrateurController
|
||||||
|
include ActiveSupport::NumberHelper
|
||||||
ITEMS_PER_PAGE = 25
|
ITEMS_PER_PAGE = 25
|
||||||
|
CSV_MAX_SIZE = 1.megabytes
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@procedure = procedure
|
@procedure = procedure
|
||||||
|
@ -158,6 +160,31 @@ module NewAdministrateur
|
||||||
notice: "Le libellé est maintenant « #{procedure.routing_criteria_name} »."
|
notice: "Le libellé est maintenant « #{procedure.routing_criteria_name} »."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def import
|
||||||
|
if (group_csv_file.content_type != "text/csv") && (marcel_content_type != "text/csv")
|
||||||
|
flash[:alert] = "Importation impossible : veuillez importer un fichier CSV"
|
||||||
|
redirect_to admin_procedure_groupe_instructeurs_path(procedure)
|
||||||
|
|
||||||
|
elsif group_csv_file.size > CSV_MAX_SIZE
|
||||||
|
flash[:alert] = "Importation impossible : la poids du fichier est supérieur à #{number_to_human_size(CSV_MAX_SIZE)}"
|
||||||
|
redirect_to admin_procedure_groupe_instructeurs_path(procedure)
|
||||||
|
|
||||||
|
else
|
||||||
|
groupes_emails = CSV.new(group_csv_file.read, headers: true, header_converters: :downcase)
|
||||||
|
.map { |r| r.to_h.slice('groupe', 'email') }
|
||||||
|
|
||||||
|
add_instructeurs_and_get_errors = InstructeursImportService.import(procedure, groupes_emails)
|
||||||
|
|
||||||
|
if add_instructeurs_and_get_errors.empty?
|
||||||
|
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
|
||||||
|
|
||||||
|
redirect_to admin_procedure_groupe_instructeurs_path(procedure)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def create_instructeur(email)
|
def create_instructeur(email)
|
||||||
|
@ -214,5 +241,13 @@ module NewAdministrateur
|
||||||
assigned = groupe_instructeur.instructeurs.map(&:email)
|
assigned = groupe_instructeur.instructeurs.map(&:email)
|
||||||
(all - assigned).sort
|
(all - assigned).sort
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def group_csv_file
|
||||||
|
params[:group_csv_file]
|
||||||
|
end
|
||||||
|
|
||||||
|
def marcel_content_type
|
||||||
|
Marcel::MimeType.for(group_csv_file.read, name: group_csv_file.original_filename, declared_type: group_csv_file.content_type)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,8 +1,35 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { QueryClientProvider } from 'react-query';
|
import { QueryClientProvider } from 'react-query';
|
||||||
|
import { matchSorter } from 'match-sorter';
|
||||||
|
|
||||||
import ComboSearch from './ComboSearch';
|
import ComboSearch from './ComboSearch';
|
||||||
import { queryClient } from './shared/queryClient';
|
import { queryClient, searchResultsLimit } from './shared/queryClient';
|
||||||
|
|
||||||
|
function expandResultsWithMultiplePostalCodes(term, results) {
|
||||||
|
// A single result may have several associated postal codes.
|
||||||
|
// To make the search results more precise, we want to generate
|
||||||
|
// an actual result for each postal code.
|
||||||
|
const expandedResults = results.flatMap((result) =>
|
||||||
|
result.codesPostaux.map((codePostal) => ({
|
||||||
|
...result,
|
||||||
|
codesPostaux: [codePostal]
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Some very large cities (like Paris) have A LOT of associated postal codes.
|
||||||
|
// As we generated one result per postal code, we now have a lot of results
|
||||||
|
// for the same city. If the number of results is above the threshold, we use
|
||||||
|
// local search to narrow the results.
|
||||||
|
const limit = searchResultsLimit(term);
|
||||||
|
if (expandedResults.length > limit) {
|
||||||
|
return matchSorter(expandedResults, term, {
|
||||||
|
keys: [(item) => `${item.nom} (${item.codesPostaux[0]})`, 'code'],
|
||||||
|
sorter: (rankedItems) => rankedItems
|
||||||
|
}).slice(0, limit + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return expandedResults;
|
||||||
|
}
|
||||||
|
|
||||||
function ComboCommunesSearch(params) {
|
function ComboCommunesSearch(params) {
|
||||||
return (
|
return (
|
||||||
|
@ -16,6 +43,7 @@ function ComboCommunesSearch(params) {
|
||||||
code,
|
code,
|
||||||
`${nom} (${codesPostaux[0]})`
|
`${nom} (${codesPostaux[0]})`
|
||||||
]}
|
]}
|
||||||
|
transformResults={expandResultsWithMultiplePostalCodes}
|
||||||
/>
|
/>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback } from 'react';
|
import React from 'react';
|
||||||
import { QueryClientProvider } from 'react-query';
|
import { QueryClientProvider } from 'react-query';
|
||||||
import { matchSorter } from 'match-sorter';
|
import { matchSorter } from 'match-sorter';
|
||||||
|
|
||||||
|
@ -7,14 +7,16 @@ import { queryClient } from './shared/queryClient';
|
||||||
|
|
||||||
const extraTerms = [{ code: '99', nom: 'Etranger' }];
|
const extraTerms = [{ code: '99', nom: 'Etranger' }];
|
||||||
|
|
||||||
function ComboDepartementsSearch(params) {
|
function expandResultsWithForeignDepartement(term, results) {
|
||||||
const transformResults = useCallback((term, results) => [
|
return [
|
||||||
...results,
|
...results,
|
||||||
...matchSorter(extraTerms, term, {
|
...matchSorter(extraTerms, term, {
|
||||||
keys: ['nom', 'code']
|
keys: ['nom', 'code']
|
||||||
})
|
})
|
||||||
]);
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboDepartementsSearch(params) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ComboSearch
|
<ComboSearch
|
||||||
|
@ -23,7 +25,7 @@ function ComboDepartementsSearch(params) {
|
||||||
scope="departements"
|
scope="departements"
|
||||||
minimumInputLength={1}
|
minimumInputLength={1}
|
||||||
transformResult={({ code, nom }) => [code, `${code} - ${nom}`]}
|
transformResult={({ code, nom }) => [code, `${code} - ${nom}`]}
|
||||||
transformResults={transformResults}
|
transformResults={expandResultsWithForeignDepartement}
|
||||||
/>
|
/>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -125,12 +125,12 @@ function ComboSearch({
|
||||||
<ComboboxPopover className="shadow-popup">
|
<ComboboxPopover className="shadow-popup">
|
||||||
{results.length > 0 ? (
|
{results.length > 0 ? (
|
||||||
<ComboboxList>
|
<ComboboxList>
|
||||||
{results.map((result) => {
|
{results.map((result, index) => {
|
||||||
const [key, str] = transformResult(result);
|
const [key, str] = transformResult(result);
|
||||||
resultsMap.current[str] = [key, result];
|
resultsMap.current[str] = [key, result];
|
||||||
return (
|
return (
|
||||||
<ComboboxOption
|
<ComboboxOption
|
||||||
key={key}
|
key={`${key}-${index}`}
|
||||||
value={str}
|
value={str}
|
||||||
data-option-value={str}
|
data-option-value={str}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -13,6 +13,11 @@ export const queryClient = new QueryClient({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Avoid hiding similar matches for precise queries (like "Sainte Marie")
|
||||||
|
export function searchResultsLimit(term) {
|
||||||
|
return term.length > 5 ? 10 : 5;
|
||||||
|
}
|
||||||
|
|
||||||
function buildURL(scope, term) {
|
function buildURL(scope, term) {
|
||||||
term = encodeURIComponent(term.replace(/\(|\)/g, ''));
|
term = encodeURIComponent(term.replace(/\(|\)/g, ''));
|
||||||
if (scope === 'adresse') {
|
if (scope === 'adresse') {
|
||||||
|
@ -23,8 +28,7 @@ function buildURL(scope, term) {
|
||||||
if (isNumeric(term)) {
|
if (isNumeric(term)) {
|
||||||
return `${api_geo_url}/communes?codePostal=${term}&limit=5`;
|
return `${api_geo_url}/communes?codePostal=${term}&limit=5`;
|
||||||
}
|
}
|
||||||
// Avoid hiding similar matches for precise queries (like "Sainte Marie")
|
const limit = searchResultsLimit(term);
|
||||||
const limit = term.length > 5 ? 10 : 5;
|
|
||||||
return `${api_geo_url}/communes?nom=${term}&boost=population&limit=${limit}`;
|
return `${api_geo_url}/communes?nom=${term}&boost=population&limit=${limit}`;
|
||||||
} else if (isNumeric(term)) {
|
} else if (isNumeric(term)) {
|
||||||
const code = term.padStart(2, '0');
|
const code = term.padStart(2, '0');
|
||||||
|
|
|
@ -1,24 +1,39 @@
|
||||||
class InstructeursImportService
|
class InstructeursImportService
|
||||||
def import(procedure, groupes_emails)
|
def self.import(procedure, groupes_emails)
|
||||||
|
created_at = Time.zone.now
|
||||||
|
updated_at = Time.zone.now
|
||||||
|
|
||||||
admins = procedure.administrateurs
|
admins = procedure.administrateurs
|
||||||
|
|
||||||
errors = []
|
groupes_emails, error_groupe_emails = groupes_emails
|
||||||
|
.map { |groupe_email| { "groupe" => groupe_email["groupe"].strip, "email" => groupe_email["email"].gsub(/[[:space:]]/, '').downcase } }
|
||||||
|
.partition { |groupe_email| Devise.email_regexp.match?(groupe_email['email']) && groupe_email['groupe'].present? }
|
||||||
|
|
||||||
groupes_emails.each do |groupe_emails|
|
errors = error_groupe_emails.map { |group_email| group_email['email'] }
|
||||||
groupe = groupe_emails["groupe"].strip
|
|
||||||
instructeur_email = groupe_emails["email"].strip.downcase
|
|
||||||
|
|
||||||
if groupe.present? && Devise.email_regexp.match?(instructeur_email)
|
target_labels = groupes_emails.map { |groupe_email| groupe_email['groupe'] }.uniq
|
||||||
gi = procedure.groupe_instructeurs.find_or_create_by!(label: groupe)
|
missing_labels = target_labels - procedure.groupe_instructeurs.pluck(:label)
|
||||||
|
|
||||||
instructeur = Instructeur.by_email(instructeur_email) || create_instructeur(admins, instructeur_email)
|
if missing_labels.present?
|
||||||
|
GroupeInstructeur.insert_all(missing_labels.map { |label| { label: label, procedure_id: procedure.id, created_at: created_at, updated_at: updated_at } })
|
||||||
|
end
|
||||||
|
|
||||||
if !gi.instructeurs.include?(instructeur)
|
target_groupes = procedure.reload.groupe_instructeurs
|
||||||
gi.instructeurs << instructeur
|
|
||||||
|
|
||||||
end
|
target_emails = groupes_emails.map { |groupe_email| groupe_email["email"] }.uniq
|
||||||
else
|
|
||||||
errors << instructeur_email
|
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|
|
||||||
|
gi = target_groupes.find { |g| g.label == groupe_email['groupe'] }
|
||||||
|
instructeur = target_instructeurs.find { |i| i.email == groupe_email['email'] }
|
||||||
|
|
||||||
|
if !gi.instructeurs.include?(instructeur)
|
||||||
|
gi.instructeurs << instructeur
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -27,7 +42,7 @@ class InstructeursImportService
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def 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,
|
||||||
SecureRandom.hex,
|
SecureRandom.hex,
|
||||||
|
|
|
@ -25,6 +25,15 @@
|
||||||
= f.text_field :label, placeholder: 'ex. Ville de Bordeaux', required: true
|
= f.text_field :label, placeholder: 'ex. Ville de Bordeaux', required: true
|
||||||
= f.submit 'Ajouter le groupe', class: 'button primary send'
|
= f.submit 'Ajouter le groupe', class: 'button primary send'
|
||||||
|
|
||||||
|
- csv_max_size = NewAdministrateur::GroupeInstructeursController::CSV_MAX_SIZE
|
||||||
|
= form_tag import_admin_procedure_groupe_instructeurs_path(@procedure), method: :post, multipart: true, class: "mt-4 form" do
|
||||||
|
= label_tag "Importer par fichier CSV"
|
||||||
|
%p.notice Le fichier csv doit comporter 2 colonnes (Groupe, Email) et être séparé par des virgules. L'import n'écrase pas les groupes et les instructeurs existants.
|
||||||
|
%p.notice Le poids du fichier doit être inférieur à #{number_to_human_size(csv_max_size)}
|
||||||
|
%p.mt-2.mb-2= link_to "Télécharger l'exemple de fichier CSV", "/import-groupe-test.csv"
|
||||||
|
= file_field_tag :group_csv_file, required: true, accept: 'text/csv', size: "1"
|
||||||
|
= submit_tag "Importer le fichier", class: 'button primary send', data: { disable_with: "Envoi..." }
|
||||||
|
|
||||||
%table.table.mt-2
|
%table.table.mt-2
|
||||||
%thead
|
%thead
|
||||||
%tr
|
%tr
|
||||||
|
|
|
@ -428,6 +428,7 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
collection do
|
collection do
|
||||||
patch 'update_routing_criteria_name'
|
patch 'update_routing_criteria_name'
|
||||||
|
post 'import'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
5
public/import-groupe-test.csv
Normal file
5
public/import-groupe-test.csv
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
Email,Groupe
|
||||||
|
camilia@gouv.fr,Nord
|
||||||
|
kara@gouv.fr,Finistère
|
||||||
|
simon@gouv.fr,Isère
|
||||||
|
pauline@gouv.fr,Bouches-du-Rhône
|
|
|
@ -349,6 +349,44 @@ describe NewAdministrateur::GroupeInstructeursController, type: :controller do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#add_groupe_instructeurs_via_csv_file' do
|
||||||
|
subject do
|
||||||
|
post :import, params: { procedure_id: procedure.id, group_csv_file: csv_file }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the csv file is less than 1 mo' 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(procedure.groupe_instructeurs.last.label).to eq("Afrique") }
|
||||||
|
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: kara") }
|
||||||
|
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 : la poids du fichier est supérieur à 1 Mo") }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the file is not a csv' 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 '#update_routing_criteria_name' do
|
describe '#update_routing_criteria_name' do
|
||||||
before do
|
before do
|
||||||
patch :update_routing_criteria_name,
|
patch :update_routing_criteria_name,
|
||||||
|
|
3
spec/fixtures/files/groupe-instructeur.csv
vendored
Normal file
3
spec/fixtures/files/groupe-instructeur.csv
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
Email,Groupe
|
||||||
|
kara@repat.africa,Afrique
|
||||||
|
kara,Afrique
|
|
|
@ -1,6 +1,5 @@
|
||||||
describe InstructeursImportService do
|
describe InstructeursImportService do
|
||||||
describe '#import' do
|
describe '#import' do
|
||||||
let(:service) { InstructeursImportService.new }
|
|
||||||
let(:procedure) { create(:procedure) }
|
let(:procedure) { create(:procedure) }
|
||||||
|
|
||||||
let(:procedure_groupes) do
|
let(:procedure_groupes) do
|
||||||
|
@ -10,7 +9,7 @@ describe InstructeursImportService do
|
||||||
.to_h
|
.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
subject { service.import(procedure, lines) }
|
subject { described_class.import(procedure, lines) }
|
||||||
|
|
||||||
context 'nominal case' do
|
context 'nominal case' do
|
||||||
let(:lines) do
|
let(:lines) do
|
||||||
|
|
Loading…
Reference in a new issue