Merge pull request #8774 from E-L-T/add-routing-to-groupe-instructeurs

Add routing rules to groupe instructeurs
This commit is contained in:
LeSim 2023-03-31 10:16:51 +00:00 committed by GitHub
commit b003fd8be8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 575 additions and 22 deletions

View file

@ -1,7 +1,7 @@
@import "colors";
@import "constants";
form.form > .conditionnel {
.conditionnel {
.condition-error {
background: $background-red;

View file

@ -0,0 +1,66 @@
class Procedure::RoutingRulesComponent < ApplicationComponent
include Logic
def initialize(revision:, groupe_instructeurs:)
@revision = revision
@groupe_instructeurs = groupe_instructeurs
end
def rows
@groupe_instructeurs.active.map do |gi|
[gi.routing_rule&.left, gi.routing_rule&.right, gi]
end
end
def targeted_champ_tag(targeted_champ, row_index)
select_tag(
'targeted_champ',
options_for_select(targeted_champs_for_select, selected: targeted_champ&.stable_id),
id: input_id_for('targeted_champ', row_index)
)
end
def value_tag(targeted_champ, value, row_index)
select_tag(
'value',
options_for_select(values_for_select(targeted_champ), selected: value),
id: input_id_for('value', row_index)
)
end
def hidden_groupe_instructeur_tag(groupe_instructeur_id)
hidden_field_tag(
'groupe_instructeur_id',
groupe_instructeur_id
)
end
private
def targeted_champs_for_select
empty_target_for_select + available_targets_for_select
end
def empty_target_for_select
[[t('.select'), empty.to_json]]
end
def available_targets_for_select
@revision.types_de_champ_public
.filter { |tdc| [:drop_down_list].include?(tdc.type_champ.to_sym) }
.map { |tdc| [tdc.libelle, tdc.stable_id] }
end
def available_values_for_select(targeted_champ)
return [] if targeted_champ.nil?
targeted_champ.options(@revision.types_de_champ_public)
end
def values_for_select(targeted_champ)
empty_target_for_select + available_values_for_select(targeted_champ)
end
def input_id_for(name, row_index)
"#{name}-#{row_index}"
end
end

View file

@ -0,0 +1,8 @@
---
fr:
select: Sélectionner
apply_routing_rules: Appliquer des règles de routage
routing_rules_notice: |
Ajoutez des règles de routage à partir de champs créés dans le formulaire.
Si les mêmes règles de routage sont appliquées à plusieurs groupes,
les dossiers seront routés vers le premier groupe affiché dans la liste.

View file

@ -0,0 +1,26 @@
.card#routing-rules
.card-title
= t('.apply_routing_rules')
%p.notice
= t('.routing_rules_notice')
.conditionnel.mt-2.width-100
%table.condition-table.mt-2.width-100
%thead
%tr
%th.far-left
%th.target Champ cible du routage
%th.operator Opérateur
%th.value Valeur
%th.delete-column
.conditionnel.mt-2.width-100
- rows.each.with_index do |(targeted_champ, value, groupe_instructeur), row_index|
= form_tag admin_procedure_routing_rules_path, method: :post, class: "form width-100 gi-#{groupe_instructeur.id}" do
%table.condition-table.mt-2.width-100
%tbody
%tr{ data: { controller: 'autosave' } }
%td.far-left Router vers « #{groupe_instructeur.label} » si
%td.target= targeted_champ_tag(targeted_champ, row_index)
%td.operator Est égal à
%td.value= value_tag(targeted_champ, value, row_index)
%td.delete-column
= hidden_groupe_instructeur_tag(groupe_instructeur.id)

View file

@ -0,0 +1,38 @@
module Administrateurs
class RoutingController < AdministrateurController
include Logic
before_action :retrieve_procedure
def update
left = champ_value(targeted_champ)
right = parsed_value
@procedure.groupe_instructeurs.find(groupe_instructeur_id).update!(routing_rule: ds_eq(left, right))
end
private
def targeted_champ
routing_params[:targeted_champ].to_i
end
def value
routing_params[:value]
end
def parsed_value
term = Logic.from_json(value) rescue nil
term.presence || constant(value)
end
def groupe_instructeur_id
routing_params[:groupe_instructeur_id]
end
def routing_params
params.permit(:targeted_champ, :value, :groupe_instructeur_id)
end
end
end

View file

@ -455,6 +455,10 @@ module Users
@dossier.assign_to_groupe_instructeur(groupe_instructeur_from_params)
end
if @dossier.procedure.feature_enabled?(:routing_rules)
RoutingEngine.compute(@dossier)
end
if dossier.en_construction?
errors += @dossier.check_mandatory_and_visible_champs
end

View file

@ -667,11 +667,11 @@ class Dossier < ApplicationRecord
end
def show_groupe_instructeur_details?
procedure.routing_enabled? && groupe_instructeur.present? && (!procedure.feature_enabled?(:procedure_routage_api) || !defaut_groupe_instructeur?)
procedure.routing_enabled? && groupe_instructeur.present? && (!procedure.feature_enabled?(:procedure_routage_api) || !defaut_groupe_instructeur?) && !procedure.feature_enabled?(:routing_rules)
end
def show_groupe_instructeur_selector?
procedure.routing_enabled? && !procedure.feature_enabled?(:procedure_routage_api)
procedure.routing_enabled? && !procedure.feature_enabled?(:procedure_routage_api) && !procedure.feature_enabled?(:routing_rules)
end
def assign_to_groupe_instructeur(groupe_instructeur, author = nil)

View file

@ -5,6 +5,7 @@
# id :bigint not null, primary key
# closed :boolean default(FALSE)
# label :text not null
# routing_rule :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# procedure_id :bigint not null
@ -80,4 +81,6 @@ class GroupeInstructeur < ApplicationRecord
def toggle_routing
procedure.update!(routing_enabled: procedure.groupe_instructeurs.active.many?)
end
serialize :routing_rule, LogicSerializer
end

View file

@ -207,7 +207,7 @@ class Procedure < ApplicationRecord
has_one :refused_mail, class_name: "Mails::RefusedMail", dependent: :destroy
has_one :without_continuation_mail, class_name: "Mails::WithoutContinuationMail", dependent: :destroy
has_one :defaut_groupe_instructeur, -> { active.order(:label) }, class_name: 'GroupeInstructeur', inverse_of: false
has_one :defaut_groupe_instructeur, -> { active.order(id: :asc) }, class_name: 'GroupeInstructeur', inverse_of: false
has_one_attached :logo
has_one_attached :notice

View file

@ -0,0 +1,9 @@
module RoutingEngine
def self.compute(dossier)
matching_groupe = dossier.procedure.groupe_instructeurs.active.find do |gi|
gi.routing_rule&.compute(dossier.champs)
end
matching_groupe ||= dossier.procedure.defaut_groupe_instructeur
dossier.update!(groupe_instructeur: matching_groupe)
end
end

View file

@ -141,21 +141,7 @@ class TypeDeChamp < ApplicationRecord
serialize :options, WithIndifferentAccess
class ConditionSerializer
def self.load(condition)
if condition.present?
Logic.from_h(condition)
end
end
def self.dump(condition)
if condition.present?
condition.to_h
end
end
end
serialize :condition, ConditionSerializer
serialize :condition, LogicSerializer
after_initialize :set_dynamic_type
after_create :populate_stable_id

View file

@ -0,0 +1,13 @@
class LogicSerializer
def self.load(logic)
if logic.present?
Logic.from_h(logic)
end
end
def self.dump(logic)
if logic.present?
logic.to_h
end
end
end

View file

@ -1,4 +1,4 @@
- if groupes_instructeurs.many?
- if groupes_instructeurs.many? && !procedure.feature_enabled?(:routing_rules)
.card
= form_for procedure,
url: { action: :update_routing_criteria_name },

View file

@ -25,3 +25,7 @@
= render partial: 'administrateurs/groupe_instructeurs/routing', locals: { procedure: @procedure }
= render partial: 'administrateurs/groupe_instructeurs/edit', locals: { procedure: @procedure, groupes_instructeurs: @groupes_instructeurs }
- if @procedure.routing_enabled? && @procedure.feature_enabled?(:routing_rules)
= render(Procedure::RoutingRulesComponent.new(revision: @procedure.active_revision,
groupe_instructeurs: @procedure.groupe_instructeurs))

View file

@ -0,0 +1,2 @@
= turbo_stream.replace 'routing-rules', render(Procedure::RoutingRulesComponent.new(revision: @procedure.active_revision,
groupe_instructeurs: @procedure.groupe_instructeurs))

View file

@ -16,7 +16,8 @@ features = [
:api_particulier,
:dossier_pdf_vide,
:hide_instructeur_email,
:procedure_routage_api
:procedure_routage_api,
:routing_rules
]
def database_exists?

View file

@ -506,6 +506,8 @@ Rails.application.routes.draw do
delete :delete_row, on: :member
end
patch :update, controller: 'routing', as: :routing_rules
put 'clone'
put 'archive'
get 'publication' => 'procedures#publication', as: :publication

View file

@ -0,0 +1,5 @@
class AddRoutingColumnToGroupeInstructeur < ActiveRecord::Migration[6.1]
def change
add_column :groupe_instructeurs, :routing_rule, :jsonb
end
end

View file

@ -585,6 +585,7 @@ ActiveRecord::Schema.define(version: 2023_03_22_150907) do
t.datetime "created_at", null: false
t.text "label", null: false
t.bigint "procedure_id", null: false
t.jsonb "routing_rule"
t.datetime "updated_at", null: false
t.index ["closed", "procedure_id"], name: "index_groupe_instructeurs_on_closed_and_procedure_id"
t.index ["procedure_id", "label"], name: "index_groupe_instructeurs_on_procedure_id_and_label", unique: true

View file

@ -0,0 +1,28 @@
describe Administrateurs::RoutingController, type: :controller do
include Logic
before { sign_in(procedure.administrateurs.first.user) }
describe '#update' do
let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :drop_down_list, libelle: 'Votre ville', options: ['Paris', 'Lyon', 'Marseille'] }]) }
let(:gi_2) { procedure.groupe_instructeurs.create(label: 'groupe 2') }
let(:drop_down_tdc) { procedure.draft_revision.types_de_champ.first }
let(:params) do
{
procedure_id: procedure.id,
targeted_champ: drop_down_tdc.stable_id,
value: 'Lyon',
groupe_instructeur_id: gi_2.id
}
end
before do
sign_in(procedure.administrateurs.first.user)
post :update, params: params, format: :turbo_stream
end
it do
expect(gi_2.reload.routing_rule).to eq(ds_eq(champ_value(drop_down_tdc.stable_id), constant('Lyon')))
end
end
end

