Merge pull request #9101 from tchak/feat-accrediatation-jo
ETQ administrateur, je voudrais pouvoir utiliser le numéro d'accréditation Paris 2024
This commit is contained in:
commit
252eafd541
41 changed files with 588 additions and 21 deletions
1
Gemfile
1
Gemfile
|
@ -30,6 +30,7 @@ gem 'devise-i18n'
|
|||
gem 'devise-two-factor'
|
||||
gem 'discard'
|
||||
gem 'dotenv-rails', require: 'dotenv/rails-now' # dotenv should always be loaded before rails
|
||||
gem 'dry-monads'
|
||||
gem 'elastic-apm'
|
||||
gem 'flipper'
|
||||
gem 'flipper-active_record'
|
||||
|
|
|
@ -218,7 +218,14 @@ GEM
|
|||
dotenv (= 2.7.6)
|
||||
railties (>= 3.2)
|
||||
dry-cli (1.0.0)
|
||||
dry-core (1.0.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-inflector (0.2.0)
|
||||
dry-monads (1.6.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.0, < 2)
|
||||
zeitwerk (~> 2.6)
|
||||
dumb_delegator (1.0.0)
|
||||
ecma-re-validator (0.3.0)
|
||||
regexp_parser (~> 2.0)
|
||||
|
@ -828,6 +835,7 @@ DEPENDENCIES
|
|||
devise-two-factor
|
||||
discard
|
||||
dotenv-rails
|
||||
dry-monads
|
||||
elastic-apm
|
||||
factory_bot
|
||||
flipper
|
||||
|
|
|
@ -48,6 +48,8 @@
|
|||
= render partial: "shared/champs/rna/show", locals: { champ: champ, profile: @profile }
|
||||
- when TypeDeChamp.type_champs.fetch(:epci)
|
||||
= render partial: "shared/champs/epci/show", locals: { champ: champ }
|
||||
- when TypeDeChamp.type_champs.fetch(:cojo)
|
||||
= render partial: "shared/champs/cojo/show", locals: { champ: champ, profile: @profile }
|
||||
- when TypeDeChamp.type_champs.fetch(:date)
|
||||
%p= champ.to_s
|
||||
- when TypeDeChamp.type_champs.fetch(:datetime)
|
||||
|
|
9
app/components/editable_champ/cojo_component.rb
Normal file
9
app/components/editable_champ/cojo_component.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
class EditableChamp::COJOComponent < EditableChamp::EditableChampBaseComponent
|
||||
def input_group_class
|
||||
if @champ.accreditation_success?
|
||||
'fr-input-group--valid'
|
||||
elsif @champ.accreditation_error?
|
||||
'fr-input-group--error'
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
en:
|
||||
accreditation_number_label: Accreditation number
|
||||
accreditation_number_notice: Identification number issued by Paris 2024
|
||||
accreditation_birthdate_label: Date of birth
|
||||
accreditation_number_error: Invalid accreditation number
|
||||
accreditation_number_verification_pending: Accreditation number verification in progress
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
fr:
|
||||
accreditation_number_label: Numéro d‘accréditation
|
||||
accreditation_number_notice: Numéro d‘identification délivré par Paris 2024
|
||||
accreditation_birthdate_label: Date de naissance
|
||||
accreditation_number_error: Le numéro d‘accréditation est incorrect
|
||||
accreditation_number_verification_pending: Vérification du numéro d‘accréditation en cours
|
|
@ -0,0 +1,23 @@
|
|||
|
||||
.fr-input-group{ class: input_group_class }
|
||||
= @form.label :accreditation_number, for: @champ.accreditation_number_input_id, class: 'fr-label' do
|
||||
- safe_join [t('.accreditation_number_label'), @champ.required? ? render(EditableChamp::AsteriskMandatoryComponent.new) : ''], ' '
|
||||
%p.fr-hint-text{ id: dom_id(@champ, :accreditation_number_notice) }= t('.accreditation_number_notice')
|
||||
= @form.text_field :accreditation_number,
|
||||
required: @champ.required?,
|
||||
aria: { describedby: [dom_id(@champ, :accreditation_number_notice), @champ.accreditation_error? ? dom_id(@champ, :accreditation_number_error) : nil].compact.join(' ') },
|
||||
data: { controller: 'format', format: 'integer' },
|
||||
class: "width-33-desktop fr-input small-margin", id: @champ.accreditation_number_input_id
|
||||
|
||||
- if @champ.accreditation_error?
|
||||
%p.fr-error-text{ id: dom_id(@champ, :accreditation_number_error) }= t('.accreditation_number_error')
|
||||
- elsif @champ.fetch_external_data_pending?
|
||||
%p.fr-info-text= t('.accreditation_number_verification_pending')
|
||||
|
||||
.fr-input-group{ class: input_group_class }
|
||||
= @form.label :accreditation_birthdate, for: @champ.accreditation_birthdate_input_id, class: 'fr-label' do
|
||||
- safe_join [t('.accreditation_birthdate_label'), @champ.required? ? render(EditableChamp::AsteriskMandatoryComponent.new) : ''], ' '
|
||||
= @form.date_field :accreditation_birthdate,
|
||||
required: @champ.required?,
|
||||
aria: { describedby: dom_id(@champ, :accreditation_birthdate) },
|
||||
class: "width-33-desktop fr-input small-margin", id: @champ.accreditation_birthdate_input_id
|
|
@ -25,15 +25,35 @@ class EditableChamp::EditableChampComponent < ApplicationComponent
|
|||
"hidden": !@champ.visible?
|
||||
),
|
||||
id: @champ.input_group_id,
|
||||
data: { controller: stimulus_controller, **data_dependent_conditions }
|
||||
data: { controller: stimulus_controller, **data_dependent_conditions, **stimulus_values }
|
||||
}
|
||||
end
|
||||
|
||||
def stimulus_values
|
||||
if @champ.fetch_external_data_pending?
|
||||
{ turbo_poll_url_value: }
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def turbo_poll_url_value
|
||||
if @champ.private?
|
||||
annotation_instructeur_dossier_path(@champ.dossier.procedure, @champ.dossier, @champ)
|
||||
else
|
||||
champ_dossier_path(@champ.dossier, @champ)
|
||||
end
|
||||
end
|
||||
|
||||
def stimulus_controller
|
||||
if autosave_enabled?
|
||||
# This is an editable champ. Lets find what controllers it might need.
|
||||
controllers = ['autosave']
|
||||
|
||||
if @champ.fetch_external_data_pending?
|
||||
controllers << 'turbo-poll'
|
||||
end
|
||||
|
||||
controllers.join(' ')
|
||||
end
|
||||
end
|
||||
|
|
|
@ -93,8 +93,8 @@ class TypesDeChampEditor::ChampComponent < ApplicationComponent
|
|||
end
|
||||
|
||||
def filter_featured_type_champ(type_champ)
|
||||
feature_name = TypeDeChamp::FEATURE_FLAGS[type_champ]
|
||||
feature_name.blank? || feature_enabled?(feature_name)
|
||||
feature_name = TypeDeChamp::FEATURE_FLAGS[type_champ.to_sym]
|
||||
feature_name.blank? || procedure.feature_enabled?(feature_name)
|
||||
end
|
||||
|
||||
def filter_type_champ(type_champ)
|
||||
|
|
|
@ -302,6 +302,20 @@ module Instructeurs
|
|||
render layout: "print"
|
||||
end
|
||||
|
||||
def annotation
|
||||
@dossier = dossier_with_champs(pj_template: false)
|
||||
annotation = @dossier.champs_private_all.find(params[:annotation_id])
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream do
|
||||
@to_show, @to_hide = []
|
||||
@to_update = [annotation]
|
||||
|
||||
render :update_annotations
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def telecharger_pjs
|
||||
files = ActiveStorage::DownloadableFile.create_list_from_dossiers(Dossier.where(id: dossier.id), with_champs_private: true, include_infos_administration: true)
|
||||
cleaned_files = ActiveStorage::DownloadableFile.cleanup_list_from_dossier(files)
|
||||
|
|
|
@ -6,11 +6,11 @@ module Users
|
|||
layout 'procedure_context', only: [:identite, :update_identite, :siret, :update_siret]
|
||||
|
||||
ACTIONS_ALLOWED_TO_ANY_USER = [:index, :recherche, :new, :transferer_all]
|
||||
ACTIONS_ALLOWED_TO_OWNER_OR_INVITE = [:show, :destroy, :demande, :messagerie, :brouillon, :submit_brouillon, :submit_en_construction, :modifier, :modifier_legacy, :update, :create_commentaire, :papertrail, :restore]
|
||||
ACTIONS_ALLOWED_TO_OWNER_OR_INVITE = [:show, :destroy, :demande, :messagerie, :brouillon, :submit_brouillon, :submit_en_construction, :modifier, :modifier_legacy, :update, :create_commentaire, :papertrail, :restore, :champ]
|
||||
|
||||
before_action :ensure_ownership!, except: ACTIONS_ALLOWED_TO_ANY_USER + ACTIONS_ALLOWED_TO_OWNER_OR_INVITE
|
||||
before_action :ensure_ownership_or_invitation!, only: ACTIONS_ALLOWED_TO_OWNER_OR_INVITE
|
||||
before_action :ensure_dossier_can_be_updated, only: [:update_identite, :update_siret, :brouillon, :submit_brouillon, :submit_en_construction, :modifier, :modifier_legacy, :update]
|
||||
before_action :ensure_dossier_can_be_updated, only: [:update_identite, :update_siret, :brouillon, :submit_brouillon, :submit_en_construction, :modifier, :modifier_legacy, :update, :champ]
|
||||
before_action :ensure_dossier_can_be_filled, only: [:brouillon, :modifier, :submit_brouillon, :submit_en_construction, :update]
|
||||
before_action :ensure_dossier_can_be_viewed, only: [:show]
|
||||
before_action :forbid_invite_submission!, only: [:submit_brouillon]
|
||||
|
@ -274,6 +274,20 @@ module Users
|
|||
@dossier = current_user.dossiers.includes(:procedure).find(params[:id])
|
||||
end
|
||||
|
||||
def champ
|
||||
@dossier = dossier_with_champs(pj_template: false)
|
||||
champ = @dossier.champs_public_all.find(params[:champ_id])
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream do
|
||||
@to_show, @to_hide = []
|
||||
@to_update = [champ]
|
||||
|
||||
render :update, layout: false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create_commentaire
|
||||
@commentaire = CommentaireService.create(current_user, dossier, commentaire_params)
|
||||
|
||||
|
@ -457,15 +471,30 @@ module Users
|
|||
|
||||
def champs_public_params
|
||||
champs_params = params.require(:dossier).permit(champs_public_attributes: [
|
||||
:id, :value, :value_other, :external_id, :primary_value, :secondary_value, :numero_allocataire, :code_postal, :identifiant, :numero_fiscal, :reference_avis, :ine, :piece_justificative_file, :code_departement, value: [],
|
||||
champs_attributes: [:id, :_destroy, :value, :value_other, :external_id, :primary_value, :secondary_value, :numero_allocataire, :code_postal, :identifiant, :numero_fiscal, :reference_avis, :ine, :piece_justificative_file, :departement, :code_departement, value: []]
|
||||
:id,
|
||||
:value,
|
||||
:value_other,
|
||||
:external_id,
|
||||
:primary_value,
|
||||
:secondary_value,
|
||||
:numero_allocataire,
|
||||
:code_postal,
|
||||
:identifiant,
|
||||
:numero_fiscal,
|
||||
:reference_avis,
|
||||
:ine,
|
||||
:piece_justificative_file,
|
||||
:code_departement,
|
||||
:accreditation_number,
|
||||
:accreditation_birthdate,
|
||||
value: []
|
||||
])
|
||||
champs_params[:champs_public_all_attributes] = champs_params.delete(:champs_public_attributes) || {}
|
||||
champs_params
|
||||
end
|
||||
|
||||
def dossier_scope
|
||||
if action_name == 'update'
|
||||
if action_name == 'update' || action_name == 'champ'
|
||||
Dossier.visible_by_user.or(Dossier.for_procedure_preview).or(Dossier.for_editing_fork)
|
||||
elsif action_name == 'restore'
|
||||
Dossier.hidden_by_user
|
||||
|
|
|
@ -84,6 +84,7 @@ class API::V2::Schema < GraphQL::Schema
|
|||
Types::Champs::Descriptor::CheckboxChampDescriptorType,
|
||||
Types::Champs::Descriptor::CiviliteChampDescriptorType,
|
||||
Types::Champs::Descriptor::CnafChampDescriptorType,
|
||||
Types::Champs::Descriptor::COJOChampDescriptorType,
|
||||
Types::Champs::Descriptor::CommuneChampDescriptorType,
|
||||
Types::Champs::Descriptor::DateChampDescriptorType,
|
||||
Types::Champs::Descriptor::DatetimeChampDescriptorType,
|
||||
|
|
|
@ -190,6 +190,34 @@ exceed the size of a 32-bit integer, it's encoded as a string.
|
|||
"""
|
||||
scalar BigInt
|
||||
|
||||
type COJOChampDescriptor implements ChampDescriptor {
|
||||
"""
|
||||
Description des champs d’un bloc répétable.
|
||||
"""
|
||||
champDescriptors: [ChampDescriptor!] @deprecated(reason: "Utilisez le champ `RepetitionChampDescriptor.champ_descriptors` à la place.")
|
||||
|
||||
"""
|
||||
Description du champ.
|
||||
"""
|
||||
description: String
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
Libellé du champ.
|
||||
"""
|
||||
label: String!
|
||||
|
||||
"""
|
||||
Est-ce que le champ est obligatoire ?
|
||||
"""
|
||||
required: Boolean!
|
||||
|
||||
"""
|
||||
Type de la valeur du champ.
|
||||
"""
|
||||
type: TypeDeChamp! @deprecated(reason: "Utilisez le champ `__typename` à la place.")
|
||||
}
|
||||
|
||||
type CarteChamp implements Champ {
|
||||
geoAreas: [GeoArea!]!
|
||||
id: ID!
|
||||
|
@ -3699,6 +3727,11 @@ enum TypeDeChamp {
|
|||
"""
|
||||
cnaf
|
||||
|
||||
"""
|
||||
Accréditation Paris 2024
|
||||
"""
|
||||
cojo
|
||||
|
||||
"""
|
||||
Communes
|
||||
"""
|
||||
|
|
|
@ -94,6 +94,8 @@ module Types
|
|||
Types::Champs::Descriptor::MesriChampDescriptorType
|
||||
when TypeDeChamp.type_champs.fetch(:epci)
|
||||
Types::Champs::Descriptor::EpciChampDescriptorType
|
||||
when TypeDeChamp.type_champs.fetch(:cojo)
|
||||
Types::Champs::Descriptor::COJOChampDescriptorType
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
module Types::Champs::Descriptor
|
||||
class COJOChampDescriptorType < Types::BaseObject
|
||||
implements Types::ChampDescriptorType
|
||||
end
|
||||
end
|
|
@ -15,6 +15,12 @@ export class FormatController extends ApplicationController {
|
|||
const target = event.target as HTMLInputElement;
|
||||
target.value = this.formatIBAN(target.value);
|
||||
});
|
||||
break;
|
||||
case 'integer':
|
||||
this.on('input', (event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
target.value = this.formatInteger(target.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -28,4 +34,8 @@ export class FormatController extends ApplicationController {
|
|||
.replace(/(.{4})/g, '$1 ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
private formatInteger(value: string) {
|
||||
return value.replace(/[^\d]/g, '');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,28 @@
|
|||
class ChampFetchExternalDataJob < ApplicationJob
|
||||
include Dry::Monads[:result]
|
||||
|
||||
def perform(champ, external_id)
|
||||
return if champ.external_id != external_id
|
||||
return if champ.data.present?
|
||||
return if (data = champ.fetch_external_data).blank?
|
||||
|
||||
champ.update_with_external_data!(data: data)
|
||||
Sentry.set_tags(champ: champ.id)
|
||||
Sentry.set_extras(external_id:)
|
||||
|
||||
result = champ.fetch_external_data
|
||||
|
||||
if result.is_a?(Dry::Monads::Result)
|
||||
case result
|
||||
in Success(data)
|
||||
champ.update_with_external_data!(data:)
|
||||
in Failure(retryable: true, reason:)
|
||||
champ.log_fetch_external_data_exception(reason)
|
||||
throw reason
|
||||
in Failure(retryable: false, reason:)
|
||||
champ.log_fetch_external_data_exception(reason)
|
||||
Sentry.capture_exception(reason)
|
||||
end
|
||||
elsif result.present?
|
||||
champ.update_with_external_data!(data: result)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
97
app/lib/api/client.rb
Normal file
97
app/lib/api/client.rb
Normal file
|
@ -0,0 +1,97 @@
|
|||
class API::Client
|
||||
include Dry::Monads[:result]
|
||||
|
||||
TIMEOUT = 10
|
||||
|
||||
def call(url:, params: nil, body: nil, json: nil, headers: nil, method: :get, authorization_token: nil, schema: nil, timeout: TIMEOUT)
|
||||
response = case method
|
||||
when :get
|
||||
Typhoeus.get(url,
|
||||
headers: headers_with_authorization(headers, authorization_token),
|
||||
params:,
|
||||
timeout: TIMEOUT)
|
||||
when :post
|
||||
Typhoeus.post(url,
|
||||
headers: headers_with_authorization(headers, json, authorization_token),
|
||||
body: json.nil? ? body : json.to_json,
|
||||
timeout: TIMEOUT)
|
||||
end
|
||||
handle_response(response, schema:)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def headers_with_authorization(headers, json, authorization_token)
|
||||
headers = headers || {}
|
||||
headers['authorization'] = "Bearer #{authorization_token}" if authorization_token.present?
|
||||
headers['content-type'] = 'application/json' if json.present?
|
||||
headers
|
||||
end
|
||||
|
||||
OK = Data.define(:body, :response)
|
||||
Error = Data.define(:type, :code, :retryable, :reason)
|
||||
|
||||
def handle_response(response, schema:)
|
||||
if response.success?
|
||||
scope = Sentry.get_current_scope
|
||||
if scope.extra.key?(:external_id)
|
||||
scope.set_extras(raw_body: response.body.to_s)
|
||||
end
|
||||
body = parse_body(response.body)
|
||||
case body
|
||||
in Success(body)
|
||||
if !schema || schema.valid?(body)
|
||||
Success(OK[body.deep_symbolize_keys, response])
|
||||
else
|
||||
Failure(Error[:schema, response.code, false, SchemaError.new(schema.validate(body))])
|
||||
end
|
||||
in Failure(reason)
|
||||
Failure(Error[:json, response.code, false, reason])
|
||||
end
|
||||
elsif response.timed_out?
|
||||
Failure(Error[:timeout, response.code, true, HTTPError.new(response)])
|
||||
elsif response.code != 0
|
||||
Failure(Error[:http, response.code, true, HTTPError.new(response)])
|
||||
else
|
||||
Failure(Error[:network, response.code, true, HTTPError.new(response)])
|
||||
end
|
||||
end
|
||||
|
||||
def parse_body(body)
|
||||
Success(JSON.parse(body))
|
||||
rescue JSON::ParserError => error
|
||||
Failure(error)
|
||||
end
|
||||
|
||||
class SchemaError < StandardError
|
||||
attr_reader :errors
|
||||
|
||||
def initialize(errors)
|
||||
@errors = errors.to_a
|
||||
|
||||
super(@errors.map(&:to_json).join("\n"))
|
||||
end
|
||||
end
|
||||
|
||||
class HTTPError < StandardError
|
||||
attr_reader :response
|
||||
|
||||
def initialize(response)
|
||||
@response = response
|
||||
|
||||
uri = URI.parse(response.effective_url)
|
||||
|
||||
msg = <<~TEXT
|
||||
url: #{uri.host}#{uri.path}
|
||||
HTTP error code: #{response.code}
|
||||
body: #{CGI.escape(response.body)}
|
||||
curl message: #{response.return_message}
|
||||
total time: #{response.total_time}
|
||||
connect time: #{response.connect_time}
|
||||
response headers: #{response.headers}
|
||||
TEXT
|
||||
|
||||
super(msg)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -223,6 +223,16 @@ class Champ < ApplicationRecord
|
|||
false
|
||||
end
|
||||
|
||||
def poll_external_data?
|
||||
false
|
||||
end
|
||||
|
||||
def fetch_external_data_pending?
|
||||
# We don't have a good mechanism right now to know if the last fetch has errored. So, in order
|
||||
# to ensure we don't poll to infinity, we stop after 5 minutes no matter what.
|
||||
fetch_external_data? && poll_external_data? && external_id.present? && data.nil? && updated_at > 5.minutes.ago
|
||||
end
|
||||
|
||||
def fetch_external_data
|
||||
raise NotImplemented.new(:fetch_external_data)
|
||||
end
|
||||
|
|
64
app/models/champs/cojo_champ.rb
Normal file
64
app/models/champs/cojo_champ.rb
Normal file
|
@ -0,0 +1,64 @@
|
|||
class Champs::COJOChamp < Champ
|
||||
store_accessor :value_json, :accreditation_number, :accreditation_birthdate
|
||||
store_accessor :data, :accreditation_success, :accreditation_first_name, :accreditation_last_name
|
||||
|
||||
after_validation :update_external_id
|
||||
|
||||
def accreditation_birthdate
|
||||
Date.parse(super)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def accreditation_success?
|
||||
accreditation_success == true
|
||||
end
|
||||
|
||||
def accreditation_error?
|
||||
accreditation_success == false
|
||||
end
|
||||
|
||||
def blank?
|
||||
accreditation_success.nil?
|
||||
end
|
||||
|
||||
def fetch_external_data?
|
||||
true
|
||||
end
|
||||
|
||||
def poll_external_data?
|
||||
true
|
||||
end
|
||||
|
||||
def fetch_external_data
|
||||
COJOService.new.(accreditation_number:, accreditation_birthdate:)
|
||||
end
|
||||
|
||||
def to_s
|
||||
"#{accreditation_number} – #{accreditation_birthdate}"
|
||||
end
|
||||
|
||||
def accreditation_number_input_id
|
||||
"#{input_id}-accreditation_number"
|
||||
end
|
||||
|
||||
def accreditation_birthdate_input_id
|
||||
"#{input_id}-accreditation_birthdate"
|
||||
end
|
||||
|
||||
def focusable_input_id
|
||||
accreditation_number_input_id
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_external_id
|
||||
if accreditation_number_changed? || accreditation_birthdate_changed?
|
||||
if accreditation_number.present? && accreditation_birthdate.present? && /\A\d+\z/.match?(accreditation_number)
|
||||
self.external_id = { accreditation_number:, accreditation_birthdate: }.to_json
|
||||
else
|
||||
self.external_id = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -18,7 +18,9 @@ class TypeDeChamp < ApplicationRecord
|
|||
self.ignored_columns += [:migrated_parent, :revision_id, :parent_id, :order_place]
|
||||
|
||||
FILE_MAX_SIZE = 200.megabytes
|
||||
FEATURE_FLAGS = {}
|
||||
FEATURE_FLAGS = {
|
||||
cojo: :cojo_type_de_champ
|
||||
}
|
||||
MINIMUM_TEXTAREA_CHARACTER_LIMIT_LENGTH = 400
|
||||
|
||||
STRUCTURE = :structure
|
||||
|
@ -68,7 +70,8 @@ class TypeDeChamp < ApplicationRecord
|
|||
cnaf: REFERENTIEL_EXTERNE,
|
||||
dgfip: REFERENTIEL_EXTERNE,
|
||||
pole_emploi: REFERENTIEL_EXTERNE,
|
||||
mesri: REFERENTIEL_EXTERNE
|
||||
mesri: REFERENTIEL_EXTERNE,
|
||||
cojo: REFERENTIEL_EXTERNE
|
||||
}
|
||||
|
||||
enum type_champs: {
|
||||
|
@ -107,7 +110,8 @@ class TypeDeChamp < ApplicationRecord
|
|||
dgfip: 'dgfip',
|
||||
pole_emploi: 'pole_emploi',
|
||||
mesri: 'mesri',
|
||||
epci: 'epci'
|
||||
epci: 'epci',
|
||||
cojo: 'cojo'
|
||||
}
|
||||
|
||||
store_accessor :options,
|
||||
|
@ -569,7 +573,8 @@ class TypeDeChamp < ApplicationRecord
|
|||
type_champs.fetch(:dossier_link),
|
||||
type_champs.fetch(:linked_drop_down_list),
|
||||
type_champs.fetch(:drop_down_list),
|
||||
type_champs.fetch(:textarea)
|
||||
type_champs.fetch(:textarea),
|
||||
type_champs.fetch(:cojo)
|
||||
true
|
||||
else
|
||||
false
|
||||
|
|
2
app/models/types_de_champ/cojo_type_de_champ.rb
Normal file
2
app/models/types_de_champ/cojo_type_de_champ.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
class TypesDeChamp::COJOTypeDeChamp < TypesDeChamp::TextTypeDeChamp
|
||||
end
|
14
app/schemas/accreditation-cojo.json
Normal file
14
app/schemas/accreditation-cojo.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "http://demarches-simplifiees.fr/accreditation-cojo.schema.json",
|
||||
"title": "Accreditation COJO",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"firstName": { "type": "string" },
|
||||
"lastName": { "type": "string" },
|
||||
"individualExistance": {
|
||||
"enum": ["Yes", "No"]
|
||||
}
|
||||
},
|
||||
"required": ["individualExistance"]
|
||||
}
|
56
app/services/cojo_service.rb
Normal file
56
app/services/cojo_service.rb
Normal file
|
@ -0,0 +1,56 @@
|
|||
class COJOService
|
||||
include Dry::Monads[:result]
|
||||
|
||||
def call(accreditation_number:, accreditation_birthdate:)
|
||||
result = API::Client.new.(url:,
|
||||
json: {
|
||||
accreditationNumber: accreditation_number.to_i,
|
||||
birthdate: accreditation_birthdate&.strftime('%d/%m/%Y')
|
||||
},
|
||||
authorization_token:,
|
||||
schema:,
|
||||
method: :post)
|
||||
|
||||
case result
|
||||
in Success(body:)
|
||||
accreditation_success = body[:individualExistance] == 'Yes'
|
||||
Success({
|
||||
accreditation_success:,
|
||||
accreditation_first_name: accreditation_success ? body[:firstName] : nil,
|
||||
accreditation_last_name: accreditation_success ? body[:lastName] : nil
|
||||
})
|
||||
in Failure(code:, reason:) if code.in?(401..403)
|
||||
Failure(API::Client::Error[:unauthorized, code, false, reason])
|
||||
else
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def schema
|
||||
JSONSchemer.schema(Rails.root.join('app/schemas/accreditation-cojo.json'))
|
||||
end
|
||||
|
||||
def url
|
||||
"#{API_COJO_URL}/api/accreditation"
|
||||
end
|
||||
|
||||
def authorization_token
|
||||
rsa_private_key&.then { JWT.encode(jwt_payload, _1, 'RS256') }
|
||||
end
|
||||
|
||||
def jwt_payload
|
||||
{
|
||||
iss: APPLICATION_NAME,
|
||||
iat: Time.zone.now.to_i,
|
||||
exp: 1.hour.from_now.to_i
|
||||
}
|
||||
end
|
||||
|
||||
def rsa_private_key
|
||||
if ENV['COJO_JWT_RSA_PRIVATE_KEY'].present?
|
||||
OpenSSL::PKey::RSA.new(ENV['COJO_JWT_RSA_PRIVATE_KEY'])
|
||||
end
|
||||
end
|
||||
end
|
9
app/views/shared/champs/cojo/_show.html.haml
Normal file
9
app/views/shared/champs/cojo/_show.html.haml
Normal file
|
@ -0,0 +1,9 @@
|
|||
= champ.to_s
|
||||
- if profile == 'instructeur'
|
||||
%dl
|
||||
%dt
|
||||
Nom et prénom dans la base d’accréditation :
|
||||
%dd= "#{champ.accreditation_last_name} #{champ.accreditation_first_name}"
|
||||
%dt
|
||||
Nom et prénom saisie dans le dossier :
|
||||
%dd= "#{champ.dossier.individual.nom} #{champ.dossier.individual.prenom}"
|
|
@ -201,3 +201,9 @@ BANNER_MESSAGE=""
|
|||
ADMINISTRATION_BANNER_MESSAGE=""
|
||||
# for usager only
|
||||
USAGER_BANNER_MESSAGE=""
|
||||
|
||||
# RSA private key to generate JWT tokens for communication with COJO services
|
||||
COJO_JWT_RSA_PRIVATE_KEY=""
|
||||
COJO_JWT_ISS=""
|
||||
|
||||
API_COJO_URL=""
|
||||
|
|
|
@ -6,6 +6,7 @@ API_EDUCATION_URL = ENV.fetch("API_EDUCATION_URL", "https://data.education.gouv.
|
|||
API_GEO_URL = ENV.fetch("API_GEO_URL", "https://geo.api.gouv.fr")
|
||||
API_PARTICULIER_URL = ENV.fetch("API_PARTICULIER_URL", "https://particulier.api.gouv.fr/api")
|
||||
API_TCHAP_URL = ENV.fetch("API_TCHAP_URL", "https://matrix.agent.tchap.gouv.fr/_matrix/identity/api/v1")
|
||||
API_COJO_URL = ENV.fetch("API_COJO_URL", nil)
|
||||
HELPSCOUT_API_URL = ENV.fetch("HELPSCOUT_API_URL", "https://api.helpscout.net/v2")
|
||||
PIPEDRIVE_API_URL = ENV.fetch("PIPEDRIVE_API_URL", "https://api.pipedrive.com/v1")
|
||||
SENDINBLUE_API_URL = ENV.fetch("SENDINBLUE_API_URL", "https://in-automate.sendinblue.com/api/v2")
|
||||
|
|
|
@ -18,7 +18,8 @@ features = [
|
|||
:hide_instructeur_email,
|
||||
:procedure_routage_api,
|
||||
:groupe_instructeur_api_hack,
|
||||
:rerouting
|
||||
:rerouting,
|
||||
:cojo_type_de_champ
|
||||
]
|
||||
|
||||
def database_exists?
|
||||
|
|
|
@ -8,6 +8,7 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
|
|||
# inflect.singular /^(ox)en/i, '\1'
|
||||
# inflect.irregular 'person', 'people'
|
||||
# inflect.uncountable %w( fish sheep )
|
||||
inflect.acronym 'COJO'
|
||||
inflect.acronym 'API'
|
||||
inflect.acronym 'ASN1'
|
||||
inflect.acronym 'IP'
|
||||
|
|
|
@ -50,6 +50,7 @@ en:
|
|||
pole_emploi: 'Pôle emploi status'
|
||||
mesri: "Data from Ministère de l’Enseignement Supérieur, de la Recherche et de l’Innovation"
|
||||
epci: "EPCI"
|
||||
cojo: "Accreditation Paris 2024"
|
||||
errors:
|
||||
type_de_champ:
|
||||
attributes:
|
||||
|
|
|
@ -50,6 +50,7 @@ fr:
|
|||
pole_emploi: 'Situation Pôle emploi'
|
||||
mesri: "Données du Ministère de l’Enseignement Supérieur, de la Recherche et de l’Innovation"
|
||||
epci: "EPCI"
|
||||
cojo: "Accréditation Paris 2024"
|
||||
errors:
|
||||
type_de_champ:
|
||||
attributes:
|
||||
|
|
|
@ -322,6 +322,7 @@ Rails.application.routes.draw do
|
|||
get 'modifier', to: 'dossiers#modifier'
|
||||
post 'modifier', to: 'dossiers#submit_en_construction'
|
||||
patch 'modifier', to: 'dossiers#modifier_legacy'
|
||||
get 'champs/:champ_id', to: 'dossiers#champ', as: :champ
|
||||
get 'merci'
|
||||
get 'demande'
|
||||
get 'messagerie'
|
||||
|
@ -434,6 +435,7 @@ Rails.application.routes.draw do
|
|||
get 'avis'
|
||||
get 'avis_new'
|
||||
get 'personnes-impliquees' => 'dossiers#personnes_impliquees'
|
||||
get 'annotations/:annotation_id', to: 'dossiers#annotation', as: :annotation
|
||||
patch 'follow'
|
||||
patch 'unfollow'
|
||||
patch 'archive'
|
||||
|
|
|
@ -239,6 +239,10 @@ FactoryBot.define do
|
|||
value { 'W173847273' }
|
||||
end
|
||||
|
||||
factory :champ_cojo, class: 'Champs::COJOChamp' do
|
||||
type_de_champ { association :type_de_champ_cojo, procedure: dossier.procedure }
|
||||
end
|
||||
|
||||
factory :champ_repetition, class: 'Champs::RepetitionChamp' do
|
||||
type_de_champ { association :type_de_champ_repetition, procedure: dossier.procedure }
|
||||
|
||||
|
|
|
@ -181,6 +181,9 @@ FactoryBot.define do
|
|||
factory :type_de_champ_epci do
|
||||
type_champ { TypeDeChamp.type_champs.fetch(:epci) }
|
||||
end
|
||||
factory :type_de_champ_cojo do
|
||||
type_champ { TypeDeChamp.type_champs.fetch(:cojo) }
|
||||
end
|
||||
factory :type_de_champ_repetition do
|
||||
type_champ { TypeDeChamp.type_champs.fetch(:repetition) }
|
||||
|
||||
|
|
4
spec/fixtures/files/api_cojo/accreditation_invalid.json
vendored
Normal file
4
spec/fixtures/files/api_cojo/accreditation_invalid.json
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"individualExistance": "maybe",
|
||||
"firstName": "Florence"
|
||||
}
|
5
spec/fixtures/files/api_cojo/accreditation_no.json
vendored
Normal file
5
spec/fixtures/files/api_cojo/accreditation_no.json
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"individualExistance": "No",
|
||||
"firstName": "Not found",
|
||||
"lastName": "Not found"
|
||||
}
|
5
spec/fixtures/files/api_cojo/accreditation_yes.json
vendored
Normal file
5
spec/fixtures/files/api_cojo/accreditation_yes.json
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"individualExistance": "Yes",
|
||||
"firstName": "Florence",
|
||||
"lastName": "Griffith-Joyner"
|
||||
}
|
|
@ -3,17 +3,21 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ChampFetchExternalDataJob, type: :job do
|
||||
let(:champ) { Struct.new(:external_id, :data).new(champ_external_id, data) }
|
||||
let(:champ) { build(:champ, external_id: champ_external_id, data:) }
|
||||
let(:external_id) { "an ID" }
|
||||
let(:champ_external_id) { "an ID" }
|
||||
let(:data) { nil }
|
||||
let(:fetched_data) { nil }
|
||||
let(:reason) { StandardError.new("error") }
|
||||
|
||||
subject(:perform_job) { described_class.perform_now(champ, external_id) }
|
||||
|
||||
include Dry::Monads[:result]
|
||||
|
||||
before do
|
||||
allow(champ).to receive(:fetch_external_data).and_return(fetched_data)
|
||||
allow(champ).to receive(:update_with_external_data!)
|
||||
allow(champ).to receive(:log_fetch_external_data_exception)
|
||||
end
|
||||
|
||||
shared_examples "a champ non-updater" do
|
||||
|
@ -38,6 +42,35 @@ RSpec.describe ChampFetchExternalDataJob, type: :job do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when the fetched data is a result' do
|
||||
context 'success' do
|
||||
let(:fetched_data) { Success("data") }
|
||||
|
||||
it 'updates the champ' do
|
||||
perform_job
|
||||
expect(champ).to have_received(:update_with_external_data!).with(data: fetched_data.value!)
|
||||
end
|
||||
end
|
||||
|
||||
context 'retryable failure' do
|
||||
let(:fetched_data) { Failure(API::Client::Error[:http, 400, true, reason]) }
|
||||
|
||||
it 'saves exception and raise' do
|
||||
expect { perform_job }.to raise_error StandardError
|
||||
expect(champ).to have_received(:log_fetch_external_data_exception).with(reason)
|
||||
end
|
||||
end
|
||||
|
||||
context 'fatal failure' do
|
||||
let(:fetched_data) { Failure(API::Client::Error[:http, 400, false, reason]) }
|
||||
|
||||
it 'saves exception' do
|
||||
perform_job
|
||||
expect(champ).to have_received(:log_fetch_external_data_exception).with(reason)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the fetched data is blank' do
|
||||
it_behaves_like "a champ non-updater"
|
||||
end
|
||||
|
|
|
@ -14,9 +14,9 @@ describe '20220705164551_remove_unused_champs' do
|
|||
|
||||
describe 'remove_unused_champs' do
|
||||
it "with bad champs" do
|
||||
expect(Champ.where(dossier: dossier).count).to eq(40)
|
||||
expect(Champ.where(dossier: dossier).count).to eq(41)
|
||||
run_task
|
||||
expect(Champ.where(dossier: dossier).count).to eq(39)
|
||||
expect(Champ.where(dossier: dossier).count).to eq(40)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
50
spec/models/champs/cojo_champ_spec.rb
Normal file
50
spec/models/champs/cojo_champ_spec.rb
Normal file
|
@ -0,0 +1,50 @@
|
|||
describe Champs::COJOChamp, type: :model do
|
||||
let(:champ) { build(:champ_cojo, accreditation_number:, accreditation_birthdate:) }
|
||||
let(:external_id) { nil }
|
||||
let(:stub) { stub_request(:post, url).with(body: { accreditationNumber: accreditation_number, birthdate: accreditation_birthdate }).to_return(body:, status:) }
|
||||
let(:url) { COJOService.new.send(:url) }
|
||||
let(:body) { Rails.root.join('spec', 'fixtures', 'files', 'api_cojo', "accreditation_#{response_type}.json").read }
|
||||
let(:status) { 200 }
|
||||
let(:response_type) { 'yes' }
|
||||
let(:accreditation_number) { 123456 }
|
||||
let(:accreditation_birthdate) { '21/12/1959' }
|
||||
|
||||
describe 'fetch_external_data' do
|
||||
subject { stub; champ.fetch_external_data }
|
||||
|
||||
context 'success (yes)' do
|
||||
it { expect(subject.value!).to eq({ accreditation_success: true, accreditation_first_name: 'Florence', accreditation_last_name: 'Griffith-Joyner' }) }
|
||||
end
|
||||
|
||||
context 'success (no)' do
|
||||
let(:response_type) { 'no' }
|
||||
it { expect(subject.value!).to eq({ accreditation_success: false, accreditation_first_name: nil, accreditation_last_name: nil }) }
|
||||
end
|
||||
|
||||
context 'failure (schema)' do
|
||||
let(:response_type) { 'invalid' }
|
||||
it {
|
||||
expect(subject.failure.retryable).to be_falsey
|
||||
expect(subject.failure.reason).to be_a(API::Client::SchemaError)
|
||||
}
|
||||
end
|
||||
|
||||
context 'failure (http 500)' do
|
||||
let(:status) { 500 }
|
||||
let(:response_type) { 'invalid' }
|
||||
it {
|
||||
expect(subject.failure.retryable).to be_truthy
|
||||
expect(subject.failure.reason).to be_a(API::Client::HTTPError)
|
||||
}
|
||||
end
|
||||
|
||||
context 'failure (http 401)' do
|
||||
let(:status) { 401 }
|
||||
let(:response_type) { 'invalid' }
|
||||
it {
|
||||
expect(subject.failure.retryable).to be_falsey
|
||||
expect(subject.failure.reason).to be_a(API::Client::HTTPError)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -88,7 +88,8 @@ describe ProcedureExportService do
|
|||
"text",
|
||||
"epci",
|
||||
"epci (Code)",
|
||||
"epci (Département)"
|
||||
"epci (Département)",
|
||||
"cojo"
|
||||
]
|
||||
end
|
||||
|
||||
|
@ -198,7 +199,8 @@ describe ProcedureExportService do
|
|||
"text",
|
||||
"epci",
|
||||
"epci (Code)",
|
||||
"epci (Département)"
|
||||
"epci (Département)",
|
||||
"cojo"
|
||||
]
|
||||
end
|
||||
|
||||
|
@ -291,7 +293,8 @@ describe ProcedureExportService do
|
|||
"text",
|
||||
"epci",
|
||||
"epci (Code)",
|
||||
"epci (Département)"
|
||||
"epci (Département)",
|
||||
"cojo"
|
||||
]
|
||||
end
|
||||
|
||||
|
|
Loading…
Reference in a new issue