From e4e1081dfa5d8a31801bb4c097526ad7b57278b2 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Feldis <5403+jbfeldis@users.noreply.github.com> Date: Wed, 13 May 2026 17:18:04 +0200 Subject: [PATCH 1/6] DP-1718: introduce FormTemplate model + auto-create default per HabilitationType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étape 1.1 — migration `form_templates` (slug unique, FK habilitation_type, service_provider_id string, jsonb steps/static_blocks/scopes_config/initialize_with), modèle avec friendly_id, paper_trail, invariants (slug pas en collision avec les UID YAML, au moins un default par HT, cascade-safe via destroyed_by_association). Étape 1.2 — callback `HabilitationType#after_create :ensure_default_form_template!` qui reprend `ordered_steps` + `form_introduction` (parité avec l'ancien `build_form_from_habilitation_type`). Rake idempotente `form_templates:backfill_defaults` pour les HTs existants en DB. Façade `AuthorizationRequestForm.db_records` non touchée à ce stade (Étape 1.3 suivra) : on prépare la donnée sans changer le surface API. --- app/models/form_template.rb | 54 ++++++++ app/models/habilitation_type.rb | 14 +++ config/locales/activerecord.fr.yml | 18 +++ .../20260513000001_create_form_templates.rb | 26 ++++ db/schema.rb | 26 +++- lib/tasks/form_templates.rake | 14 +++ spec/factories/form_templates.rb | 17 +++ spec/lib/tasks/form_templates_rake_spec.rb | 43 +++++++ spec/models/form_template_spec.rb | 118 ++++++++++++++++++ spec/models/habilitation_type_spec.rb | 27 +++- 10 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 app/models/form_template.rb create mode 100644 db/migrate/20260513000001_create_form_templates.rb create mode 100644 lib/tasks/form_templates.rake create mode 100644 spec/factories/form_templates.rb create mode 100644 spec/lib/tasks/form_templates_rake_spec.rb create mode 100644 spec/models/form_template_spec.rb diff --git a/app/models/form_template.rb b/app/models/form_template.rb new file mode 100644 index 0000000000..dd025ff9fe --- /dev/null +++ b/app/models/form_template.rb @@ -0,0 +1,54 @@ +class FormTemplate < ApplicationRecord + extend FriendlyId + + has_paper_trail + + friendly_id :name, use: :slugged + + belongs_to :habilitation_type + + validates :slug, presence: true, uniqueness: true + validate :slug_not_taken_by_yaml + validate :ht_keeps_at_least_one_default, on: :update + before_destroy :ensure_not_last_default + + after_destroy :reset_arf_cache + after_save :reset_arf_cache + + def service_provider + return nil if service_provider_id.blank? + + ServiceProvider.find(service_provider_id) + rescue StaticApplicationRecord::EntryNotFound + nil + end + + private + + def reset_arf_cache + AuthorizationRequestForm.reset! + end + + def slug_not_taken_by_yaml + return if slug.blank? + return unless AuthorizationRequestFormConfigurations.instance.all.key?(slug.to_sym) + + errors.add(:slug, :taken_by_yaml_form) + end + + def ht_keeps_at_least_one_default + return unless default_was && !default + return if habilitation_type.form_templates.where(default: true).where.not(id: id).exists? + + errors.add(:default, :last_default_form_template) + end + + def ensure_not_last_default + return if destroyed_by_association + return unless default? + return unless habilitation_type.form_templates.where(default: true).one? + + errors.add(:base, :last_default_form_template) + throw :abort + end +end diff --git a/app/models/habilitation_type.rb b/app/models/habilitation_type.rb index 3f9418f830..c5c3861726 100644 --- a/app/models/habilitation_type.rb +++ b/app/models/habilitation_type.rb @@ -12,6 +12,7 @@ class HabilitationType < ApplicationRecord friendly_id :name, use: :slugged belongs_to :data_provider + has_many :form_templates, dependent: :destroy enum :kind, { api: 'api', service: 'service' }, validate: true @@ -24,6 +25,7 @@ class HabilitationType < ApplicationRecord validates :scopes, presence: true, if: :scopes_block_selected? validate :validate_each_scope, if: :scopes_block_selected? + after_create :ensure_default_form_template! before_destroy :ensure_no_authorization_requests after_destroy :unregister_dynamic_class after_destroy :reset_static_caches @@ -63,6 +65,18 @@ def ordered_steps BLOCK_ORDER.select { |name| block_names.include?(name) } end + def ensure_default_form_template! + return if form_templates.exists?(default: true) + + form_templates.create!( + name:, + slug: "#{slug}-default", + default: true, + introduction: form_introduction, + steps: ordered_steps.map { |step_name| { name: step_name } }, + ) + end + def self.preload_requests_counts!(habilitation_types) types = habilitation_types.map(&:authorization_request_type) counts = AuthorizationRequest.where(type: types).group(:type).count diff --git a/config/locales/activerecord.fr.yml b/config/locales/activerecord.fr.yml index 8f1d2d969e..a3915cb33d 100644 --- a/config/locales/activerecord.fr.yml +++ b/config/locales/activerecord.fr.yml @@ -182,6 +182,16 @@ fr: habilitation_type/kind/values: api: API service: Service + form_template: + name: Nom du template + description: Description + introduction: Introduction + use_case: Cas d’usage + default: Template par défaut + public: Public + startable_by_applicant: Démarrable par le demandeur + single_page_view: Vue en une seule page + service_provider_id: Fournisseur de service instructor_draft_request: applicant_email: Adresse email du demandeur organization_siret: Numéro de SIRET de l'organisation @@ -268,6 +278,14 @@ fr: unreachable_uid: "génère un identifiant technique inaccessible (le nom doit commencer par une lettre, ex: « Espace agent de l'Annuaire des Entreprises »)" slug: taken_by_yaml_type: "est déjà utilisé" + form_template: + attributes: + base: + last_default_form_template: "ne peut pas être supprimé : il doit rester au moins un template par défaut pour ce type d’habilitation" + slug: + taken_by_yaml_form: "est déjà utilisé par un formulaire YAML" + default: + last_default_form_template: "ne peut pas être désactivé : il doit rester au moins un template par défaut pour ce type d’habilitation" contact_types: blank: "doit contenir au moins un type de contact lorsque le bloc « Contacts » est sélectionné" scopes: diff --git a/db/migrate/20260513000001_create_form_templates.rb b/db/migrate/20260513000001_create_form_templates.rb new file mode 100644 index 0000000000..074cef8fbb --- /dev/null +++ b/db/migrate/20260513000001_create_form_templates.rb @@ -0,0 +1,26 @@ +class CreateFormTemplates < ActiveRecord::Migration[8.1] + def change + create_table :form_templates do |t| + t.string :slug, null: false + t.references :habilitation_type, null: false, foreign_key: true + t.string :service_provider_id + t.string :name + t.text :description + t.text :introduction + t.string :use_case + t.boolean :default, null: false, default: false + t.boolean :public, null: false, default: true + t.boolean :startable_by_applicant, null: false, default: true + t.boolean :single_page_view, null: false, default: false + t.jsonb :steps, null: false, default: [] + t.jsonb :static_blocks, null: false, default: [] + t.jsonb :scopes_config, null: false, default: {} + t.jsonb :initialize_with, null: false, default: {} + + t.timestamps + end + + add_index :form_templates, :slug, unique: true + add_index :form_templates, %i[habilitation_type_id default] + end +end diff --git a/db/schema.rb b/db/schema.rb index 0906346148..5736b644e9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_21_000000) do +ActiveRecord::Schema[8.1].define(version: 2026_05_13_000001) do # These are extensions that must be enabled in order to support this database enable_extension "hstore" enable_extension "pg_catalog.plpgsql" @@ -210,6 +210,29 @@ t.index ["authorization_request_id"], name: "index_denial_of_authorizations_on_authorization_request_id" end + create_table "form_templates", force: :cascade do |t| + t.datetime "created_at", null: false + t.boolean "default", default: false, null: false + t.text "description" + t.bigint "habilitation_type_id", null: false + t.jsonb "initialize_with", default: {}, null: false + t.text "introduction" + t.string "name" + t.boolean "public", default: true, null: false + t.jsonb "scopes_config", default: {}, null: false + t.string "service_provider_id" + t.boolean "single_page_view", default: false, null: false + t.string "slug", null: false + t.boolean "startable_by_applicant", default: true, null: false + t.jsonb "static_blocks", default: [], null: false + t.jsonb "steps", default: [], null: false + t.datetime "updated_at", null: false + t.string "use_case" + t.index ["habilitation_type_id", "default"], name: "index_form_templates_on_habilitation_type_id_and_default" + t.index ["habilitation_type_id"], name: "index_form_templates_on_habilitation_type_id" + t.index ["slug"], name: "index_form_templates_on_slug", unique: true + end + create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.integer "callback_priority" t.text "callback_queue_name" @@ -731,6 +754,7 @@ add_foreign_key "bulk_authorization_request_update_notification_reads", "bulk_authorization_request_updates" add_foreign_key "bulk_authorization_request_update_notification_reads", "users" add_foreign_key "denial_of_authorizations", "authorization_requests" + add_foreign_key "form_templates", "habilitation_types" add_foreign_key "habilitation_types", "data_providers" add_foreign_key "impersonation_actions", "impersonations" add_foreign_key "impersonations", "users" diff --git a/lib/tasks/form_templates.rake b/lib/tasks/form_templates.rake new file mode 100644 index 0000000000..cea79c71f5 --- /dev/null +++ b/lib/tasks/form_templates.rake @@ -0,0 +1,14 @@ +namespace :form_templates do + desc 'Backfill a default FormTemplate for HabilitationTypes that do not have one' + task backfill_defaults: :environment do + created = 0 + HabilitationType.find_each do |habilitation_type| + next if habilitation_type.form_templates.where(default: true).exists? + + habilitation_type.ensure_default_form_template! + created += 1 + end + + Rails.logger.info("[form_templates:backfill_defaults] created #{created} default template(s)") + end +end diff --git a/spec/factories/form_templates.rb b/spec/factories/form_templates.rb new file mode 100644 index 0000000000..8d9b90fc9f --- /dev/null +++ b/spec/factories/form_templates.rb @@ -0,0 +1,17 @@ +FactoryBot.define do + factory :form_template do + habilitation_type + sequence(:name) { |n| "Template #{n}" } + description { 'Un template de test' } + introduction { nil } + use_case { nil } + default { false } + public { true } + startable_by_applicant { true } + single_page_view { false } + steps { [{ 'name' => 'basic_infos' }] } + static_blocks { [] } + scopes_config { {} } + initialize_with { {} } + end +end diff --git a/spec/lib/tasks/form_templates_rake_spec.rb b/spec/lib/tasks/form_templates_rake_spec.rb new file mode 100644 index 0000000000..d8ee7ffa75 --- /dev/null +++ b/spec/lib/tasks/form_templates_rake_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' +require 'rake' + +RSpec.describe 'form_templates rake tasks' do + before(:all) do + Rails.application.load_tasks if Rake::Task.tasks.none? { |t| t.name == 'form_templates:backfill_defaults' } + end + + describe 'form_templates:backfill_defaults' do + let(:task) { Rake::Task['form_templates:backfill_defaults'] } + + before { task.reenable } + + it 'creates a default FormTemplate for HTs missing one' do + ht_a = create(:habilitation_type) + ht_b = create(:habilitation_type) + ht_a.form_templates.delete_all + ht_b.form_templates.delete_all + + expect { task.invoke } + .to change { FormTemplate.where(default: true).count }.by(2) + + expect(ht_a.form_templates.where(default: true).count).to eq(1) + expect(ht_b.form_templates.where(default: true).count).to eq(1) + end + + it 'is idempotent: a second invocation does not create duplicates' do + ht = create(:habilitation_type) + ht.form_templates.delete_all + + task.invoke + task.reenable + + expect { task.invoke }.not_to(change(FormTemplate, :count)) + end + + it 'skips HTs that already have a default template' do + create(:habilitation_type) + + expect { task.invoke }.not_to(change(FormTemplate, :count)) + end + end +end diff --git a/spec/models/form_template_spec.rb b/spec/models/form_template_spec.rb new file mode 100644 index 0000000000..9955553ac1 --- /dev/null +++ b/spec/models/form_template_spec.rb @@ -0,0 +1,118 @@ +require 'rails_helper' + +RSpec.describe FormTemplate do + let(:habilitation_type) do + ht = create(:habilitation_type) + ht.form_templates.delete_all + ht + end + + describe 'creation' do + it 'persists with habilitation_type and name' do + template = described_class.create!(habilitation_type:, name: 'Mon Template') + + expect(template).to be_persisted + expect(template.slug).to be_present + end + end + + describe 'slug' do + it 'is auto-generated from name via friendly_id' do + template = described_class.create!(habilitation_type:, name: 'Mon Template Super') + + expect(template.slug).to eq('mon-template-super') + end + + it 'is unique' do + described_class.create!(habilitation_type:, name: 'Identique') + duplicate = described_class.new(habilitation_type:, name: 'Autre nom', slug: 'identique') + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:slug]).to be_present + end + + it 'is refused if it collides with a YAML form uid' do + yaml_uid = AuthorizationRequestFormConfigurations.instance.all.keys.first.to_s + template = described_class.new(habilitation_type:, name: 'Conflit', slug: yaml_uid) + + expect(template).not_to be_valid + expect(template.errors[:slug]).to be_present + end + end + + describe 'default invariant' do + it 'cannot be flipped from true to false when it is the only default' do + default_template = described_class.create!(habilitation_type:, name: 'Default', default: true) + described_class.create!(habilitation_type:, name: 'Other', default: false) + + default_template.default = false + + expect(default_template.save).to be(false) + expect(default_template.errors[:default]).to be_present + end + + it 'can be flipped to false when another default exists' do + default_a = described_class.create!(habilitation_type:, name: 'A', default: true) + described_class.create!(habilitation_type:, name: 'B', default: true) + + default_a.default = false + + expect(default_a.save).to be(true) + end + end + + describe '#destroy' do + it 'is forbidden when destroying the last default of a habilitation_type' do + default_template = described_class.create!(habilitation_type:, name: 'Default', default: true) + described_class.create!(habilitation_type:, name: 'Other', default: false) + + expect(default_template.destroy).to be(false) + expect(default_template.errors[:base]).to be_present + expect { default_template.reload }.not_to raise_error + end + + it 'is allowed for a non-default template' do + described_class.create!(habilitation_type:, name: 'Default', default: true) + other = described_class.create!(habilitation_type:, name: 'Other', default: false) + + expect { other.destroy }.to change(described_class, :count).by(-1) + end + + it 'is allowed for a default template when another default exists' do + default_a = described_class.create!(habilitation_type:, name: 'A', default: true) + described_class.create!(habilitation_type:, name: 'B', default: true) + + expect { default_a.destroy }.to change(described_class, :count).by(-1) + end + end + + describe '#service_provider' do + it 'returns nil when no service_provider_id is set' do + template = described_class.create!(habilitation_type:, name: 'No SP') + + expect(template.service_provider).to be_nil + end + + it 'resolves the ServiceProvider from the YAML backend when set' do + sp_id = ServiceProvider.all.first.id + template = described_class.create!(habilitation_type:, name: 'With SP', service_provider_id: sp_id) + + expect(template.service_provider).to eq(ServiceProvider.find(sp_id)) + end + + it 'returns nil when the service_provider_id does not match any YAML entry' do + template = described_class.create!(habilitation_type:, name: 'Bad SP', service_provider_id: 'inexistant') + + expect(template.service_provider).to be_nil + end + end + + describe 'paper_trail' do + it 'tracks a version on update' do + template = described_class.create!(habilitation_type:, name: 'Versionné') + + expect { template.update!(description: 'nouvelle description') } + .to change { template.versions.count }.by(1) + end + end +end diff --git a/spec/models/habilitation_type_spec.rb b/spec/models/habilitation_type_spec.rb index 5e2282bb0f..4ef261f210 100644 --- a/spec/models/habilitation_type_spec.rb +++ b/spec/models/habilitation_type_spec.rb @@ -192,7 +192,8 @@ it 'creates a version on destroy' do habilitation_type.save! - expect { habilitation_type.destroy! }.to change(PaperTrail::Version, :count).by(1) + expect { habilitation_type.destroy! } + .to change { PaperTrail::Version.where(item_type: 'HabilitationType').count }.by(1) end it 'tracks attribute changes as a Hash in object_changes' do @@ -236,6 +237,28 @@ user = build(:user) expect(user).to respond_to(:"instruction_submit_notifications_for_#{habilitation_type.uid}") end + + it 'auto-creates a default FormTemplate' do + habilitation_type.blocks = [{ 'name' => 'basic_infos' }, { 'name' => 'legal' }] + habilitation_type.form_introduction = 'Introduction du formulaire' + habilitation_type.save! + + default_template = habilitation_type.form_templates.find_by(default: true) + expect(default_template).to be_present + expect(default_template.name).to eq(habilitation_type.name) + expect(default_template.slug).to eq("#{habilitation_type.slug}-default") + expect(default_template.introduction).to eq('Introduction du formulaire') + expect(default_template.steps).to eq([{ 'name' => 'basic_infos' }, { 'name' => 'legal' }]) + end + end + + describe '#ensure_default_form_template!' do + it 'is idempotent: does not create a second default' do + habilitation_type.save! + + expect { habilitation_type.ensure_default_form_template! } + .not_to change { habilitation_type.form_templates.where(default: true).count } + end end describe 'validation on destroy' do @@ -272,7 +295,7 @@ end it 'resets AuthorizationRequestForm cache' do - expect(AuthorizationRequestForm).to receive(:reset!) + expect(AuthorizationRequestForm).to receive(:reset!).at_least(:once) habilitation_type.destroy! end From 0afa69384c1dbc763c1e3db8dd7f892f6037854a Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Feldis <5403+jbfeldis@users.noreply.github.com> Date: Thu, 14 May 2026 11:39:57 +0200 Subject: [PATCH 2/6] DP-1718: rewrite AuthorizationRequestForm.db_records to iterate FormTemplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étape 1.3 — la façade `AuthorizationRequestForm` lit désormais ses db_records depuis `FormTemplate.includes(habilitation_type: :data_provider)`. Un HT avec N FormTemplate produit N ARFs (1 default + N-1 non-default), au lieu d'1 ARF unique forcé `default: true` comme dans `build_form_from_habilitation_type` (supprimée). Surface API publique inchangée — le `uid` côté façade reste le slug, `authorization_request_class` est résolu via `template.habilitation_type`. Les nouveaux champs portés en DB depuis YAML (`use_case`, `initialize_with`, `static_blocks`, `service_provider_id`, `name`, `description`, `public`, `startable_by_applicant`, `single_page_view`, `scopes_config`) sont deep_symbolize_keys côté façade, conforme à ce que consomment les vues. Specs `authorization_request_form_spec.rb` réécrites pour passer par un vrai `FormTemplate` (au lieu de stub `db_records`), couvrant : 1 HT → N ARFs, default flag respecté, service_provider résolu via le YAML backend, jsonb symbolisé. Full RSpec 2631/0, full cucumber : flakes connus uniquement. Fix i18n: l'ajout du namespace `form_template` à `activerecord.fr.yml` (commit parent) était mal indenté et avait sectionné le bloc `habilitation_type` au milieu de `errors.models`, faisant disparaître `scopes.scope_value_duplicate` de la résolution i18n et cassant la validation visuelle des scopes en admin. Docs `docs/technique/habilitation_type_dynamique.md` mises à jour pour refléter le pont HabilitationType → FormTemplate → AuthorizationRequestForm, retrait des limitations levées (`use_case`, `initialize_with`, `static_blocks`, `service_provider` côté DB). --- app/models/authorization_request_form.rb | 37 ++++++---- config/locales/activerecord.fr.yml | 14 ++-- docs/technique/habilitation_type_dynamique.md | 29 ++++++-- .../models/authorization_request_form_spec.rb | 73 ++++++++++++------- 4 files changed, 97 insertions(+), 56 deletions(-) diff --git a/app/models/authorization_request_form.rb b/app/models/authorization_request_form.rb index b21c656a8e..ac1f732f79 100644 --- a/app/models/authorization_request_form.rb +++ b/app/models/authorization_request_form.rb @@ -28,32 +28,39 @@ def self.yaml_records end def self.db_records - return [] unless HabilitationType.table_exists? + return [] unless FormTemplate.table_exists? - HabilitationType.includes(:data_provider).filter_map do |record| - build_form_from_habilitation_type(record) + FormTemplate.includes(habilitation_type: :data_provider).filter_map do |template| + build_form_from_template(template) end rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid [] end - def self.build_form_from_habilitation_type(record) - klass = authorization_request_class_for(record) + # rubocop:disable Metrics/AbcSize + def self.build_form_from_template(template) + klass = authorization_request_class_for(template.habilitation_type) return unless klass new( - uid: record.slug, - default: true, - introduction: record.form_introduction, + uid: template.slug, + default: template.default, + name: template.name, + description: template.description, + introduction: template.introduction, + public: template.public, + startable_by_applicant: template.startable_by_applicant, + use_case: template.use_case, + single_page_view: template.single_page_view, + service_provider: template.service_provider, authorization_request_class: klass, - steps: record.ordered_steps.map { |name| { name: name } }, - static_blocks: [], - service_provider: nil, - use_case: nil, - single_page_view: nil, - scopes_config: {}, + steps: template.steps.map(&:deep_symbolize_keys), + static_blocks: template.static_blocks.map(&:deep_symbolize_keys), + scopes_config: template.scopes_config.deep_symbolize_keys, + initialize_with: template.initialize_with.deep_symbolize_keys, ) end + # rubocop:enable Metrics/AbcSize def self.authorization_request_class_for(record) AuthorizationRequest.const_get(record.uid.classify) @@ -61,7 +68,7 @@ def self.authorization_request_class_for(record) nil end - private_class_method :yaml_records, :db_records, :build_form_from_habilitation_type, :authorization_request_class_for + private_class_method :yaml_records, :db_records, :build_form_from_template, :authorization_request_class_for # rubocop:disable Metrics/AbcSize def self.build(uid, hash) diff --git a/config/locales/activerecord.fr.yml b/config/locales/activerecord.fr.yml index a3915cb33d..91e792c986 100644 --- a/config/locales/activerecord.fr.yml +++ b/config/locales/activerecord.fr.yml @@ -278,6 +278,13 @@ fr: unreachable_uid: "génère un identifiant technique inaccessible (le nom doit commencer par une lettre, ex: « Espace agent de l'Annuaire des Entreprises »)" slug: taken_by_yaml_type: "est déjà utilisé" + contact_types: + blank: "doit contenir au moins un type de contact lorsque le bloc « Contacts » est sélectionné" + scopes: + blank: "doit contenir au moins un scope lorsque le bloc « Données (scopes) » est sélectionné" + scope_name_blank: "ne peut pas être vide" + scope_value_blank: "ne peut pas être vide" + scope_value_duplicate: "est déjà utilisée par un autre scope" form_template: attributes: base: @@ -286,13 +293,6 @@ fr: taken_by_yaml_form: "est déjà utilisé par un formulaire YAML" default: last_default_form_template: "ne peut pas être désactivé : il doit rester au moins un template par défaut pour ce type d’habilitation" - contact_types: - blank: "doit contenir au moins un type de contact lorsque le bloc « Contacts » est sélectionné" - scopes: - blank: "doit contenir au moins un scope lorsque le bloc « Données (scopes) » est sélectionné" - scope_name_blank: "ne peut pas être vide" - scope_value_blank: "ne peut pas être vide" - scope_value_duplicate: "est déjà utilisée par un autre scope" impersonation: attributes: user: diff --git a/docs/technique/habilitation_type_dynamique.md b/docs/technique/habilitation_type_dynamique.md index 548f2710fd..7fb426bc4d 100644 --- a/docs/technique/habilitation_type_dynamique.md +++ b/docs/technique/habilitation_type_dynamique.md @@ -182,14 +182,29 @@ def self.backend yaml_records + db_records end +# Côté AuthorizationDefinition : 1 HabilitationType ──► 1 AuthorizationDefinition def self.db_records return [] unless HabilitationType.table_exists? HabilitationType.includes(:data_provider).map { |record| build_from_db_record(record) } rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid [] end + +# Côté AuthorizationRequestForm : 1 HabilitationType ──has_many──► N FormTemplate ──► N forms +def self.db_records + return [] unless FormTemplate.table_exists? + FormTemplate.includes(habilitation_type: :data_provider).filter_map { |t| build_form_from_template(t) } +rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid + [] +end ``` +`FormTemplate` (table `form_templates`, FK `habilitation_type_id`) intercale entre +`HabilitationType` et `AuthorizationRequestForm` : un HT a 1+ FormTemplate dont +≥1 marqué `default: true` (invariant garanti par les validations + le callback +`HabilitationType#after_create :ensure_default_form_template!`). Le slug du +FormTemplate sert d'`uid` côté façade ARF. Cf. [DP-1718](https://linear.app/pole-api/issue/DP-1718). + `StaticApplicationRecord` mémorise le backend dans `@all` et invalide via un compteur Redis (`Kredis.counter(redis_cache_key).increment`). Coordination indispensable en multi-process (Puma + Sidekiq) : sans bump Redis, un save @@ -202,9 +217,6 @@ le find aboutit indistinctement côté YAML ou DB. ## Limitations connues -- **`use_case` et `initialize_with` non disponibles côté DB** : - `build_form_from_habilitation_type` passe `use_case: nil` et n'expose pas - `initialize_with`. Le préremplissage par cas d'usage reste YAML-only. - **`custom_labels` (jsonb)** : présent en DB et factory, aucune lecture dans le code à ce jour. Surface réservée pour personnalisation éditoriale. - **Concerns existants non exposés au registrar** (présents dans @@ -213,16 +225,19 @@ le find aboutit indistinctement côté YAML ou DB. `gdpr_contacts`, `modalities`, `operational_acceptance`, `safety_certification`, `technical_team`, `volumetrie`. Procédure d'exposition → [ajout_block_dynamique.md](./ajout_block_dynamique.md). -- **`static_blocks` non porté côté DB** : `build_form_from_habilitation_type` - passe `static_blocks: []`. +- **CRUD admin de `FormTemplate`** : pas encore exposée en UI (DP-1718 PR 1). + La création/édition se fait en `rails c` ; le default est auto-créé à la + création d'un `HabilitationType` (callback `ensure_default_form_template!`). ## Tableau récap « où vit quoi » | Sujet | Fichier | | --- | --- | | Modèle DB | [`app/models/habilitation_type.rb`](.../../app/models/habilitation_type.rb) | +| Modèle DB templates de form | [`app/models/form_template.rb`](.../../app/models/form_template.rb) | | Migration création | [`db/migrate/20260225111739_create_habilitation_types.rb`](../db/migrate/20260225111739_create_habilitation_types.rb) | | Migration suffixe `-dyn` | [`db/migrate/20260401152848_add_dyn_suffix_to_habilitation_type_slugs.rb`](../db/migrate/20260401152848_add_dyn_suffix_to_habilitation_type_slugs.rb) | +| Migration form_templates | [`db/migrate/20260513000001_create_form_templates.rb`](../db/migrate/20260513000001_create_form_templates.rb) | | Registrar | [`app/services/dynamic_authorization_request_registrar.rb`](.../../app/services/dynamic_authorization_request_registrar.rb) | | Initializer Rails | [`config/initializers/dynamic_authorization_types.rb`](../../config/initializers/dynamic_authorization_types.rb) | | Concerns blocks | [`app/models/concerns/authorization_extensions/`](.../../app/models/concerns/authorization_extensions/) | @@ -255,4 +270,6 @@ le find aboutit indistinctement côté YAML ou DB. inactif côté wizard). Pattern `cnous_data_extraction_criteria` — voir [ajout_block_dynamique.md](./ajout_block_dynamique.md). - **Couplage `data_provider` (FK SQL)** : un type DB appartient toujours à - un `DataProvider`. Aucun couplage à un `ServiceProvider` côté DB. \ No newline at end of file + un `DataProvider`. Le couplage à un `ServiceProvider` (YAML-backed + `StaticApplicationRecord`) se fait au niveau du `FormTemplate` via la + colonne string `service_provider_id`, résolue côté façade. \ No newline at end of file diff --git a/spec/models/authorization_request_form_spec.rb b/spec/models/authorization_request_form_spec.rb index 007658bebc..e59bcbb093 100644 --- a/spec/models/authorization_request_form_spec.rb +++ b/spec/models/authorization_request_form_spec.rb @@ -5,49 +5,66 @@ end context 'with DB records' do - before do - stub_const('AuthorizationRequest::MonAPI', Class.new(AuthorizationRequest)) - allow(described_class).to receive(:db_records).and_return([db_form]) - described_class.reset! - end + let!(:habilitation_type) { create(:habilitation_type, name: 'Mon API') } + let(:default_template) { habilitation_type.form_templates.find_by(default: true) } - after { described_class.reset! } + before { described_class.reset! } - let(:db_form) do - described_class.new( - uid: 'mon-api', - default: true, - introduction: 'Mon introduction', - authorization_request_class: AuthorizationRequest::MonAPI, - steps: [{ name: 'basic_infos' }], - static_blocks: [], - service_provider: nil, - use_case: nil, - single_page_view: nil, - scopes_config: {}, - ) - end + after { described_class.reset! } it 'includes YAML forms' do expect(described_class.all.map(&:uid)).to include('api-entreprise') end - it 'includes DB forms' do - expect(described_class.all.map(&:uid)).to include('mon-api') + it 'includes the auto-created default FormTemplate' do + expect(described_class.all.map(&:uid)).to include(default_template.slug) end it 'returns AuthorizationRequestForm instances for DB records' do - expect(described_class.all.find { |f| f.uid == 'mon-api' }).to be_a(described_class) + expect(described_class.all.find { |f| f.uid == default_template.slug }).to be_a(described_class) end - it 'has default: true for DB forms' do - form = described_class.all.find { |f| f.uid == 'mon-api' } + it 'reflects FormTemplate.default on the ARF' do + form = described_class.all.find { |f| f.uid == default_template.slug } expect(form.default).to be(true) end - it 'has the correct authorization_request_class' do - form = described_class.all.find { |f| f.uid == 'mon-api' } - expect(form.authorization_request_class).to eq(AuthorizationRequest::MonAPI) + it 'binds the correct authorization_request_class via the habilitation_type' do + form = described_class.all.find { |f| f.uid == default_template.slug } + expected_class = AuthorizationRequest.const_get(habilitation_type.uid.classify) + expect(form.authorization_request_class).to eq(expected_class) + end + + it 'exposes N templates as N forms for a single habilitation_type' do + habilitation_type.form_templates.create!(name: 'Second cas', default: false) + habilitation_type.form_templates.create!(name: 'Troisième cas', default: false) + described_class.reset! + + forms = described_class.all.select { |f| f.authorization_request_class.to_s == habilitation_type.authorization_request_type } + expect(forms.size).to eq(3) + expect(forms.count(&:default)).to eq(1) + end + + it 'resolves the service_provider from FormTemplate.service_provider_id' do + sp_id = ServiceProvider.all.first.id + default_template.update!(service_provider_id: sp_id) + described_class.reset! + + form = described_class.all.find { |f| f.uid == default_template.slug } + expect(form.service_provider).to eq(ServiceProvider.find(sp_id)) + end + + it 'deep-symbolizes jsonb fields consumed by views' do + default_template.update!( + steps: [{ 'name' => 'basic_infos' }], + scopes_config: { 'hide' => ['scope_a'] }, + initialize_with: { 'scopes' => ['scope_b'] }, + ) + described_class.reset! + + form = described_class.all.find { |f| f.uid == default_template.slug } + expect(form.steps).to eq([{ name: 'basic_infos' }]) + expect(form.scopes_config).to eq(hide: ['scope_a']) end end end From e642ca57e6900ba288b680b34af37688224982f6 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Feldis <5403+jbfeldis@users.noreply.github.com> Date: Thu, 14 May 2026 11:43:09 +0200 Subject: [PATCH 3/6] DP-1718: cover ARF cache invalidation across FormTemplate writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étape 1.4 — 2 specs garantissent que `AuthorizationRequestForm.all` reflète une création / suppression de `FormTemplate` sans appel manuel à `reset!`. La synchronisation passe par `FormTemplate#after_save/destroy :reset_arf_cache` qui bump le compteur Redis via `StaticApplicationRecord#reset!` (invalide cross-process / cross-worktree, Redis partagé en dev). Étape 1.5 — `rubocop -A` final : `Rails/WhereExists` corrigé dans la rake de backfill (`where(default: true).exists?` → `exists?(default: true)`). --- lib/tasks/form_templates.rake | 2 +- spec/models/form_template_spec.rb | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/tasks/form_templates.rake b/lib/tasks/form_templates.rake index cea79c71f5..3daa17c444 100644 --- a/lib/tasks/form_templates.rake +++ b/lib/tasks/form_templates.rake @@ -3,7 +3,7 @@ namespace :form_templates do task backfill_defaults: :environment do created = 0 HabilitationType.find_each do |habilitation_type| - next if habilitation_type.form_templates.where(default: true).exists? + next if habilitation_type.form_templates.exists?(default: true) habilitation_type.ensure_default_form_template! created += 1 diff --git a/spec/models/form_template_spec.rb b/spec/models/form_template_spec.rb index 9955553ac1..58817c020c 100644 --- a/spec/models/form_template_spec.rb +++ b/spec/models/form_template_spec.rb @@ -115,4 +115,28 @@ .to change { template.versions.count }.by(1) end end + + describe 'AuthorizationRequestForm cache invalidation' do + before { AuthorizationRequestForm.reset! } + + after { AuthorizationRequestForm.reset! } + + it 'exposes a freshly created FormTemplate to the façade without manual reset' do + AuthorizationRequestForm.all + + template = described_class.create!(habilitation_type:, name: 'Frais') + + expect(AuthorizationRequestForm.all.map(&:uid)).to include(template.slug) + end + + it 'removes a destroyed FormTemplate from the façade without manual reset' do + described_class.create!(habilitation_type:, name: 'Default conservé', default: true) + removable = described_class.create!(habilitation_type:, name: 'À jeter', default: false) + AuthorizationRequestForm.all + + removable.destroy! + + expect(AuthorizationRequestForm.all.map(&:uid)).not_to include(removable.slug) + end + end end From 95f8c06d972f05ddc2f5f8cd5b86e7fd0ed4c575 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Feldis <5403+jbfeldis@users.noreply.github.com> Date: Wed, 27 May 2026 09:29:28 +0200 Subject: [PATCH 4/6] =?UTF-8?q?DP-1718:=20cascade=20HT=E2=86=92FT,=20after?= =?UTF-8?q?=5Fcommit=20cache,=20validate=20FT=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/authorization_request_form.rb | 18 +++++--- app/models/form_template.rb | 15 ++++++- app/models/habilitation_type.rb | 11 +---- config/locales/activerecord.fr.yml | 3 ++ docs/technique/habilitation_type_dynamique.md | 21 ++++++++- .../models/authorization_request_form_spec.rb | 43 +++++++++++++++++-- spec/models/form_template_spec.rb | 25 ++++------- spec/models/habilitation_type_spec.rb | 8 ++-- 8 files changed, 102 insertions(+), 42 deletions(-) diff --git a/app/models/authorization_request_form.rb b/app/models/authorization_request_form.rb index ac1f732f79..cd61e8c5a1 100644 --- a/app/models/authorization_request_form.rb +++ b/app/models/authorization_request_form.rb @@ -30,7 +30,7 @@ def self.yaml_records def self.db_records return [] unless FormTemplate.table_exists? - FormTemplate.includes(habilitation_type: :data_provider).filter_map do |template| + FormTemplate.includes(:habilitation_type).filter_map do |template| build_form_from_template(template) end rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid @@ -42,19 +42,20 @@ def self.build_form_from_template(template) klass = authorization_request_class_for(template.habilitation_type) return unless klass + ht = template.habilitation_type new( uid: template.slug, default: template.default, - name: template.name, - description: template.description, - introduction: template.introduction, + name: template.name.presence || ht.name, + description: template.description.presence || ht.description, + introduction: template.introduction.presence || ht.form_introduction, public: template.public, startable_by_applicant: template.startable_by_applicant, use_case: template.use_case, single_page_view: template.single_page_view, service_provider: template.service_provider, authorization_request_class: klass, - steps: template.steps.map(&:deep_symbolize_keys), + steps: cascaded_steps(template, ht), static_blocks: template.static_blocks.map(&:deep_symbolize_keys), scopes_config: template.scopes_config.deep_symbolize_keys, initialize_with: template.initialize_with.deep_symbolize_keys, @@ -62,13 +63,18 @@ def self.build_form_from_template(template) end # rubocop:enable Metrics/AbcSize + def self.cascaded_steps(template, habilitation_type) + template.steps.presence&.map(&:deep_symbolize_keys) || + habilitation_type.ordered_steps.map { |step_name| { name: step_name } } + end + def self.authorization_request_class_for(record) AuthorizationRequest.const_get(record.uid.classify) rescue NameError nil end - private_class_method :yaml_records, :db_records, :build_form_from_template, :authorization_request_class_for + private_class_method :yaml_records, :db_records, :build_form_from_template, :cascaded_steps, :authorization_request_class_for # rubocop:disable Metrics/AbcSize def self.build(uid, hash) diff --git a/app/models/form_template.rb b/app/models/form_template.rb index dd025ff9fe..5d219557d1 100644 --- a/app/models/form_template.rb +++ b/app/models/form_template.rb @@ -8,12 +8,15 @@ class FormTemplate < ApplicationRecord belongs_to :habilitation_type validates :slug, presence: true, uniqueness: true + validates :service_provider_id, + inclusion: { in: -> { ServiceProvider.all.map(&:id) } }, + allow_blank: true validate :slug_not_taken_by_yaml validate :ht_keeps_at_least_one_default, on: :update + validate :only_one_default_per_habilitation_type, if: :default? before_destroy :ensure_not_last_default - after_destroy :reset_arf_cache - after_save :reset_arf_cache + after_commit :reset_arf_cache, on: %i[create update destroy] def service_provider return nil if service_provider_id.blank? @@ -43,6 +46,14 @@ def ht_keeps_at_least_one_default errors.add(:default, :last_default_form_template) end + def only_one_default_per_habilitation_type + scope = habilitation_type.form_templates.where(default: true) + scope = scope.where.not(id: id) if persisted? + return unless scope.exists? + + errors.add(:default, :already_taken) + end + def ensure_not_last_default return if destroyed_by_association return unless default? diff --git a/app/models/habilitation_type.rb b/app/models/habilitation_type.rb index c5c3861726..b126d3b40d 100644 --- a/app/models/habilitation_type.rb +++ b/app/models/habilitation_type.rb @@ -28,9 +28,8 @@ class HabilitationType < ApplicationRecord after_create :ensure_default_form_template! before_destroy :ensure_no_authorization_requests after_destroy :unregister_dynamic_class - after_destroy :reset_static_caches after_save :register_dynamic_class - after_save :reset_static_caches + after_commit :reset_static_caches, on: %i[create update destroy] def public true @@ -68,13 +67,7 @@ def ordered_steps def ensure_default_form_template! return if form_templates.exists?(default: true) - form_templates.create!( - name:, - slug: "#{slug}-default", - default: true, - introduction: form_introduction, - steps: ordered_steps.map { |step_name| { name: step_name } }, - ) + form_templates.create!(slug: "#{slug}-default", default: true) end def self.preload_requests_counts!(habilitation_types) diff --git a/config/locales/activerecord.fr.yml b/config/locales/activerecord.fr.yml index 91e792c986..9350353b98 100644 --- a/config/locales/activerecord.fr.yml +++ b/config/locales/activerecord.fr.yml @@ -293,6 +293,9 @@ fr: taken_by_yaml_form: "est déjà utilisé par un formulaire YAML" default: last_default_form_template: "ne peut pas être désactivé : il doit rester au moins un template par défaut pour ce type d’habilitation" + already_taken: "un autre template par défaut existe déjà pour ce type d’habilitation" + service_provider_id: + inclusion: "n’existe pas dans la liste des fournisseurs de service" impersonation: attributes: user: diff --git a/docs/technique/habilitation_type_dynamique.md b/docs/technique/habilitation_type_dynamique.md index 7fb426bc4d..b8cc1b9408 100644 --- a/docs/technique/habilitation_type_dynamique.md +++ b/docs/technique/habilitation_type_dynamique.md @@ -193,7 +193,7 @@ end # Côté AuthorizationRequestForm : 1 HabilitationType ──has_many──► N FormTemplate ──► N forms def self.db_records return [] unless FormTemplate.table_exists? - FormTemplate.includes(habilitation_type: :data_provider).filter_map { |t| build_form_from_template(t) } + FormTemplate.includes(:habilitation_type).filter_map { |t| build_form_from_template(t) } rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid [] end @@ -201,10 +201,27 @@ end `FormTemplate` (table `form_templates`, FK `habilitation_type_id`) intercale entre `HabilitationType` et `AuthorizationRequestForm` : un HT a 1+ FormTemplate dont -≥1 marqué `default: true` (invariant garanti par les validations + le callback +**exactement 1** marqué `default: true` (invariant garanti par les validations +`only_one_default_per_habilitation_type` + `ht_keeps_at_least_one_default` + +`ensure_not_last_default`, plus le callback `HabilitationType#after_create :ensure_default_form_template!`). Le slug du FormTemplate sert d'`uid` côté façade ARF. Cf. [DP-1718](https://linear.app/pole-api/issue/DP-1718). +**Cascade éditoriale HT → FT default**. Le FormTemplate auto-créé ne porte que +`slug` + `default: true` ; les champs éditoriaux (`name`, `description`, +`introduction`, `steps`) restent **vides** côté template et sont résolus à la +volée dans `build_form_from_template` via `template..presence || ht.`. +Conséquence : éditer le HT propage automatiquement aux ARF qui en dépendent, +tant qu'aucun override explicite n'est posé sur le template. Quand l'UI admin +FormTemplate arrivera (DP-1718 PR2/PR3), renseigner un champ sur le template le +fera prendre le pas sur le HT. + +**Invalidation du cache ARF**. `FormTemplate.after_commit :reset_arf_cache` et +`HabilitationType.after_commit :reset_static_caches` (post-commit, pas +`after_save`) bumpent le compteur Redis **après** que la transaction soit +committée — autrement, un autre process pourrait reconstruire `@all` depuis +une vue pré-commit (race). + `StaticApplicationRecord` mémorise le backend dans `@all` et invalide via un compteur Redis (`Kredis.counter(redis_cache_key).increment`). Coordination indispensable en multi-process (Puma + Sidekiq) : sans bump Redis, un save diff --git a/spec/models/authorization_request_form_spec.rb b/spec/models/authorization_request_form_spec.rb index e59bcbb093..a1d94e95ab 100644 --- a/spec/models/authorization_request_form_spec.rb +++ b/spec/models/authorization_request_form_spec.rb @@ -45,6 +45,40 @@ expect(forms.count(&:default)).to eq(1) end + context 'when FormTemplate fields are blank (cascade from HabilitationType)' do + it 'cascades name from HT.name' do + form = described_class.all.find { |f| f.uid == default_template.slug } + + expect(default_template.name).to be_blank + expect(form.name).to eq(habilitation_type.name) + end + + it 'cascades introduction from HT.form_introduction' do + habilitation_type.update!(form_introduction: 'Bienvenue v1') + described_class.reset! + + form = described_class.all.find { |f| f.uid == default_template.slug } + expect(form.introduction).to eq('Bienvenue v1') + end + + it 'cascades steps from HT.ordered_steps when template.steps is empty' do + habilitation_type.update!(blocks: [{ 'name' => 'basic_infos' }, { 'name' => 'legal' }]) + described_class.reset! + + form = described_class.all.find { |f| f.uid == default_template.slug } + expect(form.steps).to eq([{ name: 'basic_infos' }, { name: 'legal' }]) + end + + it 'lets a non-blank template override take precedence over HT' do + habilitation_type.update!(form_introduction: 'Hérité du HT') + default_template.update!(introduction: 'Override template') + described_class.reset! + + form = described_class.all.find { |f| f.uid == default_template.slug } + expect(form.introduction).to eq('Override template') + end + end + it 'resolves the service_provider from FormTemplate.service_provider_id' do sp_id = ServiceProvider.all.first.id default_template.update!(service_provider_id: sp_id) @@ -54,17 +88,20 @@ expect(form.service_provider).to eq(ServiceProvider.find(sp_id)) end - it 'deep-symbolizes jsonb fields consumed by views' do + it 'deep-symbolizes all jsonb overrides consumed by views' do default_template.update!( - steps: [{ 'name' => 'basic_infos' }], + steps: [{ 'name' => 'basic_infos', 'options' => { 'optional' => true } }], + static_blocks: [{ 'name' => 'specific_requirements' }], scopes_config: { 'hide' => ['scope_a'] }, initialize_with: { 'scopes' => ['scope_b'] }, ) described_class.reset! form = described_class.all.find { |f| f.uid == default_template.slug } - expect(form.steps).to eq([{ name: 'basic_infos' }]) + expect(form.steps).to eq([{ name: 'basic_infos', options: { optional: true } }]) + expect(form.static_blocks).to eq([{ name: 'specific_requirements' }]) expect(form.scopes_config).to eq(hide: ['scope_a']) + expect(form.initialize_with[:scopes]).to include('scope_b') end end end diff --git a/spec/models/form_template_spec.rb b/spec/models/form_template_spec.rb index 58817c020c..31df800445 100644 --- a/spec/models/form_template_spec.rb +++ b/spec/models/form_template_spec.rb @@ -51,13 +51,12 @@ expect(default_template.errors[:default]).to be_present end - it 'can be flipped to false when another default exists' do - default_a = described_class.create!(habilitation_type:, name: 'A', default: true) - described_class.create!(habilitation_type:, name: 'B', default: true) + it 'refuses a second default for the same habilitation_type' do + described_class.create!(habilitation_type:, name: 'A', default: true) + duplicate_default = described_class.new(habilitation_type:, name: 'B', default: true) - default_a.default = false - - expect(default_a.save).to be(true) + expect(duplicate_default).not_to be_valid + expect(duplicate_default.errors[:default]).to be_present end end @@ -77,13 +76,6 @@ expect { other.destroy }.to change(described_class, :count).by(-1) end - - it 'is allowed for a default template when another default exists' do - default_a = described_class.create!(habilitation_type:, name: 'A', default: true) - described_class.create!(habilitation_type:, name: 'B', default: true) - - expect { default_a.destroy }.to change(described_class, :count).by(-1) - end end describe '#service_provider' do @@ -100,10 +92,11 @@ expect(template.service_provider).to eq(ServiceProvider.find(sp_id)) end - it 'returns nil when the service_provider_id does not match any YAML entry' do - template = described_class.create!(habilitation_type:, name: 'Bad SP', service_provider_id: 'inexistant') + it 'rejects an invalid service_provider_id at save time' do + template = described_class.new(habilitation_type:, name: 'Bad SP', service_provider_id: 'inexistant') - expect(template.service_provider).to be_nil + expect(template).not_to be_valid + expect(template.errors[:service_provider_id]).to be_present end end diff --git a/spec/models/habilitation_type_spec.rb b/spec/models/habilitation_type_spec.rb index 4ef261f210..a3dc7bb443 100644 --- a/spec/models/habilitation_type_spec.rb +++ b/spec/models/habilitation_type_spec.rb @@ -238,17 +238,17 @@ expect(user).to respond_to(:"instruction_submit_notifications_for_#{habilitation_type.uid}") end - it 'auto-creates a default FormTemplate' do + it 'auto-creates a default FormTemplate carrying only slug + default (editorial fields cascade from HT)' do habilitation_type.blocks = [{ 'name' => 'basic_infos' }, { 'name' => 'legal' }] habilitation_type.form_introduction = 'Introduction du formulaire' habilitation_type.save! default_template = habilitation_type.form_templates.find_by(default: true) expect(default_template).to be_present - expect(default_template.name).to eq(habilitation_type.name) expect(default_template.slug).to eq("#{habilitation_type.slug}-default") - expect(default_template.introduction).to eq('Introduction du formulaire') - expect(default_template.steps).to eq([{ 'name' => 'basic_infos' }, { 'name' => 'legal' }]) + expect(default_template.name).to be_blank + expect(default_template.introduction).to be_blank + expect(default_template.steps).to eq([]) end end From 77cc20fa1eafa7a41e641d93c182ffd34ed5456e Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Feldis <5403+jbfeldis@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:09:02 +0200 Subject: [PATCH 5/6] =?UTF-8?q?DP-1718:=20address=20review=20=E2=80=94=20r?= =?UTF-8?q?efactor=20default=20validations,=20tighten=20SP=20check,=20Fren?= =?UTF-8?q?ch=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/form_template.rb | 25 ++++++++++++++----------- config/locales/activerecord.fr.yml | 8 ++++---- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/app/models/form_template.rb b/app/models/form_template.rb index 5d219557d1..a3f0ab5c5a 100644 --- a/app/models/form_template.rb +++ b/app/models/form_template.rb @@ -8,9 +8,7 @@ class FormTemplate < ApplicationRecord belongs_to :habilitation_type validates :slug, presence: true, uniqueness: true - validates :service_provider_id, - inclusion: { in: -> { ServiceProvider.all.map(&:id) } }, - allow_blank: true + validate :service_provider_must_exist, if: -> { service_provider_id.present? } validate :slug_not_taken_by_yaml validate :ht_keeps_at_least_one_default, on: :update validate :only_one_default_per_habilitation_type, if: :default? @@ -32,6 +30,12 @@ def reset_arf_cache AuthorizationRequestForm.reset! end + def service_provider_must_exist + return if ServiceProvider.exists?(id: service_provider_id) + + errors.add(:service_provider_id, :inclusion) + end + def slug_not_taken_by_yaml return if slug.blank? return unless AuthorizationRequestFormConfigurations.instance.all.key?(slug.to_sym) @@ -39,25 +43,24 @@ def slug_not_taken_by_yaml errors.add(:slug, :taken_by_yaml_form) end + def other_defaults + habilitation_type.form_templates.where(default: true).where.not(id: id) + end + def ht_keeps_at_least_one_default return unless default_was && !default - return if habilitation_type.form_templates.where(default: true).where.not(id: id).exists? - errors.add(:default, :last_default_form_template) + errors.add(:default, :last_default_form_template) unless other_defaults.exists? end def only_one_default_per_habilitation_type - scope = habilitation_type.form_templates.where(default: true) - scope = scope.where.not(id: id) if persisted? - return unless scope.exists? - - errors.add(:default, :already_taken) + errors.add(:default, :already_taken) if other_defaults.exists? end def ensure_not_last_default return if destroyed_by_association return unless default? - return unless habilitation_type.form_templates.where(default: true).one? + return if other_defaults.exists? errors.add(:base, :last_default_form_template) throw :abort diff --git a/config/locales/activerecord.fr.yml b/config/locales/activerecord.fr.yml index 9350353b98..3d4873ab7b 100644 --- a/config/locales/activerecord.fr.yml +++ b/config/locales/activerecord.fr.yml @@ -183,13 +183,13 @@ fr: api: API service: Service form_template: - name: Nom du template + name: Nom du cas d’usage description: Description introduction: Introduction - use_case: Cas d’usage - default: Template par défaut + use_case: Identifiant du cas d’usage + default: Cas d’usage par défaut public: Public - startable_by_applicant: Démarrable par le demandeur + startable_by_applicant: Demande initiable par le demandeur single_page_view: Vue en une seule page service_provider_id: Fournisseur de service instructor_draft_request: From 618c0857ee1c73a3d18695d579bcf0943e923239 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Feldis <5403+jbfeldis@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:44:53 +0200 Subject: [PATCH 6/6] =?UTF-8?q?feat(dp-1721):=20catalogue=20Espace=20FD=20?= =?UTF-8?q?c=C3=B4t=C3=A9=20Instruction=20=E2=80=94=20spike=20int=C3=A9gra?= =?UTF-8?q?tion=20maquettes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spike de catalogage en lecture du référentiel Formulaires + Cas d'usage, servi sous `/instruction/fournisseurs-donnees/...` à l'audience reporter+ (admin / reporter / instructor / manager / developer via RoleHierarchy). Stack au-dessus de PR #1564 (DP-1718 : cascade HT→FT + FormTemplate) dont il consomme les nouveautés (`form_template`, `inherited?`, `from_database?`). == Routes & Pundit == Nouvelles routes dans `namespace :instruction` : - `get '/fournisseurs-donnees' → data_providers#index` (catalogue racine) - `scope '/fournisseurs-donnees/:provider_slug'` avec : - `resources :formulaires, only: %i[index show]` (AuthorizationDefinition) - `resources :cas_usages, only: :show, path: 'cas-usage', param: :uid` (AuthorizationRequestForm sous le formulaire) Hiérarchie controllers : - `Instruction::DataProvidersController` < `InstructionController` (gate `reporter?` héritée — index seul, pas de @data_provider) - `Instruction::AbstractCatalogueController` < `InstructionController` (porte le `set_data_provider` partagé par les vues FD-scopées) - `Instruction::{Formulaires,CasUsages}Controller` < `Instruction::AbstractCatalogueController` Trois policies namespacées `Instruction::*` : - DataProviderPolicy + Scope : accessible si admin OU si l'utilisateur a un rôle reporter+ sur au moins une définition du provider. TODO explicite : itération sur `AuthorizationDefinition.all` à revisiter une fois la migration BDD (DP-1719/1671) en place. - AuthorizationDefinitionPolicy + Scope : `user.reporter?(record.id)` - AuthorizationRequestFormPolicy : déléguée à la définition parente Wiring via `policy_scope([:instruction, …])` sur l'index et `authorize [:instruction, …]` dans Abstract + show controllers. Refus → redirect vers dashboard via `Pundit::NotAuthorizedError` (comportement existant d'`AccessAuthorization`). == Vue Formulaire (`instruction/formulaires/show.html.erb`) == - Section Identification : uid - Section Identité : table kind, link, access_link, cgu_link, support_email (URL → lien fr-link target=_blank, fallback « Non renseigné ») - Section Propriétés : table booléens public / startable_by_applicant / unique - Section Fonctionnalités : liste \`features\` si non vide - Section Périmètres de données : tags scopes (\`scope.name.presence || scope.value\`) - Section Structure du formulaire : blocs - Section Cas d'usage : cards par cas d'usage avec badges « par défaut » + compteurs demandes/habilitations (filtrés par form_uid sur les tables AuthorizationRequest et Authorization) Listage parent \`instruction/formulaires/index\` simplifié (cards par AuthorizationDefinition). == Vue Cas d'usage (`instruction/cas_usages/show.html.erb`) == - Header : nom + badge « Par défaut » + badge « Hérité du formulaire » si name est null sur le FormTemplate - Section Description (onglets Rendu / HTML) : Rendu via \`sanitize\` allowlist \`strong em b i u br a p ul li\` HTML via \`
\` source échappée
  - Section Introduction (onglets Rendu / HTML) :
      allowlist étendue à \`div\` (les introductions utilisent souvent
      \`

\`, \`