View file

@ -572,7 +572,7 @@ describe Instructeur, type: :model do
let(:instructeur_2) { create(:instructeur) }
let(:instructeur_3) { create(:instructeur) }
let(:procedure) { create(:procedure, instructeurs: [instructeur_2, instructeur_3], procedure_expires_when_termine_enabled: true) }
let(:gi_1) { procedure.groupe_instructeurs.first }
let(:gi_1) { procedure.defaut_groupe_instructeur }
let(:gi_2) { procedure.groupe_instructeurs.create(label: '2') }
let(:gi_3) { procedure.groupe_instructeurs.create(label: '3') }

View file

@ -0,0 +1,47 @@
describe RoutingEngine, type: :model do
include Logic
describe '.compute' do
let(:procedure) do
create(:procedure).tap do |p|
p.groupe_instructeurs.create(label: 'a second group')
p.groupe_instructeurs.create(label: 'a third group')
end
end
let(:dossier) { create(:dossier, procedure:) }
let(:defaut_groupe) { procedure.defaut_groupe_instructeur }
let(:gi_2) { procedure.groupe_instructeurs.find_by(label: 'a second group') }
subject do
RoutingEngine.compute(dossier)
dossier.groupe_instructeur
end
context 'without any rules' do
it { is_expected.to eq(defaut_groupe) }
end
context 'without any matching rules' do
before do
procedure.groupe_instructeurs.each do |gi|
gi.update(routing_rule: constant(false))
end
end
it { is_expected.to eq(defaut_groupe) }
end
context 'with a matching rules' do
before { gi_2.update(routing_rule: constant(true)) }
it { is_expected.to eq(gi_2) }
end
context 'with a closed gi with a matching rules' do
before { gi_2.update(routing_rule: constant(true), closed: true) }
it { is_expected.to eq(defaut_groupe) }
end
end
end

