feat(graphql): implement add/remove instructeurs via API

This commit is contained in:
Paul Chavard 2023-01-10 17:20:22 +01:00
parent 0557b84eed
commit afc1f12028
17 changed files with 480 additions and 41 deletions

View file

@ -109,9 +109,9 @@ module Administrateurs
def add_instructeur
emails = params['emails'].presence || [].to_json
emails = JSON.parse(emails)
emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
instructeurs, invalid_emails = Instructeur.find_or_invite(emails:, groupe_instructeur:)
instructeurs, invalid_emails = groupe_instructeur.add_instructeurs(emails:)
if invalid_emails.present?
flash[:alert] = t('.wrong_address',

View file

@ -38,7 +38,7 @@ module Instructeurs
if groupe_instructeur.remove(instructeur)
flash[:notice] = "Linstructeur « #{instructeur.email} » a été retiré du groupe."
GroupeInstructeurMailer
.remove_instructeur(groupe_instructeur, instructeur, current_user.email)
.remove_instructeurs(groupe_instructeur, [instructeur], current_user.email)
.deliver_later
else
flash[:alert] = "Linstructeur « #{instructeur.email} » nest pas dans le groupe."

View file

@ -17,7 +17,7 @@ class API::V2::Context < GraphQL::Query::Context
def current_administrateur
unless self[:administrateur_id]
raise GraphQL::ExecutionError.new("Pour effectuer cette opération, vous avez besoin dun jeton au nouveau format.", extensions: { code: :deprecated_token })
raise GraphQL::ExecutionError.new("Pour effectuer cette opération, vous avez besoin dun jeton au nouveau format. Vous pouvez lobtenir dans votre interface administrateur.", extensions: { code: :deprecated_token })
end
Administrateur.find(self[:administrateur_id])
end

View file

@ -750,5 +750,56 @@ class API::V2::StoredQuery
}
}
}
mutation groupeInstructeurCreer($input: GroupeInstructeurCreerInput!, $includeInstructeurs: Boolean = false) {
groupeInstructeurCreer(input: $input) {
groupeInstructeur {
id
instructeurs @include(if: $includeInstructeurs) {
id
email
}
}
errors {
message
}
warnings {
message
}
}
}
mutation groupeInstructeurAjouterInstructeurs($input: GroupeInstructeurAjouterInstructeursInput!, $includeInstructeurs: Boolean = false) {
groupeInstructeurAjouterInstructeurs(input: $input) {
groupeInstructeur {
id
instructeurs @include(if: $includeInstructeurs) {
id
email
}
}
errors {
message
}
warnings {
message
}
}
}
mutation groupeInstructeurSupprimerInstructeurs($input: GroupeInstructeurSupprimerInstructeursInput!, $includeInstructeurs: Boolean = false) {
groupeInstructeurSupprimerInstructeurs(input: $input) {
groupeInstructeur {
id
instructeurs @include(if: $includeInstructeurs) {
id
email
}
}
errors {
message
}
}
}
GRAPHQL
end

View file

@ -2,6 +2,19 @@ module Mutations
class BaseMutation < GraphQL::Schema::RelayClassicMutation
private
delegate :current_administrateur, to: :context
def partition_instructeurs_by(instructeurs)
instructeurs
.partition { _1.id.present? }
.then do |by_id, by_email|
[
by_id.map { Instructeur.id_from_typed_id(_1.id) },
by_email.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1.email) }
]
end
end
def validate_blob(blob_id)
begin
blob = ActiveStorage::Blob.find_signed(blob_id)

View file

@ -0,0 +1,38 @@
module Mutations
class GroupeInstructeurAjouterInstructeurs < Mutations::BaseMutation
description "Ajouter des instructeurs à un groupe instructeur."
argument :groupe_instructeur_id, ID, "Groupe instructeur ID.", required: true, loads: Types::GroupeInstructeurType
argument :instructeurs, [Types::ProfileInput], "Instructeurs à ajouter.", required: true
field :groupe_instructeur, Types::GroupeInstructeurType, null: true
field :errors, [Types::ValidationErrorType], null: true
field :warnings, [Types::WarningMessageType], null: true
def resolve(groupe_instructeur:, instructeurs:)
ids, emails = partition_instructeurs_by(instructeurs)
instructeurs, invalid_emails = groupe_instructeur.add_instructeurs(ids:, emails:)
instructeurs.each { groupe_instructeur.add(_1) }
groupe_instructeur.reload
result = { groupe_instructeur: }
if invalid_emails.present?
warning = I18n.t('administrateurs.groupe_instructeurs.add_instructeur.wrong_address',
count: invalid_emails.size,
emails: invalid_emails.join(', '))
result[:warnings] = [warning]
end
if groupe_instructeur.procedure.routing_enabled? && instructeurs.present?
GroupeInstructeurMailer
.add_instructeurs(groupe_instructeur, instructeurs, current_administrateur.email)
.deliver_later
end
result
end
end
end

