diff --git a/Gemfile b/Gemfile index 520c04354..61489a29f 100644 --- a/Gemfile +++ b/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' diff --git a/Gemfile.lock b/Gemfile.lock index 6d5caab37..4536d791a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/components/dossiers/champs_rows_show_component/champs_rows_show_component.html.haml b/app/components/dossiers/champs_rows_show_component/champs_rows_show_component.html.haml index 1ae64cfc8..3e9523527 100644 --- a/app/components/dossiers/champs_rows_show_component/champs_rows_show_component.html.haml +++ b/app/components/dossiers/champs_rows_show_component/champs_rows_show_component.html.haml @@ -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) diff --git a/app/components/editable_champ/cojo_component.rb b/app/components/editable_champ/cojo_component.rb new file mode 100644 index 000000000..e49c44b3d --- /dev/null +++ b/app/components/editable_champ/cojo_component.rb @@ -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 diff --git a/app/components/editable_champ/cojo_component/cojo_component.en.yml b/app/components/editable_champ/cojo_component/cojo_component.en.yml new file mode 100644 index 000000000..88069f927 --- /dev/null +++ b/app/components/editable_champ/cojo_component/cojo_component.en.yml @@ -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 diff --git a/app/components/editable_champ/cojo_component/cojo_component.fr.yml b/app/components/editable_champ/cojo_component/cojo_component.fr.yml new file mode 100644 index 000000000..914b94595 --- /dev/null +++ b/app/components/editable_champ/cojo_component/cojo_component.fr.yml @@ -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 diff --git a/app/components/editable_champ/cojo_component/cojo_component.html.haml b/app/components/editable_champ/cojo_component/cojo_component.html.haml new file mode 100644 index 000000000..80ac7ccf3 --- /dev/null +++ b/app/components/editable_champ/cojo_component/cojo_component.html.haml @@ -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 diff --git a/app/components/editable_champ/editable_champ_component.rb b/app/components/editable_champ/editable_champ_component.rb index f2051df8a..f335a181e 100644 --- a/app/components/editable_champ/editable_champ_component.rb +++ b/app/components/editable_champ/editable_champ_component.rb @@ -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 diff --git a/app/components/types_de_champ_editor/champ_component.rb b/app/components/types_de_champ_editor/champ_component.rb index 2ee7c9e6a..6fe4fb9a1 100644 --- a/app/components/types_de_champ_editor/champ_component.rb +++ b/app/components/types_de_champ_editor/champ_component.rb @@ -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) diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 64a10cc6e..8f2777070 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -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) diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index e6df42bcc..3d7d179da 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -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 diff --git a/app/graphql/api/v2/schema.rb b/app/graphql/api/v2/schema.rb index 8ff3cdfb1..89372aa15 100644 --- a/app/graphql/api/v2/schema.rb +++ b/app/graphql/api/v2/schema.rb @@ -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, diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index 10548210b..8a5f619ff 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -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 """ diff --git a/app/graphql/types/champ_descriptor_type.rb b/app/graphql/types/champ_descriptor_type.rb index 05fa6058a..3e59bec96 100644 --- a/app/graphql/types/champ_descriptor_type.rb +++ b/app/graphql/types/champ_descriptor_type.rb @@ -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 diff --git a/app/graphql/types/champs/descriptor/cojo_champ_descriptor_type.rb b/app/graphql/types/champs/descriptor/cojo_champ_descriptor_type.rb new file mode 100644 index 000000000..4843cc12d --- /dev/null +++ b/app/graphql/types/champs/descriptor/cojo_champ_descriptor_type.rb @@ -0,0 +1,5 @@ +module Types::Champs::Descriptor + class COJOChampDescriptorType < Types::BaseObject + implements Types::ChampDescriptorType + end +end diff --git a/app/javascript/controllers/format_controller.ts b/app/javascript/controllers/format_controller.ts index 855324860..a6a9ad14c 100644 --- a/app/javascript/controllers/format_controller.ts +++ b/app/javascript/controllers/format_controller.ts @@ -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, ''); + } } diff --git a/app/jobs/champ_fetch_external_data_job.rb b/app/jobs/champ_fetch_external_data_job.rb index 790052863..4e11bd96a 100644 --- a/app/jobs/champ_fetch_external_data_job.rb +++ b/app/jobs/champ_fetch_external_data_job.rb @@ -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 diff --git a/app/lib/api/client.rb b/app/lib/api/client.rb new file mode 100644 index 000000000..31507f72f --- /dev/null +++ b/app/lib/api/client.rb @@ -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 diff --git a/app/models/champ.rb b/app/models/champ.rb index 00211b1da..ca6c67b1e 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -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 diff --git a/app/models/champs/cojo_champ.rb b/app/models/champs/cojo_champ.rb new file mode 100644 index 000000000..05e50836d --- /dev/null +++ b/app/models/champs/cojo_champ.rb @@ -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 diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 90cec27e0..ee211fb5b 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -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 diff --git a/app/models/types_de_champ/cojo_type_de_champ.rb b/app/models/types_de_champ/cojo_type_de_champ.rb new file mode 100644 index 000000000..2747a34c7 --- /dev/null +++ b/app/models/types_de_champ/cojo_type_de_champ.rb @@ -0,0 +1,2 @@ +class TypesDeChamp::COJOTypeDeChamp < TypesDeChamp::TextTypeDeChamp +end diff --git a/app/schemas/accreditation-cojo.json b/app/schemas/accreditation-cojo.json new file mode 100644 index 000000000..b6bbeef83 --- /dev/null +++ b/app/schemas/accreditation-cojo.json @@ -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"] +} diff --git a/app/services/cojo_service.rb b/app/services/cojo_service.rb new file mode 100644 index 000000000..22f7c1d7e --- /dev/null +++ b/app/services/cojo_service.rb @@ -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 diff --git a/app/views/shared/champs/cojo/_show.html.haml b/app/views/shared/champs/cojo/_show.html.haml new file mode 100644 index 000000000..b5fa04c78 --- /dev/null +++ b/app/views/shared/champs/cojo/_show.html.haml @@ -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}" diff --git a/config/env.example.optional b/config/env.example.optional index c8e361170..2a905ab1f 100644 --- a/config/env.example.optional +++ b/config/env.example.optional @@ -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="" diff --git a/config/initializers/02_urls.rb b/config/initializers/02_urls.rb index cc69ed22a..4c7b1a256 100644 --- a/config/initializers/02_urls.rb +++ b/config/initializers/02_urls.rb @@ -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") diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb index c0aec97b9..84ed2984c 100644 --- a/config/initializers/flipper.rb +++ b/config/initializers/flipper.rb @@ -18,7 +18,8 @@ features = [ :hide_instructeur_email, :procedure_routage_api, :groupe_instructeur_api_hack, - :rerouting + :rerouting, + :cojo_type_de_champ ] def database_exists? diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index dc982817a..3e3a763be 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -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' diff --git a/config/locales/models/type_de_champ/en.yml b/config/locales/models/type_de_champ/en.yml index 379a7928b..0429c3f65 100644 --- a/config/locales/models/type_de_champ/en.yml +++ b/config/locales/models/type_de_champ/en.yml @@ -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: diff --git a/config/locales/models/type_de_champ/fr.yml b/config/locales/models/type_de_champ/fr.yml index 989cba473..ff0d3ab06 100644 --- a/config/locales/models/type_de_champ/fr.yml +++ b/config/locales/models/type_de_champ/fr.yml @@ -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: diff --git a/config/routes.rb b/config/routes.rb index 1ff48ea0b..0918cb8ab 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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' diff --git a/spec/factories/champ.rb b/spec/factories/champ.rb index 350f2814a..b3a15774d 100644 --- a/spec/factories/champ.rb +++ b/spec/factories/champ.rb @@ -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 } diff --git a/spec/factories/type_de_champ.rb b/spec/factories/type_de_champ.rb index bb9587a37..cea0c942c 100644 --- a/spec/factories/type_de_champ.rb +++ b/spec/factories/type_de_champ.rb @@ -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) } diff --git a/spec/fixtures/files/api_cojo/accreditation_invalid.json b/spec/fixtures/files/api_cojo/accreditation_invalid.json new file mode 100644 index 000000000..31b28dc95 --- /dev/null +++ b/spec/fixtures/files/api_cojo/accreditation_invalid.json @@ -0,0 +1,4 @@ +{ + "individualExistance": "maybe", + "firstName": "Florence" +} diff --git a/spec/fixtures/files/api_cojo/accreditation_no.json b/spec/fixtures/files/api_cojo/accreditation_no.json new file mode 100644 index 000000000..c849c997c --- /dev/null +++ b/spec/fixtures/files/api_cojo/accreditation_no.json @@ -0,0 +1,5 @@ +{ + "individualExistance": "No", + "firstName": "Not found", + "lastName": "Not found" +} diff --git a/spec/fixtures/files/api_cojo/accreditation_yes.json b/spec/fixtures/files/api_cojo/accreditation_yes.json new file mode 100644 index 000000000..1d54bc0a7 --- /dev/null +++ b/spec/fixtures/files/api_cojo/accreditation_yes.json @@ -0,0 +1,5 @@ +{ + "individualExistance": "Yes", + "firstName": "Florence", + "lastName": "Griffith-Joyner" +} diff --git a/spec/jobs/champ_fetch_external_data_job_spec.rb b/spec/jobs/champ_fetch_external_data_job_spec.rb index 6f5277727..a4f75cc67 100644 --- a/spec/jobs/champ_fetch_external_data_job_spec.rb +++ b/spec/jobs/champ_fetch_external_data_job_spec.rb @@ -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 diff --git a/spec/lib/tasks/deployment/20220705164551_remove_unused_champs_spec.rb b/spec/lib/tasks/deployment/20220705164551_remove_unused_champs_spec.rb index a5c277cb6..8056bbbec 100644 --- a/spec/lib/tasks/deployment/20220705164551_remove_unused_champs_spec.rb +++ b/spec/lib/tasks/deployment/20220705164551_remove_unused_champs_spec.rb @@ -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 diff --git a/spec/models/champs/cojo_champ_spec.rb b/spec/models/champs/cojo_champ_spec.rb new file mode 100644 index 000000000..844d8d5e3 --- /dev/null +++ b/spec/models/champs/cojo_champ_spec.rb @@ -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 diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index 4e2a72956..fc2d3dd27 100644 --- a/spec/services/procedure_export_service_spec.rb +++ b/spec/services/procedure_export_service_spec.rb @@ -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