View file

@ -0,0 +1,48 @@
describe 'As an administrateur I can manage procedure routing', js: true do
include Logic
let(:administrateur) { procedure.administrateurs.first }
let!(:gi_1) { procedure.defaut_groupe_instructeur }
let!(:gi_2) { procedure.groupe_instructeurs.create(label: 'a second group') }
let!(:gi_3) { procedure.groupe_instructeurs.create(label: 'a third group') }
let(:procedure) do
create(:procedure).tap do |p|
p.draft_revision.add_type_de_champ(
type_champ: :drop_down_list,
libelle: 'Un champ choix simple',
options: { "drop_down_other" => "0", "drop_down_options" => ["", "Premier choix", "Deuxième choix", "Troisième choix"] }
)
end
end
let(:drop_down_tdc) { procedure.draft_revision.types_de_champ.first }
before do
Flipper.enable(:routing_rules, procedure)
procedure.publish_revision!
login_as administrateur.user, scope: :user
end
it 'routes from a drop_down_list' do
visit admin_procedure_groupe_instructeurs_path(procedure)
within('.condition-table tbody tr:nth-child(1)', match: :first) do
expect(page).to have_select('targeted_champ', options: ['Sélectionner', 'Un champ choix simple'])
within('.target') { select('Un champ choix simple') }
within('.value') { select('Premier choix') }
end
expected_routing_rule = ds_eq(champ_value(drop_down_tdc.stable_id), constant('Premier choix'))
wait_until { gi_2.reload.routing_rule == expected_routing_rule }
end
it 'displays groupes instructeurs by alphabetic order' do
visit admin_procedure_groupe_instructeurs_path(procedure)
within('.condition-table tbody tr:nth-child(1)', match: :first) do
expect(page).to have_content 'Router vers « a second group »'
expect(page).not_to have_content 'Router vers « défaut »'
end
end
end