View file

@ -0,0 +1,52 @@
module Mutations
class GroupeInstructeurCreer < Mutations::BaseMutation
class GroupeInstructeurAttributes < Types::BaseInputObject
description "Attributs pour lajout d'un groupe instructeur."
argument :label, String, "Libelle du groupe instructeur.", required: true
argument :closed, Boolean, "Létat du groupe instructeur.", required: false, default_value: false
argument :instructeurs, [Types::ProfileInput], "Instructeurs à ajouter.", required: false, default_value: []
end
description "Crée un groupe instructeur."
argument :demarche, Types::DemarcheDescriptorType::FindDemarcheInput, "Demarche ID ou numéro.", required: true
argument :groupe_instructeur, GroupeInstructeurAttributes, "Groupes instructeur à ajouter.", required: true
field :groupe_instructeur, Types::GroupeInstructeurType, null: true
field :errors, [Types::ValidationErrorType], null: true
field :warnings, [Types::WarningMessageType], null: true
def resolve(demarche:, groupe_instructeur:)
demarche_number = demarche.number.presence || ApplicationRecord.id_from_typed_id(demarche.id)
procedure = current_administrateur.procedures.find(demarche_number)
ids, emails = partition_instructeurs_by(groupe_instructeur.instructeurs)
groupe_instructeur = procedure
.groupe_instructeurs
.build(label: groupe_instructeur.label, closed: groupe_instructeur.closed, instructeurs: [current_administrateur.instructeur].compact)
if groupe_instructeur.save
result = { groupe_instructeur: }
if emails.present? || ids.present?
instructeurs, invalid_emails = groupe_instructeur.add_instructeurs(ids:, emails:)
instructeurs.each { groupe_instructeur.add(_1) }
groupe_instructeur.reload
if invalid_emails.present?
warning = I18n.t('administrateurs.groupe_instructeurs.add_instructeur.wrong_address',
count: invalid_emails.size,
emails: invalid_emails.join(', '))
result[:warnings] = [warning]
end
end
result
else
{ errors: groupe_instructeur.errors.full_messages }
end
end
end
end

View file

@ -2,7 +2,7 @@ module Mutations
class GroupeInstructeurModifier < Mutations::BaseMutation
description "Modifier un groupe instructeur."
argument :groupe_instructeur_id, ID, "Groupe instructeur ID", required: true, loads: Types::GroupeInstructeurType
argument :groupe_instructeur_id, ID, "Groupe instructeur ID.", required: true, loads: Types::GroupeInstructeurType
argument :label, String, "Libellé du groupe instructeur.", required: false
argument :closed, Boolean, "Létat du groupe instructeur.", required: false

View file

@ -0,0 +1,27 @@
module Mutations
class GroupeInstructeurSupprimerInstructeurs < Mutations::BaseMutation
description "Supprimer des instructeurs dun groupe instructeur."
argument :groupe_instructeur_id, ID, "Groupe instructeur ID.", required: true, loads: Types::GroupeInstructeurType
argument :instructeurs, [Types::ProfileInput], "Instructeurs à supprimer.", required: true
field :groupe_instructeur, Types::GroupeInstructeurType, null: true
field :errors, [Types::ValidationErrorType], null: true
def resolve(groupe_instructeur:, instructeurs:)
ids, emails = partition_instructeurs_by(instructeurs)
instructeurs = groupe_instructeur.instructeurs.find_all_by_identifier(ids:, emails:)
instructeurs.each { groupe_instructeur.remove(_1) }
groupe_instructeur.reload
if groupe_instructeur.procedure.routing_enabled? && instructeurs.present?
GroupeInstructeurMailer
.remove_instructeurs(groupe_instructeur, instructeurs, current_administrateur.email)
.deliver_later
end
{ groupe_instructeur: }
end
end
end

View file