View file

@ -0,0 +1,262 @@
describe 'The routing with rules', js: true do
let(:password) { 'a very complicated password' }
let(:procedure) do
create(:procedure, :with_service, :for_individual, :with_zone).tap do |p|
p.draft_revision.add_type_de_champ(
type_champ: :text,
libelle: 'un premier champ text'
)
p.draft_revision.add_type_de_champ(
type_champ: :drop_down_list,
libelle: 'Spécialité',
options: { "drop_down_other" => "0", "drop_down_options" => ["", "littéraire", "scientifique"] }
)
end
end
let(:administrateur) { create(:administrateur, procedures: [procedure]) }
let(:scientifique_user) { create(:user, password: password) }
let(:litteraire_user) { create(:user, password: password) }
before do
Flipper.enable(:routing_rules, procedure)
procedure.defaut_groupe_instructeur.instructeurs << administrateur.instructeur
end
scenario 'works' do
login_as administrateur.user, scope: :user
visit admin_procedure_path(procedure.id)
find('#groupe-instructeurs').click
# add littéraire groupe
fill_in 'Ajouter un nom de groupe', with: 'littéraire'
click_on 'Ajouter le groupe'
expect(page).to have_text('Le groupe dinstructeurs « littéraire » a été créé et le routage a été activé.')
# add victor to littéraire groupe
fill_in 'Emails', with: 'victor@inst.com'
perform_enqueued_jobs { click_on 'Affecter' }
expect(page).to have_text("Linstructeur victor@inst.com a été affecté")
victor = User.find_by(email: 'victor@inst.com').instructeur
# add superwoman to littéraire groupe
fill_in 'Emails', with: 'superwoman@inst.com'
perform_enqueued_jobs { click_on 'Affecter' }
expect(page).to have_text("Linstructeur superwoman@inst.com a été affecté")
superwoman = User.find_by(email: 'superwoman@inst.com').instructeur
# add inactive groupe
click_on 'Groupes dinstructeurs'
fill_in 'Ajouter un nom de groupe', with: 'non visible car inactif'
click_on 'Ajouter le groupe'
check "Groupe inactif"
click_on 'Modifier'
# add scientifique groupe
click_on 'Groupes dinstructeurs'
fill_in 'Ajouter un nom de groupe', with: 'scientifique'
click_on 'Ajouter le groupe'
expect(page).to have_text('Le groupe dinstructeurs « scientifique » a été créé.')
# add marie to scientifique groupe
fill_in 'Emails', with: 'marie@inst.com'
perform_enqueued_jobs { click_on 'Affecter' }
expect(page).to have_text("Linstructeur marie@inst.com a été affecté")
marie = User.find_by(email: 'marie@inst.com').instructeur
# add superwoman to scientifique groupe
fill_in 'Emails', with: 'superwoman@inst.com'
perform_enqueued_jobs { click_on 'Affecter' }
expect(page).to have_text("Linstructeur superwoman@inst.com a été affecté")
# add routing rules
click_on 'Groupes dinstructeurs'
h = procedure.groupe_instructeurs.index_by(&:label).transform_values(&:id)
within(".gi-#{h['scientifique']}") do
within('.target') { select('Spécialité') }
within('.value') { select('scientifique') }
end
within(".gi-#{h['littéraire']}") do
within('.target') { select('Spécialité') }
within('.value') { select('littéraire') }
end
not_defauts = procedure.groupe_instructeurs.filter { |gi| ['littéraire', 'scientifique'].include?(gi.label) }
not_defauts.each { |gi| wait_until { gi.reload.routing_rule.present? } }
# publish
publish_procedure(procedure)
log_out
# 2 users fill a dossier in each group
user_send_dossier(scientifique_user, 'scientifique')
user_send_dossier(litteraire_user, 'littéraire')
# the litteraires instructeurs only manage the litteraires dossiers
register_instructeur_and_log_in(victor.email)
click_on procedure.libelle
expect(page).to have_text(litteraire_user.email)
expect(page).not_to have_text(scientifique_user.email)
# the search only show litteraires dossiers
fill_in 'q', with: scientifique_user.email
find('.fr-search-bar .fr-btn').click
expect(page).to have_text('0 dossier trouvé')
# weird bug, capabary appends text instead of replaces it
# see https://github.com/redux-form/redux-form/issues/686
fill_in('q', with: litteraire_user.email, fill_options: { clear: :backspace })
find('.fr-search-bar .fr-btn').click
expect(page).to have_text('1 dossier trouvé')
## and the result is clickable
click_on litteraire_user.email
expect(page).to have_current_path(instructeur_dossier_path(procedure, litteraire_user.dossiers.first))
# follow the dossier
click_on 'Suivre le dossier'
log_out
# the scientifiques instructeurs only manage the scientifiques dossiers
register_instructeur_and_log_in(marie.email)
click_on procedure.libelle
expect(page).not_to have_text(litteraire_user.email)
expect(page).to have_text(scientifique_user.email)
# follow the dossier
click_on scientifique_user.email
click_on 'Suivre le dossier'
log_out
# litteraire_user change its dossier
visit new_user_session_path
sign_in_with litteraire_user.email, password
click_on litteraire_user.dossiers.first.id.to_s
click_on 'Modifier mon dossier'
fill_in litteraire_user.dossiers.first.champs_public.first.libelle, with: 'some value'
wait_for_autosave(false)
log_out
# the litteraires instructeurs should have a notification
visit new_user_session_path
sign_in_with victor.user.email, password
## on the procedures list
expect(page).to have_current_path(instructeur_procedures_path)
expect(find('.procedure-stats')).to have_css('span.notifications')
## on the dossiers list
click_on procedure.libelle
expect(page).to have_current_path(instructeur_procedure_path(procedure))
expect(find('.tabs')).to have_css('span.notifications')
## on the dossier itself
click_on 'suivi'
click_on litteraire_user.email
expect(page).to have_current_path(instructeur_dossier_path(procedure, litteraire_user.dossiers.first))
expect(page).to have_text('Annotations privées')
expect(find('.tabs')).to have_css('span.notifications')
log_out
# the scientifiques instructeurs should not have a notification
visit new_user_session_path
sign_in_with marie.user.email, password
expect(page).to have_current_path(instructeur_procedures_path)
expect(find('.procedure-stats')).not_to have_css('span.notifications')
log_out
# the instructeurs who belong to scientifique AND litteraire groups manage scientifique and litterraire dossiers
register_instructeur_and_log_in(superwoman.email)
visit instructeur_procedure_path(procedure, params: { statut: 'tous' })
expect(page).to have_text(litteraire_user.email)
expect(page).to have_text(scientifique_user.email)
# follow the dossier
click_on scientifique_user.email
click_on 'Suivre le dossier'
visit instructeur_procedure_path(procedure, params: { statut: 'tous' })
click_on litteraire_user.email
click_on 'Suivre le dossier'
log_out
# scientifique_user updates its group
user_update_group(scientifique_user, 'littéraire')
# the instructeurs who belong to scientifique AND litteraire groups should have a notification
visit new_user_session_path
sign_in_with superwoman.user.email, password
expect(page).to have_current_path(instructeur_procedures_path)
expect(find('.procedure-stats')).to have_css('span.notifications')
end
def publish_procedure(procedure)
click_on procedure.libelle
find('#publish-procedure-link').click
fill_in 'lien_site_web', with: 'http://some.website'
click_on 'Publier'
expect(page).to have_text('Démarche publiée')
end
def user_send_dossier(user, groupe)
login_as user, scope: :user
visit commencer_path(path: procedure.reload.path)
click_on 'Commencer la démarche'
choose 'Monsieur'
fill_in 'individual_nom', with: 'Nom'
fill_in 'individual_prenom', with: 'Prenom'
click_button('Continuer')
# the old system should not be present
expect(page).not_to have_selector("#dossier_groupe_instructeur_id")
choose(groupe)
wait_for_autosave
click_on 'Déposer le dossier'
expect(page).to have_text('Merci')
log_out
end
def user_update_group(user, new_group)
login_as user, scope: :user
visit dossiers_path
click_on user.dossiers.first.id.to_s
click_on "Modifier mon dossier"
choose(new_group)
wait_for_autosave(false)
expect(page).to have_text(new_group)
log_out
end
def register_instructeur_and_log_in(email)
confirmation_email = emails_sent_to(email)
.find { |m| m.subject == 'Activez votre compte instructeur' }
token_params = confirmation_email.body.match(/token=[^"]+/)
visit "users/activate?#{token_params}"
fill_in :user_password, with: password
click_button 'Définir le mot de passe'
expect(page).to have_text('Mot de passe enregistré')
end
end