@ -1569,6 +1569,92 @@ type GroupeInstructeur {
number: Int!
}
"""
Autogenerated input type of GroupeInstructeurAjouterInstructeurs
"""
input GroupeInstructeurAjouterInstructeursInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Groupe instructeur ID.
"""
groupeInstructeurId: ID!
"""
Instructeurs à ajouter.
"""
instructeurs: [ProfileInput!]!
}
"""
Autogenerated return type of GroupeInstructeurAjouterInstructeurs.
"""
type GroupeInstructeurAjouterInstructeursPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
errors: [ValidationError!]
groupeInstructeur: GroupeInstructeur
warnings: [WarningMessage!]
}
"""
Attributs pour lajout d'un groupe instructeur.
"""
input GroupeInstructeurAttributes {
"""
Létat du groupe instructeur.
"""
closed: Boolean = false
"""
Instructeurs à ajouter.
"""
instructeurs: [ProfileInput!] = []
"""
Libelle du groupe instructeur.
"""
label: String!
}
"""
Autogenerated input type of GroupeInstructeurCreer
"""
input GroupeInstructeurCreerInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Demarche ID ou numéro.
"""
demarche: FindDemarcheInput!
"""
Groupes instructeur à ajouter.
"""
groupeInstructeur: GroupeInstructeurAttributes!
}
"""
Autogenerated return type of GroupeInstructeurCreer.
"""
type GroupeInstructeurCreerPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
errors: [ValidationError!]
groupeInstructeur: GroupeInstructeur
warnings: [WarningMessage!]
}
"""
Autogenerated input type of GroupeInstructeurModifier
"""
@ -1584,7 +1670,7 @@ input GroupeInstructeurModifierInput {
closed: Boolean
"""
Groupe instructeur ID
Groupe instructeur ID.
"""
groupeInstructeurId: ID!
@ -1606,6 +1692,38 @@ type GroupeInstructeurModifierPayload {
groupeInstructeur: GroupeInstructeur
}
"""
Autogenerated input type of GroupeInstructeurSupprimerInstructeurs
"""
input GroupeInstructeurSupprimerInstructeursInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Groupe instructeur ID.
"""
groupeInstructeurId: ID!
"""
Instructeurs à supprimer.
"""
instructeurs: [ProfileInput!]!
}
"""
Autogenerated return type of GroupeInstructeurSupprimerInstructeurs.
"""
type GroupeInstructeurSupprimerInstructeursPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
errors: [ValidationError!]
groupeInstructeur: GroupeInstructeur
}
"""
Un groupe instructeur avec ses dossiers
"""
@ -1950,6 +2068,26 @@ type Mutation {
input: DossierRepasserEnInstructionInput!
): DossierRepasserEnInstructionPayload
"""
Ajouter des instructeurs à un groupe instructeur.
"""
groupeInstructeurAjouterInstructeurs(
"""
Parameters for GroupeInstructeurAjouterInstructeurs
"""
input: GroupeInstructeurAjouterInstructeursInput!
): GroupeInstructeurAjouterInstructeursPayload
"""
Crée un groupe instructeur.
"""
groupeInstructeurCreer(
"""
Parameters for GroupeInstructeurCreer
"""
input: GroupeInstructeurCreerInput!
): GroupeInstructeurCreerPayload
"""
Modifier un groupe instructeur.
"""
@ -1959,6 +2097,16 @@ type Mutation {
"""
input: GroupeInstructeurModifierInput!
): GroupeInstructeurModifierPayload
"""
Supprimer des instructeurs dun groupe instructeur.
"""
groupeInstructeurSupprimerInstructeurs(
"""
Parameters for GroupeInstructeurSupprimerInstructeurs
"""
input: GroupeInstructeurSupprimerInstructeursInput!
): GroupeInstructeurSupprimerInstructeursPayload
}
enum Order {
@ -2085,6 +2233,18 @@ type Profile {
id: ID!
}
input ProfileInput @oneOf {
"""
Email
"""
email: String
"""
ID
"""
id: ID
}
type Query {
"""
Informations concernant une démarche.
@ -2489,3 +2649,13 @@ type ValidationError {
"""
message: String!
}
"""
Message dalerte
"""
type WarningMessage {
"""
La description de lalerte
"""
message: String!
}

View file

@ -20,5 +20,8 @@ module Types
field :dossier_modifier_annotation_ajouter_ligne, mutation: Mutations::DossierModifierAnnotationAjouterLigne
field :groupe_instructeur_modifier, mutation: Mutations::GroupeInstructeurModifier
field :groupe_instructeur_creer, mutation: Mutations::GroupeInstructeurCreer
field :groupe_instructeur_ajouter_instructeurs, mutation: Mutations::GroupeInstructeurAjouterInstructeurs
field :groupe_instructeur_supprimer_instructeurs, mutation: Mutations::GroupeInstructeurSupprimerInstructeurs
end
end

View file

@ -0,0 +1,7 @@
module Types
class ProfileInput < Types::BaseInputObject
one_of
argument :email, String, "Email", required: false
argument :id, ID, "ID", required: false
end
end

View file

@ -0,0 +1,11 @@
module Types
class WarningMessageType < Types::BaseObject
description "Message dalerte"
field :message, String, "La description de lalerte", null: false
def message
object
end
end
end

View file

@ -4,7 +4,13 @@ module EmailSanitizableConcern
def sanitize_email(attribute)
value_to_sanitize = self.send(attribute)
if value_to_sanitize.present?
self[attribute] = value_to_sanitize.gsub(/[[:space:]]/, ' ').strip.downcase
self[attribute] = EmailSanitizer.sanitize(value_to_sanitize)
end
end
class EmailSanitizer
def self.sanitize(value)
value.gsub(/[[:space:]]/, ' ').strip.downcase
end
end
end

View file

@ -51,6 +51,25 @@ class GroupeInstructeur < ApplicationRecord
.update_all(unfollowed_at: Time.zone.now)
end
def add_instructeurs(ids: [], emails: [])
instructeurs_to_add, valid_emails, invalid_emails = Instructeur.find_all_by_identifier_with_emails(ids:, emails:)
not_found_emails = valid_emails - instructeurs_to_add.map(&:email)
# Send invitations to users without account
if not_found_emails.present?
instructeurs_to_add += not_found_emails.map do |email|
user = User.create_or_promote_to_instructeur(email, SecureRandom.hex, administrateurs: procedure.administrateurs)
user.invite!
user.instructeur
end
end
# We dont't want to assign a user to a groupe_instructeur if they are already assigned to it
instructeurs_to_add -= instructeurs
[instructeurs_to_add, invalid_emails]
end
def can_delete?
dossiers.empty? && (procedure.groupe_instructeurs.active.many? || (procedure.groupe_instructeurs.active.one? && closed))
end

View file

@ -52,42 +52,18 @@ class Instructeur < ApplicationRecord
find_by(users: { email: email })
end
def self.find_or_invite(emails: [], ids: [], groupe_instructeur:)
valid_emails, invalid_emails = emails
.map(&:strip)
.map(&:downcase)
.partition { URI::MailTo::EMAIL_REGEXP.match?(_1) }
valid_ids = ids.map { _1.is_a?(String) ? id_from_typed_id(_1) : _1 }
instructeurs_by_ids = where(id: valid_ids)
instructeurs_by_emails = where(users: { email: valid_emails })
instructeurs = if valid_ids.present? && valid_emails.present?
instructeurs_by_ids.or(instructeurs_by_emails).distinct(:id)
elsif valid_emails.present?
instructeurs_by_emails
else
instructeurs_by_ids
end
not_found_emails = valid_emails - instructeurs.map(&:email)
# Send invitations to users without account
if not_found_emails.present?
administrateurs = groupe_instructeur.procedure.administrateurs
instructeurs += not_found_emails.map { invite(_1, administrateurs) }
end
# We dont't want to assign a user to a groupe_instructeur if they are already assigned to it
instructeurs -= groupe_instructeur.instructeurs
[instructeurs, invalid_emails]
def self.find_all_by_identifier(ids: [], emails: [])
find_all_by_identifier_with_emails(ids:, emails:).first
end
def self.invite(email, administrateurs)
user = User.create_or_promote_to_instructeur(email, SecureRandom.hex, administrateurs:)
user.invite!
user.instructeur
def self.find_all_by_identifier_with_emails(ids: [], emails: [])
valid_emails, invalid_emails = emails.partition { URI::MailTo::EMAIL_REGEXP.match?(_1) }
[
where(id: ids).or(where(users: { email: valid_emails })).distinct(:id),
valid_emails,
invalid_emails
]
end
def email

View file

@ -301,5 +301,71 @@ describe API::V2::GraphqlController do
end
end
end
context 'groupeInstructeurCreer' do
let(:variables) { { input: { demarche: { id: procedure.to_typed_id }, groupeInstructeur: { label: 'nouveau groupe instructeur' } }, includeInstructeurs: true } }
let(:operation_name) { 'groupeInstructeurCreer' }
it {
expect(gql_errors).to be_nil
expect(gql_data[:groupeInstructeurCreer][:errors]).to be_nil
expect(gql_data[:groupeInstructeurCreer][:groupeInstructeur][:id]).not_to be_nil
expect(gql_data[:groupeInstructeurCreer][:groupeInstructeur][:instructeurs]).to eq([{ id: admin.instructeur.to_typed_id, email: admin.email }])
expect(GroupeInstructeur.last.label).to eq('nouveau groupe instructeur')
}
context 'with instructeurs' do
let(:email) { 'test@test.com' }
let(:variables) { { input: { demarche: { id: procedure.to_typed_id }, groupeInstructeur: { label: 'nouveau groupe instructeur avec instructeurs', instructeurs: [email:] } }, includeInstructeurs: true } }
let(:operation_name) { 'groupeInstructeurCreer' }
it {
expect(gql_errors).to be_nil
expect(gql_data[:groupeInstructeurCreer][:errors]).to be_nil
expect(gql_data[:groupeInstructeurCreer][:groupeInstructeur][:id]).not_to be_nil
expect(gql_data[:groupeInstructeurCreer][:groupeInstructeur][:instructeurs]).to eq([{ id: admin.instructeur.to_typed_id, email: admin.instructeur.email }, { id: Instructeur.last.to_typed_id, email: }])
}
end
end
context 'groupeInstructeurAjouterInstructeurs' do
let(:email) { 'test@test.com' }
let(:groupe_instructeur) { procedure.groupe_instructeurs.first }
let(:existing_instructeur) { groupe_instructeur.instructeurs.first }
let(:variables) { { input: { groupeInstructeurId: groupe_instructeur.to_typed_id, instructeurs: [{ email: }, { email: 'yolo' }, { id: existing_instructeur.to_typed_id }] }, includeInstructeurs: true } }
let(:operation_name) { 'groupeInstructeurAjouterInstructeurs' }
it {
expect(gql_errors).to be_nil
expect(gql_data[:groupeInstructeurAjouterInstructeurs][:errors]).to be_nil
expect(gql_data[:groupeInstructeurAjouterInstructeurs][:warnings]).to eq([message: "yolo nest pas une adresse email valide"])
expect(gql_data[:groupeInstructeurAjouterInstructeurs][:groupeInstructeur][:id]).to eq(groupe_instructeur.to_typed_id)
expect(groupe_instructeur.instructeurs.count).to eq(2)
expect(gql_data[:groupeInstructeurAjouterInstructeurs][:groupeInstructeur][:instructeurs]).to eq([{ id: existing_instructeur.to_typed_id, email: existing_instructeur.email }, { id: Instructeur.last.to_typed_id, email: }])
}
end
context 'groupeInstructeurSupprimerInstructeurs' do
let(:email) { 'test@test.com' }
let(:groupe_instructeur) { procedure.groupe_instructeurs.first }
let(:existing_instructeur) { groupe_instructeur.instructeurs.first }
let(:new_instructeur) { create(:instructeur) }
let(:variables) { { input: { groupeInstructeurId: groupe_instructeur.to_typed_id, instructeurs: [{ email: }, { id: new_instructeur.to_typed_id }] }, includeInstructeurs: true } }
let(:operation_name) { 'groupeInstructeurSupprimerInstructeurs' }
before do
existing_instructeur
groupe_instructeur.add(new_instructeur)
end
it {
expect(groupe_instructeur.reload.instructeurs.count).to eq(2)
expect(gql_errors).to be_nil
expect(gql_data[:groupeInstructeurSupprimerInstructeurs][:errors]).to be_nil
expect(gql_data[:groupeInstructeurSupprimerInstructeurs][:groupeInstructeur][:id]).to eq(groupe_instructeur.to_typed_id)
expect(groupe_instructeur.instructeurs.count).to eq(1)
expect(gql_data[:groupeInstructeurSupprimerInstructeurs][:groupeInstructeur][:instructeurs]).to eq([{ id: existing_instructeur.to_typed_id, email: existing_instructeur.email }])
}
end
end
end